你见过 ConcurrentHashMap 使用不当报错 java.lang.IllegalStateException: Recursive update 吗?

本文探讨了在Java中遇到的`java.util.ConcurrentModificationException`异常,特别是在ShardingSphere项目中出现的`java.lang.IllegalStateException:Recursiveupdate`问题。文章详细介绍了问题背景,分析了`ConcurrentHashMap`的源码,指出递归更新导致的问题,并通过对比HashMap的行为解释了为何`ConcurrentModificationException`会出现。最后,文章提出了问题的修复策略,避免在使用`ConcurrentHashMap`的`computeIfAbsent`时发生递归调用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

了解 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 可以看出这是 computeIfAbsentcompute 方法专用的占位符。

/**
 * 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;
}

其中 prede 其实是已经确定好当前 KV 准备写入的位置。如果 KV 计算出来后,发现要写入的位置不为空,说明在计算 Value 的过程中修改了当前哈希桶的内容。

ReservationNode 的情况相似,哈希桶写入是会加锁的,所以这个修改只可能在当前线程发生的。

初步结论 | 为什么是偶发而不是必现?

经过刚才的源码分析,可以判断:

  • 如果递归更新的 Key 没有哈希碰撞,写入位置在不同的哈希桶上,互不影响,则不会发生递归更新的问题;
  • 如果递归更新的 Key 存在哈希碰撞,写入位置在相同的哈希桶上,则抛出递归更新异常;

进一步思考

假如允许递归更新,会发生什么?

假设 Key1 与 Key2 存在哈希碰撞。

  1. 第一次调用 compute 方法,为 Key1 找好了写入位置,开始计算 Value;
  2. 结果计算 Value 的逻辑中,写入了 Key2,由于存在哈希碰撞,Key2 占用了第一步为 Key1 找好的写入位置;
  3. 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

java.lang.IllegalStateException: COMPLETED 是Java中的一个异常类型,表示当前操作在已经完成的状态下被调用,这通常是由于程序逻辑错误或者不正确的调用顺序导致的。要解决这个问题,你可以考虑以下几个方面: 1. 检查代码逻辑:首先,你需要仔细检查代码逻辑,确保在调用相关方法之前,没有提前完成或结束相关操作。这可能涉及到对方法调用顺序、条件判断和循环等进行仔细检查。 2. 检查并发操作:如果你的代码涉及到多线程或并发操作,那么可能是由于线程间的竞争条件导致了该异常。你可以考虑使用同步机制(如锁、信号量等)来保证线程安全性,或者使用并发工具类(如Atomic类、ConcurrentHashMap等)来避免竞争条件。 3. 检查资源释放:有时候,该异常可能是由于资源没有正确释放导致的。比如,在使用IO流或数据库连接时,需要确保在使用完毕后及时关闭相关资源。你可以使用try-with-resources语句或者手动关闭资源来避免该异常。 4. 查看相关文档和日志:如果以上方法都没有解决问题,你可以查看相关的文档和日志,了解该异常的具体上下文和原因。这可能需要查看相关库或框架的官方文档,或者查看程序的日志文件,以便更好地理解和解决问题。 希望以上方法能够帮助你解决 java.lang.IllegalStateException: COMPLETED 异常。如果你有任何进一步的问题,请随时提问。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wuweijie@apache.org

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值