数据丢失,而且不抛出并发异常,多线程使用HashMap踩坑

前言

最近踩了一个别人挖的坑,遂写本文。

诚如标题所示,你可能会问为什么会犯这么低级的错误,为什么并发环境下没有使用线程安全类。由于涉及公司业务,我不便透露更多。简单总结原因为以下几点:

  1. 项目极其复杂
  2. 每一个模块、类、方法都看起来没有问题,但是综合起来就有问题了
  3. 存在上下文的共享状态,使得问题难以排查

在多线程环境中使用 HashMap 进行并发操作时,可能会导致数据丢失或不一致的问题。特别是,HashMapput 方法在并发情况下不会抛出异常,这使得问题更加隐蔽且难以排查。本文将探讨这些问题的根源,并推荐使用 computeIfAbsentputIfAbsentmerge 等方法来替代直接使用 put 方法,以确保数据的完整性和一致性。

多线程环境下的 HashMap 问题

在 Java 中,HashMap 是一个非线程安全的数据结构。当多个线程同时对 HashMap 进行写操作时,可能会导致数据丢失或不一致的情况。特别需要注意的是,HashMapput 方法在并发修改时不会抛出 ConcurrentModificationException,这使得问题更加难以检测和调试。本文将通过一个示例代码展示这种问题,并提供一些替代方案来解决这些问题。

在多线程环境中使用 HashMapput 方法时,可能会出现数据丢失的情况。以下是一个示例代码:

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

问题分析

  1. 数据丢失 :由于 HashMapput 操作不是原子的,多个线程同时执行 put 操作时,可能会覆盖彼此的写入,导致数据丢失。
  2. 不一致性 :在并发环境中,HashMap 的内部结构可能被破坏,导致数据不一致。
  3. 无异常提示HashMapput 方法在并发修改时不会抛出 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);

结论

  1. 多线程环境下使用线程安全类
  2. 并发编程时尽量减少副作用,所有计算结果、IO数据可以统一封装成返回值,如CompletableFuture<Result>
  3. 不要过分依赖集合类提供的并发修改检查,其不一定保证能检查出并发问题
  4. 推荐使用 computeIfAbsentputIfAbsentmerge 等方法,其提供了一定的并发检查能力
  5. 只使用HashMap#put也具有一定的"安全性"(指的是不会抛异常),适合于所谓的"防御性编程"、埋雷、埋bug
相关推荐
颜如玉2 小时前
Redis主从同步浅析
后端·开源·源码
奔跑吧邓邓子3 小时前
【Java实战⑨】Java集合框架实战:List集合深度剖析
java·实战·list·集合
小菜全3 小时前
使用Java获取本地PDF文件并解析数据
java·开发语言·python
小傅哥3 小时前
互联网大厂Java面试宝典:Spring Boot与微服务全栈提问实战解析
java·spring boot·微服务·面试·技术栈
yangchanghua1113 小时前
Caused by: java.net.SocketTimeoutException: Read timed out;
java·开发语言·spring
辗转反侧着疑惑4 小时前
MyBatis Plus 【详解】| 学习日志 | 第 17 天
java·开发语言·学习·mybatis·mybatis-plus
闪电麦坤954 小时前
数据结构:选择排序 (Selection Sort)
数据结构·算法·排序算法
LaoZhangGong1234 小时前
MQTT报文的数据结构
c语言·网络·数据结构·mqtt·w5500
胡萝卜3.04 小时前
【LeetCode&牛客&数据结构】单链表的应用
数据结构·学习·算法·leetcode·单链表