并发 09 · 并发容器:ConcurrentHashMap
前两篇讲的是"锁"和"线程池",这一篇要讲一个几乎所有 Java 后端项目都在用、但很少有人真正搞懂内部构造的家伙——ConcurrentHashMap。本地缓存、计数器、限流器的滑动窗口,甚至线程池内部记录任务状态,背后经常都有它的身影。
它要解决的问题很直白:HashMap 不是线程安全的,多线程并发 put 可能导致数据错乱,JDK 8 之前甚至会在扩容时因为链表操作顺序问题绕成一个环,get 直接死循环、CPU 干到 100%;而老前辈 Hashtable 虽然安全,却是给所有方法加一把大锁,无论 put 还是 get,同一时刻只能有一个线程在操作,并发度约等于零。ConcurrentHashMap 存在的意义,就是既要线程安全,又不能像 Hashtable 那样一刀切地全表加锁。而它实现这个目标的方式,JDK 1.7 和 1.8 是两套完全不同的设计——这也是面试里被问得最细的并发容器话题,源码里"多线程协助扩容"那段更是全系列少见的精彩设计。
线索是:先看看 HashMap/Hashtable 各自的问题;然后拆 1.7 的分段锁怎么把一把大锁拆成一堆小锁;再看 1.8 为什么抛弃分段锁、改用 CAS + synchronized 精细到桶级别;接着深入最硬核的部分——扩容时多线程怎么协作迁移数据而不出错;然后补一个容易被忽略的细节——size() 怎么统计才不用锁;最后转到另一个常用并发容器 CopyOnWriteArrayList,看看"读多写少"场景下的另一种设计哲学。
目录
- HashMap 和 Hashtable 差在哪
- JDK 1.7:分段锁(Segment)
- JDK 1.8:CAS + synchronized,锁粒度细化到桶
- 扩容:多线程如何协作迁移数据
- size() 怎么在不加锁的情况下统计
- CopyOnWriteArrayList:读多写少的另一种哲学
- 选型:什么时候用谁
一、HashMap 和 Hashtable 差在哪
秒杀场景里,我们经常要在内存里缓存商品库存:Map<Long, Integer> stockCache,key 是商品 ID,value 是库存数。这个 Map 会被大量并发线程同时读写,选错实现类,麻烦立刻就来。
HashMap 完全不是线程安全的。 多线程并发 put,轻则数据覆盖丢失,重则在扩容(resize)时出大问题——JDK 7 的 HashMap 扩容用头插法搬移链表节点,多线程环境下可能把链表搬成一个环,之后 get 操作沿着这个环一直转,直接死循环、CPU 飙满。JDK 8 换成尾插法后不会成环,但依然不是线程安全的,别指望它在并发场景下能正确工作。
Hashtable 是线程安全的,但代价极端。 它给几乎所有方法(put、get、remove……)都加了 synchronized,而且锁的是同一个对象——相当于整张表共用一把锁。同一时刻,无论多少线程在读还是写,只能有一个线程真正在操作这张表,其他线程全部排队等待。表越大、并发越高,这把"全局大锁"就越是性能瓶颈——这和第 6 篇讲读写锁时的道理一样:没必要为了保证写的安全,把读也一起拖下水。
ConcurrentHashMap 要做的事,就是把这把"全局大锁"拆开,让不冲突的操作能真正并行。怎么拆,JDK 1.7 和 1.8 给出了两个完全不同的答案。
二、JDK 1.7:分段锁(Segment)
1.7 的思路很直接:既然一把锁太大,那就切成很多把小锁,这就是"分段锁"(Lock Striping)。
整个 ConcurrentHashMap 内部维护一个 Segment 数组,默认长度 16(由并发度参数 concurrencyLevel 决定,默认值就是 16)。每个 Segment 本身就是一个"迷你版 Hashtable"——它继承自 ReentrantLock,自带一把锁,内部再维护自己的 HashEntry 数组(也就是常规的哈希桶+链表结构)。
一次 put(key, value),走两级定位:
- 先对 key 做一次 hash,定位到具体是哪个
Segment(相当于先分区); - 锁住这一个
Segment,在它内部的HashEntry数组里做常规的哈希表插入。

