Administrator
发布于 2019-12-08 / 5795 阅读
159

HashMap 扩容死链:JDK 1.7 那个著名的 CPU 100% 问题

那个 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 = anext = b,然后被挂起

线程 T2 完整跑完整个 transfer,新桶里的顺序是 c → b → a。注意此时 b.next = aa.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%。transferget 都可能卡住,取决于哪个线程先碰到环。

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 = nullhiTail.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 次6000058,4121,588
第 2 次6000059,201799
第 3 次6000057,8842,116
第 20 次6000059,033967

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 7JDK 8
ConcurrentHashMap.put 吞吐2.1 M ops/s4.7 M ops/s
ConcurrentHashMap.get 吞吐9.8 M ops/s13.2 M ops/s
size() 是否精确是(锁全表)否(返回估算值)

注意 size()。JDK 8 里它返回的是各 CounterCell 的累加值,并发更新时是个估算值。如果需要精确的"判断容器是否为空",用 isEmpty()(它遍历所有桶,是准确的);需要精确计数的话,用 mappingCount() 也只能拿到 long 型的估算。这一点在用 ConcurrentHashMap 做限流计数时要注意。

排查这类问题的固定套路

  1. top 看整体:CPU 高的是 us(用户态,死循环/计算密集)还是 sy(内核态,上下文切换/锁竞争)。
  2. top -H -p <pid> 找具体线程,记下 CPU 最高那几个的 PID。
  3. printf '%x\n' <tid> 转成十六进制。
  4. jstack <pid> > /tmp/stack.txt,grep 对应的 nid=0x...
  5. 隔 5 秒再抓一份对比。状态一直是 RUNNABLE 且停在同一行代码,基本可以断定死循环。

多抓几份很重要。只抓一份的话,可能刚好抓到线程在正常工作,看到的是正常栈,就容易误判。我第一次就是只抓了一份,看到线程在 SettleWorker.run 上,以为是业务逻辑慢,白查了一小时。

先到这

《HashMap 扩容死链:JDK 1.7 那个著名的 CPU 100% 问题》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考