Java的HashMap线程安全问题让我深夜掉光了头发

125 阅读1分钟

凌晨三点,线上报警短信又一次把我从床上拽起来——核心服务的RT(响应时间)飙升到5秒,而监控显示所有异常请求都卡在同一个风控校验模块。谁能想到,罪魁祸首竟是一个看似无害的HashMap?

问题现场:高并发下的诡异行为

那是一个日活百万的金融场景,风控模块需要缓存用户当日的交易行为。为了"提升性能",当时的代码直接使用HashMap做内存缓存:

// 错误示范:全局共享的HashMap
private static final Map<String, List<Transaction>> CACHE = new HashMap<>();

public void addTransaction(String userId, Transaction tx) {
    List<Transaction> list = CACHE.get(userId);
    if (list == null) {
        list = new ArrayList<>();
        CACHE.put(userId, list); // 注意这里!
    }
    list.add(tx);
}

上线初期风平浪静,直到大促期间流量暴涨,我们开始收到两种诡异报错:

  1. NullPointerException:明明刚放入的List,下一行就取不到了
  2. ConcurrentModificationException:遍历时List突然变脸
  • 你是不是也疑惑:这些异常在单测里明明复现不了?*

撕开HashMap的线程假面

根本原因藏在HashMap的底层实现里。当多个线程同时执行put操作时,可能触发哈希桶扩容(resize)——这个看似简单的操作实则分三步:

  1. 创建新数组
  2. 遍历旧数组重新计算哈希
  3. 迁移节点到新数组
  • 想象这个场景:*
  • 线程A刚创建新数组但还没迁移数据时,线程B来了次get操作
  • 线程B读取到的是未完成迁移的旧数组,自然可能拿到null
  • 更可怕的是,如果在迁移过程中有其他线程修改链表结构,很可能形成环形链表(JDK7的经典死循环问题)

用一段伪代码看扩容时的线程穿插:

void transfer(Entry[] newTable) {
    Entry[] src = table; // 线程A执行到这一行时挂起
    for (Entry e : src) { // 线程B此时读取到的table可能已经被破坏
        // ...迁移逻辑
    }
}

数据对比:线程安全容器的性能代价

既然要用线程安全结构,那选哪个?我们做了组压测对比(10线程并发,100万次操作):

容器类型写入耗时(ms)读取耗时(ms)内存开销
HashMap187561x
Hashtable9822131.2x
Collections.synchronizedMap8451971.1x
ConcurrentHashMap231631.05x
  • 看到没?ConcurrentHashMap几乎追平HashMap的性能!* 它通过分段锁(JDK7)或CAS+synchronized(JDK8)实现细粒度并发控制。

修复方案:选对工具只是第一步

直接替换成ConcurrentHashMap当然可以,但真实场景往往更复杂。比如我们的交易记录需要保证时序,最终方案是:

// 正确写法1:使用ConcurrentHashMap + ConcurrentLinkedQueue
private static final Map<String, Queue<Transaction>> CACHE = new ConcurrentHashMap<>();

public void addTransaction(String userId, Transaction tx) {
    CACHE.computeIfAbsent(userId, k -> new ConcurrentLinkedQueue<>()).add(tx);
}

// 正确写法2:需要更强一致性时(如金融计费场景)
public void addTransactionAtomic(String userId, Transaction tx) {
    CACHE.compute(userId, (k, v) -> {
        if (v == null) v = new CopyOnWriteArrayList<>();
        v.add(tx);
        return v;
    });
}
  • 这里有个隐藏知识点:* computeIfAbsent在JDK8中本身有线程安全问题(已在高版本修复),所以严格场景要用完整的compute方法。

避坑指南:血泪换来的经验

  1. 不要相信"只读就不需要同步":即使全是读操作,扩容期间的读仍可能拿到临时状态
  2. 慎用double-check:你以为的"优化"可能引发指令重排序问题
  3. 小心组合操作:比如map.contains(key) && map.get(key),中间可能被其他线程打断
  4. 内存可见性陷阱:就算用了volatile修饰HashMap引用,内部结构的修改仍不可见

结语:线程安全没有银弹

现在你明白为什么我总说:"看到new HashMap()先问问自己——这里真的不会有并发吗?" 在高并发世界,没有侥幸,只有敬畏。

你们团队用什么方案解决HashMap的线程安全问题?欢迎分享你的踩坑经历。