关键在于:其他 15 个 Segment 完全不受影响,可以被其他线程同时读写。 只要不同线程的 key 恰好落在不同的 Segment 上,理论上最多可以支持 16 个线程同时写——比 Hashtable 的"全表一把锁"好了一大截。这就是"分段"的价值:把并发冲突的范围,从整张表缩小到了一个 Segment。
分段锁不是没有代价的。跨 Segment 的操作会明显更复杂:例如 size() 会先在不加锁的情况下多次汇总各 Segment 的计数,并检查期间是否发生修改;连续重试仍得不到稳定结果时,才退化为锁住全部 Segment 后精确统计。并发写入的上限也受 Segment 数组长度约束(默认 16,创建后不会扩展),无法随着数据量增长而自适应提高。这些局限是 JDK 1.8 重写实现的重要原因。
三、JDK 1.8:CAS + synchronized,锁粒度细化到桶
JDK 1.8 干脆扔掉了 Segment 这层设计,直接改用我们熟悉的 HashMap 那套"数组 + 链表/红黑树"结构(Node[] table),锁的粒度进一步细化——不再是锁一整个 Segment,而是只锁"当前要操作的那一个桶"。这一步,靠的是 CAS 和 synchronized 的配合:
- 桶是空的:用 CAS 直接尝试插入。 完全不用加锁——回顾第 4 篇讲的 CAS 思想,没有别人占用时,一次原子的"比较并替换"就把新节点放进去了,成功就完事,失败(说明有别的线程抢先插入了)就重试。
- 桶不为空(已经有节点,出现哈希冲突):
synchronized锁住这个桶的头节点。 只锁这一个桶,而不是整张表,也不是一整个 Segment 区间——锁的粒度比 1.7 又细了一级,不同桶之间的操作完全并行、互不影响。
// putVal 核心逻辑简化示意
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i;
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 Node<>(hash, key, value)))
break; // 空桶,CAS 插入成功,不用加锁
} else if (onlyIfAbsent && f.hash == hash && ...)
return f.val;
else {
synchronized (f) { // 桶头节点已存在,锁住这一个桶
// 在链表/红黑树上插入或更新节点
}
}
}
为什么不继续用 ReentrantLock,改用最原始的 synchronized? 因为 JDK 6 以后 synchronized 经过大幅优化(回顾 JVM 系列第 10 篇《JMM与并发底层》讲的锁升级、锁消除、锁粗化),在低竞争场景下性能已经不输 ReentrantLock,而且不需要手动 unlock,代码更简洁、不容易因为忘记释放锁而出问题——用最合适的工具,而不是最"看起来高级"的工具。
桶内数据结构也做了升级:链表太长会转成红黑树。 哈希冲突严重时,一个桶下挂的链表可能很长,查找退化成 O(n)。JDK 8 的 HashMap 和 ConcurrentHashMap 都引入了链表转红黑树的优化,触发需要同时满足两个条件:
TREEIFY_THRESHOLD = 8:单个桶里的节点数达到 8 个,成为"转树"的候选;MIN_TREEIFY_CAPACITY = 64:但只有当整个数组的容量也达到 64,才真正转成红黑树;如果数组容量还没到 64,优先选择扩容(数组翻倍、重新分布节点)而不是转树。
这个"双重门槛"的设计很讲究:如果只是因为数组太小、节点被迫挤在少数几个桶里,那问题的根源是"桶不够多",该做的是扩容分散节点,而不是急着把某个桶变成更复杂的红黑树结构——转树的开销(维护平衡树)比扩容更大,只有在数组已经足够大、冲突依然严重时,才值得为查找效率牺牲这份维护成本。转树之后,查找从链表的 O(n) 降到红黑树的 O(log n),能有效遏制极端哈希冲突(甚至恶意构造哈希碰撞)带来的性能雪崩。
四、扩容:多线程如何协作迁移数据
ConcurrentHashMap 最精彩的设计,藏在扩容(resize/transfer)这一步——JDK 1.8 支持多个线程一起参与扩容迁移,而不是像 HashMap 那样只能单线程搬。这是全系列里源码设计最巧妙的一段,值得慢慢拆。
触发扩容后,先创建一个两倍大的新数组 nextTable,然后需要把旧数组里的每个桶迁移过去。核心问题是:迁移过程中,别的线程还在并发 get/put,怎么保证不出错、还能让多个线程一起帮忙搬?
答案靠两个关键设计:
① ForwardingNode:标记"这个桶已经搬完了"。 一个旧桶迁移完成后,会在旧数组的这个位置放一个特殊的 ForwardingNode(内部持有指向 nextTable 的引用),相当于插了一个"已搬走,去新表找"的路标。这样一来,任何线程读到这个桶时,一看是 ForwardingNode,就知道该去 nextTable 里找数据,不会读到迁移过程中的中间状态。
② sizeCtl 编码 + transferIndex:协调多个线程分工,谁负责搬哪一段。 扩容进行中,sizeCtl 会被置成一个负数,编码里包含"当前有多少个线程正在参与扩容";每个参与扩容的线程通过 transferIndex 认领一段还没搬的桶区间,搬完这一段再去认领下一段——像几个搬家工人各自认领几个箱子去搬,而不是排成一队等一个人搬完。
③ "帮忙扩容"机制:写线程遇到扩容中的桶,可以主动加入搬家。 如果一个线程在做 put 时,发现自己要操作的桶已经变成了 ForwardingNode(说明扩容正在进行),它会调用 helpTransfer() 协助迁移,再回到循环继续写入。读线程的行为不同:get() 遇到 ForwardingNode 时只沿着其中的 nextTable 引用到新表继续查找,不会参加搬迁,也不会为读取加锁。

