ConcurrentHashMap 使用:每个 Key 只调用 1 个方法

虽然 `ConcurrentHashMap` 的方法都线程安全,但是对同一个 Key 调用多个方法会引发竞态条件,对不同的 key 递归调用同一个方法会导致死锁。


让我们通过示例了解为什么会发生这种情况:


1. 调用多个方法


下面的测试中,对 Key `1` 调用了两个 `ConcurrentHashMap` 方法。方法 `update`(4至12行)先用 `get` 方法从 `ConcurrentHashMap` 读取 Value,接着用 `put` 方法保存增加后的 Value(7至10行)。


```java
public class TestUpdateWrong {
   private final ConcurrentHashMap<Integer,Integer>  map = new  ConcurrentHashMap<Integer,Integer>();
   @Interleave(group=TestUpdateWrong.class,threadCount=2)
   public void update() {
       Integer result = map.get(1);
       if (result == null) {
           map.put(1, 1);
       }
       else {
           map.put(1, result + 1);
       }
   }
   @Test
   public void testUpdate() throws InterruptedException    {
       ExecutorService executor = Executors.newFixedThreadPool(2);
       executor.execute(() -> { update(); });
       executor.execute(() -> { update(); });
       executor.shutdown();
       executor.awaitTermination(10, TimeUnit.MINUTES);
   }
   @After
   public void checkResult() {
       assertEquals(2 , map.get(1).intValue());
   }
}
```


示例代码可以从[GitHub][1]下载。


[1]:https://github.com/vmlens/examples


> 译注:导入示例后,在 `pom.xml` 里参照 <https://vmlens.com/help/manual/#the-report> 中 "Configure in maven"增加 `<pluginRepositories>` 与 `com.vmlens.interleave` plugin。


使用 `ExecutorService` 创建两个线程调用 `update`(16至17行)。为测试线程交叉,使用了[vmlens][2] `Interleave` 注解(第3行)。vmlens 是我开发的一个 Java 多线程测试工具。vmlens 对所有添加 `Interleave` 注解的方法进行线程交叉测试。运行测试,可以看到以下错误:


[2]:https://vmlens.com/


```shell
java.lang.AssertionError: expected:<2> but was:<1>
```


要了解为什么结果是1而不是期望的结果2,可以查看 vmlens 生成的报告:


ConcurrentHashMap 使用:每个 Key 只调用 1 个方法


> 译注:按照手册安装 vmlens 插件后,点击 `JUnit Test traced with vmlens` 菜单。出现竞态条件时,`vmlens` 视图会报告详细信息。注意,在 1.8.0_211 环境下运行 TestUpdateWrong 有可能不会报告上述错误。


问题在于两个线程先调用 `get` 接着调用 `put`,因此看到的都是空值,把值更新为1而不是预期的2。解决方法,只使用 `compute` 一个方法完成更新。修改后的版本看起来像下面这样:


```java
public void update() {
   map.compute(1, (key, value) -> {
       if (value == null) {
           return 1;
       } else {
           return value + 1;
       }
   });
}
```


2. 递归调用同一个方法


`ConcurrentHashMap` 递归调用同一个方法的示例:


```java
public class TestUpdateRecursive {
   private final ConcurrentHashMap<Integer, Integer> map =
           new ConcurrentHashMap<Integer, Integer>();
   public TestUpdateRecursive() {
       map.put(1, 1);
       map.put(2, 2);
   }
   public void update12() {
       map.compute(1, (key,value) -> {
           map.compute(2, (k, v) -> { return 2; });
           return 2;
       });
   }
   public void update21() {
       map.compute(2, (key,value) -> {
           map.compute(1, (k, v) -> { return 2; });
           return 2;
       });
   }
   @Test
   public void testUpdate() throws InterruptedException {
       ExecutorService executor = Executors.newFixedThreadPool(2);
       executor.execute(() -> { update12(); });
       executor.execute(() -> { update21(); });
       executor.shutdown();
       executor.awaitTermination(10, TimeUnit.MINUTES);
   }
}
```


上面的示例中,不同的 Key 在 `compute` 方法内部再次调用 `compute` 方法。`update12` 先处理 Key 1 然后是 Key 2,`update21` 先处理 Key 2 然后是 Key 1。运行测试,会看到死锁:


ConcurrentHashMap 使用:每个 Key 只调用 1 个方法


> 译注:注意,在 1.8.0_211 环境下运行 TestUpdateRecursive 有可能不会报告死锁。


要分析为什么会发生死锁,必须理解 `ConcurrentHashMap` 的内部结构。`ConcurrentHashMap` 使用数组来存储 Key/Value 映射。每次更新映射时,都会锁定存储映射的数组。因此,在上面的测试中,调用 `compute` 计算 Key 1 时锁定了 Key 1 的数组。然后尝试为 Key 2 锁定数组元素,但这时已被另一个线程锁定,调用 `compute` 计算 Key 2 并尝试锁定 Key 1 数组元素,发生死锁。


注意,只在需要更新时才对数组元素进行锁定,像 `get` 这样的只读方法不会加锁。因此,在 `compute` 方法中使用 `get` 方法没有问题。


3. 总结


使用 `ConcurrentHashMap` 可以很方便地实现线程安全,可以根据需要挑选合适的方法并且每个 Key 只使用一次。


上一篇:LeetCode-079-单词搜索


下一篇:2021SC@SDUSC【软件工程应用与实践】Cocoon项目9-分析core文件夹(八)