1. 项目背景
业务场景:某直播平台的"实时热榜"模块需要在直播PK期间实时展示Top 100热榜商品。高峰期10万并发观众同时刷新热榜排名,后端服务需要在高并发读写下维护"商品-热度值"映射表。原先的开发实现是 HashMap 外包一层 synchronized 关键字,所有读写操作串行化。大促期间随着并发数从日常3万飙升至10万,单次排行榜刷新延迟从200ms恶化到10秒------观众看到的是"上一个10秒的排名",而主播已切换到下一个PK商品,热榜数据完全错位。
痛点:
-
synchronized HashMap的串行瓶颈 :
synchronized将整个Map的读写操作互斥化------100个线程竞争同一把锁,99个线程在BLOCKED状态,CPU利用率不足5%,但请求延迟却飙升200倍。更致命的是:get+put复合操作(热度值递增)不是原子的,导致"update丢失"------A线程读出热度100,B线程也读出热度100,A写入101,B也写入101,热度值损失了一半增量。 -
扩容风暴(Resize Storm):当HashMap的size超过threshold(capacity × 0.75),触发resize------将原数组所有节点的hash重新映射到2倍容量新数组,时间复杂度O(n)。在千万级热榜条目下,单次resize需1-3秒,整个HashMap在此期间被锁死,所有读请求超时。
-
迭代快照不一致(Stale View):排行榜需要定期遍历整个Map做排序------synchronized虽能保护单次迭代,但迭代器获取的快照在遍历期间已过期,新加入的热度数据不可见,造成"幽灵热榜"(已经下播的商品还在榜上,新上榜的商品看不到)。
-
size()计算陷阱 :多线程下HashMap的size()在并发修改时可能返回Integer.MAX_VALUE甚至负数,因为内部的
size字段不是volatile,且resize期间的table数组状态不一致------虽然synchronized外部包装勉强回避了此问题,但调用size()时仍需要获取全局锁,在高频调用(每次刷新排行榜都需要)下成为新的瓶颈点。
本章从JDK 7分段锁到JDK 8+ CAS+synchronized+红黑树的完整演进,覆盖ConcurrentHashMap的7种原子复合操作(putIfAbsent/compute/computeIfAbsent/merge/replace等),深入ConcurrentLinkedQueue的Michael-Scott无锁队列算法、CopyOnWriteArrayList的写时复制快照迭代模型、BlockingQueue家族的4种阻塞语义选择,以及LongAdder在热点计数器场景下相对于AtomicLong的性能优势。读完本章你能够正确针对"高并发读写/热点计数/阻塞协调/快照迭代"四类场景做出精确的容器选型。
2. 项目设计
(凌晨2点,小胖的代码刚上线就因为热榜延迟10秒被On-Call告警拉起来。会议室白板上画着密密麻麻的线程状态图。)
小胖 (抓着一袋薯片):大师,我就想不通------一个简单的热榜功能,不就是把热度数据存起来,定时排个序嘛。我写的 synchronized (heatMap) { heatMap.put(goodsId, heat + delta); } 逻辑一点毛病没有!为啥线上延迟从200ms飙升到10秒?这不是JVM垃圾回收的问题吗?关我代码什么事?
大师(接过薯片袋放在一边):你先把零食收起来,我们来看这张图。你代码的逻辑"一点毛病没有"是在单线程视角下------但10万并发观众同时刷新热榜时,你的synchronized让所有线程排队等一把锁,就像食堂只有一个打饭窗口,10万人在外面排队------你用200ms打完一份饭,但排在队伍末尾的人要等200ms × 100000 = 20000秒,也就是5个多小时。
小白(从监控大盘抬头):但小胖的问题不止锁竞争。我看了线上的trace------热度累计有一个经典的"丢失更新"问题。你的代码写的是:
java
synchronized (heatMap) {
Long old = heatMap.get(goodsId); // 线程A读
heatMap.put(goodsId, old + delta); // 线程A写
}
看上去在synchronized块里不会有问题------但如果是Collections.synchronizedMap()包装的HashMap,而你没有synchronized块包裹get和put,只在外部做了"synchronized(map)"那就是另一回事了。就算是正确包裹------如果你的synchronized块里面的逻辑还包括了"判断key是否存在,不存在要先初始化"这种check-then-act模式,并发下也可能出问题。你确定每条热度累加路径都正确包了锁?
大师(点头):小白点到关键了。"包了锁"不等于"包对了锁"。我给你看三种常见写法:
java
// 场景:商品热度值递增
// ❌ 错误写法1:两个独立的原子操作,中间存在时间窗口
Long heat = heatMap.get(goodsId); // ← 非原子
heatMap.put(goodsId, heat + 1); // ← 非原子
// 多线程同时读到100,都写回101 → 丢失1次更新
// ❌ 错误写法2:synchronized包了但粒度不对
synchronized (heatMap) {
Long heat = heatMap.get(goodsId);
heatMap.put(goodsId, heat + 1);
} // ← 正确!但必须在所有读写处都一致加锁,且不能混用
// ✅ 正确写法:用ConcurrentHashMap的原生原子复合操作
heatMap.compute(goodsId, (k, v) -> (v == null) ? 1L : v + 1);
// ← 一个原子操作完成"读-改-写",无窗口期
get+put 两步分离就像ATM取款------先查余额再取钱,两步之间别人可能把你的钱取走了。而 compute 好比ATM机一步完成余额查询+扣款+出钞,中间不可中断。
小胖(恍然大悟):原来如此!那Collections.synchronizedMap()我也用过,它和ConcurrentHashMap到底啥区别?不都是线程安全的Map吗?
大师(在白板上画出对比表):
| 维度 | synchronizedMap | ConcurrentHashMap |
|---|---|---|
| 锁粒度 | 整个Map一把锁 | JDK7: 16个Segment锁 / JDK8+: 桶级锁(每个桶头节点独立加锁) |
| get是否需要锁 | 需要(全局锁) | 不需要(volatile读,无锁) |
| 写并发度 | 1 | 理论上=N(桶数量) |
| 复合操作原子性 | 需外部synchronized | 原生支持(compute/merge/putIfAbsent等) |
| 迭代线程安全 | 需要外部锁 | 弱一致性迭代,不抛ConcurrentModificationException |
| 扩容 | 单线程stop-the-world | 多线程协同迁移(helpTransfer) |
用生活比喻来记:synchronizedMap 像一家小店,一个收银员,所有顾客排一条队------无论买矿泉水还是买冰箱都是一样排队。ConcurrentHashMap JDK 8像大型超市的自助收银区,100个收银台各自独立,只有两个顾客同时去同一个收银台才会互相等待------而且这个等待也只是柜台级的(桶级锁),不阻塞其他人。
小白:那ConcurrentHashMap在JDK 7和JDK 8的实现差异这么大,具体是怎么演进的?分段锁被抛弃的原因是什么?
大师:这是理解CHM设计哲学的关键转折。
JDK 7------分段锁(Segment)时代:
scss
ConcurrentHashMap
├── Segment[0] → ReentrantLock → HashEntry[] → (链表)
├── Segment[1] → ReentrantLock → HashEntry[] → (链表)
├── ...
└── Segment[15] → ReentrantLock → HashEntry[] → (链表)
默认16个Segment,每个Segment内部是一个简化版HashMap。put操作先通过hash的高位决定落在哪个Segment,然后获取该Segment的ReentrantLock,在Segment内部完成插入。不同Segment之间可以并发put------理论上最大并发度为16。好比食堂16个独立窗口,每个窗口有自己的一把钥匙------只有落在同一个窗口的请求才需要排队。
核心缺点:
- 并发度受限于Segment数量(默认16,构造时可指定但无法动态扩容)。
- 每个Segment内部是传统的数组+链表------链表过长时查找退化为O(n)。
- 跨Segment操作(如size())需要获取所有Segment的锁(虽然做了多次不加锁重试的优化,但最终仍可能全量加锁)。
JDK 8------CAS + synchronized + 红黑树:
scss
Node[] table (volatile)
├── index[0] → Node → Node → Node (链表)
├── index[1] → Node → TreeNode → TreeNode (红黑树, 长度≥8时转换)
├── index[2] → (null, CAS直接插入)
├── ...
└── index[N] → ForwardingNode (扩容标记, 指向nextTable)
摒弃Segment,直接操作Node数组------好比食堂改成了256个小餐台,每个餐台只能一个人同时取餐,但空闲餐台无需等待,直接拿(CAS)。
插入流程(putVal方法,源码行号1025):
- 检查key.hashCode,调用
spread()(源码行710)进行高位扰动,降低hash碰撞。 - 定位桶位置
(n-1) & hash。 - 如果桶为空:用
casTabAt()(源码行777)CAS尝试设置头节点,成功即返回------这是无锁写入的最优路径。 - 如果桶头节点是
ForwardingNode:说明正在扩容,调用helpTransfer()(源码行2390)协助迁移------实现扩容写不阻塞。 - 如果桶非空且非ForwardingNode:
synchronized锁住桶头节点(源码行1047)------锁粒度精确到一个桶。 - 桶内查找/插入:遍历链表或红黑树。如果链表长度达到
TREEIFY_THRESHOLD(8) 且数组长度 ≥ 64,调用treeifyBin()(源码行2692)将链表转为红黑树------查找从O(n)降至O(log n)。 - 调用
addCount()更新计数并检查是否需要扩容。
抛弃Segment的原因:
- CAS在空桶插入场景下提供了零锁争用的写入路径(大部分场景桶是空的),比Segment的全局锁轻量得多。
- JDK 6之后synchronized的性能因偏向锁、轻量级锁的优化大幅提升,在低竞争场景下甚至接近CAS的开销。
- 桶级锁的粒度比Segment更细------默认16个桶就有16个锁点,而CHM的数组容量通常远大于16(默认初始16,但会自动扩容到几百上千),每把锁保护的范围更小,竞争概率更低。
小胖:扩容的事我刚遇到!热榜大促期间数据量涨了10倍,每次扩容卡好几秒------CHM怎么解决这个问题?
大师:HashMap扩容是单线程的、stop-the-world的------所有put/remove等在扩容完成前都得阻塞。而ConcurrentHashMap的扩容是多线程协同的------多个写线程可以同时参与扩容工作。
核心机制(transfer() 方法,源码行2451):
ini
扩容步骤:
1. 数组容量翻倍,创建 nextTable = new Node[n << 1]
2. 将旧table中每个桶标记为ForwardingNode(占位节点)
3. 多线程从 transferIndex 领取"任务区间"(默认stride=16个桶)
4. 每个线程独立处理自己领取的桶区间:重新hash分配到新表
5. 处理完毕的桶标记为ForwardingNode,继续领取下一个区间
6. 所有桶迁移完成后,table指向nextTable
关键设计:
┌────────────────────────────────────────────────┐
│ 旧table: [0][1]...[FN][FN][FN]...[255] │
│ ↑已迁移 ↑正在迁移 ↑待领取 │
│ transferIndex ← 递减,线程领取区间 │
│ ForwardingNode ← 标记已迁移桶,读请求自动转发 │
│ helpTransfer() ← 检测到FN后插入线程协助扩容 │
└────────────────────────────────────────────────┘
扩容期间读/写如何保证正确性:
- 读操作:遇到ForwardingNode,自动转发到nextTable继续查找(ForwardingNode.find()方法,源码行2268)------读取完全不受扩容阻塞。
- 写操作:遇到ForwardingNode,调用helpTransfer()协助扩容,扩容完成后在newTable上执行写入------写入也不会阻塞。
- 多线程协助 :
addCount()(源码行2351)每次put后检查是否需要扩容,需要时通过resizeStamp()(源码行2311)生成唯一标记,通过CAS操作sizeCtl变量协调多个线程参与transfer()------每个线程进来领一批桶,处理完再领下一批。
这就是CHM最精妙的设计------扩容不再是瓶颈,而是被"众包"给了所有在扩容期间执行写操作的线程。好比搬家时请了搬家公司,每辆卡车(线程)自领任务区间(stride),互不干扰。而HashMap扩容就像你一个人搬着沙发从1楼扛到20楼------别人只能在旁边等。
小白:那红黑树化(treeify)呢?链表的O(n)查找在什么情况下会触发treeify?会不会过度优化?
大师 :好问题。CHM的treeify有严格的触发条件(treeifyBin(),源码行2692):
scss
触发条件(两者同时满足):
1. 单个桶链表长度 ≥ TREEIFY_THRESHOLD (8)
2. 整个table数组长度 ≥ MIN_TREEIFY_CAPACITY (64)
两个条件缺一不可:
- 链表长度=8但table容量=16 → 不treeify,优先扩容(tryPresize)
- 链表长度=8且table容量≥64 → treeifyBin()转为红黑树
为什么要设64的最小容量?因为链表长度≥8在某些情况下是hash碰撞导致的------当table容量较小时(如16),扩容可以让元素更均匀分散,自然缩短链表长度。只有当容量足够大(≥64)但某些桶仍然碰撞严重,才说明hash分布本身有问题,此时转为红黑树用O(log n)替代O(n)查找。
untreeify的触发条件:当单个桶红黑树节点数 ≤ UNTREEIFY_THRESHOLD (6) 时,在resize过程中会将红黑树退化为链表------避免内存浪费。
size()计算的演进------为什么不直接用volatile long:
JDK 7时代size()需要遍历所有Segment求和,代价是O(segments.length)且可能锁所有Segment。JDK 8的size()实现更加精妙:
java
// ConcurrentHashMap 的计数方案(源码行924-926)
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
内部使用 CounterCell 数组(类似LongAdder的实现)+ baseCount 字段。每次 addCount() 不直接在单个变量上做原子递增(热点竞争太激烈),而是:
- 先尝试CAS更新
baseCount------如果成功就直接返回。 - 如果CAS失败(说明有竞争),随机探针选择一个
CounterCell(counterCells数组中的一个格子),在该格子上做CAS递增。 sumCount()计算总大小时,遍历baseCount + 所有CounterCell的值累加。
这就是分散热点 (Striped Counting)方案------把一道门的排队换成一百道门,每道门独立计数。就像演唱会进场时,100个安检口各自计数,最后汇总------而不是所有人在一个口登记名字。这也是 LongAdder 的核心设计思想,专门为"写多读少"的计数器场景优化。
小胖:还有几个并发容器我也想搞清楚------我们直播间用了一个监听器列表,每次有人关注主播要遍历通知所有监听器。宣传的CopyOnWriteArrayList"读无锁"是怎么做到的?
大师 :CopyOnWriteArrayList(COWList)的核心思想就一句话:写时复制,读时使用当前快照。
java
// CopyOnWriteArrayList 的 add() 实现(简化为示意)
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray(); // 获取当前数组
int len = es.length;
Object[] newElements = Arrays.copyOf(es, len + 1); // 复制全量 + 1
newElements[len] = e;
setArray(newElements); // volatile 写------发布新数组
return true;
}
}
// get() 实现------没有任何锁
public E get(int index) {
return elementAt(getArray(), index); // 直接读当前数组,无锁!
}
每次写操作都创建一个新的底层数组副本------Array.copyOf()是O(n)的。但get()只需要读 volatile 的array引用,没有任何加锁和同步开销。
适用场景苛刻:写极少、读极多、读需要快照一致性。监听器列表是典型例子------注册/注销监听器极少发生,但通知时高频遍历。另一个经典场景是:配置信息列表,启动时加载,运行期几乎不变。
不适用的场景:写频繁的场景下(如实时热榜),每次写都要O(n)复制全量数组------100万元素每次新增都复制100万,性能灾难。
小白:那相对于传统加锁的线程安全List,COWList还有什么隐藏的优点?
大师 :最重要的一个:快照迭代一致性 。当你用 for (Listener l : cowListenerList) 遍历时,迭代器持有的是 add() 操作执行时刻 的数组快照------哪怕另一个线程在遍历期间新增了10个监听器,遍历依然只看到旧快照,不会抛出 ConcurrentModificationException,不会漏数据(在add之前的都在快照里),也不会多数据(add之后的都不在快照里)。这在监听器的"通知当前已注册的所有监听器"场景下恰好是正确语义。好比学校花名册------每学期开学印一份新名册发给老师(写时复制),老师拿着这份名册点名时(遍历),有新生插班转学并不影响手里这份名册的快照。
技术映射(全章汇总)
| 生活比喻 | 技术概念 | 源码位置 |
|---|---|---|
| 食堂唯一窗口,一次只能服务一人 | synchronized (heatMap) ------ 全局锁,并发度=1 |
--- |
| ATM取款分两步(查余额→取钱),中间可能被他人取走 | get+put 分离 ------ 非原子复合操作 |
--- |
| ATM一步完成余额查询+扣款+出钞,中间不可中断 | ConcurrentHashMap.compute() ------ 原子复合操作 |
ConcurrentHashMap.java:1025 |
| 食堂16个独立窗口,每窗口有自己的钥匙 | JDK 7 Segment 分段锁(ReentrantLock) | ConcurrentHashMap.java (JDK 7) |
| 食堂256个小餐台,空闲餐台无需等待直接拿(CAS) | JDK 8 桶级锁 + CAS无锁写入 | ConcurrentHashMap.java:1047 / :777 |
| 搬家请搬家公司,每辆卡车自领任务区间(stride),互不干扰 | CHM多线程协同扩容(helpTransfer) | ConcurrentHashMap.java:2451 |
| 一个人搬沙发从1楼扛到20楼,别人只能在旁边等 | HashMap单线程扩容(stop-the-world) | HashMap.java:resize() |
| 演唱会100个安检口各自计数,最后汇总 | CounterCell分散计数(Striped Counting) | ConcurrentHashMap.java:924 |
| 学校花名册:开学印新名册,点名时有插班生不影响已持有的名册 | CopyOnWriteArrayList 快照迭代一致性 |
CopyOnWriteArrayList.java |
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 (Temurin) | 运行示例代码,使用最新的CHM优化 |
| JMH | 1.37 | 微基准测试(微秒级吞吐量对比) |
| 构建工具 | Gradle / 直接javac | 编译运行示例 |
| IDE | IntelliJ IDEA / VS Code | 可选,源码阅读和单步调试 |
JMH依赖配置(Gradle):
groovy
// build.gradle
plugins { id 'java' }
repositories { mavenCentral() }
dependencies {
testImplementation 'org.openjdk.jmh:jmh-core:1.37'
testAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
}
若不用Gradle,直接使用javac编译示例程序即可,但JMH基准测试建议用JMH的原生archetype生成项目以规避JIT编译器干扰。
3.2 分步实现
步骤一:ConcurrentHashMap核心源码走读
目标:阅读putVal/spread/tabAt/casTabAt/treeifyBin五个核心方法的源码,理解CHM写入流程。
1. spread()------hash扰动函数(源码行710)
java
// 源码位置:src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java:710
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS; // HASH_BITS = 0x7fffffff
}
作用:(1) 将高16位异或到低16位,增加低位的随机性,减少桶碰撞。(2) 掩掉符号位确保hash非负(用于数组索引计算)。这个扰动算法比HashMap的 (h ^ (h >>> 16)) 多做了与 HASH_BITS 的按位与操作,因为CHM的hash值还在其他地方用作特殊标记(如负数表示ForwardingNode/TreeBin),普通Node的hash必须保持正数。
2. tabAt / casTabAt------volatile读取与CAS写入(源码行773-777)
java
// 源码行773:通过Unsafe.getObjectVolatile保证可见性
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
// 源码行777:CAS原子地设置桶头节点
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
注意:CHM不使用 volatile Node[] table 的直接数组访问,而是用 Unsafe.getObjectAcquire ------这是JDK 9+的变种volatile语义(acquire fence),确保读到的Node对象引用和Node内部字段都是最新可见值。数组的volatile语义只保证引用本身的可见性,不保证引用的对象的字段可见性------所以CHM在代码中频繁使用 tabAt 而非直接 tab[i]。
3. putVal()------核心插入逻辑(源码行1025)
java
// 源码位置:src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java:1025
// 以下为关键路径的注释版摘录
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException(); // CHM不允许null键值
int hash = spread(key.hashCode()); // 1. 计算扰动hash
int binCount = 0; // 记录链表长度
for (Node<K,V>[] tab = table;;) { // 自旋CAS循环
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 2. 惰性初始化table
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 3.【无锁路径】桶为空,CAS直接插入头节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // 成功!无需加锁
}
else if ((fh = f.hash) == MOVED) // MOVED == -1
// 4.【扩容协助】桶正在迁移,帮助扩容
tab = helpTransfer(tab, f);
else {
// 5.【桶级锁路径】桶非空,synchronized锁住桶头节点
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) { // 双重检查------防止扩容后f被替换
if (fh >= 0) { // 普通链表节点(hash≥0)
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
// ... 遍历链表,找key或追加到末尾
}
}
else if (f instanceof TreeBin) { // 红黑树节点
// ... 遍历红黑树
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) // 6. 检查是否需要树化
treeifyBin(tab, i); // 链表≥8 → 可能转红黑树
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); // 7. 更新计数+检查扩容
return null;
}
核心设计要点:
- 自旋CAS循环(for(;;))------在扩容协助和CAS竞争时自动重试,不会阻塞在外部。
- 空桶CAS------最优路径,零开销写入。
- ForwardingNode检测------读/写操作都感知扩容并协助。
- synchronized锁头节点------老版本用分段锁ReentrantLock,JDK 8改为synchronized利用JVM的锁优化(偏向锁→轻量级锁→重量级锁升级)。
- null键值禁用------HashMap允许一个null键,CHM禁止null键值以避免并发场景下get()返回null时无法区分"key不存在"还是"value是null"。
4. treeifyBin()------链表转红黑树(源码行2692)
java
// 源码行2692-2715
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // 数组 < 64,优先扩容
tryPresize(n << 1); // 容量翻倍
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) { // 锁住桶头节点
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p = new TreeNode<K,V>(
e.hash, e.key, e.val, null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将桶头设置为TreeBin(内部包装红黑树根节点)
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
树化过程:先将所有Node转为TreeNode(保持链表顺序),再用new TreeBin包装------TreeBin本身是红黑树的锁持有者,在CHM中通过TreeBin的读写锁控制红黑树节点的并发操作,保证树结构的线程安全性。
步骤二:实现本地热点库存扣减(三方案对比)
目标:对比synchronized HashMap / ConcurrentHashMap.compute / LongAdder三种热度计数方案的正确性与吞吐量。
java
// HotCounterBenchmark.java ------ 热度累计三种方案对比
// 编译: javac HotCounterBenchmark.java
// 运行: java HotCounterBenchmark
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class HotCounterBenchmark {
static final int GOODS_COUNT = 10; // 10个商品
static final int THREAD_COUNT = 50; // 50个并发线程(模拟观众)
static final int OPS_PER_THREAD = 100_000; // 每个线程执行10万次热度+1
static final long EXPECTED_TOTAL = (long) THREAD_COUNT * OPS_PER_THREAD;
public static void main(String[] args) throws Exception {
// ===== 方案A: synchronized HashMap =====
Map<String, Long> syncMap = new HashMap<>();
CountDownLatch latchA = new CountDownLatch(THREAD_COUNT);
long startA = System.nanoTime();
for (int t = 0; t < THREAD_COUNT; t++) {
int threadId = t;
new Thread(() -> {
for (int i = 0; i < OPS_PER_THREAD; i++) {
String goods = "goods-" + (i % GOODS_COUNT);
synchronized (syncMap) {
Long v = syncMap.get(goods);
syncMap.put(goods, (v == null) ? 1L : v + 1);
}
}
latchA.countDown();
}).start();
}
latchA.await();
long timeA = System.nanoTime() - startA;
long totalA = syncMap.values().stream().mapToLong(Long::longValue).sum();
double lossA = (double)(EXPECTED_TOTAL - totalA) / EXPECTED_TOTAL * 100;
System.out.printf("[方案A] synchronized HashMap: 耗时=%.0fms, "
+ "累计热度=%d, 期望=%d, 丢失率=%.2f%%%n",
timeA / 1_000_000.0, totalA, EXPECTED_TOTAL, lossA);
// ===== 方案B: ConcurrentHashMap.compute =====
ConcurrentHashMap<String, Long> chm = new ConcurrentHashMap<>();
CountDownLatch latchB = new CountDownLatch(THREAD_COUNT);
long startB = System.nanoTime();
for (int t = 0; t < THREAD_COUNT; t++) {
new Thread(() -> {
for (int i = 0; i < OPS_PER_THREAD; i++) {
String goods = "goods-" + (i % GOODS_COUNT);
// compute: 原子地执行 (k, v) -> v+1 的映射
chm.compute(goods, (k, v) -> (v == null) ? 1L : v + 1);
}
latchB.countDown();
}).start();
}
latchB.await();
long timeB = System.nanoTime() - startB;
long totalB = chm.values().stream().mapToLong(Long::longValue).sum();
double lossB = (double)(EXPECTED_TOTAL - totalB) / EXPECTED_TOTAL * 100;
System.out.printf("[方案B] ConcurrentHashMap: 耗时=%.0fms, "
+ "累计热度=%d, 期望=%d, 丢失率=%.2f%%%n",
timeB / 1_000_000.0, totalB, EXPECTED_TOTAL, lossB);
// ===== 方案C: LongAdder数组(最高吞吐方案)=====
LongAdder[] adders = new LongAdder[GOODS_COUNT];
for (int i = 0; i < GOODS_COUNT; i++) {
adders[i] = new LongAdder();
}
CountDownLatch latchC = new CountDownLatch(THREAD_COUNT);
long startC = System.nanoTime();
for (int t = 0; t < THREAD_COUNT; t++) {
new Thread(() -> {
for (int i = 0; i < OPS_PER_THREAD; i++) {
int goodsId = i % GOODS_COUNT;
adders[goodsId].increment(); // LongAdder:无锁+分散热点
}
latchC.countDown();
}).start();
}
latchC.await();
long timeC = System.nanoTime() - startC;
long totalC = 0;
for (LongAdder adder : adders) {
totalC += adder.sum();
}
double lossC = (double)(EXPECTED_TOTAL - totalC) / EXPECTED_TOTAL * 100;
System.out.printf("[方案C] LongAdder数组: 耗时=%.0fms, "
+ "累计热度=%d, 期望=%d, 丢失率=%.2f%%%n",
timeC / 1_000_000.0, totalC, EXPECTED_TOTAL, lossC);
// ===== 汇总对比 =====
System.out.println("\n========== 性能对比 ==========");
System.out.printf("%-25s %10s %12s %10s%n",
"方案", "耗时(ms)", "吞吐量(ops/s)", "丢失率");
System.out.println("─".repeat(60));
System.out.printf("%-25s %10.0f %12.0f %9.2f%%%n",
"synchronized HashMap", timeA/1_000_000.0,
EXPECTED_TOTAL/(timeA/1_000_000_000.0), lossA);
System.out.printf("%-25s %10.0f %12.0f %9.2f%%%n",
"ConcurrentHashMap.compute", timeB/1_000_000.0,
EXPECTED_TOTAL/(timeB/1_000_000_000.0), lossB);
System.out.printf("%-25s %10.0f %12.0f %9.2f%%%n",
"LongAdder数组", timeC/1_000_000.0,
EXPECTED_TOTAL/(timeC/1_000_000_000.0), lossC);
}
}
预期运行结果(多核CPU环境下):
erlang
[方案A] synchronized HashMap: 耗时=850ms, 累计热度=5000000, 期望=5000000, 丢失率=0.00%
[方案B] ConcurrentHashMap: 耗时=180ms, 累计热度=5000000, 期望=5000000, 丢失率=0.00%
[方案C] LongAdder数组: 耗时=45ms, 累计热度=5000000, 期望=5000000, 丢失率=0.00%
========== 性能对比 ==========
方案 耗时(ms) 吞吐量(ops/s) 丢失率
────────────────────────────────────────────────────────────
synchronized HashMap 850 5882353 0.00%
ConcurrentHashMap.compute 180 27777778 0.00%
LongAdder数组 45 111111111 0.00%
分析:
- 三个方案在正确性上都无丢失(synchronized HashMap因为在synchronized块内完整执行了get+put),但吞吐量差距巨大。
- synchronized HashMap的吞吐量只有CHM的1/5------原因在于50个线程串行排队等一把全局锁,每次操作都要获取+释放锁,锁竞争的开销远大于业务逻辑本身。
- LongAdder的吞吐量是CHM的4倍------原因在于无需维护Map结构(无hash计算、无桶查找),纯粹的CounterCell分散写入。
- 选型启示:如果只需要简单计数器(无需key-value映射),LongAdder是最优解。如果需要key维度统计数据(如每个商品的热度),ConcurrentHashMap.compute是正确选择。
步骤三:BlockingQueue生产者-消费者实战
目标:实现多生产者-多消费者的异步消息队列,展示ArrayBlockingQueue的阻塞等待语义。
java
// BlockingQueueDemo.java ------ 生产者-消费者模型
// 场景:直播间送礼事件异步处理(削峰填谷)
// 编译: javac BlockingQueueDemo.java
// 运行: java BlockingQueueDemo
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class BlockingQueueDemo {
// 礼物事件类
record GiftEvent(long userId, String giftName, int price, long timestamp) {}
static final int QUEUE_CAPACITY = 100; // 队列容量(有界,防止OOM)
static final int PRODUCER_COUNT = 3; // 3个生产者线程
static final int CONSUMER_COUNT = 2; // 2个消费者线程
static final int EVENTS_PER_PRODUCER = 50; // 每个生产者发送50个事件
public static void main(String[] args) throws InterruptedException {
// ArrayBlockingQueue:基于数组的有界阻塞队列,FIFO
BlockingQueue<GiftEvent> queue = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
AtomicInteger producedCount = new AtomicInteger(0);
AtomicInteger consumedCount = new AtomicInteger(0);
CountDownLatch producersDone = new CountDownLatch(PRODUCER_COUNT);
// ===== 启动消费者 =====
for (int i = 0; i < CONSUMER_COUNT; i++) {
int consumerId = i;
new Thread(() -> {
try {
while (true) {
// take(): 队列为空时阻塞等待,直到有数据可取
GiftEvent event = queue.poll(2, TimeUnit.SECONDS);
if (event == null) {
// 2秒内没有新事件 → 可能生产者已结束
if (consumedCount.get() >=
PRODUCER_COUNT * EVENTS_PER_PRODUCER) {
break; // 所有事件已消费完毕,退出
}
continue;
}
consumedCount.incrementAndGet();
// 模拟异步处理:写数据库/推送通知
System.out.printf("[消费者-%d] 处理礼物: 用户%d 送出 %s "
+ "(价值%d金币)%n", consumerId, event.userId(),
event.giftName(), event.price());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "consumer-" + i).start();
}
// ===== 启动生产者 =====
String[] giftNames = {"火箭", "跑车", "嘉年华", "飞机", "玫瑰花"};
int[] giftPrices = {500, 200, 1000, 100, 1};
for (int i = 0; i < PRODUCER_COUNT; i++) {
int producerId = i;
new Thread(() -> {
Random rand = new Random();
for (int j = 0; j < EVENTS_PER_PRODUCER; j++) {
int idx = rand.nextInt(giftNames.length);
GiftEvent event = new GiftEvent(
rand.nextInt(10000),
giftNames[idx],
giftPrices[idx],
System.currentTimeMillis()
);
try {
// put(): 队列满时阻塞等待,直到有空间可用
queue.put(event);
producedCount.incrementAndGet();
System.out.printf("[生产者-%d] 发送礼物: %s%n",
producerId, event.giftName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
producersDone.countDown();
}, "producer-" + i).start();
}
producersDone.await();
System.out.printf("%n========== 汇总 ==========%n");
System.out.printf("生产事件总数: %d%n", producedCount.get());
System.out.printf("消费事件总数: %d%n", consumedCount.get());
System.out.printf("队列剩余未消费: %d%n", queue.size());
}
}
运行预期(生产者全部结束后,消费者的2秒超时检测确保收尾):
ini
[生产者-0] 发送礼物: 火箭
[消费者-0] 处理礼物: 用户1234 送出 火箭 (价值500金币)
[生产者-1] 发送礼物: 嘉年华
[消费者-1] 处理礼物: 用户5678 送出 嘉年华 (价值1000金币)
...
========== 汇总 ==========
生产事件总数: 150
消费事件总数: 150
队列剩余未消费: 0
BlockingQueue家族对比:
| 实现类 | 底层结构 | 容量 | 阻塞行为 | 适用场景 |
|---|---|---|---|---|
ArrayBlockingQueue |
数组 | 有界(必设) | put满阻塞/take空阻塞,一把ReentrantLock | 固定容量,削峰填谷 |
LinkedBlockingQueue |
链表 | 有界(默认Integer.MAX) | put/take各用独立的ReentrantLock(并发更高) | 容量不确定,高低水位 |
PriorityBlockingQueue |
堆 | 无界 | put不阻塞(扩容)/take空阻塞 | 优先级排序消费 |
DelayQueue |
PriorityBlockingQueue | 无界 | take到期才返回 | 定时任务/延迟队列 |
SynchronousQueue |
无容器 | 0 | put必须匹配take,直接交付 | 线程间直接传递(CachedThreadPool) |
步骤四:CopyOnWriteArrayList实战------监听器注册表
目标:演示COWList在监听器注册场景下的快照迭代保证。
java
// ListenerRegistryDemo.java ------ COWList 监听器注册表示例
// 场景:直播间关注事件通知所有监听器
// 编译: javac ListenerRegistryDemo.java
// 运行: java ListenerRegistryDemo
import java.util.*;
import java.util.concurrent.*;
// 监听器接口
interface FollowListener {
void onFollow(long userId, long anchorId);
}
// 监听器注册表(使用CopyOnWriteArrayList)
class ListenerRegistry {
private final List<FollowListener> listeners = new CopyOnWriteArrayList<>();
public void register(FollowListener listener) {
listeners.add(listener); // 写操作:复制全量数组 O(n)
System.out.printf("[注册] 监听器注册, 当前总数: %d%n", listeners.size());
}
public void unregister(FollowListener listener) {
listeners.remove(listener); // 写操作:复制全量数组 O(n)
System.out.printf("[注销] 监听器注销, 当前总数: %d%n", listeners.size());
}
// 通知所有已注册的监听器(读操作:无锁)
public void notifyAll(long userId, long anchorId) {
// for-each遍历------COWList返回的是add时刻的快照
for (FollowListener listener : listeners) {
listener.onFollow(userId, anchorId); // 无锁调用
}
System.out.printf("[通知] 已通知 %d 个监听器%n", listeners.size());
}
}
public class ListenerRegistryDemo {
public static void main(String[] args) throws InterruptedException {
ListenerRegistry registry = new ListenerRegistry();
// 注册3个初始监听器
for (int i = 0; i < 3; i++) {
int id = i;
registry.register((userId, anchorId) ->
System.out.printf(" 监听器-%d: 用户%d 关注了主播%d%n",
id, userId, anchorId));
}
// 线程A:频繁通知(读)
Thread notifier = new Thread(() -> {
for (long uid = 1; uid <= 10; uid++) {
registry.notifyAll(uid, 10086);
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}, "通知线程");
// 线程B:中途动态注册新监听器(写)
Thread registrar = new Thread(() -> {
try { Thread.sleep(30); } catch (InterruptedException e) {}
registry.register((userId, anchorId) ->
System.out.printf(" ★新监听器: 用户%d 关注了主播%d%n",
userId, anchorId));
}, "注册线程");
notifier.start();
registrar.start();
notifier.join();
registrar.join();
System.out.println("\n========== COWList关键特性 ==========");
System.out.println("1. 通知线程遍历期间,注册线程新增的监听器不影响当前快照");
System.out.println("2. 新增后,下一次遍历(新的for-each)才能看到新监听器");
System.out.println("3. 遍历期间绝不抛出ConcurrentModificationException");
System.out.println("4. 读操作完全无锁------适用于写极少读极多的场景");
}
}
COWList三种写操作的性能对比:
java
// CopyOnWriteArrayList 的写操作性能特征
List<Integer> cowList = new CopyOnWriteArrayList<>();
// add(E): O(n) ------ 复制全量数组
// remove(int): O(n) ------ 复制全量数组(去掉目标元素)
// set(int, E): O(n) ------ 复制全量数组(替换目标位置)
// get(int): O(1) ------ 直接数组索引访问,无锁
选型铁律:如果写的频率是"每秒几次"级别------可以用COWList。如果写的频率是"每秒几百次"级别------果断放弃COWList,改用ConcurrentLinkedQueue(如果是遍历场景需要外部快照捕获)。
步骤五:JMH微基准验证
目标:通过JMH消除JIT编译、GC、线程调度等噪声,精确对比四种热度累计方案的吞吐量。
java
// HotCounterJMH.java ------ JMH微基准测试
// 使用JMH archetype生成项目,将此文件放入 src/main/java/ 目录
// 运行: mvn clean package && java -jar target/benchmarks.jar
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
@BenchmarkMode(Mode.Throughput) // 吞吐量模式
@OutputTimeUnit(TimeUnit.SECONDS) // 单位:每秒操作数
@State(Scope.Benchmark) // 状态为Benchmark级别(所有线程共享)
@Fork(1) // 每个Benchmark fork 1个JVM进程
@Warmup(iterations = 3, time = 1) // 预热3次,每次1秒
@Measurement(iterations = 5, time = 2) // 测量5次,每次2秒
public class HotCounterJMH {
static final int THREADS = 8;
// ===== 状态对象 =====
Map<String, Long> syncMap;
ConcurrentHashMap<String, Long> chm;
LongAdder[] adders;
@Setup(Level.Trial)
public void setup() {
syncMap = Collections.synchronizedMap(new HashMap<>());
syncMap.put("heat", 0L);
chm = new ConcurrentHashMap<>();
chm.put("heat", 0L);
adders = new LongAdder[1];
adders[0] = new LongAdder();
}
// ===== Benchmark 1: synchronized Map =====
@Benchmark
@Group("syncMap")
@GroupThreads(THREADS)
public void syncMapIncrement() {
synchronized (syncMap) {
syncMap.put("heat", syncMap.get("heat") + 1);
}
}
// ===== Benchmark 2: ConcurrentHashMap.compute =====
@Benchmark
@Group("chm")
@GroupThreads(THREADS)
public void chmCompute() {
chm.compute("heat", (k, v) -> v + 1);
}
// ===== Benchmark 3: ConcurrentHashMap 手动CAS循环 =====
// 相当于 compute 的底层实现------某些场景比compute更快
@Benchmark
@Group("chmCasLoop")
@GroupThreads(THREADS)
public void chmCasLoop() {
while (true) {
Long current = chm.get("heat");
if (chm.replace("heat", current, current + 1)) {
break;
}
}
}
// ===== Benchmark 4: LongAdder =====
@Benchmark
@Group("longAdder")
@GroupThreads(THREADS)
public void longAdderIncrement() {
adders[0].increment();
}
// ===== 主函数 =====
public static void main(String[] args) throws Exception {
Options opt = new OptionsBuilder()
.include(HotCounterJMH.class.getSimpleName())
.result("jmh-result.json")
.build();
new Runner(opt).run();
}
}
JMH输出解读(典型结果):
bash
Benchmark Mode Cnt Score Error Units
HotCounterJMH.syncMap thrpt 5 2,341,123 ± 120,456 ops/s
HotCounterJMH.chm thrpt 5 18,234,567 ± 456,789 ops/s
HotCounterJMH.chmCasLoop thrpt 5 15,890,234 ± 345,678 ops/s
HotCounterJMH.longAdder thrpt 5 45,678,901 ± 987,654 ops/s
关键发现:
- CHM.compute 比 synchronized Map 快 ~7.8倍(8线程竞争下)。
- LongAdder 比 CHM.compute 快 ~2.5倍------因为在单一key的纯计数场景,CounterCell的无锁分散比CHM的桶查找+锁机制更轻量。
- CHM手动CAS循环(get+replace)略慢于compute------因为compute内部做了更精细的优化。
3.3 测试验证
| 测试用例 | 验证命令 | 预期结果 |
|---|---|---|
| 热度累计正确性 | java HotCounterBenchmark |
三种方案丢失率均为0%,吞吐量:LongAdder > CHM > syncMap |
| 复合操作原子性 | java CompoundOpDemo (见代码) |
synchronizedMap get+put有丢失,CHM.compute无丢失 |
| BlockingQueue容量控制 | java BlockingQueueDemo |
生产150条完全消费150条,无OOM |
| COWList快照迭代 | java ListenerRegistryDemo |
迭代期间新增监听器不出现在当前快照,无CME异常 |
| JMH基准 | java -jar target/benchmarks.jar -f 1 -wi 3 -i 5 |
结果稳定在误差范围内且与预期倍数一致 |
| size()正确性 | 多线程读写后调用size()与预期对比 | CHM.size()在扩容期间也返回接近正确的值(弱一致),HashMap.size()可能异常 |
复合操作原子性测试补充代码:
java
// CompoundOpDemo.java ------ 证明synchronizedMap的get+put组合不是原子的
// 编译: javac CompoundOpDemo.java
// 运行: java CompoundOpDemo
import java.util.*;
import java.util.concurrent.*;
public class CompoundOpDemo {
static final int THREADS = 20;
static final int ITERATIONS = 5000;
public static void main(String[] args) throws Exception {
// 测试1: Collections.synchronizedMap 的 get+put 复合操作(不安全)
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
syncMap.put("count", 0);
CountDownLatch latch1 = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
// 注意:get和put各自是原子的,但组合不是
Integer val = syncMap.get("count");
syncMap.put("count", val + 1);
}
latch1.countDown();
}).start();
}
latch1.await();
long expected = (long) THREADS * ITERATIONS;
long actual1 = syncMap.get("count");
System.out.printf("synchronizedMap: 期望=%d, 实际=%d, 丢失=%d%n",
expected, actual1, expected - actual1);
// 测试2: ConcurrentHashMap.compute(安全)
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.put("count", 0);
CountDownLatch latch2 = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
chm.compute("count", (k, v) -> v + 1);
}
latch2.countDown();
}).start();
}
latch2.await();
long actual2 = chm.get("count");
System.out.printf("ConcurrentHashMap: 期望=%d, 实际=%d, 丢失=%d%n",
expected, actual2, expected - actual2);
}
}
预期输出:
makefile
synchronizedMap: 期望=100000, 实际=57623, 丢失=42377
ConcurrentHashMap: 期望=100000, 实际=100000, 丢失=0
synchronizedMap的丢失率约40%------20线程×5000次迭代中,大量线程在get后put前被其他线程穿插,导致覆盖写入。
4. 项目总结
4.1 优点与缺点
| 容器类型 | 优点 | 缺点 |
|---|---|---|
ConcurrentHashMap |
读无锁(volatile);写桶级锁粒度极细;支持原子复合操作(compute/merge等7种);多线程协同扩容不阻塞读写;自动树化防止链表退化 | 不允许null键值;size()弱一致性(不是精确值);树化/反树化有内存开销;内存占用(Node+CounterCell+TreeBin)比HashMap高30-50% |
synchronizedMap (HashMap) |
语义简单无学习成本;key/value允许null;适合单线程或极低并发场景用作本地缓存 | 全局锁导致串行瓶颈,高并发下吞吐量断崖式下跌;复合操作需外部同步且易遗漏;迭代需外部锁或承担CME风险;扩容期间所有读写阻塞 |
CopyOnWriteArrayList |
读操作完全无锁(O(1));迭代器持有快照不抛CME;非常适合监听器/配置表等读多写极少场景 | 每次写操作复制全量数组(O(n)时间+O(n)内存);写并发由一把锁保护(写互斥);大批量写入时内存峰值x2;不适合作常规List(add几万次=灾难) |
ArrayBlockingQueue |
有界队列天然防OOM;put/take阻塞语义消灭轮询浪费;底层数组实现cache友好 | 容量固定不可弹性扩容;put和take共享一把ReentrantLock(需要更高并发可选LinkedBlockingQueue);容量设定过大→内存浪费,过小→生产者频繁阻塞 |
LinkedBlockingQueue |
put和take各用独立锁(读/写并发隔离);支持无界模式(Integer.MAX_VALUE作为容量上限) | 无界模式下的无界风险(生产者不会被阻塞→可能撑爆堆);链表节点的内存碎片化比数组高 |
ConcurrentLinkedQueue |
Michael-Scott算法实现真正无锁队列;offer/poll都是无锁CAS;适合高吞吐实时入队出队 | 无阻塞语义(offer永不阻塞,poll空返回null→需要上层处理);size()需要遍历全量(O(n)),不可频繁调用;内存占用每个节点+volatile引用开销 |
LongAdder |
热点分散写入(CounterCell),吞吐量是AtomicLong的3-5倍;适合高并发计数场景 | sum()不是快照(弱一致性);占用内存更多(base+CounterCell\[\]数组);不能做getAndAdd的原子返回值(需要精确原子值时用AtomicLong);在多线程低时不适合(时延高于AtomicLong) |
4.2 适用场景
5个典型适用场景:
-
实时热榜/排行榜(ConcurrentHashMap + LongAdder 混合):用CHM维护"商品→热度值"映射,用LongAdder做匿名观众的纯计数累加,CHM.compute做"商品→热度增量"的原子聚合。如果是"点赞数排行"这种只有数值没有复杂结构的场景,LongAdder数组+定时汇总更高性能。
-
会话管理(ConcurrentHashMap):Web服务器的用户Session存储------高频读写(每个请求都要get Session)、安全移除(session过期remove)、原子复合操作(computeIfAbsent做"不存在就创建"的懒加载模式)。
-
事件监听器注册表(CopyOnWriteArrayList):微服务架构中的事件总线、UI框架的列表数据变更监听------注册/注销极少(秒级),通知触发极频繁(每请求一次),迭代期间绝不抛异常。
-
任务处理流水线(BlockingQueue):爬虫的多级处理管道------下载线程→解析线程→存储线程,用BlockingQueue串联各级,天然的背压(backpressure)控制,下游处理慢时上游自动阻塞等待。
-
API访问频率计数器(LongAdder):每秒钟的接口调用次数统计------超高吞吐写入,定时线程每秒读取sum()后reset()(通过创建新LongAdder实例替换),读取弱一致性完全可接受。
2个不适用场景:
-
需要跨多个Map的分布式事务一致性:CHM只能保证单个Map内部单个桶的并发安全------如果业务需要同时更新两个CHM并保证两者要么都成功要么都失败,CHM本身无能为力,需引入两阶段提交或Seata等分布式事务框架。
-
超高写入频率的List(不适合CopyOnWriteArrayList):比如直播间的实时弹幕墙------每秒钟数千条弹幕追加到列表,每次追加都是O(n)的全量数组复制,CPU和内存双双爆炸。应改用ConcurrentLinkedQueue或Disruptor环形缓冲。
4.3 注意事项
| 陷阱类别 | 具体问题 | 规避方案 |
|---|---|---|
| 复合操作陷阱 | if (map.get(k) == null) map.put(k, initValue) ------get和put之间的窗口会被其他线程插入 |
使用 putIfAbsent(k, initValue) 或 computeIfAbsent(k, k -> initValue) |
| 复合操作陷阱 | map.put(k, map.get(k) + delta) ------读-改-写不原子 |
使用 map.compute(k, (k, v) -> v + delta) |
| 复合操作陷阱 | 先 remove(k) 再处理------如果期间另一个线程put了同key的新值 |
使用 map.remove(k, expectedValue) 条件移除,或在compute内原子完成 |
| 扩容开销 | 大量初始化插入时的连续扩容(容量过小导致) | CHM构造函数指定 initialCapacity:new ConcurrentHashMap<>(expectedSize / 0.75f + 1) |
| treeify开销 | 频繁触发treeify/untreeify来回转换(链表↔红黑树反复切换) | 提高 initialCapacity 降低碰撞概率;或业务上保证key的hash分布均匀 |
| BlockingQueue死锁 | 单线程中先take后put在容量为0的SynchronousQueue场景 | 确保producer和consumer在不同线程;对有界队列用 offer(timeout) + poll(timeout) 代替无超时的 put/take |
| COWList写风暴 | 批量初始化用for循环add几千次------每次都全量复制,O(n²)总开销 | 使用构造器 new CopyOnWriteArrayList<>(sourceCollection) 或一次性 addAllAbsent(collection) |
| 内存足迹 | CHM额外内存占用(Node+CounterCell+ForwardingNode等) | 评估后为"巨大但读写比高"的场景仍选用synchronizedMap或外部加锁 |
4.4 常见踩坑经验
案例一:排行榜服务因size()计算引发全线超时
某电商平台"实时热卖榜"页面每隔3秒调用 chm.size() 检查"是否有新数据需要重新排序"。架构师发现:在10万条目级的CHM上,JDK 8的size()虽然比JDK 7快很多,但内部的 sumCount() 仍然需要遍历CounterCell数组并做原子累加------高频调用(每秒几十万次)使得CounterCell的缓存行在多个CPU核心间反复失效(False Sharing),导致整个服务P99延迟从50ms飙升至2000ms。
根因:CounterCell数组没有使用 @Contended 注解做缓存行填充(JDK 8中尚未引入),相邻的CounterCell实例被加载到同一个64字节缓存行中,不同核心写入相邻单元格时互相触发缓存一致性协议(MESI),整个数组频繁invalid。
修复:将size()改为异步定时计算(每30秒一次,放入本地volatile变量),排行榜渲染线程只读这个缓存变量。P99延迟恢复到55ms。
案例二:computeIfAbsent的递归死锁
某网关服务的限流模块使用CHM做"IP→RateLimiter"的映射:
java
// ❌ 错误写法:在computeIfAbsent的mappingFunction里递归调用同一个CHM
ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
RateLimiter limiter = limiters.computeIfAbsent(ip, k -> {
// mappingFunction内部不能对同一个CHM执行可能触发锁桶头的操作
long totalLimiters = limiters.size(); // ← 可能触发递归锁升级
return new RateLimiter(totalLimiters);
});
computeIfAbsent内部的mappingFunction执行时,该桶可能处于synchronized状态(视hash冲突情况而定)。在此函数内再执行包含synchronized(f)的操作,可能导致锁重入异常或死锁。JDK 8中CHM对这种情况做了检测(reservationNode),但业务层不应依赖此保护。
修复:将mappingFunction的逻辑移到CHM外部------先用一个局部变量计算totalLimiters,再传给computeIfAbsent。
案例三:Dynamic Proxy + COWList的内存泄漏
某RPC框架使用COWList存储拦截器链:
java
// 看似合法的代码
List<Interceptor> chain = new CopyOnWriteArrayList<>();
// 每次RPC调用都 add 一个动态代理包装的拦截器
chain.add(Proxy.newProxyInstance(...)); // ← 泄漏!
// 调用完成后 remove
chain.remove(proxy); // ← 但因为equals判断问题,未删除成功
COWList的remove依赖 equals() 方法匹配。但动态代理对象的 equals() 调用会被路由到 InvocationHandler.invoke()------如果handler在处理equals时不透明(比如每次都返回false),remove永远无法命中。每次RPC调用新增一个代理对象×O(n)复制全量数组,内存呈线性增长。
修复:不使用代理对象本身做equals比对,而是维护一个基于拦截器class的Map去查找原始引用来做remove;或者直接使用ConcurrentLinkedDeque(迭代时可安全遍历的无锁双向队列)替代COWList在写相对较多场景下的使用。
4.5 思考题
题目1 :ConcurrentHashMap的 computeIfAbsent(key, mappingFunction) 方法在线程A调用时检测到key不存在,正准备执行mappingFunction------线程B也在同一时刻调用 put(key, value)。如果两个key相同(hash碰撞到同一个桶),线程A和线程B都锁住了同一个桶头节点。请问:CHM如何保证mappingFunction最多被执行一次?(提示:查看源码中reservationNode和synchronized块的内部逻辑)
题目2:你维护的一个微服务中,一个CHM存储了10万个key,每个value是一个List(平均100个元素)。处理逻辑是:每个请求随机选择1个key,遍历其value列表检查某个条件,如果满足则从列表末尾移除1个元素。你发现P99延迟和GC频率同步上升。请设计一个替代方案,既要保证"查找key→遍历List→移除末尾元素"的原子性,又要降低GC压力(提示:考虑CHM.compute + 不可变集合快照,或分段锁 + ConcurrentLinkedDeque组合)。
下一章预告:第19章深入线程池(ThreadPoolExecutor)、拒绝策略(AbortPolicy/CallerRunsPolicy/DiscardPolicy)、工作窃取(ForkJoinPool)与 ThreadLocal 泄漏治理------业务系统最常踩的稳定性地雷。我们将手写一个线程池监控面板,从源码级别拆解工作线程的生命周期、core/max/queue的三级缓冲模型,以及ThreadLocalMap的弱引用Key为何仍然导致内存泄漏。