具体到每个桶怎么搬:一个旧桶里的链表,会按哈希值的某一位是 0 还是 1,拆成"低位"和"高位"两条链表,分别放到新数组里两个确定的位置(这一步不需要重新计算每个元素的完整哈希,只看那一个比特位,效率很高)。
这套设计换来的是:扩容不再是"世界暂停"式的单线程搬家,而是可以被多个写线程分摊、并行完成;读操作遇到已迁移的桶时直接转到新表查找,不需要等待迁移完成。这正是 1.8 相比 1.7"并发度写死在创建时"这个短板的正面回应——扩容本身也变成了并发操作的一部分,而不是并发的对立面。
五、size() 怎么在不加锁的情况下统计
size() 要返回当前元素总数,1.7 的做法是把所有 Segment 锁一遍再加总,等于把"并发"这件事在这一刻临时取消。1.8 有更聪明的办法。
核心思路和第 4 篇讲的 LongAdder 一脉相承:不维护一个大家抢着改的全局计数器,而是把计数"打散"。 ConcurrentHashMap 内部有一个 baseCount 字段和一个 CounterCell[] 数组:正常情况下直接 CAS 更新 baseCount;竞争激烈、CAS 频繁失败时,会把计数分散写到 CounterCell 数组的不同槽位上,减少大家挤在同一个变量上抢的概率。真正要统计总数时(调用 size() 或 mappingCount()),把 baseCount 加上所有 CounterCell 槽位的值汇总起来。
这意味着 size() 的返回值是一个"近似值",而不是绝对精确的实时快照——在高并发写入的同时调用 size(),统计过程中数据还在变,返回的数字只能保证"大致准确"。这和 LongAdder.sum() 的"不追求精确、但足够快"是同一个权衡:大多数场景下,你要的是"大概多少"而不是"精确到那一刻的绝对值",为了这份精确性去牺牲并发写入的吞吐并不值得。
六、CopyOnWriteArrayList:读多写少的另一种哲学
ConcurrentHashMap 解决的是"读写都频繁"场景下的 Map 并发问题,但还有一类场景更极端:几乎只读、极少写。比如秒杀系统里的商品白名单(哪些商品参与本次活动)——每秒被上万个请求读取校验,但只有运营后台偶尔更新一次。这种场景,ConcurrentHashMap 那套"细粒度加锁"的思路其实有点浪费,因为读操作根本不该受任何锁的影响。CopyOnWriteArrayList 给出了一个更激进的方案:写时复制(Copy-On-Write)。
思路简单粗暴:读操作永远不加锁,直接读当前的数组;写操作(add/remove/set)则是:先把底层数组整个复制一份新的,在这份新数组上做修改,改完之后再用一次赋值把内部引用指向这个新数组。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 写操作互斥,用 ReentrantLock
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); // 复制一份新数组
newElements[len] = e;
setArray(newElements); // 修改完,替换引用
return true;
} finally {
lock.unlock();
}
}
写操作之间用 ReentrantLock 互斥(防止多个线程同时复制出好几份新数组、改完互相覆盖),但读操作完全不需要经过这把锁——读的时候,不管写操作复制到哪一步了,读到的永远是某一个完整版本的数组(要么是修改前的旧版本,要么是替换引用之后的新版本),绝不会读到"正在被修改的中间态"。这正是"读写分离"的极致体现:读线程和写线程,操作的根本就不是同一个数组对象。
代价也很直白:每次写操作都要复制整个数组,元素越多、写得越频繁,开销就越大。 这就是为什么它的名字里带着一个隐含的适用范围——只适合"读多写少",如果写操作频繁,CopyOnWriteArrayList 反而会比加锁方案更慢、更耗内存(并发写入时可能同一瞬间有好几份数组副本同时存在)。
还有一个经常被问到的细节:它的迭代器是"弱一致性"的。 创建迭代器那一刻,等于是"快照"了当时的数组引用;迭代过程中即使有别的线程完成了写操作(替换了新数组),迭代器手里拿的还是创建时那个旧数组,不会感知到之后的变化,也绝不会抛出 ConcurrentModificationException(这是它和 ArrayList 的迭代器很大的不同——ArrayList 遍历中被并发修改会抛这个异常,而 CopyOnWriteArrayList 不会,代价是可能读到稍微过时的数据)。用一句话概括:它保证的是某个时间点上的一致视图,不保证实时性。
七、选型:什么时候用谁
把这一篇的容器和它们的"竞品"放一起对比,选型时一目了然:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 高并发读写的 Map(缓存、计数) | ConcurrentHashMap | CAS+synchronized 细粒度加锁,读几乎无锁,写只锁冲突的桶 |
| 需要整表强一致快照的 Map | 加锁的 HashMap,或业务上接受近似值 | ConcurrentHashMap 的 size()/遍历本身也是弱一致的 |
| 读多写极少的 List(白名单、配置项) | CopyOnWriteArrayList | 读零开销,写的复制成本被极低的写频率摊薄 |
| 写频繁的 List | 显式加锁的 ArrayList,或 Collections.synchronizedList | 写时复制的整体复制开销扛不住高频写 |
| 只是想要个"线程安全"标签、别的都不讲究 | 不要用 Hashtable/Vector | 全表大锁性能太差,已是历史遗留,不建议新代码使用 |
一条主线贯穿全篇:并发容器的进化史,就是"锁的粒度越切越细、越不必要的地方越不加锁"的过程——Hashtable 的一把大锁,到 1.7 的分段锁,到 1.8 细化到桶级别的 CAS+synchronized;CopyOnWriteArrayList 则走了另一条路,用"空间换时间"把读写完全隔离开,读操作干脆不设防线。选型时问自己一句:这个容器的读写比例是多少,我能承受多大的"数据非绝对实时"的代价——答案自然就出来了。
这一篇我们把两个最常用的并发容器摸了个底。ConcurrentHashMap 的演进主线是:1.7 用 Segment 分段锁把一把大锁拆成 16 把小锁,1.8 干脆抛弃分段结构,用 CAS(空桶)+ synchronized(冲突桶)把锁粒度细化到单个桶,外加链表转红黑树抗极端冲突、多线程协作扩容抗迁移瓶颈、分散计数抗 size() 热点——每一步优化都在同一个方向上:让互不冲突的操作真正并行,只在真正冲突的地方付代价。CopyOnWriteArrayList 走的是完全不同的思路:用写时复制彻底隔离读写,代价是复制开销,只适合读多写少。
带走两句话:① 遇到并发 Map,默认选 ConcurrentHashMap,别用 Hashtable;size()/遍历只是近似一致,别指望它是强一致快照。② 遇到读远多于写的 List/Set 场景(配置、白名单),CopyOnWriteArrayList 是趁手工具,但写频繁的场景要绕开它。
下一篇,我们进入阻塞队列与生产者-消费者模型——BlockingQueue 家族(ArrayBlockingQueue/LinkedBlockingQueue/SynchronousQueue……)是线程池内部排队机制的地基(回顾第 8 篇),也是构建生产者-消费者模式最直接的工具,这一篇还会简单看一眼工业级消息中间件 Disruptor 的设计思路,作为对比参照。
更多推荐
所有评论(0)