深入探索JDK8的ConcurrentHashMap
前言
HashMap的唯一鸡肋就是非线程安全,在如今的高并发场景下它能派上的用场也将越来越少,为了兼有HashMap高效的存取能力的同时又能保证线程安全滋生了ConcurrentHashMap。在JDK1.8以前,数据结构仍然是数组、链表的方式,不过与Hashtable相比,它并不是对整个哈希表上锁,而是采用分段锁,很好理解,将定义好容量大小的哈希表均分成相等容量大小的一个小段,相当于一块大蛋糕被平均分成若干个均等的小蛋糕,也就是说当某个线程进入到其中一个段进行操作,其他线程可以并发操作其他段,虽然效率上提升了不少,但却不是最优的。探索ConcurrentHashMap底层实现是基于JDK1.8,它是直接将哈希表中某一个位置上的头节点进行锁定,是不是粒度比之前更小了,允许其他线程操作的节点更多了,效率自然也有不用说了。其实在这之前我看过很多有关于ConcurrentHashMap,发现它们要么是抄袭严重,要么关键的点没分析到位,总是点到为止,很少能见到有一两个亮眼或对某片代码有疑问的文章,所以这篇文章还是花费了挺多时间的,不过至少解除了心中的疑虑!

数据结构
// 线程安全的HashMap
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {
// 哈希表的最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
// 哈希表的默认初始容量
private static final int DEFAULT_CAPACITY = 16;
/**
* 我们都知道定义一个数组的大小是 int 类型,那么也就意味着最大的数组大小应该是Integer.MAX_VALUE,但是这里为啥要减去8呢?
* 查阅资源发现大部分的人都在说8个字节是用来存储数组的大小,半信半疑
* 分配最大数组,某些VM会在数组中存储header word,按照上面的说法指的应该是数组的大小
* 若尝试去分配更大的数组可能会造成 OutOfMemoryError: 定义的数组大小超过VM上限
* 不同的操作系统对于不同的JDK可能分配的内存会有所差异,所以8这个数字可能是为了保险起见
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* 哈希表的默认并发级别
* JDK8中该字段并未使用,只是为了兼容以前的版本
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 默认的加载因子
* 设置成0.75是对空间与时间的一个权衡(折中),加载因子过大会减少空间开销,增加查找成本
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 当添加节点后链表的长度超过该数值时会将链表转换为红黑树,提升查询速度,但同时内存使用会增大,因为树节点的大小约是常规节点的两倍
*
* 为什么是8?
* 在节点良好分布的情况下,基本不会用到红黑树。而在理想情况下的随机哈希,节点分布遵循泊松分布,链表下的长度达到8已经是非常小的概率,超过8的概率我们认为是几乎不可能发生的事情
* 不过HashMap还是做了预防措施,当链表的长度达到8时会被转换成红黑树,至于为什么不是7,个人认为8更合适,应该尽可能的提升性能.
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当红黑树的节点个数小于该数值时,红黑树将转换回链表
* 这里有个点很重要,当初我以为红黑树在删除节点后长度就会变小,那应该会按照这个指标来将其变成单向链表结构,可惜不是,红黑树在删除节点前会判断是否此树过小,若过小则转换为链表,若不是则删除节点并进行自我平衡,所以只有在重新散列时* 才会判断该数值!!!!
*
* 为什么不是7?
* 若是频繁地添加删除添加删除元素,那么HashMap将在转换中消耗很大的性能,而7的空隙让它有一个很好的缓冲
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 当链表的长度大于8时:
* 若哈希表的容量大于64,则将链表转换成红黑树
* 若哈希表的容量小于64,数据结构保持不变,对哈希表进行扩容,扩容时原来的节点可能在旧的索引上,有可能在新的索引上(原来的索引 + 旧的容量大小)
* 至少应该是4 * TREEIFY_THRESHOLD,防止重新散列和树化阈值(TREEIFY_THRESHOLD)产生冲突,因为如果链表的长度刚好达到8,这个时候转成红黑树,而如果又刚好发生扩容,那么此颗红黑树又将可能被拆分成链表
* 所以一开始的红黑树转化有可能相当于白做了,所以又加上了数组容量为64的限定条件,只能说32比16更适合作为一个限定条件
* 在哈希表容量很小的情况下,随着不断的添加节点,链表的长度会越来越大,也会有越来越多的链表,当长度超过一定的阈值之后便需要转换成红黑树,而在扩容时又需要拆解成链表,这些都是需要一定的成本,所以在容量较小的情况下直接选择扩容
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 扩容过程中每个线程负责的最小容量个数,简单来说,就是每个线程至少要负责16个位置来将其移动到新哈希表中,第一个线程是从后面开始数16个,即tab.lenght - 1 ~ tab.length - 17
* 如果旧哈希表的容量大小小于或等于16,那么只会有一个线程发生扩容和迁移节点,由于扩容和迁移节点发生在transfer方法里,虽然在调试代码中有多个线程进入到该方法中,但并不能说明多个线程同时扩容和迁移
* 最终发生扩容迁移的仍然只有一个线程,这其中是通过Unsafe来控制的
* 总结:若哈希表的容量大小小于或等于16,那么最终只会有一个线程发生扩容并迁移节点
*/
private static final int MIN_TRANSFER_STRIDE = 16;
/**
* 常量值,用于生成邮戳,标识当前线程正在扩容
* 目的是为了确保多线程下只有一个线程会发生扩容,不会有多个线程同时在发生扩容操作,不过可以有多个线程帮助迁移哈希表的节点
* 最终将该左移后的邮戳 + 1 + 扩容的线程数(1个) + 帮忙迁移(多个) 构建成sizeCtl属性,至于为什么前面要加上1,个人觉得如果不加上1的话,那么当容量达到最大的情况下sizeCtl = -1,而此值又表示在初始化状态下...所以是不是不太合理?
*/
private static int RESIZE_STAMP_BITS = 16;
/**
* 常量值,限制帮助迁移哈希表节点的线程个数,不需要所有线程都帮忙迁移
*/
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
/**
* 常量值,与邮戳进行计算来判断ConcurrentHashMap是否扩容完成/帮助迁移的线程是否超过上限,总之就是用计算后的结果来与sizeCtl进行对比
*/
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
/**
* 标识当前节点是ForwardingNode对象,即标识当前节点已经迁移到新哈希表中了
*/
static final int MOVED = -1;
/**
* 标识当前节点是红黑树,也就是当前节点使用TreeBin对象来包裹红黑树的根节点,而在HashMap中是直接使用TreeNode
* TreeBin对象中还额外加入了类似读写锁的概念,当有线程先使用读操作,其他线程的写操作会阻塞直到读操作完成,而如果先使用写操作,其他线程的读操作不会被阻塞,只不过使用了链表的方式进行查找,因为写操作可能会使红黑树的根节点发生变化
* 具体可参考TreeBin#contendedLock和TreeBin#lockRoot这两个方法
* 总结:通过在TreeBin对象中的hash = -2 来标识当前节点是红黑树
*/
static final int TREEBIN = -2;
/**
* 标识ReservationNode对象,该对象主要用来上锁
* 当调用compute相关方法时需要传入一个mappingFunction函数表达式,该表达式主要用于计算指定键对应的值,在计算过程中其他用户更新或插入的操作的线程可能会发现阻塞(同一个索引处)
* 但是对于查找方法来说是不会发生阻塞,那么这个情况下查找会返回null
*/
static final int RESERVED = -3; // hash for transient reservations
/**
* 通过与hash值 & 计算来保证hash值不会出现负数
*/
static final int HASH_BITS = 0x7fffffff;
// 获取CPU数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
/**
* 哈希表
* 采用懒加载,只有在第一次插入节点后才开始初始化哈希表
*/
transient volatile Node<K,V>[] table;
// 新哈希表,当把旧哈希表的所有节点迁移到新哈希表后,那么就会把该值赋给table,最终在将该值给置null
private transient volatile Node<K,V>[] nextTable;
/**
* 在没有线程竞争的情况下用于统计哈希表的节点个数,若出现竞争则使用counterCells数组
*/
private transient volatile long baseCount;
/**
* 用于控制哈希表的初始化与扩容,严格来说,是只会有一个线程发生扩容,其他线程帮助迁移哈希表的节点
* 1. 当值为-1时,表示哈希表正在初始化
* 2. 当值为-(1 + 活跃的线程数)时,表示哈希表正在发生扩容,活跃的线程数指的是一个发生扩容的线程 加上 帮助迁移哈希表的线程,扩容时使用
* 此时的数值中高16位表示邮戳,标识ConcurrentHashMap正在扩容中,而且只会有一个线程正在扩容,其他线程帮助迁移节点,低16位表示迁移的线程数
* 3. 此属性还可以当作哈希表的容量大小值,在调用ConcurrentHashMap构造函数时使用
* 4. 此属性还可以当作哈希表的阈值,即当哈希表中节点的个数超过阈值后就会发生扩容,类似HashMap#threshold,在初始化哈希表后使用
*/
private transient volatile int sizeCtl;
/**
* 由于采用的从后面开始遍历,索引呈现递减,所以此属性可以说是剩余未迁移节点的数量/索引
* 因为存在多个线程帮忙迁移节点,所以可能存在竞争的情况,同时要去处理同一块区间,每个线程默认分配16个节点,所以此属性必须要使用Unsafe去更新值,也就是说只有一个线程会获得某个区间的使用权
* 当此属性等于0时表示所有的节点迁移完成
*/
private transient volatile int transferIndex;
/**
* 通过标识来控制加锁(1)或释放锁(0),控制CounterCell数组
* 1. CounterCell数组初始化的时候需要上锁,防止多线程同时初始化
* 2. CounterCell数组扩容的时候需要上锁
* 3. 若CounterCell数组的某个索引上为null,就要创建CounterCell对象,否则多个线程并发创建
*/
private transient volatile int cellsBusy;
/**
* counterCells数组是LongAdder高性能实现的必杀器,当发生线程竞争的情况,会将该线程随机分配到某个索引上,若索引上已经存在counterCells对象了,那么
* 就将当前线程所携带的节点个数值进行累加,若不存在counterCells对象,那么就创建counterCells对象包装节点个数值,每个线程都与一个counterCells对象绑定在一起
* 所以不同索引处的线程就可以并发修改节点个数值,减少了线程之间的竞争,提高了效率,最终再将counterCells数组的所有counterCells对象的节点个数值相加起来,在与baseCount相加就是最终的结果了
* counterCells数组在多次累加冲突的情况下会发生扩容,数组的最大容量是当前计算机的CPU数量
* LongAdder的吞吐量比AtomicLong高,但消耗的内存空间自然就更多了
*/
private transient volatile CounterCell[] counterCells;
/**
* 1. 发现没有,与HashMap相比,少了个阈值,即当哈希表的节点个数超过阈值后会发生扩容,所以应该是有其中某个属性包含了阈值的含义,即sizeCtl
* 2. 虽然ConcurrentHashMap提供了带有加载因子的构造函数,但实际上在计算过程中并未使用指定的加载因子进行计算,这个设计有点不合理...要么就摒弃
*/
}
构造函数
JDK8中的ConcurrentHashMap即使你指定了2的幂次方作为指定容量,但最终的结果却不是(虽然在JDK11以后做了修改),所以在对容量没有要求的情况下最好使用默认。
// 默认初始化
public ConcurrentHashMap() {
}
/**
* 指定初始化容量
* 此时sizeCtl = 哈希表的容量大小
* @param initialCapacity 指定初始容量
*/
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
/**
* 这里的代码很奇怪,也就是说即使你传入的参数是个2的幂次方,结果的容量大小并不是指定的容量大小,为什么就不能像HashMap那样子做呢?
* 于是去看了Oracle官方的bug库,也有人提出针对ConcurrentHashMap(int,float,int)构造函数与当前构造函数传入的参数是一样的,而得出的sizeCtl容量大小却是两个
* 如ConcurrentHashMap(22,0.75,1) -> 32 , ConcurrentHashMap(22) -> 64 很矛盾...
* 虽然在JDK11/12版本中做了修改,但我还是觉得写法没有HashMap中看的舒服...
* 这里贴下Oralce官方有关ConcurrentHashMap构造函数bug的地址:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8202422
*/
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
/**
* 指定集合插入到哈希表
* 此时sizeCtl = 哈希表的默认容量大小
* @param m 指定集合
*/
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
/**
* 指定初始化容量、加载因子、并发级别
* 虽然这里传入了指定的加载因子,但实际上在初始化阈值时并未使用指定加载因子
* @param initialCapacity 指定初始容量
* @param loadFactor 加载因子
* @param concurrencyLevel 并发级别
*/
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel)
initialCapacity = concurrencyLevel;
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
简单方法
/**
* 获取最小(最接近指定容量大小)2的幂次方
* @param c 指定容量大小
* @return 最小2的幂次方
*/
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
/**
* 初始化哈希表或扩容或帮助迁移哈希表
* @param size 指定容量大小
*/
private final void tryPresize(int size) {
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c; // 取容量的最大值
/**
* 通过CAS方式判断是否有其他线程正在初始化哈希表,即是否有其他线程正在构造数组
* 若有的话,则CAS将返回false,后续就会退出方法
* 若没有的话,则CAS将返回true,同时修改sizeCtl = -1,表示当前线程正在构造哈希表
* 哈希表构造完成后,修改sizeCtl成阈值(3/4),即哈希表发生扩容的标识
*/
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; // 构造哈希表
table = nt;
sc = n - (n >>> 2); // n - n/4 = 3n/4 (4分之3) 即sc变成了哈希表容量大小的3/4
}
} finally {
sizeCtl = sc; // sizeCtrl 等于4分之3的哈希表容量大小
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY) // c 表示最接近指定容量大小,sc 表示阈值,表明添加的节点的个数并未超过阈值,不用进行扩容,直接退出
break;
else if (tab == table) {
int rs = resizeStamp(n); // 生成邮戳
// 源代码中有多出如下代码片段,但实际上是有些错误的,这些错误可以在Oracle官方看到,在addCount方法中提供了详细的介绍
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
/**
* 结合指定变量生成邮戳
* @param n 指定变量
* @return 邮戳
*/
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
/**
* 将旧哈希表中的所有节点迁移到新哈希表中
* 扩容机制:
* 1. 只会有一个线程发生扩容,其他线程帮忙迁移节点,这么多线程一起迁移哈希表,自然是要有个规则,每个线程负责至少迁移16个节点,那么接下来考虑的是哪个线程负责哪16个节点
* 2. 所有的线程都会去获取transferIndex的值,此值表示还剩余多少节点未分配,这些线程就开始抢以步伐为16的区间,谁抢到就算谁的,直到所有的节点都抢空了...有没有像超市大妈...
* 3. 没有抢到区间的线程自然就退出了,抢到的线程就开始从后往前一一迁移节点到新哈希表中,索引呈现递减的趋势,迁移完成的节点用fwd标识,以允许其他线程通过get方法访问到节点,如果没迁移完成则会循环获取
* 4. 在迁移过程中,不管是红黑树还是链表都会使用创建新节点的方式进行迁移,目的是为了其他线程能够读取节点,也就是说即使在扩容过程中,仍然允许并发读
* 5. 对于不为null的节点如果是链表则在创建新节点过程中会使用lastRun的方式进行迁移,可以减少新节点的创建,而对于红黑树来说则不行,因为红黑树的结构更为复杂,有可能为了减少新节点的创建的操作可能会引发循环引用,反而增加了开销
* 6. 每个线程把区间内的节点都迁移完成后,还要再去看看还有没有可分配的区间,如果没有且不是最后一个线程则直接退出,如果是最后一个线程则还要再把整个哈希表遍历一遍再次检查下是否有遗漏的节点没迁移;如果还存在可分配的区间则继续抢
* 继续迁移,直到没有可分配的区间了
*
* @param tab 旧哈希表
* @param nextTab 新哈希表
*/
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
/**
* stride在英文单词中指的是步伐,在代码中的含义是表示在哈希表发生扩容过程中,既然是扩容,那么就有数据从旧哈希表迁移到新哈希表的这样一种动作
* 那么就要思考到底是要如何迁移,是由当前线程去处理所有的节点呢,还是如何? 继续分析
* 如果是由当前线程去处理所有的节点,那么在保证线程安全的前提下,就必须要求其他线程不能进行任何的插入删除更新操作,否则可能造成数据的不一致,这样子的方式无疑降低了效率
* 所以它允许其他线程帮忙处理旧哈希表的迁移,那么多的线程一起处理迁移必须要制定要规则,否则容易造成混乱,至于规则请看后续分析...
*
* stride表示每个线程应该处理多少个数组,即每个线程要负责迁移多少个数组到新哈希表中,其实这里还有一些分析:
* NCPU指的是计算机的处理器个数,至少会有一个处理器,即NCPU = 1,如果是这种情况,那么stride = n = tab.length,也就是当前线程要迁移所有的数组,为什么呢?
* 因为如果允许其他线程来帮忙处理迁移的话,那么在单处理器下当前线程就需要先暂停手头上的工作,发生上下文切换,然后由其他线程去处理,这样子做是不是奇葩了...
* 如果NCPU > 1 表示有多个处理器,那么就可以在不用暂停当前线程的情况下允许其他线程帮忙迁移,而且/NCPU可以为每个处理器平均分配
*
* 总结:stride的值总是会大于等于MIN_TRANSFER_STRIDE(16),所以说每个线程至少要处理16个节点
*/
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
if (nextTab == null) { // nextTab == null表示还未开始迁移,要先构建新哈希表
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 构建新哈希表,同HashMap一样,与旧哈希表的容量相比还是2倍大小!!!
nextTab = nt;
} catch (Throwable ex) { // 这里会发生异常猜测可能是由于内存溢出了
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
/**
* nextn:新哈希表的容量大小
* advance: 是否可以迁移下一个节点,前提是先分配到了指定迁移节点的区间
* finishing: 扩容是否完成,只有最后一个迁移完成的线程会修改此值
*/
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
/**
* 1.advance表示是否可以迁移下一个节点,不过这有一个前提是分配到了指定迁移节点的区间,不然一切都是白说,如果成功分配到了,那么才开始考虑是否迁移节点
* 还有一点因为中途有可能出现节点迁移失败的情况,那么就要重复去迁移此节点,此时的advance是false
* 迁移是从哈希表的尾部开始,即从 i = tab.length - 1 处开始迁移,依次递减,每个线程默认至少迁移16个节点
* 2. bound表示迁移节点的结束索引,也就是随着索引的递减到bound后就表示线程已经迁移完指定的区间了,那么此时会去判断是否还有未迁移的区间呢,通过transferIndex是否等于0来判断
* 若有自然就获取下一个迁移的区间,若没有自然就退出了
* 3. i自然表示要迁移节点的索引值,当 i < bouond就表示指定区间已经迁移完成了,那么要去获取下一个指定区间了
*
* 所以总体来说,下面的while代码片段是在获取指定迁移节点的区间,没有的话就走下面的if语句了,有的话就会更新transferIndex的值,表示这个区间我拿走了,以便其他线程知道,不会发生重复区间的迁移,随后就开始
* 一个一个的往新哈希表迁移节点了
*
* 以下假设采用默认迁移16个节点
*
* 假设哈希表的容量是64,第一个线程进来,也就是要求扩容的线程,advance默认是true,接着--i > bound的结果自然是false,接着判断transferIndex是否为0,此时的transferIndex = 64
* 接着更新transferIndex的值,如果更新成功的话,那说明此区间由当前线程负责了,如不成功则说明此区间已经被其他线程拿下来,那么当前线程就需要重新去拿区间了,分配到区间后就更新bound与i
* 表示从哪个节点处开始更新,更新到哪个节点结束,这就形成了一个区间
*/
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
/**
* 1. i < 0,此时i只可能等于-1,表示给当前线程分配的指定迁移节点的区间的所有节点都已经迁移完成了,且已经没有可分配给当前线程新的区间,也就是说哈希表中的区间都已经分配完了,当前线程也都迁移完成了,可以功成身退了(退出)
* 2. i >= n || i + n >= nextn,对于这两个情况我没有想到有什么场景,也在Oracle的官方bug库上看不到有关此处的问题,鉴于自己的能力有限不敢去提,哈哈哈,说下我的疑惑
* i的值是受nextIndex所影响,而nextIndex是受transferIndex所影响,所以i也是受transferIndex所影响,所以i要发生等于或大于的情况,只有transferIndex发生变化,那么transferIndex表示剩余未分配节点的数量
* 要让它变化也只有在哈希表的容量发生变化,那么哈希表发生变化就需要扩容,所以要影响这些变化就可能需要多个线程并发扩容,可这个结论与sizeCtl的设定初衷又是矛盾的,本身sizeCtl就是为了防止多个线程同时发生扩容
* 所以我并不明白这里的点,当然了,它这里的写法并不是错误的,我只是认为它不可能发生....
*/
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { // 所有的节点确实都迁移完成了
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); // 所有的节点真正迁移完成了,此时的sizeCtl表示新哈希表的阈值,为下一个扩容做准备
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // 针对上面提到的第一点,已经完成任务的可能退出了,那么线程数就减去1
// 看看当前线程是否是最后一个退出的线程,因为第一个线程扩容时sizeCtl = (rs << RESIZE_STAMP_SHIFT) + 2,这是sizeCtl开始扩容时的初始化,所以节点迁移完成后的sizeCtl应该也要等于初始值
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true; // 表示哈希表的所有节点都迁移完成了
/**
* 上面已经认定了哈希表的所有节点都迁移完成了,但为什么这里还要再次检查哈希表呢,也就是重新遍历整张表,说下我的想法,此想法未经过验证
* 多线程下的环境是很复杂的,说不定会出现什么幺蛾子,虽然所有的节点都已经迁移完成了,但实际上当前线程只知道自己把该迁移的节点都迁移了,谁知道其他线程有没有好好做事情
* 有没有把该负责的区间都处理好了,当前线程确定不了,放心不下,所以只好在重新检查一遍,检查下是否有遗漏的,这也算是一种保守策略
*/
i = n;
}
}
else if ((f = tabAt(tab, i)) == null) // 如果旧哈希表中指定索引处为null,则使用fwd对象来填充,通过fwd.hash == MOVED标识该位置已经迁移完毕
advance = casTabAt(tab, i, null, fwd); // 在多线程的情况下有可能导致该节点迁移失败,不过没关系,会进行重复迁移
else if ((fh = f.hash) == MOVED) // f.hash == MOVED说明当前节点是fwd,标识当前节点已经被迁移过,说明当前线程负责的区间已经迁移完成了,现在获取到的是其他线程在负责的部分,所以当前线程可以继续去获取要帮助迁移的区间
advance = true;
else { // 走到这里表明指定索引处存在未迁移的节点,那么先使用头节点作为锁对象来上锁,防止其他线程修改
// 下面的代码片段是红黑树或链表要进行迁移的动作,其中会发生节点的复制(克隆)
synchronized (f) {
if (tabAt(tab, i) == f) { // 有可能出现已经获取到f指向当前节点,但未及时上锁,而被其他线程修改或迁移了,所以这里再上锁后又判断了一次,防止进行不必要的迁移
Node<K,V> ln, hn;
if (fh >= 0) { // fh:当前节点的哈希值,fh > 0表示链表,参考treeifyBin方法可以知道当转换成红黑树的时候节点变成TreeBin对象,hash变成-2
/**
* 这里的操作跟HashMap是类似的,将链表中的节点分为高位一条链表、低位一条链表,如何区分高位与低位就在于新哈希表的容量大小对应的二进制的最后一位(从右向左看) & 当前节点的hash的二进制的对应位置的结果是否为1
* 若为1那么自然是高位,否则就是低位,高位与低位的存储区别在于在新哈希表中存储的位置就不同,低位在新哈希表中存储在原索引位置上,而高位则存储在原索引 + 旧哈希表的容量大小
* 在HashMap中已经详解介绍了,可去翻看
*
* 接着在说下为什么要使用lastRun,而不像HashMap那样子处理呢?
* 因为ConcurrentHashMap是支持并发读的,也就是说即使在扩容的情况下,其他线程依然可以进行读取操作,那么问题了,如果它按照HashMap那样子的话,HashMap是直接将链表进行拆分
* 也就是说,有可能上一个线程插入了节点导致了扩容进而拆分链表,那么下一个线程有可能因为链表高低位的拆分而查找不到对象,就是有可能读取不到数据,既然ConcurrentHashMap支持并发读,那么就不能直接
* 操作节点之间的关系,通常来说,可以遍历链表,判断节点的高低位然后创建新节点来关联关系,这样子肯定是可以的,不够还有更优的办法,它加入了lastRun,利用原来的节点可以重复的情况可以减少新节点的创建
* 简单来说,比如链表呈现 0 -> 1 -> 0 -> 1 -> 0 -> 0 -> 0,其中1和0表示高低位,既然要分成高低两条链表,那么只需要把0的节点关联起来即可,同理1的节点也是如此,那么思考下后面3个连续的0是否需要重新
* 关联关系,自然是不用了,在原来的链表上就已经是关联好的,所以只要与前面两个0的节点关联起来就可以了,由于是这两个节点是断开的,所以只能新创建节点了,不然要影响并发读了,最终只要新创建两个节点,并与
* 最后的3个节点关联起来就可以了,因为最后的3个节点的关系始终都没有变化,始终都是一致的,要么都是高位,要么都是低位,所以这是可以利用的,这就是上面说的,可以重复利用原来的节点减少新节点的创建
* 说到这里你们应该能明白lastRun的意思了吧,指的就是最后一次高低位变化的节点,要么是高位,要么是低位,总之从lastRun节点开始,后续的所有节点都跟lastRun同是一样高(低)位,而lastRun之前的节点就需要
* 创建新节点来关联关系了
*
*/
int runBit = fh & n; // runBit 就是在计算高低位
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) { // p:当前节点的下一个节点
int b = p.hash & n; // b:当前节点的下一个节点的hash值
if (b != runBit) { // 计算出最后一次高低位变化的节点,lastRun节点后面的所有节点都跟lastRun一样的高位或低位
runBit = b;
lastRun = p;
}
}
// 看看lastRun是高位还是低位,以便与之前的节点关联关系
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) { // lastRun节点的后续节点不需要再关联关系,因为它们都跟lastRun节点是一样的高位或低位,利用了原来的节点,所以只要关联lastRun节点之前的就可以了
int ph = p.hash; K pk = p.key; V pv = p.val; // ph:当前节点的hash值 pk:当前节点的key pv:当前节点的value
/**
* 解释下低位
* 将链表中第一个低位的节点与lastRun关联起来,即低位节点的下一个节点是lastRun节点,然后把低位节点当作第二个低位节点的下一个节点,第二个低位节点当作第三个低位节点的下一个节点,直到遇到lastRun节点
* 假设原链表: A(0) -> B(1) -> C(0) -> D(1) -> E(0)(lastRun) -> F(0) -> G(0),其中0表示低位,1表示高位
* 那么低位链表:C(0) -> A(0) -> E(0) -> F(0) -> G(0)
* 那么高位链表:D(1) -> B(1)
*
* 如果lastRun是高位的话,那么也是同理,其实你仔细一看,总有一条链表会出现原来高位链表的倒序
*/
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 接下来就是将高低位两条链表迁移到新哈希表的位置上了
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd); // 当前节点迁移完成了用fwd对象填充来标识
advance = true;
}
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
/**
* 上面咱们理解的头头是道,就是应该利用lastRun来减少节点的创建,可是看下面的代码片段正好和HashMap差不多啊
* 如果你仔细看过HashMap中关于转换成红黑树的过程的话,那么你应该知道它是由原本的单链表转换双向链表,为什么要转成双向链表,因为链表中的某一个节点有可能变成根节点,它会将根节点变成
* 哈希表的头节点,这就造成了根节点的上下节点的关系发生变化,所以原来根节点的上下节点需要重新关联关系,有点绕,也有点复杂,其实就是为了说明对于红黑树来说,它需要双向链表,不能用单向链表
* 否则在移除节点时就要重头开始判断,降低效率。所以如果直接使用lastRun的话是不能满足红黑树的,lastRun及其后续的节点仍然要上下节点的关联关系,当然了,有人认为可以在遍历
* lastRun让其关联关系就可以了,没错,是可以的,但你想想,红黑树的节点它不向链表那么简单,它包含了很多属性,比如left、right、parent,有可能牵扯到已经新创建的节点,会显得很乱,所以在这里
* 直接遍历创建是比较好的
*/
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
/**
* 假设原链表:A(0) -> B(1) -> C(0) -> D(1) -> E(0) -> F(0) -> G(0)
* 低位链表: G <-> F <-> E <-> C <-> A
* 高位链表: D <-> B
* 结果很明显,呈现高低位呈现倒序
*/
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 判断高位低链表的长度是否小于6,如果小于则将双向链表变成单向链表,否则就构建红黑树了
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : (hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : (lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
/**
* 插入新节点或更新节点或删除节点都要更新哈希表的节点个数
* 插入新节点还需要考虑是否要扩容或帮助迁移节点
* @param x 插入的节点个数,-1的情况是删除节点
* @param check 用于判断是否要考虑扩容
*/
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
/**
* 关于哈希表节点个数的统计方式与思想跟LongAdder类是一样的,虽然我对LongAdder类也是第一次见,但笔者也只准备说说它的思想,目前不会做其代码上的深入了,其实有关它的代码并不难
*
* 为什么使用LongAdder而不是使用AtomicLong?
* 在多个线程并发插入元素而修改节点个数时,AtomicLong只会允许其中一个线程修改成功,没修改成功的线程只能循环修改直到成功,这使得效率降低了很多了,而LongAdder中有两个属性,一个base属性
* 一个Cell数组,base属性就好比是AtomicLong中的value一样,不过这个属性是在没有竞争的情况下使用,也就是说在没有竞争的情况下LongAdder的效率和AtomicLong是一样,因为并没有用到cell数组
* 而有了竞争之后cell数组就排上用场了,它用一个Cell对象将每个线程所携带的节点个数值,也就是每个线程插入的节点个数值包装起来,简单来说就是Cell对象中包含了节点个数的属性值,并未每个线程取随机数
* 取随机数的目的是为了随机分配到数组的位置上,计算索引的方式就跟hash & (n.lenght - 1)一样,相当于每个线程都与Cell数组中的某一个Cell对象绑定在一起了,多个不同索引处的线程就可以并发修改自己的属性值
* 最后在将所有Cell对象的属性值加起来,在与base相加即可,Cell数组还会发生扩容,最大长度是当前计算机的CPU个数
*
* 总结:LongAdder优先考虑base,如果失败了在使用Cell(CounterCell),LongAdder的吞吐量比AtomicLong高,但占用的空间更多
*/
if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { // 前者判断表示之前发生过竞争,可以直接使用Cell对象,后者表示刚好发生第一次竞争,要去初始化Cell数组
CounterCell a; long v; int m;
boolean uncontended = true;
/**
* as == null || (m = as.length - 1) < 0 表示Cell数组还未初始化
* (a = as[ThreadLocalRandom.getProbe() & m]) == null Cell数组已经初始化过了,若结果为true表示此索引处还未发生累加,后续会为它创建Cell对象
* !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x) 表示此索引处已经有Cell对象了,那么就使用Cell对象进行累加操作,如果失败了说明要进入到指定方法中重新生成随机数并重新计算
*/
if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
/**
* 走到这里说明已经使用Cell对象成功累加了节点个数值
* check用于判断是否要考虑扩容
* 若check = -1表示并未插入新节点,并不需要考虑扩容
* 若check = 1则还要是否发生冲突,发生冲突的话说明有多个线程在插入新节点,这个时候只要求修改节点个数成功的线程去考虑扩容就可以了,冲突的线程退出就可以了,因为扩容的线程在扩容结束了还会计算节点的个数
* 不过冲突的线程退出只能在check = 1的情况下,毕竟这可能造成误差,只是误差只有一个
* 若check > 1 即使线程之间发生冲突也会考虑扩容
*/
if (check <= 1)
return;
s = sumCount(); // 统计哈希表中的节点个数
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
/**
* s > sizeCtl有两种情况发生:
* 1. 哈希表中的节点个数达到阈值了,要进行扩容,此时的sc > 0,也就是下面else if执行语句
* 2. 有其他线程正在发生扩容,此时的sc < 0,所以s很容易就大于sc,而当前线程就要判断是否要帮助扩容,接下来重点来了!!!
* 在上面我们反复强调所谓的邮戳就是用来保证不会发生多个线程同时扩容的情况,也就是始终只有一个线程会发生扩容,其他线程帮忙迁移节点
* sc的高16位表示邮戳,低16位表示迁移节点的线程数 + 1,所以这里的无符号右移16位就是为了获取邮戳
* 接下来的判断sc == rs + 1 你会发现显得莫名其妙,sc是个负数,而rs是个正数,怎么可能相等,这一块我也是耗费了很长时间,觉得顶尖大师怎么可能会犯低级错误
* 于是我翻遍了谷歌的所有有关ConcurrentHashMap的相关文章,终于有人说这已经再Oracle官方bug上提出问题并在JDK12上修复了
* 提供Oracle官方提供的bug库: https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8214427
* 所以正确的写法应该是:sc == (rs << RESIZE_STAMP_SHIFT) + 1 || sc == ( rs << RESIZE_STAMP_SHIFT ) + MAX_RESIZERS
* 第一个 sc == (rs << RESIZE_STAMP_SHIFT) + 1是在判断正在扩容的线程是否结束扩容了,如果已经结果扩容的话,那么结果为true,因为扩容的线程会使 sc = (rs << RESIZE_STAMP_SHIFT) + 2
* 而在扩容结束后用sc - 1,结果即是 sc = (rs << RESIZE_STAMP_SHIFT) + 1
* 第二个是在判断帮助迁移节点的线程数是否超过了上限,如果是的话自然就不用再帮助了
* nextTable = null 表示扩容结束了或者线程正要开始扩容,即nextTable还未赋值,此时此刻可能出现两种情况,所以要帮助扩容的话还需要加上其他条件,不过在nextTable还没赋值之前其实谁也不知道新哈希表的容量大小是多少
* 相当于是认为也不知道到底要不要帮忙,所以这里不好确认只能采用保守策略不帮忙了
* transferIndex = 0 表示要已经没有要迁移的区间,所有的区间都已经分配给其他线程了,不用当前线程来操劳了
*/
while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) { // 有其他线程正在扩容,是否要帮助迁移节点
/**
* Oracle JDK12中修复了此处的逻辑问题,正确写法:sc == (rs << RESIZE_STAMP_SHIFT) + 1 || sc == ( rs << RESIZE_STAMP_SHIFT ) + MAX_RESIZERS
*/
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) // 帮忙去迁移节点,线程数加1
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) // 上面提到的第一种情况,开始扩容
transfer(tab, null);
s = sumCount();
}
}
}
/**
* 获取指定哈希表中指定索引处的节点
* @param tab 指定哈希表
* @param i 指定索引
* @return 结果值
*/
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
/**
* 更新指定哈希表中指定索引处的节点为指定节点
* @param tab 指定哈希表
* @param i 指定索引
* @param c 指定索引处的预期节点
* @param v 指定索引处的新节点
* @return 是否更新成功
*/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
/**
* 插入节点
* @param key 键值
* @param value 值
* @return null或旧值
*/
public V put(K key, V value) {
return putVal(key, value, false);
}
/**
* 插入节点
* 若指定索引处不存在节点则直接插入
* 若指定索引处存在节点,还要判断是否有其他线程正在扩容,若不是则锁住指定索引上的头节点,防止多线程修改,若有其他线程正在扩容则帮助迁移节点
* @param key 指定键
* @param value 指定值
* @param onlyIfAbsent 在键值对存在的情况下发生重复时添加是否不允许修改值,为true则表示不允许
* @return 旧值或null
*/
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; // 主要用于计算链表的长度,若长度超过8则需要转换成红黑树
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 初始化哈希表
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // (n - 1) & hash的原理就跟HashMap中一样了,如果当前索引下为null则直接插入,由于多线程的影响可能会发生覆盖
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED) // 走到这里说明当前位置的节点已经被迁移,说明有其他线程正在扩容,去看看需不需要帮助
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { // 走到这里说明当前索引存在节点,对其头节点上锁,使得其他线程不能删除/更新相同索引处的节点,包含当前索引处的所有节点,当然除了读取操作可以任意操作了,但这就可能导致读取的数据可能不是最新的
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 查找链表
binCount = 1; // binCount用于计算链表的长度
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { // hash值相同的则进行替换
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, null);
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) // 当链表的长度超过8时则需要转换成红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); // 更新节点个数并选择性帮助扩容
return null;
}
/**
* 计算哈希值
* 与HashMap差别就在于 & HASH_BITS,如果不 & HASH_BITS的值有可能hash会出现负数的情况,而在代码中多处通过hash < 0(负数)来判断是红黑树还是链表,所以为了避免负数带来效率上的影响
* 通过 & HASH_BITS 避免最高位永远是0,也就是说hash值永远是正数
* @param h 哈希值
* @return 计算后的哈希值
*/
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
// 剩下的方法基本都是跟遍历获取有关系,其中还跟ForkJoin关联起来了,就不做一一分析了,毕竟有6000行的代码,咱们有更重要的事要做...
总结
-
ConcurrentHashMap中并未对读操作(get)进行加锁,所以说在多线程下可以
并发读、并发读写,但这可能会造成读取到的数据并不是最新的,所以ConcurrentHashMap并不具备强一致性。 -
HashMap在将链表装成红黑树后,红黑树的根节点变成了头节点,而ConcurrentHashMap在将链表装换成红黑树后,并未将根节点放到哈希表的头节点上。
-
ConcurrentHashMap#transfer对链表节点的迁移中采用了lastRun(最后一次高低位变换的节点),其实现是利用原链表上的节点可以重复的情况来减少新节点的创建,因为从lastRun节点开始,后续的所有节点都跟lastRun同是一样高(低)位,而lastRun之前的节点就不一样,所以创建新节点。
-
ConcurrentHashMap
扩容机制(摘自transfer方法,不理解的地方可直接看方法分析):-
只会有一个线程发生扩容,其他线程帮忙迁移节点,这么多线程一起迁移哈希表,自然是要有个规则,每个线程负责至少迁移16个节点,那么接下来考虑的是哪个线程负责哪16个节点。
-
所有的线程都会去获取transferIndex的值,此值表示还剩余多少节点未分配,这些线程就开始抢以步伐为16的区间,谁抢到就算谁的,直到所有的节点都抢空了...有没有像超市大妈...。
-
没有抢到区间的线程自然就退出了,抢到的线程就开始从后往前一一迁移节点到新哈希表中,索引呈现递减的趋势,迁移完成的节点用fwd标识,以允许其他线程通过get方法访问到节点,如果没迁移完成则会循环获取。
-
在迁移过程中,不管是红黑树还是链表都会使用创建新节点的方式进行迁移,目的是为了其他线程能够读取节点,也就是说即使在扩容过程中,仍然允许并发读。
-
对于不为null的节点如果是链表则在创建新节点过程中会使用lastRun的方式进行迁移,可以减少新节点的创建,而对于红黑树来说则不行,因为红黑树的结构更为复杂,有可能为了减少新节点的创建的操作可能会引发循环引用,反而增加了开销。
-
每个线程把区间内的节点都迁移完成后,还要再去看看还有没有可分配的区间,如果没有且不是最后一个线程则直接退出,如果是最后一个线程则还要再把整个哈希表遍历一遍再次检查下是否有遗漏的节点没迁移;如果还存在可分配的区间则继续抢继续迁移,直到没有可分配的区间了。
-
-
ConcurrentHashMap
统计节点个数的机制(摘自addCount方法):在多个线程并发插入元素而修改节点个数时,AtomicLong只会允许其中一个线程修改成功,没修改成功的线程只能循环修改直到成功,这使得效率降低了很多了,而LongAdder中有两个属性,一个base属性,一个Cell数组。base属性就好比是AtomicLong中的value一样,不过这个属性是在没有竞争的情况下使用,也就是说在没有竞争的情况下LongAdder的效率和AtomicLong是一样,因为并没有用到cell数组,而有了竞争之后cell数组就排上用场了,它用一个Cell对象将每个线程所携带的节点个数值,也就是每个线程插入的节点个数值包装起来,简单来说就是Cell对象中包含了节点个数的属性值,并未每个线程取随机数。取随机数的目的是为了随机分配到数组的位置上,计算索引的方式就跟hash & (n.lenght - 1)一样,相当于每个线程都与Cell数组中的某一个Cell对象绑定在一起了,多个不同索引处的线程就可以并发修改自己的属性值,最后在将所有Cell对象的属性值加起来,在与base相加即可,Cell数组还会发生扩容,最大长度是当前计算机的CPU个数。
结束语
经过这次阅读ConcurrentHashMap底层代码的经历来看,想给读者一个忠告。对于一些复杂的知识点最好是能够亲身去经历,比如自己尝试阅读,当然了,这其中可以参考别人的文章、书籍,但是千万千万不能盲目的相信,一定要经过自己的验证,你不能保证别人的知识点的正确性,所以一定要自己阅读并验证,第一次阅读源码或许很辛苦,我基本上都是一行一行的理解,后续源码看多了你自然就通畅了,不要畏惧,这是你强大的必经道路之一,等你总结并分享这些知识点的时候你会很有成就感,信心十足,就算以后面试遇到相关问题你也不会怕,所以一定要相信自己!
参考资料
https://lequ7.com/2019/07/06/java/JDK-yuan-ma-nei-xie-shi-er-zhi-bing-fa-ConcurrentHashMap-xia-pian/
浙公网安备 33010602011771号