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

文章详情

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

ConcurrentHashMap 1.7 与 1.8 核心差异:从分段锁到CAS与synchronized的演进

ConcurrentHashMap 1.7 与 1.8 核心差异:从分段锁到CAS与synchronized的演进 ConcurrentHashMap 是 Java 并发编程里绕不开的一个类几乎每个做后端、中间件、并发编程的人都会跟它打交道。只要稍微看过一点并发源码就一定会遇到那个经典问题JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 到底有什么区别网上的结论几乎统一是“1.7 用分段锁1.8 用 CAS 加 synchronized”但如果面试官再追问一句“为什么”很多人就卡住了。这篇文章我不想罗列干巴巴的差异表而是把每个差异背后的设计动机、数据结构变化、核心方法实现逻辑都拆开讲。读完之后你能回答的不是“它改了”而是“它为什么这么改改完带来了什么收益和代价”。适合两类人看一类是刚开始啃 Java 并发源码想系统搞明白 ConcurrentHashMap 原理的另一类是准备面试需要一套有深度、有条理的答案的人。我会把 put、get、size、扩容这些关键点在两个版本里的处理方式都拿出来逐项对比最后再分享一些我在实际排查问题上踩过的坑。1. 整体设计思路从“多把锁管多段”到“一把锁管一个桶”1.1 1.7 的分段锁结构先看 1.7 的设计。它的底层是 Segment 数组加 HashEntry 数组的两层结构。Segment 本身继承自 ReentrantLock也就是说每个 Segment 天生就是一把锁。默认情况下 Segment 数组长度是 16所以整个 Map 被切成了 16 个独立的“小段”每个小段内部再维护一张 HashEntry 数组真正存数据的是 HashEntry 链表。往容器里写数据时第一步先通过 hash 定位到具体是哪个 Segment然后对这个 Segment 加锁。加锁只影响当前这一段其他 15 段完全不受影响所以理论上 16 个线程可以同时写不同段互不干扰。这就是“分段锁”这个名字的由来也是它对比 HashTable 那种粗粒度全表锁最大的进步。默认 16 这个数字有讲究。并发量不高的时候16 个段足够用段数越多锁竞争越分散但内存开销也会变大。1.7 允许通过构造函数传 concurrencyLevel 指定初始段数但运行过程中段数是固定的不能动态扩展。这就意味着 1.7 的并发上限在构造时就已经被定死了。1.2 1.8 的锁粒度大幅缩小1.8 的结构完全重写了。最直观的变化是去掉了 Segment底层变成了 Node 数组加链表再加红黑树。锁的粒度从“段”直接降到了“桶”也就是 Node 数组里的某一个下标位置的元素。写入时如果目标桶是空的就直接用 CAS 把新节点放进去整个过程无锁。如果目标桶不是空的就用 synchronized 锁住这个桶的头节点然后在这个桶内部的链表或红黑树上做插入。锁的持有时间被压缩到“只处理一个桶内的操作”比 1.7 的“锁住整个 Segment 下的所有桶”范围小得多。一个 64 长度的数组理论上支持 64 个线程同时写入不同桶比 16 个段的上限明显更高。1.3 为什么 1.8 要抛弃 Segment这里必须说到 ReentrantLock 与 synchronized 在 JDK 层面的变化。1.6 之后 synchronized 做了大量锁优化引入了偏向锁、轻量级锁、锁粗化、锁消除这些机制。在锁竞争不激烈、临界区很短的情况下synchronized 的开销可能比显式 ReentrantLock 更小因为它能借助 JIT 编译期优化做锁升级与降级。1.8 里锁持有时长被压缩到一两个链表节点的操作属于典型的“短临界区”这个场景正好是 synchronized 擅长的。另外还有代码维护层面的考虑。锁粒度从 Segment 降到桶之后整个 class 结构更简洁不需要两套哈希定位逻辑红黑树的引入也使得单个桶内操作从 O(n) 降到 O(log n)高冲突场景下性能更有保障。所以 1.8 不是单纯为了换一种锁的 API而是在整体结构上配合数据组织方式做了一次彻底重构。2. 核心数据结构对比节点类型与链表树化2.1 HashEntry 和 Node 的差异1.7 的数据节点叫 HashEntry。它的 key、hash、next 都是 final 的value 是 volatile 的。next 不可变意味着一个节点一旦被插入它在链表上的后继关系就不能改所以移除节点时需要重建前面的链表节点这也让锁内的链表操作变得更简单可控。1.8 的节点叫 Node。它的 key、hash 同样是 final但 val 和 next 都用 volatile 修饰作用是保证无锁场景下的可见性。Node 本身主要服务于普通链表桶而当某个桶树化之后桶里放的就不是普通 Node 了而是 TreeBin 这个代理对象。2.2 链表、TreeNode 和 TreeBin 的分工1.8 的树化是渐进式的不是某个桶链表稍微长一点就直接转树。每个桶先维持链表结构当冲突达到一定规模后链表中的节点会被封装成 TreeNode再由 TreeBin 统一管理。TreeBin 不直接存业务数据它负责锁、读写锁协调、树旋转等内部事务。这里的关键点在查询路径上。普通链表桶查询时遍历链表逐个比对 hash 和 key树化桶查询时先通过头节点判断这是 TreeBin再走红黑树的查找逻辑。两种结构混用意味着 1.8 的代码里到处都要判断节点的实际类型。源码里用 hash 值的几个特殊常量来做这种判断例如 MOVED 表示扩容转发节点、TREEBIN 表示红黑树代理节点这些标记值都是负数不会和正常 key 的 hash 冲突。2.3 ForwardingNode 与扩容状态机ForwardingNode 是 1.8 引入的一个很关键的角色它只在扩容期间出现。扩容发生时原数组里的某个桶如果已经迁移完桶位置就会放一个 ForwardingNode它的 hash 固定为 MOVED。任何线程执行 put 或 get 时只要发现目标桶是 ForwardingNode就知道扩容正在进行然后要么协助扩容要么转到新数组中继续查找。这种设计让扩容从“独占式”变成了“协作式”不再需要阻塞整个 Map 的读写。相比 1.7 那种单个线程在一个 Segment 内独自搬数据1.8 的迁移过程对所有读写线程是透明的。代价是并发逻辑变复杂但换来的是扩容期间系统依然能持续提供服务这在长生命周期的高并发系统里意义非常大。3. 核心操作逐项对比put、get、size 和扩容3.1 put 方法从“二次哈希定位”到“CAS 自旋加锁桶头”1.7 的 put 流程分两步定位。先对 key 的 hashCode 再做一次扩散哈希减少哈希冲突然后通过(hash segmentShift) segmentMask算出 Segment 下标再在 Segment 内部用(tab.length - 1) hash定位 HashEntry 桶。定位到 Segment 后会先尝试用 tryLock 快速获取锁获取不到就进入循环等待直到加锁成功。整个过程是阻塞式的线程会一直占用 CPU 等待锁锁持有时间越长等待成本越高。1.8 的 put 流程完全不同。它在循环里先判断目标桶是否为空为空就直接用 CAS 插入成功就退出循环不为空且是 ForwardingNode 就帮忙扩容否则用 synchronized 锁住桶头节点再在链表或红黑树里查找并插入。如果链表长度超过阈值会调用 treeifyBin 尝试树化但这里有个前提条件数组长度必须大于等于 64否则会优先去扩容数组而不是直接树化。下面是 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()); int binCount 0; for (NodeK,V[] tab table;;) { int n tab.length; int i (n - 1) hash; NodeK,V f tabAt(tab, i); if (f null) { // 桶空直接 CAS 插入无锁 if (casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))) break; } else if (f.hash MOVED) { // 扩容中协助迁移 tab helpTransfer(tab, f); } else if (f.hash TREEBIN) { // 红黑树桶走树节点插入 synchronized (f) { binCount 2; // 树节点 putTreeVal } } else { // 普通链表桶锁住头节点再遍历 synchronized (f) { // 遍历链表找到则覆盖找不到则尾插 } } } addCount(1L, binCount); return null; }如果你之前只背过“1.8 是 CAS 加 synchronized”这个结论可能一直没理解为什么还需要 for 循环和自旋。其实 CAS 插入失败只是说明目标桶被其他线程抢先了外层循环保证线程重新读取最新数组并再次尝试。这种乐观重试机制是为了减少无谓的锁竞争只有在桶内已经有节点确实需要操作链表时才会升级到 synchronized。3.2 get 方法两个版本都几乎无锁get 方法值得单独说一下因为在两个版本里读操作都不需要加锁。1.7 的 HashEntry 的 value 和 next 是 volatile 的所以读线程能拿到最新值。1.8 的 Node 的 val 和 next 同样是 volatile并且整个 table 数组引用本身也是 volatile 的这样数组扩容时读线程也能感知到新数组。1.8 的 get 流程是先通过tabAt(tab, (n - 1) hash)取出头节点然后判断头节点 hash 是否小于 0。如果小于 0说明这个节点可能是 ForwardingNode 或 TreeBin需要走特殊查找逻辑否则直接遍历链表。过程中不需要加任何锁读并发非常高。这里有个容易忽略的细节volatile 修饰的是数组引用和节点里的字段但数组每个元素的普通读并不能保证看到最新值所以源码里用了 Unsafe 的 getObjectVolatile 来读取数组元素这就保证了元素级别的可见性。从实战角度说ConcurrentHashMap 的读路径在 1.8 下非常轻快这也是为什么很多读多写少的系统敢放心大量使用它而不必担心锁竞争拖垮吞吐。3.3 size 方法从“二次无锁统计”到“分段累加计数”size() 是两个版本差别很大的一个方法也是面试高频点。1.7 里统计 size 时先不加锁对所有 Segment 的 count 字段求和两次。如果两次结果一致说明统计期间没有写操作发生直接返回如果不一致说明统计过程中有并发写这时会锁住所有 Segment再重新统计一遍保证返回的是精确值。1.8 的计数方式改成了类似 LongAdder 的思路。Map 内维护一个 baseCount 字段每次 put 或 remove 成功后调用 addCount 累加。累加时先尝试对 baseCount 做 CAS如果竞争激烈导致 CAS 失败就用 CounterCell 数组分散计数。每个线程基于 ThreadLocalRandom 选择一个 CounterCell 槽位把增量累加到自己的槽上。size() 方法最终把 baseCount 和所有 CounterCell 的值加在一起返回。这种设计最大的收益是“写并发越高计数不需要全局锁”代价是 size() 返回的是一个近似值并不保证绝对精确。如果你真的需要精确总数官方也不建议用 size() 做强一致性的业务判断而是推荐用 mappingCount() 拿 long 类型的总数。3.4 扩容机制从“段内独占 rehash”到“多线程协助迁移”1.7 的扩容时机和 HashMap 类似当某个 Segment 内的数量超过 threshold 时这个 Segment 内部的数组会 rehash容量扩大一倍。扩容过程只锁住当前 Segment其他 Segment 不受影响但当前 Segment 内的所有写操作在这期间都会阻塞。还有一个细节很容易被忽略1.7 迁移链表时用的是头插法逐个把旧链表的节点插入到新链表头部这就造成了节点顺序反转。曾经 HashMap 在并发扩容下出过著名的死循环问题ConcurrentHashMap 1.7 因为对单个 Segment 加了锁不会出现同一批节点的并发 rehash所以那段历史问题在这里没有复现但头插反转的代码风格维持到了 1.7 的末尾。1.8 的扩容完全不同。它把迁移任务拆到数组桶一级多个线程可以同时协助迁移不同的桶。源码里有一个 nextTable 字段是扩容后的新数组还有一个 transferIndex用来记录迁移进度多个线程各自领取一段区间的桶来搬。迁移完一个桶就在原位置放一个 ForwardingNode 作为标记。链表迁移时 1.8 采用了一个优化点把原链表按hash n拆分成低位链表和高位链表整段整体搬到新数组的对应位置而不是逐个节点重复头插。这样既保留了节点相对顺序又大大减少了重建链表的时间。源码里的 lastRun 节点利用了链表尾部的连续同组节点直接复用这个技巧很微妙也正是 1.8 源码阅读里容易让人眼前一亮的地方。4. 参数细节与边界行为树化阈值、负载因子与 null 限制4.1 树化阈值 8 与退化阈值 6 的设计逻辑1.8 源码里定义了几个重要常量。TREEIFY_THRESHOLD 8表示链表长度达到 8 时尝试树化UNTREEIFY_THRESHOLD 6表示扩容拆分后如果链表长度降到 6 以下就把红黑树退化成普通链表MIN_TREEIFY_CAPACITY 64表示只有数组长度大于等于 64 时才会真的树化否则优先扩容。为什么树化而不是一直用链表因为当哈希冲突严重时链表查询是 O(n)红黑树是 O(log n)桶内数据多时后者的优势非常明显。但红黑树的节点体积更大维护成本更高所以不能链表稍长就立刻树化阈值设成 8 是综合了空间开销和查询效率的选择。小于 8 时链表遍历本身很快树化的收益体现不出来。退化阈值设成 6 而不是 7 或者 8是因为要留出缓冲区间。如果退化阈值就是 8那么插入到 8 转树删除到 7 转链表再插入又转树极端情况下会反复横跳带来树和链表之间不必要的高频切换开销。设置 6 作为下限能有效避免这种边界抖动。4.2 初始容量、负载因子与并发级别的差异1.7 构造器里的 concurrencyLevel 参数对应的就是 Segment 数组长度默认是 16。Segment 内部 HashEntry 数组的初始容量也可以单独指定负载因子的作用依然是控制每个 Segment 内数组的扩容时机。这里的并发度受 Segment 个数限制也就是最大并发写线程数约等于 Segment 数实际运行中不能动态调大。1.8 里构造器仍然保留了 concurrencyLevel 参数但含义变了。它只作为初始容量估算的一个依据不再代表锁定单元的个数。1.8 的锁单元是数组桶数组容量随扩容增长扩得越大能同时锁住的桶就越多所以理论上并发写能力是随容量动态扩展的。扩容后桶数量翻倍并发上限几乎同步翻倍这是 1.8 和 1.7 在伸缩性上的根本区别。4.3 为什么 ConcurrentHashMap 不允许 null 键和 null 值这个问题面试官经常追问。ConcurrentHashMap 的 put 方法里直接抛异常if (key null || value null) throw new NullPointerException()。原因是 get 方法返回 null 时调用方无法区分“这个 key 不存在”和“这个 key 对应的 value 本身就是 null”。HashMap 允许 null 值是因为单线程场景下可以通过 containsKey 再查一次来确认。但 ConcurrentHashMap 是并发容器如果允许 null 值那么读线程在没有锁的情况下看到一个 null就需要额外的同步机制去确认键是否存在这会显著增加读路径的复杂度而且 null 在很多并发算法里被当作特殊哨兵使用。与其引入这种歧义不如直接禁止。很多人会把这一点记成“ConcurrentHashMap 不能存 nullHashMap 能”但如果能说清楚背后的语义模糊问题面试层次会完全不同。5. 常见问题与排查技巧实录5.1 面试高频追问1.7 的死循环、迭代器弱一致性、size 的坑面试官不会只满足于“1.7 分段锁、1.8 CAS 加 synchronized”这种一句话答案。以下几个追问出现的频率非常高。第一个追问1.7 的 ConcurrentHashMap 会像 HashMap 那样在并发扩容时造成死循环吗答案是不会。因为 1.7 对单个 Segment 的 rehash 是加锁的同一个 Segment 不会同时被两个线程扩容所以不存在多线程同时操作同一个链表导致环的问题。但 1.7 的性能瓶颈在锁竞争而不是功能障碍。第二个追问ConcurrentHashMap 的迭代器是弱一致还是强一致两个版本都是弱一致。遍历时迭代器不会加锁也不会立刻反映当前时刻所有未完成的修改。某个线程在遍历过程中 put 了一个新元素正在遍历的迭代器不一定能看到。这是为了不阻塞读而选择的一种折中策略。第三个追问size() 到底准不准1.8 的 size() 和 mappingCount() 都返回统计时刻的近似值因为计数是分散累加的相加过程中可能又有新的写操作。如果你需要精确总数来做容量规划建议在业务低峰期统计或者直接维护一个自己的计数器。5.2 实战选型什么时候该用 ConcurrentHashMap虽然 ConcurrentHashMap 的名气很大但也不是万能钥匙。如果你的业务需要按插入顺序遍历元素或者需要范围查询它并不适合这时应该考虑 ConcurrentSkipListMap。如果你的场景是读多写少ConcurrentHashMap 的读路径几乎无锁是一个非常合适的选择。如果写并发极高想要更高的写吞吐可以评估一下 LongAdder 的计数思想或者考虑分片多个 ConcurrentHashMap 来进一步分散热点。还有一个容易踩的坑不要把 ConcurrentHashMap 当成无限并发安全的“万能容器”。如果业务逻辑本身是“先读后写再根据读到的旧值决定新值”单纯靠 Map 的原子操作是不够的这里推荐用 compute 或 merge 这类原子 API而不是自己先 get 再 put。很多线上数据覆盖问题根因都是忽略了复合操作的原子性。5.3 排查技巧扩容期 GC 日志与线程栈分析在实际生产环境里我见过很多线程全部 Blocked 在 ConcurrentHashMap 的 synchronized 代码块上的情况。用 jstack 看线程栈如果大量线程停在 putVal 的 synchronized 块里通常有两种可能一种是对应桶的链表或红黑树操作异常耗时比如 key 的 hashCode 实现很差导致 hash 冲突严重另一种是正在扩容其他写线程在协助迁移或者等待迁移完成。如果怀疑是后者可以观察 GC 日志和线程状态的时间点是否与扩容时间吻合。扩容会消耗一定 CPU并可能让单次 put 耗时明显抬升。定位到了之后考虑在业务低峰期预热扩容或者根据数据量合理设置初始容量减少扩容发生频率。这里我自己的习惯是在创建 ConcurrentHashMap 时就根据预估最大数据量除以 0.75 来设定初始容量这样能减少很多扩容带来的延迟尖刺。最后再补充一个细节。1.7 的扩容只发生在某个 Segment 内部所以不同时间点可能不同 Segment 各自扩容整体表现是局部的1.8 的扩容却是全表范围的一旦触发所有写线程都可能参与协助迁移。因此 1.8 在扩容期间的“惊群效应”反而比 1.7 更明显但换来的是扩容总时长大幅缩短。这是收益与代价并存的设计理解了这个才算真正把 1.7 和 1.8 的差异看通透。
返回列表