凌晨三点,线上报警短信又一次把我从床上拽起来------核心服务的RT(响应时间)飙升到5秒,而监控显示所有异常请求都卡在同一个风控校验模块。谁能想到,罪魁祸首竟是一个看似无害的HashMap?
问题现场:高并发下的诡异行为
那是一个日活百万的金融场景,风控模块需要缓存用户当日的交易行为。为了"提升性能",当时的代码直接使用HashMap做内存缓存:
java
// 错误示范:全局共享的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);
}
上线初期风平浪静,直到大促期间流量暴涨,我们开始收到两种诡异报错:
NullPointerException:明明刚放入的List,下一行就取不到了ConcurrentModificationException:遍历时List突然变脸
- 你是不是也疑惑:这些异常在单测里明明复现不了?*
撕开HashMap的线程假面
根本原因藏在HashMap的底层实现里。当多个线程同时执行put操作时,可能触发哈希桶扩容(resize)------这个看似简单的操作实则分三步:
- 创建新数组
- 遍历旧数组重新计算哈希
- 迁移节点到新数组
- 想象这个场景:*
- 线程A刚创建新数组但还没迁移数据时,线程B来了次
get操作 - 线程B读取到的是未完成迁移的旧数组,自然可能拿到null
- 更可怕的是,如果在迁移过程中有其他线程修改链表结构,很可能形成环形链表(JDK7的经典死循环问题)
用一段伪代码看扩容时的线程穿插:
java
void transfer(Entry[] newTable) {
Entry[] src = table; // 线程A执行到这一行时挂起
for (Entry e : src) { // 线程B此时读取到的table可能已经被破坏
// ...迁移逻辑
}
}
数据对比:线程安全容器的性能代价
既然要用线程安全结构,那选哪个?我们做了组压测对比(10线程并发,100万次操作):
| 容器类型 | 写入耗时(ms) | 读取耗时(ms) | 内存开销 |
|---|---|---|---|
| HashMap | 187 | 56 | 1x |
| Hashtable | 982 | 213 | 1.2x |
| Collections.synchronizedMap | 845 | 197 | 1.1x |
| ConcurrentHashMap | 231 | 63 | 1.05x |
- 看到没?ConcurrentHashMap几乎追平HashMap的性能!* 它通过分段锁(JDK7)或CAS+synchronized(JDK8)实现细粒度并发控制。
修复方案:选对工具只是第一步
直接替换成ConcurrentHashMap当然可以,但真实场景往往更复杂。比如我们的交易记录需要保证时序,最终方案是:
java
// 正确写法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方法。
避坑指南:血泪换来的经验
- 不要相信"只读就不需要同步":即使全是读操作,扩容期间的读仍可能拿到临时状态
- 慎用double-check:你以为的"优化"可能引发指令重排序问题
- 小心组合操作 :比如
map.contains(key) && map.get(key),中间可能被其他线程打断 - 内存可见性陷阱:就算用了volatile修饰HashMap引用,内部结构的修改仍不可见
结语:线程安全没有银弹
现在你明白为什么我总说:"看到new HashMap()先问问自己------这里真的不会有并发吗?" 在高并发世界,没有侥幸,只有敬畏。
你们团队用什么方案解决HashMap的线程安全问题?欢迎分享你的踩坑经历。