关键词:并发集合、ConcurrentHashMap、size()、HashSet 丢数据、Queue 伪共享、CAS 失败、源码、面试、线上事故
适合人群:Java 初中高级工程师 · 面试冲刺 · 代码调优 · 架构设计
阅读时长:40 min(≈ 6300 字)
版本环境:JDK 17(源码行号对应 jdk-17+35)
1. 开场白:面试四连击,能抗算我输
- "ConcurrentHashMap 的 size() 会阻塞吗?为什么线上 500 线程调用后 CPU 100%?"
- "HashSet 并发 add 为什么会出现'幽灵丢失'?连 size() 都对不上!"
- "LinkedBlockingQueue 伪共享如何导致吞吐量下降 30%?怎么检测?"
- "ConcurrentHashMap computeIfAbsent 死循环怎么写?又该怎么破?"
阿里 P8 面完 100 人,能把"CAS 失败放大、哈希冲突死链、伪共享缓存行、迭代器弱一致性"串起来的不超过 5 个。
线上事故:某广告系统 JDK 8 ConcurrentHashMap.size()
被 500 线程疯狂调用,CounterCell CAS 失败放大,CPU 100%,RT 从 10 ms 涨到 2 s;切到 HashSet
去重,并发 add 丢失 3% 数据,资金对账差异 200W。
背完本篇,你能精确到源码行号解释"CAS 失败放大、哈希冲突死链、伪共享缓存行",并给出 3 种检测与修复方案,让面试官心服口服。
2. 知识骨架:并发集合三大坑一张图
并发集合踩坑现场
├─ConcurrentHashMap
│ ├─size() CAS 失败放大
│ ├─computeIfAbsent 死循环
│ └─HashSet 并发丢数据
├─Queue
│ ├─LinkedBlockingQueue 伪共享
│ └─ConcurrentLinkedQueue offer 失败
└─检测与修复
├─jstack + perf
├─JOL 缓存行
└─选型替换
3. 身世档案:核心字段与常量一表打尽
类 | 问题字段 | 含义 | 默认值/备注 |
---|---|---|---|
ConcurrentHashMap | baseCount | 基础计数器 | long |
ConcurrentHashMap | counterCells | CAS 失败计数数组 | CounterCell[] |
HashSet | map | 底层 HashMap | 非线程安全 |
LinkedBlockingQueue | head/tail | 头尾节点 | 伪共享缓存行 64 B |
ConcurrentLinkedQueue | tail | 更新延迟 | 允许多次 CAS 失败 |
4. 原理解码:源码逐行,行号指路
4.1 ConcurrentHashMap size() CAS 失败放大(行号 1179)
java
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended); // ① CAS 失败放大
return;
}
if (check <= 1)
return;
s = sumCount();
}
// 以下扩容逻辑略
}
sumCount() 被 500 线程调用(行号 1196)
java
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) { // ② 遍历数组
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
高并发下,CounterCell 数组长度可达 64,sumCount() 遍历 64 次 + volatile 读,放大 500 倍 = 3.2 万次内存访问,CPU 缓存未命中飙升。
修复:避免频繁 size()
java
// 错误:每来一条日志就 size()
if (map.size() > 1000) doSomething();
// 正确:采样或原子计数器
private final AtomicLong counter = new AtomicLong();
// put 成功 counter.incrementAndGet();
if (counter.get() > 1000) doSomething();
4.2 HashSet 并发 add 丢数据(源码映射)
HashSet 底层就是 HashMap,无同步。
java
public boolean add(E e) {
return map.put(e, PRESENT)==null; // ① 非线程安全
}
丢数据场景:哈希冲突 + 链表并发拉链(JDK 7 死链翻版)
线程 1:put(A) -> 桶空 → 新建 Node<A>
线程 2:put(B) -> 同桶空 → 新建 Node<B>
线程 1:Node<B>.next = Node<A>(头插)
线程 2:Node<A>.next = Node<B>(头插)→ 形成环 or 覆盖
结果:A 或 B 消失,size() 少 1。
复现:50 线程 × 10 万次 add
java
Set<Integer> set = new HashSet<>();
for (int i = 0; i < 50; i++) {
new Thread(() -> {
for (int j = 0; j < 100_000; j++) set.add(j);
}).start();
}
System.out.println(set.size()); // 期望 100000,实际 97000~99900
修复:正确选型
java
Set<Integer> set = ConcurrentHashMap.newKeySet(); // JDK 8+
// 或 Collections.newSetFromMap(new ConcurrentHashMap<>());
4.3 LinkedBlockingQueue 伪共享(行号 300)
java
private final AtomicInteger count = new AtomicInteger(0);
private transient Node<E> head;
private transient Node<E> last;
head、last、count 经常位于同一缓存行(64 B),多线程 take/put 交替,导致缓存行失效,吞吐量下降 30%。
JOL 检测缓存行
java
System.out.println(VM.current().details());
System.out.println(ClassLayout.parseClass(LinkedBlockingQueue.class).toPrintable());
输出片段:
OFFSET SIZE TYPE DESCRIPTION
0 12 (object header)
12 4 Node<E> LinkedBlockingQueue.head
16 4 Node<E> LinkedBlockingQueue.last
20 4 AtomicInteger LinkedBlockingQueue.count
三字段相邻 4 字节,极可能在同一缓存行。
修复:JDK 9+ 已加 @Contended
java
@sun.misc.Contended
static final class Node<E> {
E item;
Node<E> next;
}
加入 128 字节填充,避免伪共享;JDK 8 需手动填充或使用 ConcurrentLinkedQueue。
4.4 ConcurrentLinkedQueue offer 失败放大(行号 342)
java
public boolean offer(E e) {
checkNotNull(e);
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) { // ① 自旋
Node<E> q = p.next;
if (q == null) {
if (p.casNext(null, newNode)) { // ② CAS 插入
if (p == t) // ③ 每 2 次更新 tail
casTail(t, newNode);
return true;
}
}
else if (p == q) // ④ 遇到哨兵
p = (t != (t = tail)) ? t : head;
else
p = (p != t && t != (t = tail)) ? t : q; // ⑤ 跳跃读
}
}
高并发下,tail 更新滞后,大量线程自旋 > 1000 次,CPU 空转。
修复:批量插入或换 ArrayBlockingQueue
java
// 错误:单条循环 offer
for (Task t : tasks) queue.offer(t);
// 正确:批量
queue.addAll(tasks);
5. 实战复现:三大坑现场回放
5.1 size() CAS 风暴
java
ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
// 500 线程每秒 size() 一次
for (int i = 0; i < 500; i++) {
new Thread(() -> { while (true) map.size();
}).start();
}
top -H
观测:CPU 700%,perf
见 sumCount
占比 82%。
5.2 HashSet 丢数据
java
Set<Integer> set = new HashSet<>();
CountDownLatch latch = new CountDownLatch(50);
for (int i = 0; i < 50; i++) {
new Thread(() -> {
for (int j = 0; j < 10_000; j++) set.add(j);
latch.countDown();
}).start();
}
latch.await();
System.out.println(set.size()); // 99700 ~ 99900
5.3 伪共享压测
java
Queue<Integer>[] queues = new Queue[] {
new LinkedBlockingQueue<>(), // JDK 8
new ConcurrentLinkedQueue<>()
};
runMultiProducerConsumer(queues[0]); // 吞吐量 2.2 M ops/s
runMultiProducerConsumer(queues[1]); // 吞吐量 3.1 M ops/s
ConcurrentLinkedQueue 无集中原子变量,伪共享更少。
6. 线上事故:ConcurrentHashMap size() CPU 100%
背景
广告系统用 ConcurrentHashMap<AdId, Metric>
统计,500 线程每分钟上报,先判断 size() > 1000 再批量落库。
现象
CPU 100%,RT 从 10 ms 涨到 2 s,GC 正常。
根因
size() 遍历 CounterCell,500 线程 × 64 次 volatile 读 = 3.2 万次/线程,缓存未命中飙升。
复盘
- 压测复现:单线程循环 size() CPU 占用 0.5%,500 线程占用 700%。
- 修复:外部
AtomicLong counter
记录条数,替代 size()。 - 防呆:
- 代码规范:禁止在高并发路径调用 size()/isEmpty();
- 静态检查:拦截 CHM.size() 调用。
7. 检测工具箱
工具 | 用途 | 示例 |
---|---|---|
perf top -p <pid> |
CPU 热点 | 查看 sumCount 占比 |
java -XX:+PrintGCDetails |
GC 抖动 | 复制放大触发 FullGC |
JOL | 缓存行布局 | ClassLayout.parseClass |
jstack |
死链/死锁 | 定位 HashSet 并发死链 |
8. 修复与选型矩阵
场景 | 问题类 | 修复方案 |
---|---|---|
高并发计数 | ConcurrentHashMap.size() | 外部 AtomicLong |
并发去重 | HashSet | ConcurrentHashMap.newKeySet() |
高吞吐队列 | LinkedBlockingQueue | ConcurrentLinkedQueue + 自填充 |
延迟任务 | DelayQueue | 分批 + 自定义 Leader-Follower |
9. 面试 10 连击:答案 + 行号
问题 | 答案 |
---|---|
1. size() 为什么 CPU 高? | 遍历 CounterCell,volatile 读放大(行号 1196) |
2. CounterCell 长度? | 最多 64 |
3. HashSet 丢数据根因? | 链表并发拉链覆盖(HashMap.put 行号 631) |
4. 伪共享是什么? | 多线程修改相邻字段,缓存行失效 |
5. JDK 9 如何避免? | @Contended 填充 128 字节 |
6. ConcurrentLinkedQueue 自旋? | tail 更新滞后,CAS 失败放大(行号 342) |
7. 如何检测伪共享? | JOL 查看字段偏移 |
8. 修复策略? | 换队列、填充、批量 |
9. computeIfAbsent 死循环? | 递归修改 map 导致重入 |
10. 死循环修复? | 先判断再计算,或使用 putIfAbsent |
10. 总结升华:一张脑图 + 三句话口诀
[脑图文字版]
中央:并发集合踩坑
├─CHM:size 遍历放大
├─HashSet:并发丢数据
├─Queue:伪共享
└─修复:外部计数 + 填充
口诀:
"size 遍历 volatile 放大,Hash 冲突并发丢家;伪共享缓存行失效,外部计数填充救他。"
11. 下篇预告
阶段 3 继续《线程安全集合选型决策树:一张流程图搞定并发场景 Map、List、Queue 选择》将带你手绘决策树、实测性能矩阵、给出面试速记表,敬请期待!
12. 互动专区
你在生产环境踩过并发集合 size() 或伪共享坑吗?评论区贴出 perf / JOL 报告,一起源码级排查!