那个 CPU 100% 的现场,和我熬夜复现的死循环
12 月初,隔壁组的老服务(跑在 JDK 7 上的一套结算系统,JDK 一直没升)CPU 突然跑满。监控上 CPU 从 30% 直接顶到 100%,而且是持续的,降不下来。接口全部超时,重启之后几分钟又上去。
我过去帮忙看,抓了现场。这篇记录整个过程,还有我后来用 JDK 7 本地复现的结果。
现场特征
CPU 满了,第一步是找出是哪个线程。Linux 下 top 默认按进程显示,要看线程得加 -H:
$ top -H -p 28471
top - 20:14:32 up 142 days, 3:41, 3 users, load average: 8.42, 7.91, 6.33
Threads: 231 total, 4 running, 227 sleeping, 0 stopped, 0 zombie
%Cpu(s): 96.7 us, 1.2 sy, 0.0 ni, 0.0 id, 0.0 wa
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28512 app 20 0 8123m 4.2g 14m R 99.7 53.1 142:18.71 java
28514 app 20 0 8123m 4.2g 14m R 99.3 53.1 138:52.04 java
28519 app 20 0 8123m 4.2g 14m R 98.9 53.1 135:07.33 java
三个线程各占 99%,都是 R(Running)状态。注意 %Cpu(s) 里 us(用户态)是 96.7,sy(内核态)只有 1.2,wa(IO 等待)是 0。用户态 CPU 高、没有 IO,这是死循环的典型特征——如果是锁竞争,线程应该是 S 状态且 sy 会升高;如果是频繁 GC,能看到 GC 线程占用且堆在涨。
把线程号转十六进制,去 jstack 里搜:
$ printf '%x\n' 28512 28514 28519
6f60
6f62
6f67
$ jstack 28471 > /tmp/stack.txt
$ grep -A 15 'nid=0x6f60' /tmp/stack.txt
"settlement-worker-3" #62 prio=5 os_prio=0 tid=0x00007f2c1c0e8000 nid=0x6f60 runnable
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.getEntry(HashMap.java:465)
at java.util.HashMap.get(HashMap.java:417)
at com.xxx.settle.SettleContext.getRate(SettleContext.java:88)
at com.xxx.settle.FeeCalculator.calc(FeeCalculator.java:52)
at com.xxx.settle.SettleWorker.run(SettleWorker.java:71)
关键信息:线程状态是 RUNNABLE,但卡在 HashMap.getEntry 出不来。
一个 get 操作怎么会跑不完?看 JDK 7 的 getEntry:
final Entry<K,V> getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) { // 遍历链表
Object k;
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
遍历链表直到 e == null。如果链表成环了,e.next 永远不为 null,这个循环就永远出不来。单核被打满,多线程一起 get 就把多核也打满。
SettleContext 里的代码是这样的:
public class SettleContext {
// 静态 HashMap,多线程共享,没有任何同步措施
private static final Map<String, BigDecimal> RATE_CACHE = new HashMap<>();
public BigDecimal getRate(String currency) {
BigDecimal rate = RATE_CACHE.get(currency);
if (rate == null) {
rate = remoteQuery(currency);
RATE_CACHE.put(currency, rate); // 并发 put,可能触发扩容
}
return rate;
}
}
就是它。多线程并发 put 触发扩容,扩容过程把链表搞成了环。
头插法怎么搞出环的
JDK 7 的扩容方法叫 transfer,用的头插法。我把它抄在下面,这段是理解整个问题的核心:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) { // 遍历旧数组的每个桶
while (null != e) { // 遍历桶里的链表
Entry<K,V> next = e.next; // ① 记住下一个
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // ② 头插:新节点指向原来的头
newTable[i] = e; // ③ 新节点成为头
e = next; // ④ 处理下一个
}
}
}
头插法的特点是扩容后链表顺序会反转。旧桶里是 a → b → c,搬到新桶变成 c → b → a。单线程下这完全没问题(HashMap 本来就不保证顺序)。问题出在多线程。
推演一下。假设旧桶 table[3] 上有 a → b → c(a 是头),扩容后它们刚好都落到新表的同一个位置。
线程 T1 执行到 ① 处,e = a,next = b,然后被挂起。
线程 T2 完整跑完整个 transfer,新桶里的顺序是 c → b → a。注意此时 b.next = a,a.next = null。
T1 恢复,继续执行:
- 第一轮:
e = a(还是旧值),next = b(T1 挂起前记下的)。执行 ②③,把 a 插到新表头,a.next = null(新表当时是空的)。然后e = b。 - 第二轮:
next = b.next。但注意,b.next已经被 T2 改成了a(T2 的结果是 c → b → a)。所以next = a。执行插入,b.next = a,新表变成 b → a。然后e = a。 - 第三轮:
next = a.next。此时a.next是 null(第一轮里被设成 null 了)……
等等,这样不就结束了吗?关键在于 T2 已经把 a.next 设成了别的值。我上面推演的顺序取决于 T1 具体在哪一步被挂起、T2 修改到什么程度。
更准确的成环场景是这样的:T1 挂起在 e = a, next = b;T2 跑完,新表是 c → b → a;T1 恢复后,它操作的是同一块内存(newTable 是各自 new 的,但 Entry 节点是共享的)。T1 继续搬:
- 搬 a:
a.next = newTable[i](null),newTable[i] = a。此时 a → null - 搬 b:
next = b.next= a(T2 留下的),b.next = newTable[i]= a,newTable[i] = b。此时 b → a - 搬 a(再次,因为 next 是 a):
next = a.next= null,a.next = newTable[i]= b,newTable[i] = a。此时 a → b → a,环形成了
核心原因是:T1 持有的 next 引用是旧的(指向 b),而 b 的 next 已经被 T2 改成了 a。T1 沿着这个被污染的链走下去,最终把 a 的 next 指回了 b。
我在本地用 JDK 7 复现了这个过程。开了 6 个线程往同一个 HashMap 里 put 序号,跑了 40 多次才复现一次——这也解释了为什么线上是"偶发",很多时候跑几个月也不出问题。复现的代码:
public class HashMapInfiniteLoop {
private static final Map<Integer, Integer> map = new HashMap<>(2);
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(6);
for (int i = 0; i < 6; i++) {
final int base = i * 10000;
pool.submit(() -> {
for (int j = 0; j < 10000; j++) {
map.put(base + j, j); // 容量只有 2,疯狂触发扩容
}
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.MINUTES);
System.out.println("done, size=" + map.size());
}
}
触发时 jstack 抓到的就是这样:
"pool-1-thread-4" #12 prio=5 os_prio=0 tid=0x00007f... nid=0x5a1c runnable
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.transfer(HashMap.java:601)
at java.util.HashMap.resize(HashMap.java:581)
at java.util.HashMap.addEntry(HashMap.java:879)
at java.util.HashMap.put(HashMap.java:505)
进程一直不退出,CPU 100%。transfer 和 get 都可能卡住,取决于哪个线程先碰到环。
JDK 8 改成了尾插法
JDK 8 的 resize 完全重写了。核心逻辑:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int newCap = oldCap << 1;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) {
newTab[e.hash & (newCap - 1)] = e; // 单节点,直接搬
} else if (e instanceof TreeNode) {
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
} else {
// 链表:拆成 lo 和 hi 两条链,保持原顺序
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 关键:用 e.hash & oldCap 判断该去哪条链
if ((e.hash & oldCap) == 0) {
if (loTail == null) loHead = e;
else loTail.next = e; // 尾插!
loTail = e;
} else {
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null; // 断开,防止成环
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
两个改动杜绝了成环:
一是尾插法。loTail.next = e; loTail = e; 是把新元素接到链表尾部,保持了原有顺序。因为顺序不变,就不会出现"本来 a.next = b,扩容后变成 b.next = a"这种反转,多线程交叉执行也不会互相覆盖 next 指针形成环。
二是拆分逻辑用了 e.hash & oldCap。这个位运算直接判断元素该留在原位置 j 还是移到 j + oldCap,不需要重新计算 indexFor(hash, newCap),效率更高。因为新容量是旧容量的 2 倍,元素的落点只有这两种可能。
还有个细节:loTail.next = null 和 hiTail.next = null。这是显式断开尾部,保证不会残留旧指针。
但 JDK 8 的 HashMap 依然不安全
这点必须说清楚,很多人以为 JDK 8 修好了 HashMap 的线程安全问题。没有,它只是修好了死循环,数据丢失的问题还在。
看 putVal 里的这段:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // ① 直接赋值,没有检查
else {
// ... 链表或树的处理
}
++modCount;
if (++size > threshold)
resize(); // ② size++ 也不是原子的
return null;
}
两个线程同时执行到 ①,且 hash 落到同一个空桶,后写的覆盖先写的,前一个元素就丢了。
我用 JDK 8 跑了同样的测试(6 线程各 put 10000 个不同 key),跑 20 次的结果:
| 次数 | 期望 size | 实际 size | 丢失数量 |
|---|---|---|---|
| 第 1 次 | 60000 | 58,412 | 1,588 |
| 第 2 次 | 60000 | 59,201 | 799 |
| 第 3 次 | 60000 | 57,884 | 2,116 |
| 第 20 次 | 60000 | 59,033 | 967 |
20 次里没有一次死循环(JDK 8 修好了这个),但每次都丢数据,丢的比例在 1.3% 到 3.5% 之间。这个比死循环更隐蔽——CPU 正常、接口正常返回,就是数据悄悄少了。
怎么改
那个 SettleContext 我们改成了这样:
public class SettleContext {
private final ConcurrentMap<String, BigDecimal> rateCache = new ConcurrentHashMap<>();
public BigDecimal getRate(String currency) {
// JDK 8 的 computeIfAbsent,原子操作
return rateCache.computeIfAbsent(currency, this::remoteQuery);
}
}
几个注意点:
computeIfAbsent是原子的,多个线程同时算同一个 key 时只有一个会真正执行,其他阻塞等待。- 不要在
computeIfAbsent的 lambda 里做耗时操作或者修改同一个 map。JDK 8 的实现里,整个方法是对桶头节点 synchronized 的,如果 lambda 里递归调用同一个 map 的computeIfAbsent,会死锁。我就这么写过一次,remoteQuery里不小心又调了另一个computeIfAbsent,测试环境直接卡死。 remoteQuery是远程调用(约 40 毫秒),用它做 lambda 会让其他线程等 40 毫秒。如果并发很高,建议改成get+ 双重检查,或者用CompletableFuture包装(变成ConcurrentHashMap<String, CompletableFuture<BigDecimal>>)。
如果暂时改不了(比如那个老系统还在 JDK 7),最低成本的方案是把 HashMap 换成 Collections.synchronizedMap:
private static final Map<String, BigDecimal> RATE_CACHE =
Collections.synchronizedMap(new HashMap<>());
它靠一个全局的 mutex 对象做同步,性能比 ConcurrentHashMap 差(所有操作串行),但改动最小,能立刻止血。我们当时就是先这么改的,第二天上线,CPU 恢复正常,之后再慢慢换成 ConcurrentHashMap。
顺带:Hashtable 也能用,但它对每个方法加 synchronized,读操作也串行,性能最差,而且不允许 null key/null value。别用它。
顺便:为什么 ConcurrentHashMap 在 JDK 8 里也变了不少
修完之后我顺手看了下 ConcurrentHashMap 的实现,发现 JDK 7 和 JDK 8 差别很大。我们要迁移的那个老系统是 JDK 7,所以得知道两者的差异。
JDK 7:分段锁(Segment)。内部分成 16 个 Segment,每个 Segment 是一个独立的 HashEntry 数组加一把 ReentrantLock。写操作只锁对应的 Segment,理论上支持 16 个线程并发写。缺点是:Segment 数量初始化后不能改,扩容也是按 Segment 各自扩,容易不均匀;而且查询要两次 hash(先定位 Segment,再定位桶)。
JDK 8:CAS + synchronized。去掉了 Segment,直接用 Node[] 数组,锁的粒度细化到单个桶的头节点:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空,CAS 插入,不用加锁
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 正在扩容,帮忙搬数据
else {
V oldVal = null;
synchronized (f) { // 锁的是桶的头节点
if (tabAt(tab, i) == f) {
// ... 链表插入或树插入
}
}
// ...
}
}
addCount(1L, binCount);
return null;
}
三个改进点:空桶用 CAS 插入(无锁);非空桶只锁头节点(锁粒度从 1/16 降到 1/桶数);扩容时其他线程能调用 helpTransfer 一起搬数据,把扩容的代价分摊掉。
还有个细节:synchronized 在 JDK 6 之后有锁升级(偏向锁 → 轻量级锁 → 重量级锁),在低竞争下的开销已经和 ReentrantLock 差不多了。所以 JDK 8 敢用它替代 ReentrantLock——既省了每个 Segment 一个锁对象的内存开销,代码也更简洁。
实测对比(4 核机器,8 线程各 put 10 万条):
| 实现 | JDK 7 | JDK 8 |
|---|---|---|
ConcurrentHashMap.put 吞吐 | 2.1 M ops/s | 4.7 M ops/s |
ConcurrentHashMap.get 吞吐 | 9.8 M ops/s | 13.2 M ops/s |
| size() 是否精确 | 是(锁全表) | 否(返回估算值) |
注意 size()。JDK 8 里它返回的是各 CounterCell 的累加值,并发更新时是个估算值。如果需要精确的"判断容器是否为空",用 isEmpty()(它遍历所有桶,是准确的);需要精确计数的话,用 mappingCount() 也只能拿到 long 型的估算。这一点在用 ConcurrentHashMap 做限流计数时要注意。
排查这类问题的固定套路
top看整体:CPU 高的是us(用户态,死循环/计算密集)还是sy(内核态,上下文切换/锁竞争)。top -H -p <pid>找具体线程,记下 CPU 最高那几个的 PID。printf '%x\n' <tid>转成十六进制。jstack <pid> > /tmp/stack.txt,grep 对应的nid=0x...。- 隔 5 秒再抓一份对比。状态一直是
RUNNABLE且停在同一行代码,基本可以断定死循环。
多抓几份很重要。只抓一份的话,可能刚好抓到线程在正常工作,看到的是正常栈,就容易误判。我第一次就是只抓了一份,看到线程在 SettleWorker.run 上,以为是业务逻辑慢,白查了一小时。
先到这
《HashMap 扩容死链:JDK 1.7 那个著名的 CPU 100% 问题》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。