多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

ConcurrentHashMap 1.7 到 1.8 演进:分段锁、CAS 与扩容机制全解析

ConcurrentHashMap 1.7 到 1.8 演进:分段锁、CAS 与扩容机制全解析 1. 一句话讲清 1.7 和 1.8 的核心差异这题基本是 Java 并发方向的必考题不管是校招还是社招只要聊到 ConcurrentHashMap大概率会被追问一句1.7 和 1.8 之间有哪些区别。说实话我早几年带新人时很多同学能把结论背得滚瓜烂熟——一个是分段锁一个是 CAS 加 synchronized——但真问到为什么 1.8 要推倒重来扩容时别的线程怎么帮忙size() 到底准不准就答不上来了。这篇就把整个演进过程拆开从数据结构、锁粒度、put 流程、扩容机制到计数方案全部过一遍适合正在准备面试的人也适合业务代码里已经在用 ConcurrentHashMap 但想搞清楚内部原理的开发者。1.1 一张表看差异总览先把最重要的差异列出来后面每一条都会展开讲。维度JDK 1.7JDK 1.8数据结构Segment 数组 HashEntry 数组 链表Node 数组 链表 红黑树锁粒度按 Segment 分段加锁ReentrantLock对桶头节点加锁synchronized并发上限受 concurrencyLevel 限制默认 16 段理论上不同的桶可并行无固定上限读操作无锁 volatile 读无锁 volatile 读遇迁移节点会跳到新表扩容Segment 内部局部扩容全表扩容多线程协助迁移计数各 Segment count 求和 modCount 校验baseCount CounterCell 数组分摊红黑树没有链表长度达到 8 且数组长度达到 64 时树化1.2 为什么 1.8 要重写这套实现1.7 的设计在当年不算差Segment 继承 ReentrantLock把一个全局锁拆成默认 16 把锁已经比 HashTable 那种整表锁强很多。但问题在于锁粒度还是太粗。一个 Segment 里面可能挂着很多个桶只要两个线程操作的是同一个 Segment 里的不同桶依然要排队。如果 hash 分布不均匀某些 Segment 数据特别多并发瓶颈就会非常明显。另外就是 JDK 6 之后对 synchronized 做了一系列重量级优化引入了偏向锁、轻量级锁、锁消除、锁粗化。synchronized 不再是性能差的代名词在不那么激烈的竞争下它的性能甚至不输 ReentrantLock而且不需要手动释放锁、不容易因为异常导致死锁。再加上 JDK 8 想解决大量 key 撞到同一个桶导致链表过长的问题干脆引入了红黑树。几件事凑到一起ConcurrentHashMap 在 1.8 里等于是把整个并发模型换掉了。2. 数据结构对比连环锁 vs 精细锁这一段是理解后面所有行为的基础建议先把长什么样刻在脑子里。2.1 1.7 的连环结构1.7 的结构可以拆成三层最外层是一个 Segment 数组每个 Segment 是一个继承了 ReentrantLock 的对象第二层是每个 Segment 内部维护一个 HashEntry 数组第三层是 HashEntry 数组的每个桶位后面挂着的链表。定位一个 key 需要两次哈希先用 key 的 hash 值的高位计算出它在哪个 Segment再在这个 Segment 内部定位到具体桶位。如果把 Segment 比作一栋楼的楼层HashEntry 数组就是每一层里的房间同一层里的房间如果 hash 冲突就顺着链表往下找。默认的 concurrencyLevel 是 16也就是说 ConcurrentHashMap 在最理想的情况下同一时刻最多只有 16 个线程在并发写。注意不是最多 16 个桶能并行写而是最多 16 个段能并行写每个段内部的写操作依然要抢同一把锁。这就是 1.7 最明显的天花板。2.2 1.8 的轻量结构1.8 去掉了 Segment外层只有一个 Node 数组。Node 是一个普通的链表节点它的 next 和 value 字段都是 volatile 修饰的数组的每个槽位在读写时也会借助 Unsafe 的 volatile 语义来保证可见性。定位一个 key 现在只需要一次哈希spread(key.hashCode())然后(n - 1) hash找到桶位。桶位上的首节点如果是 null就尝试用 CAS 直接写入如果已经有人占了就对这个首节点加 synchronized 锁然后做链表插入或者更新。锁的范围从一个段缩小到了一个桶头两个线程只要操作的不是同一个桶完全可以并行。当链表长度越来越长达到 8 且数组长度不小于 64 时这个桶会从链表结构转换成红黑树。红黑树的根节点被包在一个叫 TreeBin 的对象里写操作锁的是 TreeBin 的头读操作则依赖 TreeBin 内部的读写状态来做协调。2.3 树化阈值为什么是 8退化阈值为什么是 6这是面试官特别爱追问的细节。8 这个数字不是拍脑袋定的它来自泊松分布的计算。假设 hash 足够随机、散列足够均匀在负载因子是 0.75 的情况下一个桶里链表长度达到 8 的概率大约是千万分之六这个概率低到基本可以认为不会出现。所以正常情况下红黑树根本不会被触发只有当 hashCode 写得很烂、大量 key 撞到同一个桶时树化才会替补出场避免查询性能从 O(1) 退化到 O(n)。退化阈值选 6 而不是 8是为了留出缓冲区间。如果链表长度到了 8 就树化、低于 8 就马上变回链表那在阈值附近反复增删元素时结构会在链表和树之间疯狂切换带来无谓的重建开销。8 和 6 之间差两档就是为了防止这种震荡。3. put/get/remove 流程差异从抢段锁到 CAS 抢头条理解了结构再看具体操作就顺了。这里我会贴一些关键代码片段但不是让你背源码而是帮你看懂流程里每一步在干什么。3.1 1.7 的 put 是怎么抢锁的1.7 的 put 大概分三步先根据 key 的 hash 找到 Segment然后调用 Segment 的 put 方法在这个方法里先尝试tryLock()拿不到锁就进入scanAndLockForPut用有限次数的自旋去等锁实在等不到就调用lock()阻塞。final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntryK,V node tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { // 此时已经持锁可以做遍历、替换、插入 } finally { unlock(); } return oldValue; }scanAndLockForPut这个细节值得注意。为什么不自旋到底因为多核 CPU 下无脑自旋会白白消耗 CPU而直接阻塞又可能因为锁很快被释放而造成线程切换开销。所以实现里做了一个折中先自旋一段时间同时利用这段等待时间把要插入的节点预先创建好如果真的需要锁在别的线程手里再去真正阻塞。这种写法在 1.8 里看不到了因为 1.8 的 put 压根不需要抢段锁它只需要用 CAS 去抢一个空桶位或者用 synchronized 锁住一个非空桶位。3.2 1.8 的 putVal 是怎么抢头条的1.8 的 put 核心流程写在一个死循环里保证并发失败后可以重试final V putVal(K key, V value, boolean onlyIfAbsent) { if (key null || value null) throw new NullPointerException(); int hash spread(key.hashCode()); for (NodeK,V[] tab table;;) { NodeK,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) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))) break; } else if ((fh f.hash) MOVED) tab helpTransfer(tab, f); else { V oldVal null; synchronized (f) { // 校验头节点没变然后遍历链表插入/更新 } if (binCount ! 0) { if (binCount TREEIFY_THRESHOLD) treeifyBin(tab, i); break; } } } addCount(1L, binCount); return null; }这段代码里最有意思的是抢头条设计如果桶位是空的直接用 CAS 把新节点放进去一次成功连锁都不用如果桶位不空才 synchronized 锁住头节点在锁内做链表遍历。为什么用 synchronized 而不是 ReentrantLock除了前面说的锁优化之外还有一个很现实的原因synchronized 在进入和退出时不需要手动管理代码更不容易出错。锁的对象是桶头节点如果链表很长整个链表的插入串行化也没办法但正常情况下链表很短锁持有时间极短竞争自然就小。3.3 get 为什么全程无锁不管 1.7 还是 1.8get 都是无锁的。秘密在于 volatile。1.7 里 HashEntry 的 value 和 next 都是 volatile1.8 里 Node 的 value 和 next 也是 volatile。一个线程写入并解锁后另一个线程通过 volatile 读一定能看到最新值这就是 happens-before 规则的功劳。1.8 的 get 还多了一个细节如果读的过程中发现桶头节点的 hash 是 MOVED说明这个桶正在被迁移get 会顺着 ForwardingNode 里的 nextTable 引用跳到新的哈希表继续查。也就是说扩容过程中读线程不会读到查不到的假结果要么在新表里找到要么在新表里也确认不存在。这里要提醒一句ConcurrentHashMap 的读是无锁的但它是弱一致性的。你在读的同时有线程在写你读到的是某一时刻的一致性快照可能是旧值也可能漏掉刚写进去的新值。这不算 bug是性能和一致性之间的取舍。3.4 remove 在 1.8 里怎么做remove 和 put 类似先定位到桶如果桶是空的就返回 null如果桶头是 ForwardingNode就协助扩容否则锁住桶头节点在锁内校验桶头没有变化然后沿着链表找到目标节点把前驱节点的 next 指向目标节点的 next完成删除。删除后如果桶是红黑树就走 TreeBin 的删除逻辑树节点数降到 6 以下可能会退化回链表。我记得有个同事在排查线上问题时问过删除操作锁住头节点会不会把读也堵住不会。读走的是 Node 的 volatile 链不需要锁写之间互相阻塞是因为如果不阻塞两个线程同时改链表指针很容易把链表搞断。读和写之间靠 volatile 保证可见性但读到的可能是删除前或删除后的状态这属于弱一致性的正常表现。4. 扩容机制从段内翻倍到全表协作迁移扩容是 1.7 和 1.8 差异最大、也最能体现并发设计功底的一块。面试时如果能把扩容讲清楚基本就能证明你是真懂 ConcurrentHashMap 而不是背答案。4.1 1.7 的 rehash 和它的局限1.7 的扩容发生在 Segment 内部。当一个 Segment 里的元素个数超过 threshold初始容量乘负载因子就把这个 Segment 里的 HashEntry 数组扩大成原来的两倍然后在这个 Segment 的锁保护下把旧数组里的元素重新哈希到新数组。这样做的好处是扩容不会影响其他 Segment其他段的读写完全不受干扰。坏处也很明显扩容操作只能由持锁线程一个人干如果这个 Segment 里的数据特别多迁移就会很慢而这个段内的所有写操作都会卡在锁上。段间不平衡的问题会让某些热点段频繁扩容成为瓶颈。4.2 1.8 的多线程协助迁移1.8 的扩容是对整个 table 进行的。触发时机有两个一是 put 之后 addCount 发现元素总数超过阈值二是链表太长但数组长度不足 64 时treeifyBin 会改成触发扩容而不是直接树化。扩容的核心是 transfer 方法它有几个关键设计sizeCtl 这个字段承担了多重身份。等于 -1 表示正在初始化负数且不是 -1 时表示有扩容在进行值的低位部分和参与扩容的线程数相关正数表示下一次扩容的阈值。迁移时把整个数组按stride划分成若干连续区间每个线程抢一段区间来迁移迁完再抢下一段。stride 的计算和 CPU 核数有关多核机器上一般是n 3 / NCPU最小不低于 16这是为了避免区间太小导致频繁竞争。每个桶迁移完成后会在旧数组的那个位置放一个 ForwardingNode它的 hash 固定为 MOVED并且持有 nextTable 的引用。后续任何线程通过这个桶读写时都会认出这个标记。迁移过程中还有个叫 lastRun 的优化遍历链表时找到最后一个 next 链指向的节点 hash 全部一致的子链可以直接整段搬走不用一个节点一个节点地重新计算。一个线程在 put 或者其它操作时发现桶头是 ForwardingNode不会傻等而是马上调用 helpTransfer 加入迁移大家一起搬搬完再尝试自己的写操作。这种遇到迁移就帮忙的设计极大缩短了大表扩容时的阻塞时间。4.3 迁移中的读写如何确保不丢数据迁移期间其他线程的读写会不会读到一半的数据这个问题得分情况说写线程发现 ForwardingNode就直接参与扩容写操作会等到这个桶迁完、在新表里继续读线程发现 ForwardingNode就跳到 nextTable 去读也不会丢数据。因为数组扩容都是翻倍新表是旧表的 2 倍大小一个 key 在旧表里的桶位和在新表里的桶位存在确定的映射关系所以迁移过程中 旧表找不到就去新表找 是安全的。至于每个桶内部迁移本身是在锁或 CAS 的保护下完成的迁移完成的桶才能放 ForwardingNode所以不存在读到半个桶的情况。我在实际项目里观察过 1.8 扩容的表现一个几百万元素的 Map 触发扩容由于多个请求线程都会参与搬运整体停顿明显比 1.7 短。当然如果扩容期间完全没有并发请求只有 put 线程自己在搬那耗时还是差不多的毕竟数据量摆在那。5. 计数方案size() 到底怎么算出来的ConcurrentHashMap 的 size() 从来都不是实时精确值这一点要反复强调。1.7 和 1.8 对计数的处理思路完全不一样。5.1 1.7 的二次快照校验1.7 的每个 Segment 内部维护一个 count 字段记录这个段里的元素个数。size() 的做法是先不加锁地遍历所有 Segment把 count 累加一遍同时记录每个段的 modCount修改次数总和然后立刻再遍历一遍如果两次 modCount 总和一致说明遍历过程中没有写操作发生这次和就是可信的。如果两次不一致说明有并发写那就把所有 Segment 的锁都锁上再重新算一遍。RETRIES_BEFORE_LOCK 这个阈值是 2也就是说最多尝试两次不加锁的快照还不成功就上全锁。这个方案的问题在哪一是极端情况下会把所有 Segment 锁住相当于瞬间变成全局锁二是即使加了所有段锁算出来的值也只是一个瞬间值因为算完锁一放外面的写操作又进来了。所以 size() 本质上就是个近似值只不过这个近似的可信度比较高。5.2 1.8 的 baseCount CounterCell1.8 把计数逻辑换成了类似 LongAdder 的思路。维护一个 baseCount以及一个 CounterCell 数组。当并发竞争不激烈时直接 CAS 更新 baseCount一旦 CAS 失败就把计数分摊到 CounterCell 数组的某个 cell 上每个 cell 各自累加最后 size() 时把 baseCount 和所有 cell 的值加起来。这个设计和 1.7 相比最大的进步是计数不再需要锁而且高并发下多个线程可以同时更新不同的 cell不会互相阻塞。代价是 sum 的结果更近似——因为在累加的那一刻其他线程可能还在改 cell。所以 1.8 还提供了 mappingCount()返回 long 类型而 size() 返回 int本质上是一个四舍五入的近似值。有个容易忽略的点CounterCell 数组的长度也就是 CPU 核数附近的一个 2 的幂元素是 volatile long。如果你用它做精确的库存扣减一定会踩坑因为它从设计上就没打算提供精确计数。5.3 弱一致迭代器是什么意思ConcurrentHashMap 的迭代器是弱一致性的。什么意思它不会抛 ConcurrentModificationException写出迭代器时的快照之后其他线程做的修改迭代器可能看得到、也可能看不到遍历过程中也不会加锁。这在大多数场景下是好事比如一个常驻的缓存 Map你遍历它打印指标时不希望因为别的线程正好在写就崩溃。但如果你用迭代器去判断遍历到的数据是不是最新就会被坑到。我自己就见过一个线上问题一个定时任务用 ConcurrentHashMap 的 keySet() 遍历然后根据 key 是否存在来决定要不要触发某个动作结果因为弱一致性刚 put 进去的 key 没有被这一次遍历看到任务漏跑了一轮。这种问题不是框架的 bug而是使用方对语义的误解。6. 容易被忽略的细节差异数据结构、锁、扩容、计数这四大块讲完面试的基本盘就有了。下面这几个细节虽然小但非常能区分一个人到底是背了八股还是真的用过。6.1 null key 和 null value 都是禁区1.7 和 1.8 的 ConcurrentHashMap 都禁止 key 或 value 为 null。这一点和 HashMap 不一样HashMap 允许一个 null key也允许 null value。ConcurrentHashMap 为什么不行最经典的解释是如果 map.get(key) 返回 null你没法区分是这个 key 不存在还是这个 key 对应的 value 就是 null。在并发环境下这种二义性会带来麻烦所以设计者干脆从源头禁止。1.8 的 putVal 方法第一行就是if (key null || value null) throw new NullPointerException()可以直接看到这个约束。6.2 concurrencyLevel 在 1.8 里变成了什么1.7 的构造参数里concurrencyLevel 是一个重量级概念它决定 Segment 数组的大小会向上取到 2 的幂默认 16。也就是说你在 1.7 里写new ConcurrentHashMap(16, 0.75f, 16)得到的是 16 段并发上限。1.8 里已经没有 Segment 了concurrencyLevel 参数虽然还在构造方法里但它只用来作为初始容量的最小提示。具体来说如果传入的 initialCapacity 比 concurrencyLevel 小就把 initialCapacity 提到 concurrencyLevel 那么高然后 table 的初始容量按 tableSizeFor 向上取 2 的幂。这个改动说明一个问题并发级别不再和内部结构绑定桶数越多天然并发能力越强。6.3 1.8 新增的原子复合方法1.8 给 ConcurrentHashMap 加了一批函数式方法compute、computeIfAbsent、computeIfPresent、merge。这些方法把判断 更新做成了原子操作在锁内完成整个函数的执行。比如map.computeIfAbsent(key, k - expensiveLoad(k))多个线程同时调最终只会有一个线程真正执行 expensiveLoad其他人拿到同一个结果。用起来方便但有一个坑回调函数里绝对不能再去操作同一个 ConcurrentHashMap否则轻则死循环重则 StackOverflowError严重时能把机器打挂。这一点在 1.8 的文档里其实有说明但很多人不看。7. 高频追问和实战排查速查这一节整理成表方便面试前和排查问题时直接翻。7.1 面试高频追问速查表问题结论补充理由1.8 用 synchronized 是不是性能回退不是JDK 6 后锁升级让 synchronized 优化充分且锁持有时间短为什么不能用 null key/value避免二义性无法区分 key 不存在和 value 为 nullsize() 是精确的吗不是近似值建议使用 mappingCount()迭代器会抛 ConcurrentModificationException 吗不会弱一致性迭代器不加锁扩容时其他线程在干嘛帮忙迁移ForwardingNode 标记 helpTransfer链表多长才树化8泊松分布概率约千万分之六数组多长才允许树化64MIN_TREEIFY_CAPACITY否则先扩容1.7 的并发上限是多少默认 16 个 SegmentconcurrencyLevel 可调但固定上限7.2 实战里最容易踩的坑第一个坑就是拿 size() 当精确值用。比如有人写if (map.size() threshold) { doSomething(); }在并发环境下这个判断可能偏大也可能偏小如果偏小该触发的动作没触发如果偏大可能触发多余动作。需要精确计数时应该用 AtomicLong 自己维护或者用 LongAdder 专门做统计。第二个坑是 computeIfAbsent 里的回调函数写了复杂的耗时逻辑比如查数据库、调远程接口。虽然并发场景下只会有一个线程真正执行但其他线程会阻塞等待这个回调完成。如果回调很慢调用方全都会被拖住效果和一个慢锁没有区别。别把只执行一次误解成不阻塞。第三个坑是遍历时做删除。虽然弱一致迭代器不会抛异常但如果你想边遍历边删最好用iterator.remove()或者在遍历收集好 key 之后再统一删不要边 iter 边直接 map.remove(key)否则某些元素可能被漏删或者重复处理。第四个坑是不要拿 ConcurrentHashMap 去解决所有并发问题。它只能保证单次操作的原子性像先判断再 put这种复合操作如果不用 compute 系列方法依然是不安全的。多线程环境下做如果不存在就写入这种操作优先考虑 computeIfAbsent 而不是 get put 两步走。8. 选型建议和个人实践体会用了几年的实际感受是1.8 之后ConcurrentHashMap 在绝大多数并发 Map 场景都值得直接用不需要自己再去封装分段锁之类的轮子。读多写少的缓存场景无锁读带来的收益很明显读少写多的高并发计数场景CounterCell 的分散计数也比 1.7 的单段计数稳得多。我自己的习惯是能用 ConcurrentHashMap 的地方就别用 HashTable也别图省事用 Collections.synchronizedMap 包一层。那个包装类的锁粒度是整个 Map并发量一上去就是瓶颈。需要做 Key 维度的原子更新优先看 compute/merge需要遍历快照用 entrySet 配合弱一致迭代器不要在遍历里做不可重入的操作需要精确计数单独维护 LongAdder。最后分享一个小经验接手旧项目从 Java 7 迁移到 Java 8 时除了看有没有用旧的构造参数重点检查有没有依赖 size() 精确值、有没有在 compute 回调里嵌套操作同一个 Map、有没有把 ConcurrentHashMap 序列化后跨进程用。这三个点是我见过的迁移事故高发区。1.8 的 ConcurrentHashMap 整体更聪明但只有理解了它的取舍才能真的用对地方。
返回列表