ConcurrentHashMap如何控制并发
ConcurrentHashMap的加锁是不会把整个HashMap锁住的,锁住的是一个Hash槽位,提高了并发性能
总结就是:CAS + 锁Hash槽位 + volatile
从ConcurrentHashMap的两个方法来分析:
-
put()
通过上面CAS和Synchronized的分析,我们可以知道put方法是通过CAS和Synchronized保证的线程安全、数据一致性的
-
get()
get()是不需要加锁的,put方法需要进行加锁,也就是说一个线程在进行写操作的时候,get方法也可以正常进行
因为get()方法没有被锁住,注意跟Hashtable做对比。
那为什么get()可以不加锁?
因为ConcurrentHashMap用来存放数据的Node数组是用volatile进行修饰的,所有修改操作都可以第一时间被感知到
所以哪怕在写操作中进行get也是可以的,只要写操作在get操作结束前完成,get操作一样会拿到最新修改的值
可是这也引出来一个问题,那要是写操作一直到get操作结束后还没完成该怎么办?这个就是我们要讨论的读写冲突的问题
既然你说put的时候要用synchronized进行加锁,而get得时候不用加锁,这不是产生冲突了吗,这不是违背了本意吗?
其实这个问题就是在问你,你对ConcurrentHashMap读写冲突问题的理解
我将读写冲突问题分为两类
-
读操作和写操作同时发生在一个槽位,写操作在读操作结束前完成
这种读写冲突是可以完美解决的,因为ConcurrentHashMap存储数据的Node数组是由volatile修饰的,只要读操作没有完成
写操作结束后,所有线程第一时间都会获得最新的值
-
读操作和写操作同时发生在一个槽位,写操作在读操作结束后完成
这种读写冲突是无法避免的,我们称之为弱一致性,弱一致性指的是,线程进行读操作可能会得到旧的值,因为新的值还没修改成功
分析一下volatile
volatile修饰的变量,只能从主存中取数据,不在每个线程的工作内存中取(这一块涉及jvm的内存模型的一些知识)
所以volatile在一定程度上保证了可见性,因为所有操作都是操作的主存中的数据,而非工作内存
volatile不保证原子性,这个怎么理解?
java
volatile int a = 0;
a++;
这里的a涉及两三个操作,先从主存中拿到a的值,然后把a的值+1,再写入主存。
在拿到a的值到修改a的值前的这段时间,a可能已经被修改了
那现在来看这个问题:若violate i=0, 有3个线程同时对其+1,i的值是多少?最终的结果是不确定的,可能是1、2或3
ConcurrentHashMap的并发扩容是什么
在ConcurrentHashMap的多线程扩容过程中,多个线程可以协作进行数据迁移",指的是多个线程并发地将旧数据重新插入到新的数组(也就是扩容后的新表)中。
过程具体如下:
- 初始化新表:扩容开始时,会创建一个新的、容量更大的Node数组(表)。
- 标记迁移 :正在迁移的桶会被设置为一个特殊标记的Node,即
forwardingNode
,它的hash
值为MOVED == -1
,这用来告诉其他线程这个桶正在迁移过程中。 - 多线程参与 :当某个线程发现桶被标记为迁移状态时,它可以选择调用
helpTransfer
方法来协助数据迁移,而不是立即返回或等待。这样,不只是发起扩容的线程,其他访问到这些正在迁移桶的线程也可以参与到迁移工作中,形成了多线程并发迁移的局面。 - 并发迁移数据:参与迁移的线程会遍历旧表的每个桶,将其中的节点重新哈希并插入到新表的相应位置。由于新表的容量更大,这个过程通常伴随着重新分配,即原来在一个桶中的节点可能分散到新表的多个桶中。
- 完成迁移并原子切换:一旦所有桶的数据迁移完成,会通过CAS操作原子性地将新表设置为当前表,旧表则被废弃,这个过程确保了所有线程都能看到最新、已扩容的表,同时避免了数据不一致的问题。
ConcurrentHashMap锁的是什么
这个需要我们通过源码的形式进行分析,源码较长,这里只放出核心部分
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
}
}
addCount(1L, binCount);
return null;
}
synchronized (f) 这里就是ConcurrentHashMap锁的对象了,那这个f是什么?就是这个hash槽的头结点
ConcurrentHashMap为什么不能放null
二义性问题
如果支持了Null值的话,在多线程的场景下containsKey()为true后,调用get()方法返回为Null的话,就无法确定到底是因为这个key
的值本身就是Null,还是说在containsKey()到get()的方法的这段时间内,这个key被删掉了
问题来了,那为什么HashMap又可以放null?
因为HashMap设计的本身就是为了面对的是线程不安全的场景,默认是只考虑单线程的时候的情况
单线程不会出现多线程的问题,不会说在containsKey()到get()的方法的这段时间内,这个key被删掉了的这种情况
我看到有的人说,因为ConcurrentHashMap插入的过程中需要对头结点进行CAS或者加锁,key是null的话就加不了锁
这句话很明显就是错误的,如果这样理解了说明对Map还是不理解,Map底层就是Node<K,V>的数组[],你K为null,value为null
并不影响这个Node对象的创建,所以也不会存在key是null的话就加不了锁的情况