发布于 

JDK1.8 ConcurrentHashMap#computeIfAbsent慎用

JDK1.8 ConcurrentHashMap#computeIfAbsent慎用

书接上上回,在我们讨论完JDK1.8 ConcurrentHashMap死锁Bug之后,对ConcurrentHashMap#computeIfAbsent还心有余悸,内心还想着:不会吧,大名鼎鼎的ConcurrentHashMap会有bug?

这次ConcurrentHashMap又给我们带来了新的彩蛋)

Introduce this PR

在一个愉快的工作日,下午三点。正是下午茶的时候),但是我们只有自费的下午茶。我闲逛起了Github,看到了一个标题:ConcurrentHashMap#computeIfAbsent have performance problem in jdk1.8,顿时起了兴致。

介绍下这个dubbo-issue

image-20230223171200421

mxsm大佬说:

  1. 我自己写了一个”基准”测试,测试ConcurrentHashMap#computeIfAbsent在JDK1.8和JDK11版本的性能差距很大。
  2. openjdk也承认了这是bug,并且在jdk9的版本修复了。jdk-bug-link
  3. dubbo当前也支持运行的jdk1.8上。
  4. 综上所诉,我们要修复这个性能问题。

JMH基准测试

什么是JMH?

JMH 的全名是 Java Microbenchmark Harness,它是由 Java 虚拟机团队开发的一款用于 Java 微基准测试工具。
基准测试是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。 而JMH是一个用来构建,运行,分析Java或其他运行在JVM之上的语言的 纳秒/微秒/毫秒/宏观 级别基准测试的工具。 而且JMH大大方便我们进行一场严格的性能测试,提供了多种测试模式,多种测试的维度,并且使用简单,添加对应的注解就可以进行测试。

JMH测试ConcurrentHashMap#computeIfAbsent在JDK1.8和JDK11版本的性能差距:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;


@Warmup(iterations = 2, time = 5)
@Measurement(iterations = 3, time = 5)
@State(Scope.Benchmark)
@Fork(2)
public class HashMapBenchmark {

private static final String KEY = "noah";

private final Map<String, Object> concurrentMap = new ConcurrentHashMap<>();

@Setup(Level.Iteration)
public void setup() {
concurrentMap.clear();
}

@Benchmark
@Threads(16)
public Object benchmarkGetBeforeComputeIfAbsent() {
Object result = concurrentMap.get(KEY);
if (null == result) {
result = concurrentMap.computeIfAbsent(KEY, key -> 1);
}
return result;
}

@Benchmark
@Threads(16)
public Object benchmarkComputeIfAbsent() {
return concurrentMap.computeIfAbsent(KEY, key -> 1);
}

}

代码解释:benchmarkGetBeforeComputeIfAbsent和benchmarkComputeIfAbsent。

  • 相同点:都是判断ConcurrentHashMap不存在指定key的时候,往map设置value并且返回新值。如果指定key已经存在,则不做任何操作,并且返回旧值。
  • 不同点:benchmarkGetBeforeComputeIfAbsent先查一下map是否存在制定key。

在JDK1.8下JMH性能测试结果:

Benchmark                                                      Mode  Cnt          Score           Error  Units
ConcurrentHashMapBenchmark.benchmarkComputeIfAbsent thrpt 6 40032480.594 ± 2652852.116 ops/s
ConcurrentHashMapBenchmark.benchmarkGetBeforeComputeIfAbsent thrpt 6 899447023.121 ± 126458224.528 ops/s

不会吧,为什么性能差距那么大呢。先get资源,再调用computeIfAbsent性能会更佳。这里先卖个关子),这是JDK1.8下面的bug,下面揭晓。

我们继续看下JDK11的性能测试结果:

Benchmark                                                      Mode  Cnt          Score           Error  Units
ConcurrentHashMapBenchmark.benchmarkComputeIfAbsent thrpt 6 681173570.883 ± 63060380.600 ops/s
ConcurrentHashMapBenchmark.benchmarkGetBeforeComputeIfAbsent thrpt 6 825966664.716 ± 111714396.868 ops/s

可以看到在JDK11的版本下,两个方法调用的吞吐量没有数量级别的差距。

Explain this bug

开篇一张图,接下来全靠说:JDK-BUG-8161372

image-20230224110542905

英语学习环节又到啦,在2016年7月13日的时候发现了这个bug。在JDK1.8版本都会受到影响,在JDK9的版本上面修复了。

ConcurrentHashMap.computeIfAbsent(k,f),当k存在map的时候,操作computeIfAbsent期望的结果是不阻塞的。但是在JDK1.8并不是这样的,并且提交问题的大佬给出了自己的解决方案。

image-20230224111251376

大佬说:我用自己封装的computeIfAbsent2(k,f)方法,比JDK自带的computeIfAbsent吞吐量高6倍。我们从大佬的代码),跟我们benchmarkGetBeforeComputeIfAbsent方法和思路是一致的,本质都是让map要put值的时候,先调用get方法判断key是否存在。

Middleware how to fix bug

我们通过基准测试和看jdk-bug报道,了解了这个bug,并且得知要JDK9才修复这个问题。那我们很多中间件,比如dubbo、rocketMq、mybatis-plus等等都还支持JDK1.8版本,现在都出到JDK18了,逃)。

这里直接说下各个中间件的思路:

  1. 写一个工具类包一个ConcurrentMap#computeIfAbsent这个方法,先判断一下JDK版本。
  2. 如果是JDK1.8的话,先看key是否存在map中,如果为null的话,则调用computeIfAbsent
  3. 其他版本>1.8版本,直接调用computeIfAbsent,基准测试)

接下来展示环节,眼见为实)

Dubbo

org.apache.dubbo.common.utils.ConcurrentHashMapUtils#computeIfAbsent

image-20230224115602374

RocketMq

org.apache.rocketmq.common.utils.ConcurrentHashMapUtils#computeIfAbsent

image-20230224115910141

Mybatis-plus

com.baomidou.mybatisplus.core.toolkit.CollectionUtils#computeIfAbsent

image-20230224120133865

JDK fix bug

到这里我们知道各个中间件通过包工具类的方式来规避了性能问题,但是我们肯定很好奇jdk是怎么fix这个bug的呢?

image-20230224122656020

左边是JDK1.8的版本,右边是JDK11的版本。

可以看到,JDK11的computeIfAbsent方法有2个大的改动。我们先说第二个,不知道老板你还有没有印象呢?我们在JDK1.8 ConcurrentHashMap死锁Bug文章中说过,通过抛出异常来解决死锁的问题。更多关于这个死锁的问题,可以去考古下~~

我们重点说下第一个点:

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

在没有获取锁的情况下,判断一个节点key和新key是否相等。那老板你肯定会问这时候如果hash冲突了?

因为在散列表中,hash冲突机率是有的但是一般情况下会很少,所以就只检查了第一个节点是否相等就能解决大部分情况下出现的这种性能问题。主要是为了防止让代码流程进入到else逻辑里面,因为else里面有重量级锁。

好了,到此本文完结~~