虽然 `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 生成的报告:
> 译注:按照手册安装 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。运行测试,会看到死锁:
> 译注:注意,在 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 只使用一次。