前言
最近踩了一个别人挖的坑,遂写本文。
诚如标题所示,你可能会问为什么会犯这么低级的错误,为什么并发环境下没有使用线程安全类。由于涉及公司业务,我不便透露更多。简单总结原因为以下几点:
- 项目极其复杂
- 每一个模块、类、方法都看起来没有问题,但是综合起来就有问题了
- 存在上下文的共享状态,使得问题难以排查
在多线程环境中使用 HashMap
进行并发操作时,可能会导致数据丢失或不一致的问题。特别是,HashMap
的 put
方法在并发情况下不会抛出异常,这使得问题更加隐蔽且难以排查。本文将探讨这些问题的根源,并推荐使用 computeIfAbsent
、putIfAbsent
和 merge
等方法来替代直接使用 put
方法,以确保数据的完整性和一致性。
多线程环境下的 HashMap 问题
在 Java 中,HashMap
是一个非线程安全的数据结构。当多个线程同时对 HashMap
进行写操作时,可能会导致数据丢失或不一致的情况。特别需要注意的是,HashMap
的 put
方法在并发修改时不会抛出 ConcurrentModificationException
,这使得问题更加难以检测和调试。本文将通过一个示例代码展示这种问题,并提供一些替代方案来解决这些问题。
在多线程环境中使用 HashMap
的 put
方法时,可能会出现数据丢失的情况。以下是一个示例代码:
java
public static void main(String[] args) {
HashMap<Object, Object> map = new HashMap<>();
ConcurrentHashSet<Object> threads = new ConcurrentHashSet<>();
IntStream.range(0, 100000).parallel().forEach(x -> {
if (threads.add(currentThread())) {
System.out.println("currentThread = " + currentThread().getName());
}
map.put(x, x);
});
System.out.println("map.size() = " + map.size());
}
在上述代码中,我们期望 map
的大小为 100,000,但实际输出可能会小于这个值。这是因为 HashMap
在多线程环境下并不是线程安全的。
我的运行环境输出如下:
java
currentThread = main
currentThread = ForkJoinPool.commonPool-worker-1
currentThread = ForkJoinPool.commonPool-worker-2
currentThread = ForkJoinPool.commonPool-worker-5
currentThread = ForkJoinPool.commonPool-worker-3
currentThread = ForkJoinPool.commonPool-worker-6
currentThread = ForkJoinPool.commonPool-worker-4
currentThread = ForkJoinPool.commonPool-worker-7
map.size() = 84920
问题分析
- 数据丢失 :由于
HashMap
的put
操作不是原子的,多个线程同时执行put
操作时,可能会覆盖彼此的写入,导致数据丢失。 - 不一致性 :在并发环境中,
HashMap
的内部结构可能被破坏,导致数据不一致。 - 无异常提示 :
HashMap
的put
方法在并发修改时不会抛出ConcurrentModificationException
,这意味着即使发生了问题,程序也不会立即报错,增加了问题排查的难度。
源码分析
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 只有这里修改了修改计数
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put 方法的具体实现如上,可以看出,HashMap 只在代码最后进行修改操作计数操作,并没有进行计数检查操作,也不可能抛出并发修改异常(ConcurrentModificationException)。因此如果只进行多线程 put 操作,不会有异常,但是数据可能有丢失。
替代方案
为了避免上述问题,可以使用以下相对安全的方法(执行并发检查的方法):
1. computeIfAbsent
computeIfAbsent
方法可以在键不存在时计算并插入值,确保操作的原子性。
java
map.computeIfAbsent(key, k -> newValue);
这里不妨看下源码:
java
public V computeIfAbsent(K key,
Function<? super K, ? extends V> mappingFunction) {
if (mappingFunction == null)
throw new NullPointerException();
int hash = hash(key);
Node<K,V>[] tab; Node<K,V> first; int n, i;
int binCount = 0;
TreeNode<K,V> t = null;
Node<K,V> old = null;
if (size > threshold || (tab = table) == null ||
(n = tab.length) == 0)
n = (tab = resize()).length;
if ((first = tab[i = (n - 1) & hash]) != null) {
if (first instanceof TreeNode)
old = (t = (TreeNode<K,V>)first).getTreeNode(hash, key);
else {
Node<K,V> e = first; K k;
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
old = e;
break;
}
++binCount;
} while ((e = e.next) != null);
}
V oldValue;
if (old != null && (oldValue = old.value) != null) {
afterNodeAccess(old);
return oldValue;
}
}
int mc = modCount;
V v = mappingFunction.apply(key);
// 检查修改计数器
if (mc != modCount) { throw new ConcurrentModificationException(); }
if (v == null) {
return null;
} else if (old != null) {
old.value = v;
afterNodeAccess(old);
return v;
}
else if (t != null)
t.putTreeVal(this, tab, hash, key, v);
else {
tab[i] = newNode(hash, key, v, first);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
}
modCount = mc + 1;
++size;
afterNodeInsertion(true);
return v;
}
可以看出,其执行了并发修改检查。
2. putIfAbsent
putIfAbsent
方法在键不存在时插入值,避免覆盖已有值。
java
map.putIfAbsent(key, newValue);
3. merge
merge
方法可以在存在键时合并值,提供更灵活的更新策略。
java
map.merge(key, newValue, (oldValue, newValue) -> oldValue + newValue);
结论
- 多线程环境下使用线程安全类
- 并发编程时尽量减少副作用,所有计算结果、IO数据可以统一封装成返回值,如
CompletableFuture<Result>
- 不要过分依赖集合类提供的并发修改检查,其不一定保证能检查出并发问题
- 推荐使用
computeIfAbsent
、putIfAbsent
和merge
等方法,其提供了一定的并发检查能力 只使用HashMap#put也具有一定的"安全性"(指的是不会抛异常),适合于所谓的"防御性编程"、埋雷、埋bug