性能有坑 | 慎用 Java 8 ConcurrentHashMap 的 computeIfAbsent

前言

我们先看一段代码,代码中使用 Map 的时候,有可能会这么写:

Java 8 的 java.util.Map 里面有个方法 computeIfAbsent,能够简化以上代码:

以上这种写法除了简洁,如果使用的是 java.util.concurrent.ConcurrentHashMap,还能够在并发调用的情况下确保 calculateValue 方法不会被重复调用,保证原子性。

不过,前段时间对 Apache ShardingSphere-Proxy 做压测时遇到一个问题,当 BenchmarkSQL 连接 ShardingSphere Proxy 的 Terminal 数量比较高时,其中一条很简单的插入 SQL 执行延迟增加了很多。借助 Async Profiler 发现 Java 8 ConcurrentHashMap 的 computeIfAbsent 在性能上有坑。

不了解 Apache ShardingSphere 的读者可以参考 https://github.com/apache/shardingsphere

排查

考虑到当时的压测的现象是 BenchmarkSQL 并发数(Terminals)越高,New Order 业务中一条简单且重复执行的 insert SQL 执行延时越长。但是 ShardingSphere-Proxy 的所在机器的 CPU 也没有压满,考虑是不是 Proxy 代码层面存在瓶颈,于是借助 async-profiler 对压测状态下的 Proxy JVM 采样。

关于 async-profiler 可以参考 https://github.com/jvm-profiling-tools/async-profiler,后续我也考虑写一些相关文章。

使用 IDEA 读取采样获得的 jfr 文件,看到 Java Monitor Blocked 事件居然有三百多万次!

根据堆栈,找到 ShardingSphere 这段使用了 computeIfAbsent 代码,以下为节选:

https://github.com/apache/shardingsphere/blob/3b840b339ac580a7247b866f5904c514b169065f/shardingsphere-infra/shardingsphere-infra-executor/src/main/java/org/apache/shardingsphere/infra/executor/sql/prepare/driver/DriverExecutionPrepareEngine.java#L65

以上这段代码在每一次 Proxy 与数据库交互前都会执行,即通过 Proxy 执行 CRUD 操作的必经之路,而且里面的 type 目前只有 2 种,分别是 JDBC.STATEMENT 和 JDBC.PREPARED_STATEMENT,所以在高并发的情况下会有大量的线程调用同一个 key 的 computeIfAbsent。

我的理解是,如果在 key 存在的情况下,computeIfAbsent 操作就不存在修改的情况了,直接 get 出来就好,那事实如何?

看一下 computeIfAbsent 方法的实现(JDK 是 Oracle 8u311),节选代码并加了一些注释:

根据我对源码的理解,即使 key 存在,computeIfAbsent 去找 key 的时候,都会进入 synchronized 代码。

那这相比 ConcurrentHashMap 不加锁的 get 操作不就影响性能了吗?Google 一下相应的话题,发现了一些内容:

https://bugs.openjdk.java.net/browse/JDK-8161372

这个问题早就有人提过了,也在 JDK 9 处理了。截至本文编写 JDK 17 已经正式发布了。

解决

在目前 JDK 8 仍然盛行的环境下,我们有必要考虑如何避免上面的问题,于是相应的处理方法就诞生了:https://github.com/apache/shardingsphere/pull/13275/files

https://github.com/apache/shardingsphere/blob/300cbe86cf3d837a925c68c9babcec4839a2078f/shardingsphere-infra/shardingsphere-infra-executor/src/main/java/org/apache/shardingsphere/infra/executor/sql/prepare/driver/DriverExecutionPrepareEngine.java#L76-L80

每次从 Map 中获取 value 前,都先用 get 做一次检查,value 不存在才使用 computeIfAbsent 放入 value。由于 ConcurrentHashMap 的 computeIfAbsent 可以保证操作原子性,这里也不需要自己加 synchronized 或者做多重检查之类的操作。

问题解决~

附:JMH 测试

测试环境

测试代码

JDK 8 测试结果

可以看到,两种方式在性能上相差了很多个数量级,直接调用 computeIfAbsent 的性能是每秒百万级,先调用 get 做检查的性能是每秒十亿级,而且这仅仅是 16 线程的测试。

在资源方面,benchComputeIfAbsent 测试期间 CPU 利用率一直维持在 20% 左右;而 benchGetBeforeComputeIfAbsent 测试期间的 CPU 利用率一直 100%。

JDK 17 测试结果

JDK 17 测试结果看来,computeIfAbsent 的性能相比先 get 稍微低一些,但性能至少在同一个数量级上了。而且两个用例运行期间 CPU 都是满载的。

总结

如果在 Java 8 的环境下使用 ConcurrentHashMap,一定要注意是否会并发对同一个 key 调用 computeIfAbsent,如果存在需要先尝试调用 get。

或者干脆升级到 Java 11 或 Java 17。

参考链接


性能有坑 | 慎用 Java 8 ConcurrentHashMap 的 computeIfAbsent

发布者

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注