第18章:ConcurrentHashMap与并发容器实战

1. 项目背景

业务场景:某直播平台的"实时热榜"模块需要在直播PK期间实时展示Top 100热榜商品。高峰期10万并发观众同时刷新热榜排名,后端服务需要在高并发读写下维护"商品-热度值"映射表。原先的开发实现是 HashMap 外包一层 synchronized 关键字,所有读写操作串行化。大促期间随着并发数从日常3万飙升至10万,单次排行榜刷新延迟从200ms恶化到10秒------观众看到的是"上一个10秒的排名",而主播已切换到下一个PK商品,热榜数据完全错位。

痛点:

  1. synchronized HashMap的串行瓶颈 :synchronized 将整个Map的读写操作互斥化------100个线程竞争同一把锁,99个线程在BLOCKED状态,CPU利用率不足5%,但请求延迟却飙升200倍。更致命的是:get+put 复合操作(热度值递增)不是原子的,导致"update丢失"------A线程读出热度100,B线程也读出热度100,A写入101,B也写入101,热度值损失了一半增量。

  2. 扩容风暴(Resize Storm):当HashMap的size超过threshold(capacity × 0.75),触发resize------将原数组所有节点的hash重新映射到2倍容量新数组,时间复杂度O(n)。在千万级热榜条目下,单次resize需1-3秒,整个HashMap在此期间被锁死,所有读请求超时。

  3. 迭代快照不一致(Stale View):排行榜需要定期遍历整个Map做排序------synchronized虽能保护单次迭代,但迭代器获取的快照在遍历期间已过期,新加入的热度数据不可见,造成"幽灵热榜"(已经下播的商品还在榜上,新上榜的商品看不到)。

  4. 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个独立窗口,每个窗口有自己的一把钥匙------只有落在同一个窗口的请求才需要排队。

核心缺点:

  1. 并发度受限于Segment数量(默认16,构造时可指定但无法动态扩容)。
  2. 每个Segment内部是传统的数组+链表------链表过长时查找退化为O(n)。
  3. 跨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):

  1. 检查key.hashCode,调用 spread()(源码行710)进行高位扰动,降低hash碰撞。
  2. 定位桶位置 (n-1) & hash。
  3. 如果桶为空:用 casTabAt()(源码行777)CAS尝试设置头节点,成功即返回------这是无锁写入的最优路径。
  4. 如果桶头节点是 ForwardingNode:说明正在扩容,调用 helpTransfer()(源码行2390)协助迁移------实现扩容写不阻塞。
  5. 如果桶非空且非ForwardingNode:synchronized 锁住桶头节点(源码行1047)------锁粒度精确到一个桶。
  6. 桶内查找/插入:遍历链表或红黑树。如果链表长度达到 TREEIFY_THRESHOLD (8) 且数组长度 ≥ 64,调用 treeifyBin()(源码行2692)将链表转为红黑树------查找从O(n)降至O(log n)。
  7. 调用 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() 不直接在单个变量上做原子递增(热点竞争太激烈),而是:

  1. 先尝试CAS更新 baseCount ------如果成功就直接返回。
  2. 如果CAS失败(说明有竞争),随机探针选择一个 CounterCell(counterCells 数组中的一个格子),在该格子上做CAS递增。
  3. 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个典型适用场景:

  1. 实时热榜/排行榜(ConcurrentHashMap + LongAdder 混合):用CHM维护"商品→热度值"映射,用LongAdder做匿名观众的纯计数累加,CHM.compute做"商品→热度增量"的原子聚合。如果是"点赞数排行"这种只有数值没有复杂结构的场景,LongAdder数组+定时汇总更高性能。

  2. 会话管理(ConcurrentHashMap):Web服务器的用户Session存储------高频读写(每个请求都要get Session)、安全移除(session过期remove)、原子复合操作(computeIfAbsent做"不存在就创建"的懒加载模式)。

  3. 事件监听器注册表(CopyOnWriteArrayList):微服务架构中的事件总线、UI框架的列表数据变更监听------注册/注销极少(秒级),通知触发极频繁(每请求一次),迭代期间绝不抛异常。

  4. 任务处理流水线(BlockingQueue):爬虫的多级处理管道------下载线程→解析线程→存储线程,用BlockingQueue串联各级,天然的背压(backpressure)控制,下游处理慢时上游自动阻塞等待。

  5. API访问频率计数器(LongAdder):每秒钟的接口调用次数统计------超高吞吐写入,定时线程每秒读取sum()后reset()(通过创建新LongAdder实例替换),读取弱一致性完全可接受。

2个不适用场景:

  1. 需要跨多个Map的分布式事务一致性:CHM只能保证单个Map内部单个桶的并发安全------如果业务需要同时更新两个CHM并保证两者要么都成功要么都失败,CHM本身无能为力,需引入两阶段提交或Seata等分布式事务框架。

  2. 超高写入频率的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为何仍然导致内存泄漏。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理

相关推荐
vipxieliang1 小时前
ValidX 在微服务架构中的验证策略
java·spring boot
小羊没烦恼!1 小时前
在Scrum中实施敏捷建模
java·开发语言·windows·算法·c#
❀͜͡傀儡师1 小时前
20 年沉淀,CAS 8.0 重新定义企业级 SSO:适配 JDK 25 与 Spring Boot 4.1
java·开发语言·spring boot
牧瀬クリスだ2 小时前
Spring统一功能处理
java·spring boot·spring·状态模式
SL_staff2 小时前
从钉钉日报到动态数据看板:面向业务侧的低代码BI实践路径
java·数据分析·数据可视化
TDengine (老段)2 小时前
TDengine TSDB 实战排障四(升级与兼容)
android·java·大数据·数据库·物联网·时序数据库·tdengine
nhdh2 小时前
SpringAI与SpringAIAlibaba:标准与生态的完美互补
java·人工智能·spring
不会写DN2 小时前
Go日志库工程选型与逃逸分析评测报告
java·服务器·golang
SL_staff3 小时前
合规性折旧:当知识资产因主权缺位在审计中‘功能性清零’
java·设计模式·开源