HashMap 内部没有任何加锁机制(如 synchronized 或 CAS),设计之初便如此。当多线程并发修改内部的数组和链表时,指针和状态就彻底乱套了,这里面到底发生了什么?
一、 JDK 1.7 的致命伤:扩容引发的“夺命死循环”
这是 HashMap 历史上最臭名昭著的 Bug。在 1.7 版本中,解决 Hash 冲突使用的是头插法(新元素直接插在链表最前面)。
1. 案发现场
假设 HashMap 触发了扩容(Resize),需要把老数组里的节点一个个挪到新数组里,搬家操作(transfer 操作)。
2. 车祸全过程(一定要记住)
- 线程 A 慢半拍: 线程 A 开始扩容,刚拿到老链表里的两个节点(假设是
Node1 -> Node2),准备执行转移逻辑,结果此时 CPU 时间片用完,线程 A 被挂起了。 - 线程 B 快如闪电: 线程 B 抢占 CPU 后瞬间完成了扩容。由于是头插法,节点在转移后顺序会反转。在新数组里,链表变成了
Node2 -> Node1。 - 惨剧发生: 线程 A 恢复运行,它手里拿的还是旧指针。按照逻辑,它先把
Node1移过去,接着处理Node2。由于Node2在新数组里已经指向了Node1,而线程 A 的逻辑又会让Node1指向Node2……
3. 后果
一个环形链表诞生了!一旦后续有业务代码调用 get(),且恰好落在这个形成环的槽位,程序就会在里面无限循环。
- 现象: 服务器某个 CPU 核心瞬间飙升到 100%,重启前无法恢复。
二、 JDK 1.8 的改良与新毛病:数据无声丢失
为了解决死循环,JDK 1.8 做了大手术:引入红黑树,并把头插法改成了尾插法。顺序不反转了,死循环确实没了,但它依然不是省油的灯。
1. 案发现场:并发 put 覆盖
假设两个线程同时往同一个数组下标(槽位)塞数据。
2. 车祸全过程
- 线程 A 检查: 发现槽位是空的,心想“稳了”,准备把自己的节点放进去。但在执行赋值前的一刹那,被挂起了。
- 线程 B 截胡: 线程 B 也发现这个槽位是空的,手起刀落,直接把自己的节点放了进去,成功收工。
- 线程 A 盲目执行: 线程 A 醒来后,它不知道坑位已经被占了(因为它挂起前已经判断过为空了),直接强行执行赋值操作。
3. 后果
线程 B 辛辛苦苦存进去的数据,被线程 A 无情地**覆盖(丢失)**了。这种“检查后执行(Check-Then-Act)”的非原子操作,是多线程环境下数据丢失的罪魁祸首。
三、 避不开的通病:size 统计“假账”
除了链表结构会被搞乱,HashMap 内部记录元素总数的 size 变量也是个重灾区。
size++ 在 JVM 底层并不是原子操作,它分为三步:读取值 -> 加一 -> 写回内存。
- 情景: 线程 A 和 B 同时读取到
size = 10。 - 结果: 各自加一后写回
11。实际上存进了两个元素,size应该变12。
这会导致容量统计严重失真,进而导致扩容机制在错误的时间点触发,甚至出现逻辑矛盾。
总结:两代版本,两种死法
| 版本 | 关键机制 | 并发后果 |
|---|---|---|
| JDK 1.7 | 头插法 | 扩容时产生环形链表,导致 CPU 100% |
| JDK 1.8 | 尾插法 | 并发 put 导致状态判断失效,数据被覆盖/丢失 |
| 通用 | size++ 非原子 | 内部状态计数失准 |
避坑指南:
在高并发环境下,只要涉及多线程共享 Map,请直接上 ConcurrentHashMap。别指望靠运气避开这些 Bug,毕竟生产环境的“墨菲定律”从来没让人失望过。
四.我的思考
线程 A 恢复运行,它手里拿的还是旧指针。按照逻辑,它先把 Node1 移过去,接着处理 Node2。由于 Node2 在新数组里已经指向了 Node1,而线程 A 的逻辑又会让 Node1 指向 Node2……,, 线程a移过去的时候最后也应该是node2指向node1,为什么还是node1指向node2呢?
这是我被绕晕的一个地方: 拿源码出来看
// 假设这是那个 do-while 循环的内部
Entry<K,V> next = e.next; // 1. 记住当前节点的下一个兄弟是谁(从主内存读)
e.next = newTable[i]; // 2. 头插法:把当前节点的 next 指向新数组的头(修改局部变量)
newTable[i] = e; // 3. 把当前节点设为新数组的新头(修改局部变量)
e = next; // 4. 指针往后推,准备处理下一个节点(更新局部变量)
运行程序:
0. 初始状态
- 老数组里的真实情况是:
Node1 -> Node2 -> null - 线程 A 被挂起时,它手里的局部变量是:
e = Node1,next = Node2。
1. 线程 2 (T2) 冲进来干坏事
线程 2 飞速跑完扩容,它把堆内存里真实的节点关系改成了:
Node2 -> Node1 -> null(注意最致命的一点:主内存里,Node2的next指向了Node1!)
2. 线程 1 醒来,灾难发生
线程 1 解开穴道,继续闭眼执行代码:
【第一轮循环:处理 Node1(T1 的第一次搬运)】
- e=Node1, next=Node2。
- 它执行代码 2 和 3:把
Node1塞进自己的新桶,让Node1.next = null。 - 它执行代码 4:
e = next;(它把e更新为自己在挂起前记住的那个Node2)。 - 【结果】 :新桶里有了一个
A -> null,T1 准备处理Node2。
【第二轮循环:诡异的开始(T1 再次读到 A)】
-
此时 e=Node2。
-
它执行代码 1:
next = e.next;- 关键高潮! T1 去主内存里摸(放大镜图标),此时
Node2在主内存里真实的next是指着Node1的! - 所以,T1 被骗了,它读出来的
next竟然变成了Node1!
- 关键高潮! T1 去主内存里摸(放大镜图标),此时
-
它执行代码 2 和 3:把
Node2塞进新桶的最前面,产生Node2.next = Node1。 -
它执行代码 4:
e = next;(因为刚才next被读成了Node1,所以e竟然再次变回了Node1! ) -
【结果】 :新桶里有了
B -> A -> null,T1 再次准备处理那个被视为新节点的Node1。
【第三轮循环:灾难降临(T1 荒诞地打死结)】
-
e=Node1。
-
它执行代码 1:
next = e.next;(去读主内存,现在是null,所以next = null)。 -
它执行代码 2:
e.next = newTable[i];- 致命一击! 这里的
e是Node1,而当前的newTable[i]是上一轮放进去的Node2。 - 所以它亲手写下了:
Node1.next = Node2
- 致命一击! 这里的