一文说透如何同步的方式操作HashMap
文章目录
写在前面
很多人都知道HashMap
是非线程安全的。比如下面这段代码,多运行几次,基本每次会抛出异常:
final Map<Integer, String> map = new HashMap<>();
long count = 0;
final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
final String targetValue = "v";
map.put(targetKey, targetValue);
//range的范围是0~65534
new Thread(() -> {
IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
}).start();
while (true) {
if (!targetValue.equals(map.get(targetKey))) {
System.out.println("跑了" + count + "次,抛出异常了");
throw new RuntimeException("HashMap is not thread safe.");
}
count++;
}
抛出异常就是HashMap
的证明。上面的示例中,一个线程不断的put,另一个线程检查一开始设置的某个key的value是否有变化。正常情况下当然不会有变化。但是因为HashMap
是非线程安全的,在put过程中会触发resize(扩容),而这个动作在多线程环境下容易形成死循环或者数据错乱。
使用 Collections.synchronizedMap同步
这是java.util.Collections
提供的一个静态方法,用这个方法包装下HashMap
,它就变成线程安全的了。示例:
final Map<Integer, String> map = new HashMap<>();
long count = 0;
// Synchronized HashMap
Map<Integer, String> synchronizedMap
= Collections.synchronizedMap(map);
final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
final String targetValue = "v";
synchronizedMap.put(targetKey, targetValue);
//range的范围是0~65534
new Thread(() -> {
IntStream.range(0, targetKey).forEach(key -> synchronizedMap.put(key, "someValue"));
}).start();
while (true) {
if (!targetValue.equals(synchronizedMap.get(targetKey))) {
System.out.println("跑了" + count + "次,抛出异常了");
throw new RuntimeException("HashMap is not thread safe.");
}
count++;
}
这段代码无论你运行多少次都不会抛出异常了。
synchronizedMap
实现线程安全的原理也很简单,它首先基于当前的map对象生成一个新的map类型synchronizedMap
, 这是Collections
类里面的一个内部类。
public static <K,V> Map<K,V> synchronizedMap(Map<K,V,> m) {
return new SynchronizedMap<>(m);
}
进入源码可以看到它的所有操作都用了synchronized
加了一个对象锁,
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
public void putAll(Map<? extends K, ? extends V> map) {
synchronized (mutex) {m.putAll(map);}
}
public void clear() {
synchronized (mutex) {m.clear();}
}
使用ConcurrentHashMap同步
还是上面那个示例:
final Map<Integer, String> synchronizedMap = new ConcurrentHashMap<>();
long count = 0;
final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
final String targetValue = "v";
synchronizedMap.put(targetKey, targetValue);
//range的范围是0~65534
new Thread(() -> {
IntStream.range(0, targetKey).forEach(key -> synchronizedMap.put(key, "someValue"));
}).start();
while (true) {
if (!targetValue.equals(synchronizedMap.get(targetKey))) {
System.out.println("跑了" + count + "次,抛出异常了");
throw new RuntimeException("HashMap is not thread safe.");
}
count++;
}
运行不抛出异常说明同步成功了。
在java7中,ConcurrentHashMap 是一个segment数组,segment通过继承 ReentrantLock来进行加锁,锁的颗粒度比较细,相当于每次锁住的是一个segment。这样性能更高。
java8的方式有些区别,是通过CAS实现的,这里不展开。
使用迭代器访问的情况需要关注下
synchronizedMap
虽然是线程安全的map,但是它返回的迭代器(iterator)依然是HashMap的迭代器,而HashMap是fail-fast
,并发修改的时候会报错。
看下面这个示例:
try {
Map<String, String> hashMap = new HashMap<>();
hashMap.put("1", "Hello");
hashMap.put("2", "World");
Map<String, String> synchronizedMap = Collections.synchronizedMap(hashMap);
Iterator<String> it = synchronizedMap.keySet().iterator();
while(it.hasNext()) {
Object ele = it.next();
System.out.println(synchronizedMap);
if (ele.equals("1")) {
synchronizedMap.remove(ele); //出错 修改了映射结构 影响了迭代器遍历
}
}
} catch (Exception e) {
// TODO: handle exception
e.printStackTrace();
}
会抛出ConcurrentModificationException
异常。
如果使用ConcurrentHashMap
则不同,因为它本身是对Map底层做了重新实现,针对并发访问进行了优化,可以认为在一定程度是“并发迭代器”。至于实现的原理我这里就不多说了,推荐一篇文章:
http://ifeve.com/java-concurrent-hashmap-1/
同步的HashMap也不是银弹
有些人会误以为使用了同步的HashMap
就可以“为所欲为”了,其实在某些场景下ConcurrentHashMap
也存在线程安全问题。下面是个示例:
public class TestHashMap {
public static final int N = 5;
public static final String KEY = "count";
final Map<String, Integer> mapTest = new ConcurrentHashMap<String, Integer>();
//初始化
public TestHashMap() {
mapTest.put(KEY, 0);
}
public static void main(String[] args) throws Exception{
TestHashMap testHashMap = new TestHashMap();
Thread thread1 = new Thread() {
@Override
public void run() {
for(int j=0; j<100; j++){
testHashMap.add();
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
for(int j=0; j<100; j++){
testHashMap.add();
}
}
};
thread1.start();
thread2.start();
thread1.join();
thread2.join();
//打印结果跟你想的是不是一样呢?
testHashMap.printResult();
}
public void add() {
mapTest.put(KEY, mapTest.get(KEY)+1);
}
public void printResult() {
System.out.println(mapTest.get(KEY));
}
}
这个其实想想是很容易理解的,这是一个先读后写测场景,写的数据依赖前面读的结果。所以我们应该要保证读-写这整个操作的原子性,而ConcurrentHashMap
本身只是保证Map内部数据结构操作的原子性。
这个跟我们在数据库里操作一行数据很像,比如A、B两个线程操作1号账户的钱包(表里的一行记录),余额为1000元。A线程为该账户增加100元,B线程同时为该账户扣除50元,A先提交,B后提交。最后实际账户余额为1000-50=950元,但本该为1000+100-50=1050。
虽然数据库本身有行锁,但是我们操作的时候还是需要给上面的流程整体加锁(悲观锁,乐观锁都可以)
说了这么多,上面这个问题的解决方案也呼之欲出了,只需要在add
方法加锁即可。
public synchronized void add() {
mapTest.put(KEY, mapTest.get(KEY)+1);
}
总结下synchronizedMap 和 ConcurrentHashMap的区别
首先,从上面分析的同步原理看,synchronizedMap
加锁是基于操作的,简单粗暴。而ConcurrentHashMap
是分段加锁,锁的颗粒度更细,性能自然更高。高并发的场景下还是建议使用后者。
还有一个区别是,ConcurrentHashMap
永远不会抛出ConcurrentModificationException
异常。而synchronizedMap
在迭代遍历时,如果某些元素被删除了,会触发fail-fast
机制抛出ConcurrentModificationException
异常。
本文地址:https://blog.csdn.net/pony_maggie/article/details/110789664