了解 Java 的同学,也许会见过一个异常 java.util.ConcurrentModificationException
,这个一般在迭代器访问 Collection、Map 等数据结构过程中,修改了数据结构中的元素导致。
前段时间,ShardingSphere 遇到一个偶发报错的问题:java.lang.IllegalStateException: Recursive update
。这个问题其实和并发修改问题有相似之处。本文主要记录该问题的排查与分析过程。
文章目录
问题背景
相关 issue java.lang.IllegalStateException: Recursive update caused by #24251
Apache ShardingSphere 在 Proxy 模块重构后的一段时间里,频繁发生偶发报错 java.lang.IllegalStateException: Recursive update
。
引发该问题的重构 PR:https://github.com/apache/shardingsphere/pull/24251
java.util.ServiceConfigurationError: org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLSessionVariableHandler: Provider org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLDefaultSessionVariableHandler could not be instantiated
at java.base/java.util.ServiceLoader.fail(ServiceLoader.java:586)
at java.base/java.util.ServiceLoader$ProviderImpl.newInstance(ServiceLoader.java:813)
at java.base/java.util.ServiceLoader$ProviderImpl.get(ServiceLoader.java:729)
at java.base/java.util.ServiceLoader$3.next(ServiceLoader.java:1403)
at org.apache.shardingsphere.infra.util.spi.ShardingSphereServiceLoader.load(ShardingSphereServiceLoader.java:56)
at org.apache.shardingsphere.infra.util.spi.ShardingSphereServiceLoader.<init>(ShardingSphereServiceLoader.java:46)
at java.base/java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1708)
at org.apache.shardingsphere.infra.util.spi.ShardingSphereServiceLoader.getServiceInstances(ShardingSphereServiceLoader.java:73)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.findService(TypedSPILoader.java:71)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.getService(TypedSPILoader.java:126)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.getService(TypedSPILoader.java:113)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLSetVariableAdminExecutor.lambda$execute$0(MySQLSetVariableAdminExecutor.java:56)
at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$1(Collectors.java:180)
at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
at java.base/java.util.HashMap$KeySpliterator.forEachRemaining(HashMap.java:1715)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLSetVariableAdminExecutor.execute(MySQLSetVariableAdminExecutor.java:56)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.executor.MySQLSetVariableAdminExecutorTest.assertExecute(MySQLSetVariableAdminExecutorTest.java:66)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:578)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727)
... 省略 JUnit 调用栈
Caused by: java.lang.IllegalStateException: Recursive update
at java.base/java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1763)
at org.apache.shardingsphere.infra.util.spi.ShardingSphereServiceLoader.getServiceInstances(ShardingSphereServiceLoader.java:73)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.findService(TypedSPILoader.java:71)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.findService(TypedSPILoader.java:55)
at org.apache.shardingsphere.proxy.backend.handler.admin.executor.DefaultSessionVariableHandler.<init>(DefaultSessionVariableHandler.java:36)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLDefaultSessionVariableHandler.<init>(MySQLDefaultSessionVariableHandler.java:28)
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:67)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:500)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:484)
at java.base/java.util.ServiceLoader$ProviderImpl.newInstance(ServiceLoader.java:789)
... 88 more
抛异常的是 ConcurrentHashMap 实例是以下的 LOADERS
。
public final class ShardingSphereServiceLoader<T> {
private static final Map<Class<?>, ShardingSphereServiceLoader<?>> LOADERS = new ConcurrentHashMap<>();
private final Class<T> serviceInterface;
代码可见 ShardingSphere
https://github.com/apache/shardingsphere/blob/5.3.1/infra/util/src/main/java/org/apache/shardingsphere/infra/util/spi/ShardingSphereServiceLoader.java#L36
排查过程
阅读 ConcurrentHashMap Javadoc
ConcurrentHashMap 这么报错肯定有它的理由,先搜一下文档,发现确实存在关于该报错的说明。
Java 8
Java 8 的文档已经说明了,computeIfAbsent
的逻辑应该简短,且不许更新 map 内容。
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html#computeIfAbsent-K-java.util.function.Function-
Java 17
Java 17 的文档,把不许更新 map 内容的说明单独拎出来强调了。
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html#computeIfAbsent(K,java.util.function.Function)
ConcurrentHashMap 源码分析
本文使用的 JDK 为 Oracle OpenJDK 19.0.1。
搜索相关异常,发现 ConcurrentHashMap 有 9 处代码会抛出该异常。
其中,有 7 处代码判断的是,如果是节点是 ReservationNode
的实例,就抛出异常:
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
以下是 ReservationNode
的定义,从 javadoc 可以看出这是 computeIfAbsent
和 compute
方法专用的占位符。
/**
* A place-holder node used in computeIfAbsent and compute.
*/
static final class ReservationNode<K,V> extends Node<K,V> {
ReservationNode() {
super(RESERVED, null, null);
}
Node<K,V> find(int h, Object k) {
return null;
}
}
当 ConcurrentHashMap 的某个哈希桶上已经存在 ReservationNode
,说明该实例的正在被调用 computeIfAbsent
方法。
那有没有可能这个调用是在多线程同时发生的?
当 key 映射到指定哈希桶上时,后续的写入逻辑会在该点位上以 synchronized
代码块执行,如果是不同线程调用 computeIfAbsent
方法,那么只有最先调用的线程能够执行写入逻辑,其他线程只能等待进入临界区。
所以,能够遇到 ReservationNode
的线程,肯定也是写入 ReservationNode
的线程。
另外 2 处代码大致意思是,如果 pred.next
不为空,则抛出异常:
Node<K,V> pred = e;
if ((e = e.next) == null) {
if ((val = mappingFunction.apply(key)) != null) {
if (pred.next != null)
throw new IllegalStateException("Recursive update");
added = true;
pred.next = new Node<K,V>(h, key, val);
}
break;
}
其中 pred
和 e
其实是已经确定好当前 KV 准备写入的位置。如果 KV 计算出来后,发现要写入的位置不为空,说明在计算 Value 的过程中修改了当前哈希桶的内容。
与 ReservationNode
的情况相似,哈希桶写入是会加锁的,所以这个修改只可能在当前线程发生的。
初步结论 | 为什么是偶发而不是必现?
经过刚才的源码分析,可以判断:
- 如果递归更新的 Key 没有哈希碰撞,写入位置在不同的哈希桶上,互不影响,则不会发生递归更新的问题;
- 如果递归更新的 Key 存在哈希碰撞,写入位置在相同的哈希桶上,则抛出递归更新异常;
进一步思考
假如允许递归更新,会发生什么?
假设 Key1 与 Key2 存在哈希碰撞。
- 第一次调用
compute
方法,为 Key1 找好了写入位置,开始计算 Value; - 结果计算 Value 的逻辑中,写入了 Key2,由于存在哈希碰撞,Key2 占用了第一步为 Key1 找好的写入位置;
- Key1 的 Value 计算好后,由于原本找好写入的位置已经被占用了,这时候怎么办?重新计算写入位置?
如果这种递归调用不仅两层,而是递归了很多层,compute
系列方法内部的逻辑可能会变得复杂且低效。
所以,直接禁止递归更新,也许是一种保持逻辑清晰、高效的方式。
换成 HashMap 会发生什么?
ConcurrentHashMap 不允许递归 compute 改变已有映射关系,那 HashMap 呢?
public final class ShardingSphereServiceLoader<T> {
- private static final Map<Class<?>, ShardingSphereServiceLoader<?>> LOADERS = new ConcurrentHashMap<>();
+ private static final Map<Class<?>, ShardingSphereServiceLoader<?>> LOADERS = new HashMap<>();
private final Class<T> serviceInterface;
报错信息变成了标准的 java.util.ConcurrentModificationException
java.util.ConcurrentModificationException
at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1229)
at org.apache.shardingsphere.infra.util.spi.ShardingSphereServiceLoader.getServiceInstances(ShardingSphereServiceLoader.java:73)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.findService(TypedSPILoader.java:71)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.getService(TypedSPILoader.java:126)
at org.apache.shardingsphere.infra.util.spi.type.typed.TypedSPILoader.getService(TypedSPILoader.java:113)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLSetVariableAdminExecutor.lambda$execute$0(MySQLSetVariableAdminExecutor.java:56)
at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$1(Collectors.java:180)
at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
at java.base/java.util.HashMap$KeySpliterator.forEachRemaining(HashMap.java:1715)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.MySQLSetVariableAdminExecutor.execute(MySQLSetVariableAdminExecutor.java:56)
at org.apache.shardingsphere.proxy.backend.mysql.handler.admin.executor.MySQLSetVariableAdminExecutorTest.assertExecute(MySQLSetVariableAdminExecutorTest.java:66)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:578)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727)
... 省略 JUnit 调用栈
所以,ConcurrentHashMap Recursive update
本质上和 ConcurrentModificationException
类似,只不过 ConcurrentHashMap 允许一定程度上(例如哈希不碰撞)的并发修改。
修复方式
避免 ConcurrentHashMap 的 computeIfAbsent
系列方法存在递归调用的情况。
ShardingSphere 修复案例:
Avoid ConcurrentHashMap Recursive update #24416