[{"data":1,"prerenderedAt":425},["ShallowReactive",2],{"\u002Fjava-interview\u002Fhashmap":3,"java-interview-search:\u002Fjava-interview\u002Fhashmap":380},{"id":4,"title":5,"body":6,"category":366,"cover":367,"date":368,"description":369,"extension":370,"group":371,"meta":372,"navigation":373,"order":374,"path":375,"seo":376,"sitemap":377,"stem":378,"__hash__":379},"javaInterview\u002Fjava-interview\u002Fhashmap.md","HashMap 的底层结构、扩容与线程安全",{"type":7,"value":8,"toc":351},"minimark",[9,13,18,21,29,40,43,47,62,69,76,80,83,90,108,123,127,133,144,151,155,161,164,175,179,182,185,188,192,199,202,223,238,246,256,270,284,287,302,311,314],[10,11,12],"p",{},"HashMap 几乎是必问题。只背「数组 + 链表 + 红黑树」只能拿到及格分，能讲清为什么这样设计、并发时具体坏在哪里，才是区分度所在。以下以 JDK 8 的实现为基准，JDK 17\u002F21 的实现主体没有变化。",[14,15,17],"h2",{"id":16},"_1-hashmap-的底层结构是什么","1. HashMap 的底层结构是什么？",[10,19,20],{},"JDK 8 使用「数组 + 链表 + 红黑树」：数组的每个槽位称为桶，哈希冲突的元素先以链表挂载，链表足够长时转为红黑树。",[10,22,23,24,28],{},"关键常量：默认容量 16、最大容量 ",[25,26,27],"code",{},"1 \u003C\u003C 30","、默认负载因子 0.75、树化阈值 8、退化阈值 6、树化的最小容量 64。",[10,30,31,32,35,36,39],{},"节点是 ",[25,33,34],{},"Node\u003CK,V>","，保存 hash、key、value 和 next；树化后变成 ",[25,37,38],{},"TreeNode","，额外维护红黑树所需的父子指针和颜色，所以单个节点的内存开销明显更大。",[10,41,42],{},"JDK 7 只有数组加链表，且插入使用头插法；JDK 8 改为尾插，并引入红黑树，这是两个版本最值得说明的差异。",[14,44,46],{"id":45},"_2-哈希扰动和下标计算为什么这样设计","2. 哈希扰动和下标计算为什么这样设计？",[10,48,49,50,53,54,57,58,61],{},"取 key 的 ",[25,51,52],{},"hashCode()"," 后，先做一次扰动：",[25,55,56],{},"h ^ (h >>> 16)","，把高 16 位异或到低 16 位。随后用 ",[25,59,60],{},"(n - 1) & hash"," 定位桶，其中 n 是数组长度。",[10,63,64,65,68],{},"因为容量是 2 的幂，",[25,66,67],{},"(n - 1)"," 的低位全是 1，与运算等价于取模但更快。问题在于：如果不扰动，只有低位参与定位，高位差异被完全丢弃，大量 key 会挤进少数桶。",[10,70,71,72,75],{},"扰动让高位也参与运算，以很小的成本改善分布。注意这并不能消除冲突，",[25,73,74],{},"hashCode"," 设计糟糕时依旧会退化，所以不要试图用 HashMap 抵抗恶意构造的哈希碰撞。",[14,77,79],{"id":78},"_3-put-的完整流程是什么","3. put 的完整流程是什么？",[10,81,82],{},"先判断 table 是否为空，为空则初始化；桶为空就直接放新节点；桶首节点的 hash 相同且 key 相等，就覆盖 value。",[10,84,85,86,89],{},"否则分两种情况：树节点走 ",[25,87,88],{},"putTreeVal","；链表则从头部遍历到尾部，途中发现相等 key 就覆盖，没找到就追加到尾部，并检查链表长度是否达到树化门槛。",[10,91,92,93,96,97,100,101,100,104,107],{},"插入完成后，如果元素总数超过阈值 ",[25,94,95],{},"容量 × 0.75","，执行扩容。",[25,98,99],{},"putIfAbsent","、",[25,102,103],{},"computeIfAbsent",[25,105,106],{},"merge"," 都复用了这套流程，区别只在命中时是否覆盖以及返回值的语义。",[10,109,110,111,114,115,118,119,122],{},"面试还可以补一句：key 为 ",[25,112,113],{},"null"," 时固定放在 0 号桶，这也是 ",[25,116,117],{},"HashMap"," 与 ",[25,120,121],{},"ConcurrentHashMap"," 的一个行为差异。",[14,124,126],{"id":125},"_4-扩容是怎么做的jdk-7-和-8-有什么差别","4. 扩容是怎么做的？JDK 7 和 8 有什么差别？",[10,128,129,130,132],{},"扩容时容量翻倍，阈值也翻倍；元素数超过 ",[25,131,95],{}," 触发，树化失败但容量不足 64 时也会先扩容。",[10,134,135,136,139,140,143],{},"JDK 8 不需要重新计算哈希：把旧桶拆成低位和高位两条链，判断 ",[25,137,138],{},"(e.hash & oldCap)","，为 0 留在原下标 j，否则移到 ",[25,141,142],{},"j + oldCap","。这样既省掉重算，又保持了链表内部的相对顺序。",[10,145,146,147,150],{},"JDK 7 会对每个节点重新计算索引，并且用头插法迁移，并发扩容时可能让两个线程把链表连成环，之后 ",[25,148,149],{},"get"," 死循环。JDK 8 改尾插后环的问题基本消失，但数据覆盖、size 不准、丢更新依然存在。",[14,152,154],{"id":153},"_5-为什么容量是-2-的幂负载因子为什么是-075","5. 为什么容量是 2 的幂？负载因子为什么是 0.75？",[10,156,157,158,160],{},"容量为 2 的幂有两个直接好处：",[25,159,60],{}," 可以替代取模，位运算更快；扩容时只需看哈希的某一位，就能决定元素留在原位还是移动旧容量个位置。",[10,162,163],{},"负载因子 0.75 是时间与空间的折中：调低会减少冲突但浪费空间、增加扩容次数；调高则节省空间但链表变长。源码注释给出的依据是，在 0.75 附近、哈希均匀分布时，单个桶内节点数近似服从参数 0.5 的泊松分布。",[10,165,166,167,170,171,174],{},"实际使用时，如果能预估元素数量，直接指定初始容量更有效。注意构造函数会把容量向上取整到 2 的幂，但 ",[25,168,169],{},"HashMap(int initialCapacity)"," 并不会自动除以 0.75，所以想放 n 个元素，初始容量应给到 ",[25,172,173],{},"n \u002F 0.75 + 1","。",[14,176,178],{"id":177},"_6-链表树化的阈值为什么是-8退化又是-6","6. 链表树化的阈值为什么是 8，退化又是 6？",[10,180,181],{},"树化需要同时满足两个条件：链表长度达到 8，且数组容量不小于 64。后者不满足时只扩容、不树化，因为小表里冲突更多是容量不足造成的。",[10,183,184],{},"阈值 8 来自概率：哈希均匀时桶内节点数服从泊松分布，长度达到 8 的概率约为千万分之六，正常情况几乎不会触发。红黑树节点约为普通节点的两倍，只有极端冲突时树化才划算。",[10,186,187],{},"退化阈值设为 6 而不是 8，是为了留出缓冲带，避免在 7、8 之间反复树化与退化。节点删除后，当树的结构不足以维持时会退化为链表；扩容拆分时，如果某条链上的节点数不超过 6，也会退回链表。",[14,189,191],{"id":190},"_7-hashmap-为什么线程不安全","7. HashMap 为什么线程不安全？",[10,193,194,195,198],{},"并发 ",[25,196,197],{},"put"," 可能同时判断某个桶为空，然后各自写入，后写覆盖先写；两个线程同时扩容时，可能基于旧表复制，导致部分节点丢失或 size 计数错误。",[10,200,201],{},"JDK 7 的头插式扩容还可能形成环形链表，CPU 打满；JDK 8 改为尾插并保留相对顺序，环的问题基本不复现，但「不安全」这个结论并没有改变。",[10,203,204,207,208,211,212,215,216,218,219,222],{},[25,205,206],{},"Hashtable"," 是方法级 ",[25,209,210],{},"synchronized","，粒度太粗；",[25,213,214],{},"Collections.synchronizedMap"," 也是整表锁。并发场景优先 ",[25,217,121],{},"，它的读基本无锁，写只锁桶，并且通过 ",[25,220,221],{},"sizeCtl"," 协调扩容。",[10,224,225,226,228,229,232,233,235,236,174],{},"还要注意：即使换成 ",[25,227,121],{},"，",[25,230,231],{},"if (!map.containsKey(k)) map.put(k, v)"," 仍不是原子的，应改用 ",[25,234,99],{}," 或 ",[25,237,103],{},[14,239,241,242,245],{"id":240},"_8-作为-key-的对象要满足什么条件如何用-linkedhashmap-实现-lru","8. 作为 key 的对象要满足什么条件？如何用 ",[25,243,244],{},"LinkedHashMap"," 实现 LRU？",[10,247,248,249,252,253,255],{},"key 必须正确实现 ",[25,250,251],{},"equals"," 和 ",[25,254,74],{},"，并且哈希值在放入后保持稳定。如果用可变对象做 key，修改了参与哈希计算的字段，之后按原值就查不到了，等于在表里制造了一个「幽灵条目」。",[10,257,258,260,261,264,265,235,267,269],{},[25,259,244],{}," 在哈希表之上维护一条双向链表。构造时传 ",[25,262,263],{},"accessOrder = true","，每次 ",[25,266,149],{},[25,268,197],{}," 命中都会把节点移到链表尾部，头部自然成为最久未使用的元素。",[10,271,272,273,276,277,280,281,283],{},"再重写 ",[25,274,275],{},"removeEldestEntry","，在元素数超过容量时返回 ",[25,278,279],{},"true","，就得到一个简单的 LRU。生产环境要注意：访问顺序模式下 ",[25,282,149],{}," 也会修改结构，遍历时需要额外同步；容量估算、过期时间和并发控制通常还需要外面再包一层。",[14,285,286],{"id":286},"易错点与自测",[288,289,290,294,297],"ul",{},[291,292,293],"li",{},"「链表长度到 8 就树化」不准确，还要容量至少 64；「缩到 6 就退化」也取决于删除路径与树结构。",[291,295,296],{},"初始容量不等于能装多少元素，阈值是容量乘以负载因子。",[291,298,299,301],{},[25,300,117],{}," 线程不安全的表现不止死循环，数据覆盖和计数错误更常见。",[10,303,304,305,307,308,310],{},"自测：① 手写 ",[25,306,60],{}," 并解释为什么等价于取模；② 说明扩容时高低位拆分的判断条件；③ 给出一个用 ",[25,309,244],{}," 实现 LRU 的最小骨架。",[14,312,313],{"id":313},"参考资料",[288,315,316,335,343],{},[291,317,318,325,326,100,329,118,332],{},[319,320,324],"a",{"href":321,"rel":322},"https:\u002F\u002Fgithub.com\u002Fopenjdk\u002Fjdk\u002Fblob\u002Fmaster\u002Fsrc\u002Fjava.base\u002Fshare\u002Fclasses\u002Fjava\u002Futil\u002FHashMap.java",[323],"nofollow","HashMap 源码（OpenJDK）"," — 常量、",[25,327,328],{},"putVal",[25,330,331],{},"resize",[25,333,334],{},"treeifyBin",[291,336,337,342],{},[319,338,341],{"href":339,"rel":340},"https:\u002F\u002Fdocs.oracle.com\u002Fen\u002Fjava\u002Fjavase\u002F21\u002Fdocs\u002Fapi\u002Fjava.base\u002Fjava\u002Futil\u002FHashMap.html",[323],"java.util.HashMap API（Java 21）"," — 允许 null 键值与非线程安全的官方说明",[291,344,345,350],{},[319,346,349],{"href":347,"rel":348},"https:\u002F\u002Fdocs.oracle.com\u002Fen\u002Fjava\u002Fjavase\u002F21\u002Fdocs\u002Fapi\u002Fjava.base\u002Fjava\u002Futil\u002Fconcurrent\u002FConcurrentHashMap.html",[323],"java.util.concurrent.ConcurrentHashMap API"," — 并发语义与复合操作",{"title":352,"searchDepth":353,"depth":353,"links":354},"",2,[355,356,357,358,359,360,361,362,364,365],{"id":16,"depth":353,"text":17},{"id":45,"depth":353,"text":46},{"id":78,"depth":353,"text":79},{"id":125,"depth":353,"text":126},{"id":153,"depth":353,"text":154},{"id":177,"depth":353,"text":178},{"id":190,"depth":353,"text":191},{"id":240,"depth":353,"text":363},"8. 作为 key 的对象要满足什么条件？如何用 LinkedHashMap 实现 LRU？",{"id":286,"depth":353,"text":286},{"id":313,"depth":353,"text":313},"java-interview",null,"2026-10-24","沿着 put、扩容和树化三条主线拆解 JDK 8 HashMap，讲清下标计算、阈值设计以及并发场景下的真实风险","md","Java 核心",{},true,"30","\u002Fjava-interview\u002Fhashmap",{"title":5,"description":369},{"loc":375},"java-interview\u002Fhashmap","fN16inOHs7Jut5FWs3VOPUasm-kMt8UnJn-239lRsS8",[381,385,389,393,397,401,405,409,413,417,421],{"id":375,"title":5,"titles":382,"content":383,"level":384},[],"沿着 put、扩容和树化三条主线拆解 JDK 8 HashMap，讲清下标计算、阈值设计以及并发场景下的真实风险 HashMap 几乎是必问题。只背「数组 + 链表 + 红黑树」只能拿到及格分，能讲清为什么这样设计、并发时具体坏在哪里，才是区分度所在。以下以 JDK 8 的实现为基准，JDK 17\u002F21 的实现主体没有变化。",1,{"id":386,"title":17,"titles":387,"content":388,"level":353},"\u002Fjava-interview\u002Fhashmap#_1-hashmap-的底层结构是什么",[5],"JDK 8 使用「数组 + 链表 + 红黑树」：数组的每个槽位称为桶，哈希冲突的元素先以链表挂载，链表足够长时转为红黑树。 关键常量：默认容量 16、最大容量 1 \u003C\u003C 30、默认负载因子 0.75、树化阈值 8、退化阈值 6、树化的最小容量 64。 节点是 Node\u003CK,V>，保存 hash、key、value 和 next；树化后变成 TreeNode，额外维护红黑树所需的父子指针和颜色，所以单个节点的内存开销明显更大。 JDK 7 只有数组加链表，且插入使用头插法；JDK 8 改为尾插，并引入红黑树，这是两个版本最值得说明的差异。",{"id":390,"title":46,"titles":391,"content":392,"level":353},"\u002Fjava-interview\u002Fhashmap#_2-哈希扰动和下标计算为什么这样设计",[5],"取 key 的 hashCode() 后，先做一次扰动：h ^ (h >>> 16)，把高 16 位异或到低 16 位。随后用 (n - 1) & hash 定位桶，其中 n 是数组长度。 因为容量是 2 的幂，(n - 1) 的低位全是 1，与运算等价于取模但更快。问题在于：如果不扰动，只有低位参与定位，高位差异被完全丢弃，大量 key 会挤进少数桶。 扰动让高位也参与运算，以很小的成本改善分布。注意这并不能消除冲突，hashCode 设计糟糕时依旧会退化，所以不要试图用 HashMap 抵抗恶意构造的哈希碰撞。",{"id":394,"title":79,"titles":395,"content":396,"level":353},"\u002Fjava-interview\u002Fhashmap#_3-put-的完整流程是什么",[5],"先判断 table 是否为空，为空则初始化；桶为空就直接放新节点；桶首节点的 hash 相同且 key 相等，就覆盖 value。 否则分两种情况：树节点走 putTreeVal；链表则从头部遍历到尾部，途中发现相等 key 就覆盖，没找到就追加到尾部，并检查链表长度是否达到树化门槛。 插入完成后，如果元素总数超过阈值 容量 × 0.75，执行扩容。putIfAbsent、computeIfAbsent、merge 都复用了这套流程，区别只在命中时是否覆盖以及返回值的语义。 面试还可以补一句：key 为 null 时固定放在 0 号桶，这也是 HashMap 与 ConcurrentHashMap 的一个行为差异。",{"id":398,"title":126,"titles":399,"content":400,"level":353},"\u002Fjava-interview\u002Fhashmap#_4-扩容是怎么做的jdk-7-和-8-有什么差别",[5],"扩容时容量翻倍，阈值也翻倍；元素数超过 容量 × 0.75 触发，树化失败但容量不足 64 时也会先扩容。 JDK 8 不需要重新计算哈希：把旧桶拆成低位和高位两条链，判断 (e.hash & oldCap)，为 0 留在原下标 j，否则移到 j + oldCap。这样既省掉重算，又保持了链表内部的相对顺序。 JDK 7 会对每个节点重新计算索引，并且用头插法迁移，并发扩容时可能让两个线程把链表连成环，之后 get 死循环。JDK 8 改尾插后环的问题基本消失，但数据覆盖、size 不准、丢更新依然存在。",{"id":402,"title":154,"titles":403,"content":404,"level":353},"\u002Fjava-interview\u002Fhashmap#_5-为什么容量是-2-的幂负载因子为什么是-075",[5],"容量为 2 的幂有两个直接好处：(n - 1) & hash 可以替代取模，位运算更快；扩容时只需看哈希的某一位，就能决定元素留在原位还是移动旧容量个位置。 负载因子 0.75 是时间与空间的折中：调低会减少冲突但浪费空间、增加扩容次数；调高则节省空间但链表变长。源码注释给出的依据是，在 0.75 附近、哈希均匀分布时，单个桶内节点数近似服从参数 0.5 的泊松分布。 实际使用时，如果能预估元素数量，直接指定初始容量更有效。注意构造函数会把容量向上取整到 2 的幂，但 HashMap(int initialCapacity) 并不会自动除以 0.75，所以想放 n 个元素，初始容量应给到 n \u002F 0.75 + 1。",{"id":406,"title":178,"titles":407,"content":408,"level":353},"\u002Fjava-interview\u002Fhashmap#_6-链表树化的阈值为什么是-8退化又是-6",[5],"树化需要同时满足两个条件：链表长度达到 8，且数组容量不小于 64。后者不满足时只扩容、不树化，因为小表里冲突更多是容量不足造成的。 阈值 8 来自概率：哈希均匀时桶内节点数服从泊松分布，长度达到 8 的概率约为千万分之六，正常情况几乎不会触发。红黑树节点约为普通节点的两倍，只有极端冲突时树化才划算。 退化阈值设为 6 而不是 8，是为了留出缓冲带，避免在 7、8 之间反复树化与退化。节点删除后，当树的结构不足以维持时会退化为链表；扩容拆分时，如果某条链上的节点数不超过 6，也会退回链表。",{"id":410,"title":191,"titles":411,"content":412,"level":353},"\u002Fjava-interview\u002Fhashmap#_7-hashmap-为什么线程不安全",[5],"并发 put 可能同时判断某个桶为空，然后各自写入，后写覆盖先写；两个线程同时扩容时，可能基于旧表复制，导致部分节点丢失或 size 计数错误。 JDK 7 的头插式扩容还可能形成环形链表，CPU 打满；JDK 8 改为尾插并保留相对顺序，环的问题基本不复现，但「不安全」这个结论并没有改变。 Hashtable 是方法级 synchronized，粒度太粗；Collections.synchronizedMap 也是整表锁。并发场景优先 ConcurrentHashMap，它的读基本无锁，写只锁桶，并且通过 sizeCtl 协调扩容。 还要注意：即使换成 ConcurrentHashMap，if (!map.containsKey(k)) map.put(k, v) 仍不是原子的，应改用 putIfAbsent 或 computeIfAbsent。",{"id":414,"title":363,"titles":415,"content":416,"level":353},"\u002Fjava-interview\u002Fhashmap#_8-作为-key-的对象要满足什么条件如何用-linkedhashmap-实现-lru",[5],"key 必须正确实现 equals 和 hashCode，并且哈希值在放入后保持稳定。如果用可变对象做 key，修改了参与哈希计算的字段，之后按原值就查不到了，等于在表里制造了一个「幽灵条目」。 LinkedHashMap 在哈希表之上维护一条双向链表。构造时传 accessOrder = true，每次 get 或 put 命中都会把节点移到链表尾部，头部自然成为最久未使用的元素。 再重写 removeEldestEntry，在元素数超过容量时返回 true，就得到一个简单的 LRU。生产环境要注意：访问顺序模式下 get 也会修改结构，遍历时需要额外同步；容量估算、过期时间和并发控制通常还需要外面再包一层。",{"id":418,"title":286,"titles":419,"content":420,"level":353},"\u002Fjava-interview\u002Fhashmap#易错点与自测",[5],"「链表长度到 8 就树化」不准确，还要容量至少 64；「缩到 6 就退化」也取决于删除路径与树结构。 初始容量不等于能装多少元素，阈值是容量乘以负载因子。 HashMap 线程不安全的表现不止死循环，数据覆盖和计数错误更常见。 自测：① 手写 (n - 1) & hash 并解释为什么等价于取模；② 说明扩容时高低位拆分的判断条件；③ 给出一个用 LinkedHashMap 实现 LRU 的最小骨架。",{"id":422,"title":313,"titles":423,"content":424,"level":353},"\u002Fjava-interview\u002Fhashmap#参考资料",[5],"HashMap 源码（OpenJDK） — 常量、putVal、resize 与 treeifyBin java.util.HashMap API（Java 21） — 允许 null 键值与非线程安全的官方说明 java.util.concurrent.ConcurrentHashMap API — 并发语义与复合操作",1793263559667]