为什么你的HashMap会在多线程环境下突然崩溃?
为什么你的HashMap会在多线程环境下突然崩溃面试必问HashMap线程安全问题深度剖析大家好我是苏ignet。今天聊一个让无数Java程序员踩坑的问题——HashMap的线程安全性。你以为用了JDK8就万事大吉了错JDK8虽然修复了扩容死循环的问题但线程不安全依然是HashMap的基因缺陷。本文带你深入理解背后的原理避免在实际项目中踩坑。一、先看一个真实案例public class HashMapCrashDemo {private static MapString, Integer map new HashMap();public static void main(String[] args) throws InterruptedException {// 模拟10个线程同时写入for (int i 0; i 10; i) {new Thread(() - {for (int j 0; j 1000; j) {map.put(key j, j);}}).start();}Thread.sleep(2000);System.out.println(预期大小: 10000, 实际大小: map.size());}}运行结果会让你大吃一惊每次执行结果都不一样有时是9999有时是9852更严重的情况会触发死循环导致CPU打满。这不是偶然而是HashMap设计的必然结果。二、HashMap为什么不安全HashMap的线程不安全源于三个核心问题问题1并发put导致数据覆盖当你执行map.put(key, value)时背后经历了以下步骤// HashMap.put() 简化流程public V put(K key, V value) {// 步骤1计算hash值int hash hash(key);// 步骤2判断是否扩容临界点检查if (size threshold) {resize(); // 扩容操作}// 步骤3插入数据addEntry(hash, key, value, bucketIndex);return null;}在多线程环境下步骤2和步骤3之间可能被其他线程打断时间线T1线程检查size15未超过阈值准备插入T2线程检查size15未超过阈值准备插入T1线程插入数据A到bucketT2线程插入数据B到bucket覆盖了T1的数据结果数据被覆盖size只增加了1而不是2。问题2JDK7扩容死循环JDK7的HashMap采用头插法扩容// JDK7 扩容代码简化void transfer(Entry[] newTable) {Entry[] src table;int newCapacity newTable.length;for (int j 0; j src.length; j) {EntryK,V e src[j];if (e ! null) {do {EntryK,V next e.next; // 记录下一个节点e.next newTable[i]; // 头插法新桶指向旧链表头newTable[i] e; // 当前节点成为新链表头e next; // 处理下一个节点} while (e ! null);}}}并发扩容时两个线程可能这样操作原链表A - B - nullT1线程处理A新建链表 A - null然后CPU切换T2线程完整执行A - B - null顺序反转了T1线程恢复继续处理BB.next A注意A.next已经被T2改成了null结果A - B - A 形成环形链表后续get操作无限遍历 - CPU 100%问题3size非原子性// 这不是原子操作size;// 实际分解为三步// 1. temp size (读取)// 2. temp temp 1 (计算)// 3. size temp (写入)// 两个线程可能读到同一个值都1后写入// 结果size只增加了1而不是2三、JDK8做了什么改进JDK8做了两个关键改进改进1尾插法替代头插法// JDK8 扩容采用尾插法do {EntryK,V next e.next;e.next null; // 先断开newTable[j oldCap] e; // 尾插到新桶e next;} while (e ! null);这样即使链表反转也不会形成环形结构。改进2解决了死循环但没解决数据覆盖// JDK8并发put的问题场景// 两个线程同时put到同一个空桶// 判断都是空的 - 都会执行插入 - 后者覆盖前者四、为什么volatile解决不了问题有同学会问给size加volatile不行吗// 错误方案private volatile int size;// 实际测试依然不安全// 因为 size 本身就不是原子操作// volatile只能保证可见性不能保证原子性volatile的三重保证- ✅ 可见性一个线程修改其他线程立即可见- ✅ 有序性禁止指令重排序- ❌ 原子性size这种读-改-写三步操作依然会被打断五、正确解决方案方案1ConcurrentHashMap推荐// JDK8使用CAS synchronized性能大幅提升ConcurrentHashMapString, Integer map new ConcurrentHashMap();// 使用computeIfAbsent实现原子不存在则插入map.computeIfAbsent(key, k - calculateValue());特性ConcurrentHashMap线程安全✅锁粒度桶级锁CASnull支持不允许null迭代器弱一致性方案2Collections.synchronizedMap慎用MapString, Integer map Collections.synchronizedMap(new HashMap());// 依然有问题synchronized(map) {if (!map.containsKey(key)) {map.put(key, value); // 这两步之间可能被其他线程插入}}适用场景低并发、简单操作的临时方案。方案3Hashtable不推荐HashtableString, Integer map new Hashtable();// 方法级synchronized锁整个表性能差// 基本已被淘汰不建议使用六、生产环境避坑指南❌ 禁止这样用// 场景1Spring注入HashMap做缓存Servicepublic class UserService {private MapString, User userCache new HashMap(); // 危险}// 场景2静态HashMap做全局缓存private static final MapString, Object CACHE new HashMap(); // 危险// 场景3多线程环境中使用HashMapExecutorService pool Executors.newFixedThreadPool(10);pool.submit(() - map.put(k, v)); // 危险✅ 正确姿势// 场景1使用ConcurrentHashMapprivate final MapString, User userCache new ConcurrentHashMap();// 场景2Guava Cache生产环境推荐LoadingCacheString, User cache CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(10, TimeUnit.MINUTES).build(key - loadUser(key));// 场景3ConcurrentHashMap的原子操作map.putIfAbsent(key, value); // 不存在则插入map.replace(key, oldValue, newValue); // 替换条件判断原子化map.computeIfAbsent(key, k - { // 不存在则计算插入// 复杂计算逻辑return computeExpensiveValue(k);});七、总结问题JDK8扩容死循环❌ 解决数据覆盖✅ 存在size计数错误✅ 存在迭代异常✅ 存在核心结论1.HashMap永远不是线程安全的不要抱有任何侥幸心理2. JDK8只是优化了死循环问题其他并发问题依然存在3. 生产环境必须使用ConcurrentHashMap4. 如果需要更丰富的缓存功能考虑Guava Cache或Caffeine希望今天的分享对你有帮助欢迎留言讨论你在项目中踩过的HashMap坑往期推荐- [Spring事务失效的深层原因与解决方案](https://blog.csdn.net/qq_34358104/article/details/xxxxx)4月20日- [线程池拒绝策略你真的用对了吗](https://blog.csdn.net/qq_34358104/article/details/xxxxx)- [JVM调优实战一次Full GC的排查与解决](https://blog.csdn.net/qq_34358104/article/details/xxxxx)