记一个ConcurrentHashMap使用不当导致的并发事故
@[toc]
# 引言
我们都知道ConcurrentHashMap可以保证键值对并发插入安全,因为其key值唯一性的原因,所以hutool对其进行了进一步的封装实现了一个ConcurrentHashSet,代码如下,即判断put后是否返回null,若是null则说明是第一次插入,反之就是存在重复元素,返回已存在的元素值。从而保证并发插入元素线程安全且唯一。
//hutool的ConcurrentHashSet通过判断返回null得知之前是否插入过重复元素
@Override
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
2
3
4
5
但是如果对于这些映射容器的键使用不当就可能导致唯一键值对多次插入的情况,所以本文将基于笔者前段时间遇到的经典的例子为切入点,深入剖析该问题的原因和解决思路。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 详解ConcurrentHashMap并发重复插入问题
# 需求说明
我们现在有这样一个需求,大体是通过数据库获取要处理的任务并按照如下步骤执行:
- 从数据库读取未完成(status为0)的任务,将其采用并发容器(ConcurrentHashSet)存放,key为这个任务对象
- 工作线程处理,并在内存中将其设置为1
- 定时任务线程从容器中读取这些任务并移除
- 将已完成任务状态写回库中

# 落地代码
对应任务表的实体类封装如下,我们的加载到ConcurrentHashSet会被多个线程并发的调度处理,处理过程中会并发更新状态。
@Data
public class Task {
private int id;
/**
* 任务名称
*/
private String taskName;
/**
* 0.未开始
* 1.进行中
* 2.已完成
*/
private int status;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
对应的实现代码如下,可以看到从数据库读取未开始的任务,线程1将其更新为处理完成后更新为处理中,线程2处理完成后更新为已完成:
public static void main(String[] args) throws InterruptedException {
ConcurrentHashSet<Task> set = new ConcurrentHashSet<>();
CountDownLatch countDownLatch = new CountDownLatch(2);
//假设从数据库读取一个task
Task task = new Task();
task.setId(1);
task.setTaskName("任务1");
task.setStatus(0);
set.add(task);
//模拟多线程并发更新
//线程1更新为处理中
new Thread(() -> {
log.info("线程1处理中....");
task.setStatus(1);
set.add(task);
countDownLatch.countDown();
}, "t1").start();
//线程2更新为已完成
new Thread(() -> {
log.info("线程2处理中....");
task.setStatus(2);
set.add(task);
countDownLatch.countDown();
}, "t2").start();
countDownLatch.await();
log.info("set size:{}", set.size());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
输出结果如下,可以看到明明同一个对象,结果插入了3次:
00:44:32.637 [main] INFO com.sharkChili.webTemplate.Main - set size:3
调试查看set内部,3个元素都指向我们的唯一的任务-1。

# 事故原因
我们都知道JDK8版本无论是HashMap还是ConcurrentHashMap底层采用数组+链表/红黑树,元素进行插入前都需要进行hash运算定位数组索引,然后使用equal和hashCode比较的过程元素是否存在。
很明显,我们上文并发操作元素时修改了status字典,导致每次得出的hashCode结果值改变了,进而导致同一个元素因为不同的hashCode插入到不同的位置,出现去重失败:

对应笔者也给出ConcurrentHashMap的put方法底层实现:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//计算key的hash值,因为我们动态修改了status导致hash值不同
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//因为hash值不同每次定位到的i位置不同,最终存到不同的位置
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
}
.....
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 解决方案
很明显出现这个问题的原因就是因为并发操作修改的status影响了hashcode计算结果,进而导致并发操作变得无效,因为id是全局唯一的,所以直接重写hashCode和equals方法,让Task对象的计算和比对都通过id进行:
@Data
public class Task {
//......略
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Task task = (Task) o;
return id == task.id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 小结
总的来说,对于这类涉及并发操作的重构,建议梳理清晰的数据流向并结合源码工作流程加以推断分析,最终明确问题风险点直接进行逻辑修复并及时提测。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。