今天,感谢我的同事「汝止」的分享。
双十一前期,发现线上一台服务器偶发性的线程 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 对象上。ReservationNode 在 computeIfAbsent() 方法构建 value 值的时候被用作占位节点。
参见源码: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; }
发生死循环是由于在初始化 bucket 的时候,computeIfAbsent() 方法初始化一个 ReservationNode 来占位。等待计算完毕后替换当前的占位对象。此时,又触发了 ConcurrentHashMap 扩容。
参见源码:java.util.concurrent.ConcurrentHashMap#transfer
ConcurrentHashMap 扩容忽略了 ReservationNode 情况,因此导致死锁,然后就一直 for 循环处理了。
值得庆幸的是,JDK 1.9 解决了这个问题,但是对于 JDK 1.8 的用户还是需要规避的。