【进阶Android】HashMap 的并发“车祸”

8 阅读6分钟

HashMap 内部没有任何加锁机制(如 synchronized 或 CAS),设计之初便如此。当多线程并发修改内部的数组和链表时,指针和状态就彻底乱套了,这里面到底发生了什么?


一、 JDK 1.7 的致命伤:扩容引发的“夺命死循环”

这是 HashMap 历史上最臭名昭著的 Bug。在 1.7 版本中,解决 Hash 冲突使用的是头插法(新元素直接插在链表最前面)。

1. 案发现场

假设 HashMap 触发了扩容(Resize),需要把老数组里的节点一个个挪到新数组里,搬家操作(transfer 操作)。

2. 车祸全过程(一定要记住)

  1. 线程 A 慢半拍: 线程 A 开始扩容,刚拿到老链表里的两个节点(假设是 Node1 -> Node2),准备执行转移逻辑,结果此时 CPU 时间片用完,线程 A 被挂起了。
  2. 线程 B 快如闪电: 线程 B 抢占 CPU 后瞬间完成了扩容。由于是头插法,节点在转移后顺序会反转。在新数组里,链表变成了 Node2 -> Node1
  3. 惨剧发生: 线程 A 恢复运行,它手里拿的还是旧指针。按照逻辑,它先把 Node1 移过去,接着处理 Node2。由于 Node2 在新数组里已经指向了 Node1,而线程 A 的逻辑又会让 Node1 指向 Node2……

3. 后果

一个环形链表诞生了!一旦后续有业务代码调用 get(),且恰好落在这个形成环的槽位,程序就会在里面无限循环。

  • 现象: 服务器某个 CPU 核心瞬间飙升到 100%,重启前无法恢复。

二、 JDK 1.8 的改良与新毛病:数据无声丢失

为了解决死循环,JDK 1.8 做了大手术:引入红黑树,并把头插法改成了尾插法。顺序不反转了,死循环确实没了,但它依然不是省油的灯。

1. 案发现场:并发 put 覆盖

假设两个线程同时往同一个数组下标(槽位)塞数据。

2. 车祸全过程

  1. 线程 A 检查: 发现槽位是空的,心想“稳了”,准备把自己的节点放进去。但在执行赋值前的一刹那,被挂起了。
  2. 线程 B 截胡: 线程 B 也发现这个槽位是空的,手起刀落,直接把自己的节点放了进去,成功收工。
  3. 线程 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注意最致命的一点:主内存里,Node2next 指向了 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
  • 它执行代码 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];

    • 致命一击! 这里的 eNode1,而当前的 newTable[i] 是上一轮放进去的 Node2
    • 所以它亲手写下了:Node1.next = Node2