#注意。 注意ConcurrentHashMap中的原子操作



自远古以来,Java就拥有了很棒的Map接口及其实现,特别是HashMap 。 从Java 5开始,还有ConcurrentHashMap 。 考虑这两种实现方式,它们的演变以及这种演变会导致开发人员不专心的情况。

警告:本文使用来自OpenJDK 8源代码的引用,该源代码在GNU通用公共许可证版本2下分发。

Java 8之前的时代


那些发现等待时间很长的人,首先是Java 7,然后是Java 8(现在不像现在,每六个月有一个新版本),记住使用Map进行哪些操作最受欢迎。 这是:


如果您需要在集合中添加值,则使用第一种方法,要获取现有值,请使用第二种方法。

但是,如果需要延迟初始化怎么办? 然后出现了这样的代码:

String getOrPut(String key) { String result = map.get(key); //(1) if (result == null) { //(2) result = createValue(key); //(3) map.put(key, result); //(4) } return result; } 

  1. 我们通过钥匙得到价值
  2. 检查是否找到所需的值
  3. 如果找不到值,则创建它
  4. 通过键为收藏添加价值

原来有点麻烦,不是吗? 而且,在使用简单的HashMap的情况下,这只是不方便阅读的代码,因为 他没有穿线。 但是对于ConcurrentHashMap,会弹出一个附加功能:如果多个线程在其中一个将值写入集合(3)之前设法检查条件(1),则可以多次调用createValue(2)方法。 这种行为通常会导致不良后果。

在Java 8之前,根本没有优雅的选择。 如果您需要躲避多个值的创建,则必须使用其他锁。
Java 8使事情变得更容易。 好像...

Java 8来了...


Java 8带给我们的最令人期待的功能是什么? 是的,lambda。 不仅是美洲驼,而且还支持标准库中所有各种API。 地图数据结构未被忽略。 特别是出现了以下方法:


由于这些方法,可以重写前面给出的代码要简单得多:

 String getOrPut(String key) { return map.computeIfAbsent(key, this::createValue); } 

显然,没有人会放弃简化其代码的机会。 此外,在ConcurrentHashMap的情况下, computeIfAbsent方法也自动执行。 即 仅当缺少所需值时,createValue才会被精确调用一次。

IDE也没有通过。 因此,IntelliJ IDEA提供了自动替换旧版本和新版本的功能:




很明显,代码简化和IDE提示都鼓励开发人员使用此新API。 结果,同一computeIfAbsent开始出现在代码中的很多地方。
再见...

突然之间!


直到下一次负载测试的时候到了。 然后出现了一件可怕的事情:



该应用程序适用于以下版本的Java:

 openjdk版本“ 1.8.0_222”
 OpenJDK运行时环境(内部版本1.8.0_222-8u222-b10-1ubuntu1〜18.04.1-b10)
 OpenJDK 64位服务器VM(内部版本25.222-b10,混合模式)


对于那些不熟悉诸如YourKit之类的出色工具的用户。

在屏幕快照中,水平粗线及时显示了应用程序线程的操作。 根据特定时间流的状态,将条带涂成相应的颜色:

  • 黄色-流空闲,正在等待工作;
  • 绿色-线程正在运行,正在执行程序代码;
  • 红色-该线程被另一个线程阻止。

也就是说,事实证明,几乎所有线程(实际上,屏幕截图中显示的线程远远超过所有线程)几乎始终处于阻塞状态。 而且,所有锁都在ConcurrentHashMap的相同computeIfAbsent中! 尽管存在这种事实,但由于此特定负载测试的具体情况,该集合中最多只能存储6-8个值。 即 给定位置中的几乎所有操作仅是对现有值的读取。

但是等等,怎么办? 实际上,即使在有关阻塞方法的文档中,也仅在更新的附录中说过:
“如果指定的键尚未与某个值关联,请尝试使用给定的映射函数计算其值,并将其输入到此映射中,除非为null。 整个方法调用是原子执行的,因此每个键最多可应用一次该功能。 在计算进行期间,可能会阻止其他线程在此映射上进行的某些尝试的更新操作,因此计算应简短而简单,并且不得尝试更新此映射的任何其他映射。”

实际上,一切并非如此。 如果查看此方法的源代码,结果发现它包含两个非常厚的同步块:

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 { boolean added = false; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; V ev; if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { val = e.val; break; } Node<K,V> pred = e; if ((e = e.next) == null) { if ((val = mappingFunction.apply(key)) != null) { added = true; pred.next = new Node<K,V>(h, key, val, null); } break; } } } else if (f instanceof TreeBin) { binCount = 2; TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> r, p; if ((r = t.root) != null && (p = r.findTreeNode(h, key, null)) != null) val = p.val; else if ((val = mappingFunction.apply(key)) != null) { added = true; t.putTreeVal(h, key, val); } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (!added) return val; break; } } } if (val != null) addCount(1L, binCount); return val; } 


从以上示例可以看出,结果只能在六个点形成,并且几乎所有这些位置都在同步块内部。 出乎意料的。 此外,简单的获取根本不包含同步:

ConcurrentHashMap.get的实现
 public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; } 


那该怎么办呢? 实际上,只有两种选择:要么返回原始代码,要么使用它,但是在经过稍微修改的版本中:

 String getOrPut(String key) { String result = map.get(key); return (result != null) ? result : map.computeIfAbsent(key, this::createValue); } 

结论


总的来说,这种看似平庸的重构造成的致命后果非常令人意外。 仅通过压力测试即可成功挽救这种情况,压力测试成功地揭示了性能下降。

幸运的是,较新的Java版本解决了此问题: JDK-8161372

因此要小心,不要相信诱人的技巧并编写测试。 特别压力大。

Java给大家!

UPD1:正如Coldwind正确指出的那样 ,该问题是已知的: JDK-8161372 。 而且,它似乎在Java 9中是固定的。但是,在以Java 8,Java 11甚至Java 13发布本文时,此方法保持不变。

UPD2: vkovalchuk引起了我的粗心。 确实,对于Java 9和更高版本,通过添加另一个条件并返回结果而不会阻塞来解决此问题:

  else if (fh == h // check first node without acquiring lock && ((fk = f.key) == key || (fk != null && key.equals(fk))) && (fv = f.val) != null) return fv; 


最初,我遇到了Java下一版本的情况:

 openjdk版本“ 1.8.0_222”
 OpenJDK运行时环境(内部版本1.8.0_222-8u222-b10-1ubuntu1〜18.04.1-b10)
 OpenJDK 64位服务器VM(内部版本25.222-b10,混合模式)


当我查看更高版本的资源时,我确实错过了这些内容,这使我误入歧途。

因此,为了公正起见,我更正了本文的正文。

Source: https://habr.com/ru/post/zh-CN479778/


All Articles