主要细节问题:
- 什么时候触发扩容?扩容阈值是多少?
- 扩容时的线程安全怎么做的?
- 其他线程怎么感知到扩容状态,从而一起进行扩容?
- 多个线程一起扩容时,怎么拆分任务,是不是任务粒度越小越好?
ConcurrentHashMap.get(key)
方法是没有加锁的,怎么保证在这个扩容过程中,其他线程的get(key)
方法能获取到正确的值,不出现线程安全问题?
魔鬼在细节里,一起看下源码,然后回答下上面的细节问题,先看下触发扩容的代码,在往map中put新数据后会调用这个addCount(long x, int check)
方法,计算当前map的容量,当容量达到扩容阈值时会触发扩容逻辑。
触发扩容源码:
1 | java复制代码private final void addCount(long x, int check) { |
- 容量计算逻辑:
在计算map容量的逻辑里借用了LongAdder
的思想,如果ConcurrentHashMap
里用一个size
来记录当前容量的话,那么在并发put时,所有的线程都回去竞争修改这个变量,竞争会非常激烈,性能低下,那么LongAdder
的思路是降低锁的粒度,我维护一个Long的数组,多个线程并发修改时,选取数组中没有被占用的Long进行加减,最后计算结果时我将数组内的数字加起来近就行了,这样就提升了数倍的吞吐,减少锁竞争的改了,所以这里也是一样维护了一个CounterCell
数组,CounterCell
类里就是一个long属性,当调用size()
方法获取当前容量时,只需要将这个数组里的所有值加起来就行了,CounterCell
代码如下:
1 | java复制代码static final class CounterCell { |
- 扩容邮戳计算逻辑:
1 | java复制代码int rs = resizeStamp(n); |
Integer.numberOfLeadingZeros(n)
是计算n的二进制下高位连续0的个数,比如n转成二进制后是0000 0000 0000 1000 1111 0101 0110 1101,那么得到的结果就是12,因为高位是12个连续的0,而(1 << (RESIZE_STAMP_BITS - 1))
中RESIZE_STAMP_BITS
的值是16,所以最后的结果转成二进制是1000 0000 0001 1100
这里有三个关键点:
- sizeCtl在没有触发扩容时,是用来表示扩容阈值的,这时候sizeCtl是个正数,当map内数据数量达到这个阈值时,会触发扩容逻辑
- 当某个线程触发扩容时,会通过CAS修改sizeCtl值,修改的逻辑是将上面生成的扩容邮戳向左位移16位,然后+2,这时候由于符号位是1(因为邮戳的算法决定了把邮戳向左位移16位后,符号位是1),所以sizeCtl一定是个负数,也正是由于是cas操作,所以只会有一个线程cas成功并开启扩容流程,不会有多个扩容流程被开启。
- 当sizeCtl为负数时,说明在扩容中,这时候其他线程可以一起扩容,需要先通过cas将sizeCtl+1,这样可以通过sizeCtl的低16位来判断有多少并发线程在一起做扩容,从而判断哪个线程最后完成扩容,然后做收尾工作,这个收尾工作包括将当前对象的table指向新表,将sizeCtl重新设置成表示扩容阈值的正数等
下面看下扩容源码:
1 | java复制代码// tab是旧表,nextTab是一个两倍容量的空的新表 |
看到这里可以回答上面的问题:
- 什么时候触发扩容?扩容阈值是多少?
在每次往map中put数据时会重新计算map中的size,如果达到扩容阈值就会触发扩容逻辑,扩容因子是0.75,即:当容量达到总容量的3/4时,触发扩容
2. 扩容时的线程安全怎么做的?
这个要细化下线程安全的场景,分为下面几种:
* 扩容线程与扩容线程间的并发场景
扩容线程和扩容线程间在进行任务分配时,是从数组尾部往头部以桶为单位截取,并且用来标记已分配区域的指针`transferIndex`是`volatile`修饰的,所以线程间是可见的,通过cas来修改`transferIndex`值,保证线程间没有重复的桶

* 扩容线程与写线程的并发场景
这里要分两种情况:
一、在触发扩容流程时,需要通过CAS将sizeCtl从正数改成负数,并且+2,这样只会有一个线程cas成功,避免其他的写线程也触发扩容流程。
二、怎么避免其他写线程往处于扩容中、扩容完毕的桶里写数据,因为扩容线程是遍历桶内的链表或者B树来rehash,如果往已经遍历的链表或者B树中插入新数据,扩容线程是无法感知到的,会导致新表中没有这些数据,这个要结合`put(k,v)`方法来说,对于空桶来说,不管是put操作还是扩容操作,都是通过cas操作来往空桶中添加数据,所以在出现并发往空桶写时,只会有一个线程成功,而不管是put的线程失败还是扩容的线程失败时,都会重新获取里面的值,再重新触发对应的put或者扩容逻辑,避免并发问题,`put(k,v)`代码截图如下:

对于有数据的桶,put操作和扩容操作都是通过`synchronized`在桶上加锁来避免并发写问题。
* 扩容线程与读线程之间的并发场景
这个在第5个问题里解答
- 其他线程怎么感知到扩容状态,从而一起进行扩容?
在对某个桶进行扩容时候,在完成扩容后会生成一个ForwardingNode
放在老表的对应下标的位置下,当有其他线程修改这个桶内数据时,如果发现这个类型的节点,就会一起进行扩容,put(k,v)
代码截图如下: - 多个线程一起扩容时,怎么拆分任务,是不是任务粒度越小越好?
扩容任务是一个纯计算逻辑的任务,所以会根据机器内核数来决定,如果只有一个核,则由一个线程处理完就行了,这时候引入多线程扩容反而会引入上线文切换开销,同时源码里设置的每个线程负责的桶的数量最少是16,因为粒度太小的话,会导致线程的cas操作竞争太多,比如对transferIndex、sizeCtl变量的cas操作 ConcurrentHashMap.get(key)
方法是没有加锁的,怎么保证在这个扩容过程中,其他线程的get(key)
方法能获取到正确的值,不出现线程安全问题?
线程安全场景是,get(key)
在获取下标后,数组可能已经扩容,数据被rehash,这时候通过老的下标可能会取不到值,这里是用读写分离的思路解决,这个读写分离在迁移桶内数据过程中、迁移桶内数据完毕、整个扩容完成 三个阶段都能体现出来:
* 在转移桶内数据时,不移动桶内数据并且不修改桶内数据的next指针,而是new一个新的node对象放到新表中,这样不会导致读取数据的线程在遍历链表时候因为next引用被更改而查询不到数据;
* 在桶内数据迁移完后,在原table的桶内放一个`ForwardingNode`节点,通过这个节点的`find(k)`方法能获取到对应的数据;
* 在整个库容完成后,将新表引用赋值给`volatile`的变量table,这样更新引用的动作对其他线程可见;从而保证在这三个过程中都能读取到正确的值。
本文转载自: 掘金