感谢我的同事「汝止」的分享。

问题描述

双十一备战前期,发现线上一台服务器偶发性的线程 cpu 占用会到 100%,并且一直饱和,需要重启服务器才能恢复。进过排查发现 ConcurrentHashMap.computeIfAbsent 在 JDK 1.8 存在 BUG。

原因分析

现在,我们从真实的业务代码中,模拟一个类似的场景。

public class ConcurrentHashMapDemo {

    private Map<Integer, Integer> cache = new ConcurrentHashMap<>(15);

    public static void main(String[] args) {

        ConcurrentHashMapDemo ch = new ConcurrentHashMapDemo();

        System.out.println(ch.fibonaacci(80));

    }

    public int fibonaacci(Integer i) {

        if (i == 0 || i == 1) {

            return i;

        }

        return cache.computeIfAbsent(i, (key) -> {

            System.out.println("fibonaacci : " + key);

            return fibonaacci(key - 1) + fibonaacci(key - 2);

        });

    }

}

如果你将这个代码跑起来,你会发现的这个程序将进入死循环,而无法结束。通过阅读源码发现,ConcurrentHashMap.computeIfAbsent() 方法停留在了一个 ReservationNode 对象上。

参见源码:

java.util.concurrent.ConcurrentHashMap#computeIfAbsent

public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {

        if (key == null || mappingFunction == null)

            throw new NullPointerException();

        int h = spread(key.hashCode());

        V val = null;

        int binCount = 0;

        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) & h)) == null) {

                Node<K,V> r = new ReservationNode<K,V>();

                synchronized (r) {

                    if (casTabAt(tab, i, null, r)) {

                        binCount = 1;

                        Node<K,V> node = null;

                        try {

                            if ((val = mappingFunction.apply(key)) != null)

                                node = new Node<K,V>(h, key, val, null);

                        } finally {

                            setTabAt(tab, i, node);

                        }

                    }

                }

                if (binCount != 0)

                    break;

            }

            else if ((fh = f.hash) == MOVED)

                tab = helpTransfer(tab, f);

            else {

                // 忽略

            }

        }

        if (val != null)

            addCount(1L, binCount);

        return val;

    }

ReservationNode 在 computeIfAbsent() 方法构建 value 值的时候被用作占位节点。换句话说,computeIfAbsent() 方法会初始化一个 ReservationNode 来占位,它会等待计算完毕后替换当前的占位对象。此时,如果正好赶在 ConcurrentHashMap 达到容量 0.75 的时候进行扩容,由于ConcurrentHashMap 扩容忽略了 ReservationNode 情况。因此,可能会导致扩容无法替换占位符,同时占位符等待替换的情况,然后就一直 for 循环处理了。读者如果感兴趣,可以阅读扩容的源码:java.util.concurrent.ConcurrentHashMap#transfer

值得庆幸的是,JDK 1.9 解决了这个问题,但是对于 JDK 1.8 的用户还是需要规避的。

解决方案

发现问题就好办了,解决方案也很简单 : 不用递归的方式创建 ConcurrentHashMap 对象。