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

了解 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

猜你喜欢

转载自blog.csdn.net/wu_weijie/article/details/129289929