淄博市住房和城乡建设局网站,二手车交易市场,做网站开发最多能做几年,网站开发中数据库的功能一、开篇#xff1a;一个广为流传的误区在 Java 开发者的圈子里#xff0c;有一个流传甚广的说法#xff1a;JDK 1.8 修复了 HashMap 在多线程环境下的死循环问题#xff0c;所以虽然它线程不安全#xff0c;但至少不会导致服务器 Load 飙升了。这个说法只对了…一、开篇一个广为流传的误区在 Java 开发者的圈子里有一个流传甚广的说法JDK 1.8 修复了 HashMap 在多线程环境下的死循环问题所以虽然它线程不安全但至少不会导致服务器 Load 飙升了。这个说法只对了一半。确实JDK 8 通过将头插法改为尾插法解决了多线程扩容时链表形成环状数据结构Circular Linked List的问题。但是这并不意味着 JDK 8 的 HashMap 就是线程安全的更不意味着它完全摆脱了死循环的阴影。事实上JDK 8 的 HashMap 在特定的并发场景下依然可能导致服务器 CPU 100% 的严重故障。这种新形态的死循环往往隐藏在红黑树的旋转、节点转移等更复杂的逻辑中排查起来比 JDK 7 的链表成环更加困难。为了让你直观感受问题的真实性我们先看一个来自阿里开发者的真实线上案例 某天监控系统告警一台服务器的 Load 突然飙升。运维人员使用top、jstack等命令分析后发现大量线程卡死在了HashMap.TreeNode.getTreeNode()方法上。进一步分析堆转储Heap Dump文件发现在一个红黑树结构中两个TreeNode节点的parent引用竟然互相指向了对方0x72745d828 与 0x72745d7b8 两个 TreeNode 节点的 Parent 引用都是对方。这形成了一个环导致遍历红黑树的代码陷入了死循环。而此时开发者使用的正是 JDK 8。这个案例告诉我们即使是在 JDK 8 中HashMap 也绝非“免死金牌”。特性JDK 7JDK 8数据结构数组 链表数组 链表 红黑树插入方式头插法在链表头部插入尾插法在链表尾部插入扩容后元素位置可能反转链表顺序保持原链表顺序或拆分为高低位两条链主要死循环风险多线程扩容时链表成环多线程操作时红黑树指针错乱如 parent 互指本文将带着你从源码、图解、实战排查三个维度彻底搞清楚 HashMap 在多线程环境下的那些“坑”。二、前车之鉴JDK 7 的死循环是如何发生的要理解 JDK 8 的改进与不足首先得搞清楚 JDK 7 的问题根源。JDK 7 的死循环发生在扩容resize阶段核心元凶是头插法。2.1 扩容源码分析当 HashMap 中的元素个数超过阈值threshold 容量 * 负载因子时会调用resize()方法将数组容量扩大一倍并将旧数组中的数据迁移到新数组中。这个迁移过程由transfer()方法完成 java// JDK 7 HashMap.transfer() 方法关键代码 void transfer(Entry[] newTable, boolean rehash) { int newCapacity newTable.length; for (EntryK,V e : table) { // 遍历旧数组的每个桶 while(null ! e) { EntryK,V next e.next; // 步骤1记住下一个节点 int i indexFor(e.hash, newCapacity); // 计算在新数组中的索引 e.next newTable[i]; // 步骤2e.next 指向新桶的第一个元素头插 newTable[i] e; // 步骤3将 e 放到新桶的第一个位置 e next; // 步骤4继续处理下一个节点 } } }这段代码的逻辑本身没有问题但在多线程环境下问题就暴露了。2.2 图解两个线程如何制造环形链表假设我们有一个 HashMap桶下标为 5 的位置有一条链表A - B - null。现在有两个线程同时触发扩容。初始状态线程一和线程二都持有对旧链表的引用。对于线程一它的局部变量e指向 Anext指向 B。线程一被挂起线程一执行完Entry next e.next;此时eA,nextB后CPU 时间片耗尽被挂起 。线程二完成扩容线程二拿到 CPU 时间片顺利完成整个扩容迁移。由于头插法的特性新链表顺序会反转。假设扩容后 A 和 B 还在同一个新桶中那么新链表变成了B - A - null。此时线程二的新数组中B 的next指向 AA 的next为 null。线程一恢复执行线程一恢复执行它手里还握着旧的引用e指向 A虽然此时 A 已经被线程二移到了新数组next指向 B。第一次循环线程一执行e.next newTable[i];将 A 的next指向线程二的新数组的表头即 B。此时 A.next B。执行newTable[i] e;将新数组的该桶指向 A。执行e next;e指向 B。第二次循环线程一执行Entry next e.next;此时 B 的next是什么在线程二完成扩容后B.next A。所以线程一得到的next指向 A。执行e.next newTable[i];将 B 的next指向当前新桶的表头也就是 A。执行newTable[i] e;将新桶的表头设为 B。执行e next;e又指向了 A。关键的一刻发生了此时新桶中的结构是B - A并且 A 的next在之前的步骤中被设为了 B即A - B。一个循环链表A - B就这样形成了 。当后续有任何查询get操作落到这个桶上时遍历链表的过程就会陷入A - B - A - B ...的死循环CPU 瞬间飙升到 100%。三、拨乱反正JDK 8 的改进与“修复”JDK 8 的开发者们显然意识到了头插法在多线程下的危险性并进行了大刀阔斧的改进。3.1 核心改进一尾插法JDK 8 彻底放弃了头插法转而使用尾插法。在resize()方法的迁移逻辑中JDK 8 会维护两条链表loHead低位链表头和hiHead高位链表头并且通过尾指针loTail和hiTail将元素按原本的顺序依次添加到链表尾部 。java// JDK 8 HashMap.resize() 迁移部分简化版 NodeK,V loHead null, loTail null; NodeK,V hiHead null, hiTail null; NodeK,V next; do { next e.next; if ((e.hash oldCap) 0) { // 低位链表 if (loTail null) loHead e; else loTail.next e; // 尾插法将当前节点追加到尾部 loTail e; } else { // 高位链表 // ... 类似操作 } } while ((e next) ! null);由于使用了尾插法且迁移过程中节点的引用关系是顺序复制而不是倒置修改多线程环境下即使并发迁移也不会再出现 JDK 7 中那种你改我、我改你导致引用错乱成环的情况 。从这个角度看JDK 8 确实解决了因链表成环导致的经典死循环问题。3.2 核心改进二引入红黑树当链表长度超过阈值默认为 8时JDK 8 会将链表转换为红黑树TreeNode。红黑树的查询时间复杂度为 O(log n)远优于链表的 O(n)这大大提高了在高哈希冲突下的性能。然而成也萧何败也萧何。红黑树这种复杂的数据结构在并发环境下引入了一个新的、更隐蔽的风险点。四、新的隐患JDK 8 中依然存在的死循环JDK 8 虽然解决了链表成环问题但如果 HashMap 被多线程并发读写尤其是触发了红黑树的转换与操作依然可能导致死循环。这就是我们在开篇案例中看到的现象。4.1 红黑树成环的场景分析开篇引用的那个真实案例中开发者通过jhat分析堆转储文件发现了一个诡异的现象两个TreeNode节点的parent引用互相指向了对方 。这种场景是如何发生的虽然没有一个固定的公式但可以推断是由于多线程并发执行了红黑树的旋转rotate或插入/删除操作导致树的结构被破坏。推测性的复现流程假设有一个红黑树结构线程 A 和线程 B 同时准备对树进行结构调整例如在putVal方法中调用treeifyBin或balanceInsertion。线程 A 读取了节点 X 的 parent 为 Y准备执行左旋操作。在左旋过程中它会修改 X、Y 以及相关子节点的引用关系。在它完成部分修改但尚未完成全部操作时例如已经将 Y 设为 X 的左子节点但还没来得及更新 Y 的 parent 指针线程 A 被挂起。线程 B 获得了 CPU 时间片它同样试图操作这棵树。它读取到的状态是中间状态、不一致的。线程 B 根据自己的逻辑对树进行了右旋或其他调整。它将某个节点的 parent 引用指向了 A。此时线程 A 恢复执行继续完成它未完成的旋转操作可能将某个节点的 parent 指向了 B。最终的结果就是A 认为 B 是它的 parentB 也认为 A 是它的 parent。一个在树结构中的环产生了。当后续有任何操作需要遍历这棵树例如get或put时沿着 parent 链寻找根节点或进行其他遍历就会陷入死循环。4.2 源码中的可疑地带虽然我们无法在 JDK 源码中直接找到一个能“自动”成环的 bug但红黑树的实现如HashMap.TreeNode中的root()、rotateLeft()、rotateRight()、balanceInsertion()等方法充满了指针操作 。这些操作在单线程下是严谨且高效的但在并发环境下多个线程同时修改这些指针没有任何同步机制来保证原子性和可见性就极有可能破坏数据结构的不变性。java// HashMap.TreeNode 中的 rotateLeft 方法JDK 8 static K,V TreeNodeK,V rotateLeft(TreeNodeK,V root, TreeNodeK,V p) { TreeNodeK,V r, pp, rl; if (p ! null (r p.right) ! null) { // ... 一系列指针操作 if ((rl p.right r.left) ! null) rl.parent p; if ((pp r.parent p.parent) null) (root r).red false; else if (pp.left p) pp.left r; else pp.right r; r.left p; p.parent r; } return root; }在多线程环境下如果两个线程同时调用rotateLeft或rotateRight作用于相邻或相关的节点没有任何锁的保护这些parent和left/right引用的赋值操作就可能交织在一起产生无法预测的后果包括成环。五、不止死循环JDK 8 HashMap 的其他并发问题即使不考虑死循环将 JDK 8 的 HashMap 用于多线程环境也会遇到一系列严重问题。5.1 数据覆盖丢失这是最常见的并发问题。看putVal方法中的一段逻辑 java// JDK 8 HashMap.putVal 方法简化版 final V putVal(int hash, K key, V value, ...) { NodeK,V[] tab; NodeK,V p; int n, i; if ((tab table) null || (n tab.length) 0) n (tab resize()).length; // 如果当前位置为空直接插入 if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null); else { // ... 处理哈希冲突 } }如果线程 A 和线程 B 同时执行到if ((p tab[i]) null)这一行并且计算出的桶下标i相同且该位置确实为空。线程 A 判断p null为真准备插入新节点。在线程 A 还未执行tab[i] newNode(...)之前线程 B 也判断p null为真因为 A 还没写入。然后两个线程都执行插入操作。后执行的线程会覆盖先执行的线程导致前一个线程 put 的数据永久丢失。5.2 扩容期间的数据丢失在扩容迁移数据时也可能会发生数据丢失。虽然没有 JDK 7 那么严重的成环问题但多线程下一个线程的迁移结果可能会被另一个线程的迁移结果所覆盖导致部分数据凭空消失。5.3 modCount 与 Fail-Fast 机制HashMap 内部有一个字段modCount记录结构被修改的次数。当使用迭代器遍历时会检查modCount是否被其他线程修改。如果被修改会立即抛出ConcurrentModificationException。虽然这不是死循环或数据丢失但这同样是在并发环境下的非预期行为可能导致程序异常终止。5.4 无可见性保证HashMap 的成员变量如table、size都没有使用volatile修饰。这意味着一个线程对 HashMap 的修改如插入新值、扩容后的新数组对其他线程是不可见的。其他线程可能永远看不到这些变化导致读取到过期的null值或错误的数据 。六、线上故障排查如何定位 HashMap 引起的 Load 飙升当你的服务器出现 CPU 100%并且怀疑是 HashMap 引起时可以按照以下步骤进行排查。6.1 经典四连击top命令找到 CPU 使用率最高的 Java 进程 PID。bashtoptop -H -p [pid]查看该进程内哪个线程TID消耗 CPU 最高 。bashtop -H -p 你的Java进程PIDprintf %x\n [tid]将消耗最高的线程 ID十进制转换为十六进制因为jstack输出的线程 ID 是十六进制的。bashprintf %x\n CPU最高的线程TIDjstack [pid] | grep -A 20 [nid]打印线程堆栈并过滤出那个倒霉线程的调用栈。bashjstack Java进程PID | grep -A 20 转换后的十六进制线程ID6.2 分析堆栈线索如果看到java.util.HashMap.put()或get()并且在循环中反复出现尤其是伴随着resize或TreeNode的方法这基本实锤是 HashMap 并发导致的死循环或重度资源竞争。如果堆栈中有大量java.lang.OutOfMemoryError的痕迹可能是因为并发put导致 HashMap 内部数据结构错乱不断开辟新空间最终 OOM。如果问题难以复现需要分析堆转储文件bashjmap -dump:live,formatb,filedump.hprof Java进程PID然后用 MATMemory Analyzer Tool或jhat分析。对于成环问题可以重点关注java.util.HashMap$TreeNode对象查看其引用关系是否异常如 parent 循环引用。七、最佳实践多线程环境下 Map 的正确选型说了这么多其实解决方案早就写在 JDK 的文档和无数前辈的血泪史中了。7.1 终极方案ConcurrentHashMap对于多线程环境没有任何理由直接使用 HashMap。首选就是java.util.concurrent.ConcurrentHashMap。JDK 7 版本使用分段锁Segment技术将数据分段每段有一把锁只有访问同一段的数据时才需要竞争锁大大提高了并发度。JDK 8 版本进一步优化摒弃了分段锁采用CAS synchronized的方式实现。只在写入的桶链表/树的头节点上加锁锁粒度更细并发性能更强 。同时它提供了弱一致性的迭代器不会抛出ConcurrentModificationException。7.2 备选方案Collections.synchronizedMap(new HashMap())通过一个简单的包装在所有方法上都加上synchronized锁。这相当于给整个 Map 加了一把大锁虽然线程安全但并发性能极差相当于串行化操作 。只适合并发度极低的场景或者作为临时的过渡方案。Hashtable一个古老的线程安全类和synchronizedMap类似也是全表锁性能较差现在基本已被淘汰。7.3 使用 ConcurrentHashMap 时的注意点即使使用了ConcurrentHashMap也不能完全高枕无忧有几个细节需要注意 size()方法的准确性ConcurrentHashMap的size()方法返回的是一个估计值在并发修改下可能不精确。如果需要精确计数的业务场景如统计在线人数可能需要额外的同步措施或使用LongAdder等原子类配合。复合操作仍需加锁ConcurrentHashMap保证了单个put、get、remove等方法的线程安全。但像“如果存在则删除”、“先取值再更新”这类复合操作如果需要原子性应使用其提供的compute、computeIfAbsent、merge等原子方法或者在外部加锁。value不能为 nullConcurrentHashMap的put方法不允许 value 为null这与 HashMap 不同。这是为了避免在并发环境下无法通过get()返回null来判断是 key 不存在还是 value 本身就是 null。结语JDK 8 对 HashMap 的改进是巨大的它通过尾插法解决了困扰无数开发者的链表成环死循环问题并通过红黑树提升了最坏情况下的性能。但是这绝不是一个在多线程环境中使用 HashMap 的理由。JDK 8 的 HashMap 依然会因红黑树指针错乱而产生新的死循环数据覆盖、丢失、不可见等问题依然存在。