面到并发这块的时候我一定会从 HashMap 聊到 ConcurrentHashMap。看起来是同一个话题的延续实际上我在这等着你呢。候选人分三档。第一档多线程用 ConcurrentHashMapHashMap 会死循环。答对了但没有任何信息量——展现的是记忆不是理解。第二档ConcurrentHashMap 用分段锁把数组分成多个 Segment不同 Segment 可以并发操作。这是八股文标准答案。但 JDK 1.8 已经不用分段锁了你说出这句话等于告诉我你的知识停在 2014 年。第三档是我真正在等的CAS synchronized 的精确配合。这才是 JDK 1.8 ConcurrentHashMap 的核心设计哲学。今天我们从源码出发用大白话把 Doug Lea 的设计思路讲清楚。先搞清楚一件事CAS 和锁不是一个层面的东西很多人面试的时候把 CAS 和锁并列讲实际上它俩不在一个维度。synchronized 是悲观锁的典型——我不管你跟我争不争反正我先锁上再说谁都别碰。CAS 是乐观锁的思路——我先尝试一下如果中间没人动过那就成了如果有人动了我就重来。JDK 1.8 ConcurrentHashMap 的高明之处是把这两种思路揉在了一起而且揉得极聪明。先假设没竞争万一有了再上锁。这个先乐观后悲观的思路后来也被数据库拿来反复讲但 Doug Lea 在 2014 年就已经写进源码了。前提synchronized 已经不是你以为的那个 synchronized 了很多人对 synchronized 的印象还停在十年前——重锁、慢、能不用就不用。这个认知得彻底翻篇。JDK 1.6 之后synchronized 经历了一次脱胎换骨的重写内置三层锁膨胀机制。一开始是偏向锁——如果只有一个线程拿这把锁JVM 直接在对象头里记个线程 ID 就行了加锁解锁是零开销的。如果有第二个线程来争了升到轻量级锁——也叫自旋锁用 CAS 循环等一会儿不放弃 CPU。真的等太久比如几十万次自旋都拿不到才升到重量级锁——把线程挂起来进入等待队列走操作系统内核调度。Doug Lea 在 1.7 的 ConcurrentHashMap 里用的是 ReentrantLock他写的 java.util.concurrent 全家桶但到了 1.8他主动换回了 synchronized。为什么因为他看到 JVM 的锁膨胀优化已经足够好了——大部分情况下槽位锁只需要偏向锁就够了一旦用了偏向锁加锁的代价几乎为零。换用 synchronized 之后代码也更短、GC 压力更小。这就叫相信 JVM。不过说句题外话JDK 15 之后偏向锁被标记废弃了因为很多应用比如高并发 Web 服务本质上就不存在只有一个线程拿锁的场景。但那是后话面试不需要扯到 JDK 15。四步拆解 put 操作第一步spread() 扰动函数static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }数组长度默认才 16取模时只用低 4 位。如果两个 key 的低 4 位碰巧一样哪怕高 28 位完全不同也会撞到同一个槽位。比如说 key1 的 hashCode 是 0x00000001key2 是 0xFFFF0001在 16 长度的数组里会落到同一个槽位下标 1。但如果你把高 16 位和低 16 位异或一下高位的差异就被扩散到了低位。spread() 干的就是这个事——让 key 的所有 bits 都参与选槽减少无意义的哈希碰撞。第二步initTable() —— 谁先到谁干活后来的原地等while ((tab table) null || tab.length 0) { if ((sc sizeCtl) 0) Thread.yield(); else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // 初始化数组... sizeCtl n - (n 2); // n * 0.75 break; } }所有线程来了先看 sizeCtl。负数说明已经有人在初始化了调Thread.yield()让出 CPU等下再回来。不是负数就用 CAS 把它从 0 改成 -1抢到执行权。注意这里用的是Thread.yield()不是 sleep不是 park——因为初始化就是new Node[16]一句话的事情毫秒级完成用不着挂起线程。最后sizeCtl n * 0.75是扩容阈值一个字段两种语义零额外内存开销。这个设计叫字段复用面试官听到这个词会眼前一亮。第三步空槽位 CAS 无锁写入if ((f tabAt(tab, i)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))) break; }槽位为空就是处女地CAS 原子写入——如果这期间被人抢先了回到外层循环重来。这是 ConcurrentHashMap 性能的杀手锏16 个槽位线程 A 写 slot[5]、线程 B 写 slot[3]操作的是完全不同的内存地址不需要任何锁。你拿 Hashtable 比一比就知道了——Hashtable 整个 put 方法用 synchronized 包着就像 16 车道高速路设了一个收费站所有人排队过一个窗口。1.8 CHM 是 16 个车道各自独立收费谁跟谁都不挨着。第四步synchronized(f) —— 撞车了才上锁synchronized (f) { if (tabAt(tab, i) f) { if (fh 0) { // 链表遍历 for (NodeK,V e f;; binCount) { ... } } } }槽位不为空说明哈希碰撞了没办法 CASCAS 只能替换整个位置不能给已有的链表末尾追加。锁的是槽位头节点 f不是整个表。线程 A 锁 slot[5] 遍历链表线程 B 自由操作 slot[3]。注意tabAt(tab, i) f这行双保险——从读到锁之间有个时间窗口如果头节点被扩容迁移换掉了那锁住的 f 就不在 slot[i] 上了。fh 0区分链表和红黑树MOVED(-1) 表示正在扩容、TREEBIN(-2) 表示红黑树根节点。每个分支里还走了一遍 volatile readtabAt 用的Unsafe.getObjectVolatile保证读到的是最新内存。对比表Hashtable1.7 CHM1.8 CHM锁粒度全表Segment(16槽/组)单槽位并发度116数组长度空槽写入加锁Segment锁CAS无锁扩容时写入全表卡死Segment内卡多线程协同看这个变化趋势——每一步改进都是在缩小锁的范围、增加无锁的路径。1.7 把全表一把锁改成了16 把锁已经是大进步了但 1.8 直接做到了每个槽位各自处理最理想的情况下根本不需要锁。这个演进思路你工作中也能用先假设不需要锁实在不行再上。扩容机制顺带一提的加分项不想把文章写得太长但扩容是个值得说的点。1.8 CHM 的转移过程是多线程协同的——每个线程认领一段连续的槽位把自己的那段迁移完了就去领下一段。领完了没事干就去帮其他人的忙或者直接返回。核心是transfer()方法里的stride计算数组长度 / 16每个线程至少迁移 16 个槽位。迁移中的槽位头节点会被标记为 ForwardingNodehash MOVED后面来的新 put 看到这个标记就知道这槽位要转移自己主动帮忙。这不仅提高了扩容效率还减少了等待时间。最后插一句扩容阈值sizeCtl在整个生命周期里变了好几轮——初始化时用负数标记、初始化后变0.75n、扩容中又变负数。一个变量三种含义你要是能把这段讲一遍面试官大概率会认为你源码功底扎实。再说个隐藏点get 方法完全无锁很多人认为 ConcurrentHashMap 的 get 方法很简单——tabAt读数组元素链表头尾遍历没了。但它的锁安全是怎么保证的C 语言语义里两行代码之间另一个线程可能把链表改了。答案是 volatile 读 Node 的 next 和 val 都是 volatile 修饰的。get 永远读到最新的链表结构。不需要锁完全靠内存语义保证。这句话如果能说清楚面试官对你的印象分直接拉满。但有一个前提get 方法读到的是某一时刻的快照不是严格连续一致的视图。比如两个线程同时操作你读到的是并发发生那一瞬间的数据——对 ConcurrentHashMap 来说这就够了它保证的是外部的线程安全不是事务隔离级别。再挖个深坑size() 怎么算的baseCount CounterCell你可能会想ConcurrentHashMap 的 size() 总不能遍历整个 Map 吧那得卡死。答案是它维护了一个 baseCount 加上一个 CounterCell 数组。大部分情况下就是一个 CAS 往 baseCount 上加——U.compareAndSwapLong(this, BASECOUNT, bc, bc 1)。如果 CAS 竞争激烈高并发写入就降级到 CounterCell 数组每个线程绑定一个 CounterCell 往自己的格子里加。这样累加的时候不争同一个变量。最后调用 size() 时把 baseCount 和所有 CounterCell 的值加一遍。这个设计跟 LongAdder 一脉相承——Doug Lea 在 Java 8 里写了一整套 Striped64 机制CounterCell 用的就是这个。还有一个容易被忽略的点ConcurrentHashMap 的 size() 返回的是估计值。高并发下它不是精确值因为你在累加的同时其他线程还在写。但实际业务中基本上不需要精确的 Map 大小——缓存的最大容量限制、扩容阈值这些都不是靠 size() 判定的。需要精确计数的时候你就得在外面自己加个锁或者用 LongAdder 维护。 面试标准回答JDK 1.8 ConcurrentHashMap 用 CAS synchronized 替代了 1.7 的分段锁。put 流程先用 spread() 对 hashCode 做扰动然后进入自旋循环。数组没初始化用 CAS 抢 initTable 的执行权——抢到的干活抢不到的 yield 自旋。就绪之后读目标槽位如果是 null 直接用 CAS 把新节点塞进去不需要任何锁——这是性能的核心保证。如果槽位有数据说明哈希碰撞了对槽位头节点加 synchronized注意锁的只是这一个节点不是整个表锁粒度降到单槽位级而且 synchronized 经过 JVM 锁膨胀优化后大部分情况就是偏向锁零开销。如果发现头节点 hash 是 MOVED(-1)表示有线程正在扩容当前线程调用 helpTransfer 去协助扩容而不是傻等。这个设计体现了先乐观后悲观的锁升级思想——先假设没竞争用 CAS竞争发生了才升级到 synchronized和数据库的乐观锁→悲观锁是同一个模式。get 方法则完全无锁靠 volatile 保证可见性。另外 size() 方法用了 baseCount 加 CounterCell 的分段计数机制高并发下多个线程写各自的格子不会争同一个变量性能比 1.7 好很多。唠点键盘之外的8 年大厂 Java 老兵不讲八股文只讲面试官心里在想什么。如果觉得有帮助点个关注支持一下。我最近整理了一份《大厂面试老兵资料包》里面有评分表、高频场景题还有今年新出的 AIGC 面试题都是 200 多场面试里一点点攒的市面上没人这么整理过。需要的朋友私信我回复「666」我发你领取方式。