岩仓亚莉亚免安装绿色版
359M · 2025-10-17
最近踩了一个别人挖的坑,遂写本文。
诚如标题所示,你可能会问为什么会犯这么低级的错误,为什么并发环境下没有使用线程安全类。由于涉及公司业务,我不便透露更多。简单总结原因为以下几点:
在多线程环境中使用 HashMap
进行并发操作时,可能会导致数据丢失或不一致的问题。特别是,HashMap
的 put
方法在并发情况下不会抛出异常,这使得问题更加隐蔽且难以排查。本文将探讨这些问题的根源,并推荐使用 computeIfAbsent
、putIfAbsent
和 merge
等方法来替代直接使用 put
方法,以确保数据的完整性和一致性。
在 Java 中,HashMap
是一个非线程安全的数据结构。当多个线程同时对 HashMap
进行写操作时,可能会导致数据丢失或不一致的情况。特别需要注意的是,HashMap
的 put
方法在并发修改时不会抛出 ConcurrentModificationException
,这使得问题更加难以检测和调试。本文将通过一个示例代码展示这种问题,并提供一些替代方案来解决这些问题。
在多线程环境中使用 HashMap
的 put
方法时,可能会出现数据丢失的情况。以下是一个示例代码:
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
在多线程环境下并不是线程安全的。
我的运行环境输出如下:
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
,这意味着即使发生了问题,程序也不会立即报错,增加了问题排查的难度。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 操作,不会有异常,但是数据可能有丢失。
为了避免上述问题,可以使用以下相对安全的方法(执行并发检查的方法):
computeIfAbsent
computeIfAbsent
方法可以在键不存在时计算并插入值,确保操作的原子性。
map.computeIfAbsent(key, k -> newValue);
这里不妨看下源码:
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;
}
可以看出,其执行了并发修改检查。
putIfAbsent
putIfAbsent
方法在键不存在时插入值,避免覆盖已有值。
map.putIfAbsent(key, newValue);
merge
merge
方法可以在存在键时合并值,提供更灵活的更新策略。
map.merge(key, newValue, (oldValue, newValue) -> oldValue + newValue);
CompletableFuture<Result>
computeIfAbsent
、putIfAbsent
和 merge
等方法,其提供了一定的并发检查能力