多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JDK 1.8 HashMap底层原理:红黑树之外的哈希、扩容与并发改动

JDK 1.8 HashMap底层原理:红黑树之外的哈希、扩容与并发改动 大家聊 JDK 1.8 的 HashMap十有八九第一句话都是“引入了红黑树”。这个判断方向没错但远不是全貌。红黑树解决的是哈希碰撞极端场景下查询退化成 O(n) 的问题而在它背后哈希函数、扩容流程、插入顺序、初始化时机、数据结构定义、新增 API这一整套东西全都被重排过。我为了排查一个线上服务频繁 Full GC 的问题把 JDK 7 和 JDK 8 的 HashMap 源码逐版对着读了好几遍越读越觉得“引入红黑树”这个标签把一个系统级重构给说小了。这篇文章围绕 JDK 1.8 的 HashMap 底层实现原理重点讲红黑树之外的改动。内容按源码阅读顺序展开适合准备啃 JDK 源码但对 HashMap 1.8 改动只有模糊印象的同学也适合工作中被并发环境下的 HashMap CPU 飙升、容量设置不合理等问题困扰的开发者。看完你会明白为什么说 1.8 的 HashMap 是一次从头到尾的重新设计。1. 先看全貌除了红黑树这次到底改了多少东西很多人对 1.8 HashMap 的认知停留在“链表太长就转红黑树”但真要问改动点能说全的人不多。原因也很简单源码里很多改动是悄无声息发生的不像红黑树那样有明确的类结构和算法亮点一眼就能看出来。可恰恰是这些“不显眼”的改动决定了日常使用的性能和边界行为。1.1 一张表看清 1.7 到 1.8 的改动改动点JDK 1.7 行为JDK 1.8 行为核心目的哈希函数4 次移位异或扰动1 次 16 位右移异或降低碰撞、减少计算开销寻址方式indexFor 静态方法(n - 1) hash 内联消除方法调用、简化实现链表插入头插法尾插法规避并发扩容下的环形链表扩容重散列每个节点重新取模原位置或原位置旧容量避免 rehash 开销桶内数据结构链表链表 红黑树降低长链表查找时间树化/退化参数无8 / 6 / 64性能与空间平衡初始化时机构造时分配数组首次 put 时分配数组减少无谓内存占用threshold 语义容量 × 加载因子空表时暂存初始容量支撑懒加载底层节点类EntryNode / TreeNode为树化提供类型基础新增 API无 default 方法getOrDefault / compute / merge 等简化日常开发1.2 为什么说这是“系统性重构”而非局部优化这些改动不是各做各的它们之间存在明显的联动关系。比如新的哈希函数只需要一次异或计算出的 hash 值直接存在 Node 节点里扩容时就可以靠(hash oldCap)快速判断节点去留不用重新算一遍哈希这是一整套配合。再比如懒初始化让“new HashMap()”这个操作几乎零成本而 1.7 里只要 new 一个 HashMap 就会立刻分配 16 个元素的 Entry 数组线上代码里大量创建的短生命周期 HashMap 会白白吃内存。红黑树只是其中最显眼的一环。如果只盯着树化看你会漏掉一个关键事实1.8 的 HashMap 在扩容、插入、初始化这些核心路径上的行为和 1.7 已经完全不同了。这种系统性重构才配得上“性能优化”这四个字。2. 哈希函数与寻址逻辑从四次扰动回归到一次异或先看最简单但影响面最大的改动哈希函数本身。HashMap 定位桶位置依赖(n - 1) hash也就是只取 hash 的低位参与数组下标计算。当容量小时高位信息等于白算如何把高位“扰动”到低位直接决定碰撞概率。2.1 1.7 的扰动函数和它的成本JDK 1.7 的 HashMap 里对 key 的 hashCode 要做四次异或三次位移static int hash(int h) { h ^ (h 20) ^ (h 12); return h ^ (h 7) ^ (h 4); }这段代码当时的设计意图很明确把 hashCode 高位的差异性尽可能扩散到低位保证h (length - 1)得到的下标分布足够均匀。问题是它每次 get 和 put 都要执行四次异或加三次位移累计起来就不是小开销。尤其在高频调用场景下这个计算成本是跑在每条读写路径上的。2.2 1.8 的 16 位右移异或凭什么更高效JDK 1.8 把上面的扰动函数直接砍成一次异或加一次位移static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }原理是把 32 位哈希值的高 16 位和低 16 位做异或让高 16 位的信息也混进低 16 位。由于 HashMap 的容量始终是 2 的幂容量不超过 65536 时有效下标只需要低 16 位这种混合已经足够。设计者的取舍是“一次 16 位无符号右移加一次异或计算足够廉价散列效果也足够好就不需要更复杂的扰动函数了。”我给你一个直观的例子。假设两个 key 的 hashCode 分别是0x00010001和0x00020001它们的低 16 位完全相同。如果直接用原始 hashCode 参与下标计算在容量 16 的 HashMap 里它们必然撞进同一个桶。而经过 1.8 的哈希处理后0x00010001 ^ (0x00010001 16)0x000100000x00020001 ^ (0x00020001 16)0x00020003容量 16 时(16 - 1) hash得到的下标分别是 0 和 3不再碰撞。这就是那一次异或的价值。2.3 寻址表达式变化与旧机制的移除1.7 里查下标要先调indexFor(h, length)这个静态方法虽然 JIT 可能把它内联掉但源码层面多一层封装。1.8 直接在putVal、getNode里写(n - 1) hash做法更裸意图也更清楚。另外1.7 为 String 等类型提供的“备用哈希”机制通过-XX:UseAltHashing开启在 1.8 被彻底移除了原因就是新的哈希函数足够好不再需要针对特殊类型做额外处理。这个改动看起来只是“少算了几次位运算”但在一个每秒执行几十万次 put/get 的服务里减少哈希计算本身就是实打实的收益。3. 扩容机制重写不用重新散列的 resize 是怎么做到的扩容是 HashMap 所有操作里最重的一步。1.7 的扩容要把每个节点重新算下标1.8 却可以通过一次位运算决定节点去留这个差异直接决定了扩容性能。3.1 1.7 的 transfer 为什么慢且危险1.7 的 transfer 方法长这样void transfer(Entry[] newTable, boolean rehash) { int newCapacity newTable.length; for (EntryK,V e : table) { while (null ! e) { EntryK,V next e.next; if (rehash) { e.hash null e.key ? 0 : hash(e.key); } int i indexFor(e.hash, newCapacity); e.next newTable[i]; newTable[i] e; e next; } } }问题有两个。一是慢每个节点都要重新走一遍indexFor哪怕哈希值没变也要重新算下标。二是危险节点插入新桶用的是头插法旧链表遍历顺序是 A - B插入新桶后顺序会变成 B - A链表方向整体反转。多线程同时扩容时两个线程基于同一份旧链表各自反转就可能构造出 A - B - A 的环形链表之后任何 get 操作都会死循环直接打满 CPU。3.2 1.8 的链表拆分与位运算推导1.8 的 resize 不再重算所有节点的下标而是把每个桶里的链表拆成两条一条留在原下标一条去“原下标 旧容量”。核心判断就一行if ((e.hash oldCap) 0) { // 留在原位置 } else { // 移动到 j oldCap }为什么能这么判断因为容量永远是 2 的幂扩容时新容量是旧容量的两倍相当于下标掩码从oldCap - 1变成oldCap * 2 - 1仅仅多出来一个高位比特参与计算。这个比特的值恰好就是 hash 的二进制位里oldCap对应的那一位所以e.hash oldCap的结果就是新增的那个“下标决定性比特”。举个例子旧容量 16一个节点 hash 为 55 15 5留在下标 5。另一个节点 hash 为 2121 15 5旧下标同样是 5但21 16 16说明扩容后它的下标要变成5 16 21。验证一下21 31 21没错。整个过程只用一次位运算不重新计算哈希不重新取模性能差距非常明显。3.3 懒初始化与 threshold 的语义变化1.8 的懒初始化其实不是独立改动它和扩容机制是绑在一起的。构造 HashMap 时不再分配数组只是把你要的初始容量经tableSizeFor处理后放进 thresholdpublic HashMap(int initialCapacity, float loadFactor) { // ... this.loadFactor loadFactor; this.threshold tableSizeFor(initialCapacity); }真正分配数组的动作发生在第一次 put 时的 resize 里else if (oldThr 0) { newCap oldThr; } // ... NodeK,V[] newTab (NodeK,V[])new Node[newCap]; table newTab;所以new HashMap(10000)在 1.8 里不会立刻占用 10000 个桶的数组空间只有真正往里放数据时才分配。这个改动对小对象频繁创建的场景特别友好。这里有个容易踩的坑。如果写成new HashMap(7)tableSizeFor(7)返回 8第一次 put 后 capacity 是 8但 threshold 会按8 * 0.75变成 6。也就是说你明明只放了 7 个元素HashMap 在第 7 个元素插入时会触发一次扩容。如果预先知道要存 N 个元素初始容量应该设成N / 0.75 1再向上取整比如存 10 个元素就用10 / 0.75 1 14传 14 进去后容量是 16threshold 变成 12够用。4. 从头插法到尾插法一个影响并发安全的改动JDK 1.8 的 put 逻辑里新节点不再插到链表头而是追加到链表尾。这个改动表面上只是“顺序变了”实际上解决的是 1.7 中最著名的并发死循环问题。4.1 头插法怎么引发 CPU 100% 的经典死循环1.7 的 addEntry 是这样的EntryK,V e table[bucketIndex]; table[bucketIndex] new Entry(hash, key, value, e);新节点先把原来的头节点作为next再把自己放到桶头典型头插法。正常单线程下没问题两个线程同时 resize 就出事了。假设旧桶链表是 A - B线程 T1 和 T2 同时迁移。T1 处理完 A把 A 放到新桶里T2 基于还没有被 T1 完全修改的引用继续处理反复头插之后就可能出现 B.next AA.next B 的环形结构。一旦出现环后续 get 这个桶里的 key就会在链表里无限绕圈CPU 直接飙满服务卡死。我当时排查过一个线上故障jstack 打出来的线程栈全部卡在HashMap.get上就是这个问题。1.8 把头插改成尾插之后这种环形链表的结构性缺陷被彻底绕开了。4.2 尾插法做了什么取舍1.8 的 putVal 在遍历链表找 key 的过程中如果走到p.next null就把新节点挂上去if ((e p.next) null) { p.next newNode(hash, key, value, null); if (binCount TREEIFY_THRESHOLD - 1) { treeifyBin(tab, hash); } break; }有人担心尾插是不是比头插多了一次遍历链表到尾巴的开销。其实不会因为 put 本来就要遍历链表判断 key 是否存在走到末尾时自然就拿到了尾节点追加是顺手的事不多花额外时间。尾插的代价是链表顺序会变成“插入顺序”但这在绝大多数场景下不是坏事。必须强调一个容易误会的点尾插法只是让“并发扩容时无限死循环”这个极端问题消失了HashMap 本身在 1.8 里依然不是线程安全的。并发 put 照样会丢数据、覆盖值只是不再以 CPU 100% 那种极端方式暴露问题。真要多线程用还得 ConcurrentHashMap。5. 树化参数的门道8、6、64 怎么读红黑树相关参数是 1.8 最出名的改动但很多人不知道这三个数字为什么要设计成 8、6、64而不是随便拍的。这三个数背后有概率论、工程折中和性能约束三层逻辑。5.1 8来自泊松分布的一个安全值JDK 源码注释里明确说在默认负载因子 0.75 的情况下一个桶内节点数量服从参数约为 0.5 的泊松分布。各数量对应的概率大致是桶内节点数概率00.6065306610.3032653320.0758163330.0126360640.0015795250.0001579560.0000131670.0000009480.00000006一个桶里出现 8 个节点的概率只有约六亿分之一。换句话说如果某个桶真的长到 8 个节点要么哈希函数设计得太差要么数据分布极度异常这时候把链表转成红黑树用额外的树形开销换O(n)到O(log n)的查询时间非常划算。5.2 6滞后设计避免反复横跳树化阈值是 8退化为链表的阈值却是 6而不是同样用 8。如果两个阈值一样比如都是 8那么一个桶里的节点数在 7、8、9 之间反复横跳时HashMap 就会不断地在链表和红黑树之间来回转换。树化本身要按红黑树规则重建节点关系退化又要重新拉直链表代价都不低禁不起频繁触发。把阈值错开一个“缓冲区”形成滞回区间7 进 7 退都不会触发转换这就是工程上常见的 hysteresis 思想和空调温度控制在 24 度开 26 度关是一个道理。5.3 64容量不够时先扩容而不是树化treeifyBin的开头有一个判断if (tab null || (n tab.length) MIN_TREEIFY_CAPACITY) { resize(); }意思是即使某个桶已经超过 8 个节点只要整个 HashMap 的容量不到 64就不树化而是先扩容一倍。原因很直接容量小时碰撞大概率是“桶不够用”造成的而不是哈希分布烂。扩容一倍后桶数量翻倍原本挤在一起的节点很容易四散分开链表自然变短相反如果 16 容量就树化过不了几次 put 又要扩容树形结构白搭。另外扩容拆分红黑树时也有对应的退化逻辑。如果拆分出来的两条链表节点数小于等于 6就会调用untreeify把它还原成普通链表保证树不会在小链表上白占空间。6. 数据结构与 API 层面的增量Node、TreeNode 与 default 方法除了算法和流程1.8 还改了底层的数据结构定义并且给 Map 接口补了一批 default 方法。这些改动直接影响了开发者日常怎么用 HashMap。6.1 Node 到 TreeNode 的家族谱1.7 的链表节点是内部类Entry1.8 把它改名为Node结构基本没变hash、key、value、next 四个字段。真正的增量是新增了TreeNodestatic final class TreeNodeK,V extends LinkedHashMap.EntryK,V { TreeNodeK,V parent; TreeNodeK,V left; TreeNodeK,V right; TreeNodeK,V prev; boolean red; }注意TreeNode继承的是LinkedHashMap.Entry而LinkedHashMap.Entry又继承Node并额外加了 before/after 指针。这么设计是因为红黑树节点必须同时能待在普通桶的链式结构里还要能在LinkedHashMap的遍历链上被引用到。prev字段专门用于删除节点时快速摘除自己避免从桶里重新找前驱。6.2 Map 接口新增的 default 方法与 HashMap 的重写JDK 8 给 Map 接口加了一批 default 方法HashMap 并不是直接用默认实现而是自己重写了一份更高效的逻辑。比如putIfAbsent直接复用putVal多传了一个onlyIfAbsent参数computeIfAbsent内部只计算一次 hash并且只在 key 不存在时才调用 mapping 函数避免了你手动写“containsKey get put”三步导致 hash 被算两次的问题。这些方法解决了实际开发里的高频痛点// 以前要这么写 String value map.get(key); if (value null) { value computeValue(key); map.put(key, value); } // 1.8 之后 String value map.computeIfAbsent(key, this::computeValue);merge方法对计数场景特别友好map.merge(key, 1, Integer::sum);一行代码完成“没有就初始化 1有就在原值上加 1”比以往手工判断省太多事。这些 API 不是 HashMap 独有的但 HashMap 在 1.8 里对这些方法做了针对性重写避免走 default 方法的效率损耗细节在源码里都有体现。6.3 Spliterator 给流计算铺路1.8 的keySet()、values()、entrySet()都实现了spliterator()方法返回HashMapSpliterator支持对键值对做并行流遍历。这个看似不起眼的功能实际上让map.keySet().parallelStream()这种写法成为可能。每个槽位会在遍历时被拆分给不同线程处理拆分算法基于红黑树节点和链表节点的分布做了适配。7. 常见问题排查与实战心得7.1 1.8 的 HashMap 真能用多线程吗不能。这是最重要的结论没有之一。尾插法只是移除了“扩容时形成环形链表导致死循环”的极端可能性但两个线程同时 put 时的覆盖、size 计数错乱、读到中间状态等并发问题1.8 的 HashMap 依然全都有。线上并发场景请直接用ConcurrentHashMap不要因为听说 1.8 修复了死循环就觉得 HashMap 可以上多线程。7.2 高频问题速查表现象原因处理建议构造后内存占用高1.7 分配桶数组1.8 懒初始化1.8 没问题换 1.7 需注意设置 initialCapacity 后提前扩容threshold 初始存的是幂等容量首次 put 后按 loadFactor 重算用N / 0.75 1估算容量并发 put 数据丢失HashMap 线程不安全换 ConcurrentHashMapget 死循环打满 CPU1.7 头插法并发扩容产生环升级 1.8并修正并发用法桶内节点很多但没树化容量小于 64先扩容属正常行为不是 bug树化后退化不明显拆分后节点数小于等于 6 自动退化属正常行为7.3 我踩过的几个坑和现在的习惯第一个坑是习惯性用new HashMap(16)做容量预设结果存 15 个元素时在扩容边缘反复横跳。后来我养成了习惯凡是能预估容量的场景统一用(int)(expectedSize / 0.75f 1f)算宁可多给一点容量也不要触发扩容。第二个坑是拿 HashMap 做有超时语义的本地缓存结果并发一高就出现脏读。排查到最后不是 HashMap 的锅是我用错了工具。本地缓存用 ConcurrentHashMap 加过期时间字段或者直接用 Caffeine比研究 HashMap 源码更快。第三个经验和性能有关。如果你的 key 是自定义对象重写 hashCode 时不要偷懒只返回固定值或只用少量低效位因为 1.8 哈希函数的作用是“放大差异”它没法弥补源 hashCode 本来的缺陷。好的 hashCode 是让高位低位都有变化再配合 1.8 那一次 16 位右移异或才能发挥最大价值。我个人在实际排查中还有一个小技巧遇到 HashMap 性能问题先开-XX:PrintFlagsFinal确认当前 JDK 版本和默认参数再看是不是有非默认的 loadFactor 设置。很多所谓“HashMap 性能差”的问题最后都指向错误使用场景而不是 HashMap 本身。把 1.8 这些改动吃透至少能少走一半弯路。
返回列表