「Java 进阶之路」系列 Day22
写在前面
HashMap 是被问得最深的一个集合类,随便一道追问就能扯出扰动函数、树化阈值、扩容时的位运算优化这些细节。这篇把 JDK 8 之后 HashMap 的底层结构和扩容机制从头捋一遍,讲清楚每一个看似"抠细节"的设计背后到底图的是什么。
一、是什么:数组 + 链表 + 红黑树的混合结构
HashMap 内部是一个 Node[] 数组,每个位置叫一个"桶"(bucket)。存进去的键值对先算出一个下标落到某个桶里:桶里没东西就直接放;如果好几个不同的 key 算出同一个下标(哈希冲突),就在这个桶里挂成一条链表;链表长度涨到一定程度,会转成红黑树以提升查找效率。
这个混合结构的设计意图很直接:纯数组没办法处理哈希冲突;纯链表查找是O(n),效率太差;数组做快速定位、链表/树处理同一个桶内的冲突,是空间和查找效率的折中方案。
二、为什么这样设计:几个容易被忽略的细节
扰动函数:为什么不直接用 hashCode()
java
// HashMap内部的hash方法(简化版)
static int hash(Object key) {
int h = key.hashCode();
return h ^ (h >>> 16); // 高16位和低16位做异或
}
HashMap 定位桶的公式是 (容量 - 1) & hash,如果容量是16,容量-1 二进制就是0000...1111,这个按位与操作只有hash值的低4位在起作用 ,高位信息完全被浪费。如果两个key的hashCode低位相同、只有高位不同,不做处理的话会被分到同一个桶里,增加冲突概率。hash() 方法把高16位和低16位做异或,让原本用不上的高位信息也参与到最终结果的低位运算中,相当于把"分布信息"从高位"打"下来,减少了这种冲突。
容量为什么必须是2的幂
定位桶用的 (容量-1) & hash 这个位运算,只有在容量是2的幂时,才等价于 hash % 容量 取模运算的效果(而且分布均匀)------位运算比取模运算快得多,这是 HashMap 拿性能换出来的一个设计前提。这也是为什么即使你 new HashMap<>(17) 传了个奇怪的初始值,JDK 内部也会自动把它调整成不小于17的最近一个2的幂(这里是32),保证这个位运算优化始终成立。
put 的完整流程
为什么树化阈值选 8,退化阈值选 6,不是同一个数
链表长度超过8才转红黑树,是因为在哈希分布良好的正常情况下,一个桶挂到8个节点的概率极低(按泊松分布计算,大约是百万分之六十),也就是说绝大多数场景根本不会触发树化,只有hashCode设计得很差或者遭遇了针对性构造的哈希碰撞攻击时才会用到------红黑树节点本身比普通链表节点占用更多内存(要维护平衡、存储颜色和多个指针),只有真正冲突严重、值得用空间换查找效率时才转换。
树退化回链表的阈值是6,不是8,这是故意留了一个"缓冲区"------如果树化和退化都用同一个阈值8,节点数量在8附近反复增删时,会导致链表和树来回抖动转换,白白浪费性能;用6做退化阈值、8做树化阈值,中间隔出3个数字的缓冲空间,避免这种震荡。
三、怎么用:扩容机制与 JDK 8 的一个巧妙优化
HashMap 有一个"负载因子",默认0.75,阈值 = 容量 × 负载因子。元素个数超过这个阈值就触发扩容,新容量是旧容量的2倍。
扩容意味着所有节点要重新计算应该落在哪个桶里------但 JDK 8 之后有个巧妙的优化,不需要重新完整计算每个节点的hash:
bash
扩容前容量是16(二进制10000),扩容后是32(二进制100000)
一个节点原来的桶下标,是hash值的低4位决定的
扩容后多了一位参与定位(低5位),只需要看这新增的这一位是0还是1:
这一位是0 → 新桶下标和原下标相同,节点留在原位置
这一位是1 → 新桶下标 = 原下标 + 旧容量(16)
判断这"新增的一位"是0还是1,只需要做一次 hash & oldCapacity 的位运算,不需要把hash和新容量重新做一次完整的取模计算------这个优化把扩容时每个节点的重定位计算,从"重新算一次哈希定位"简化成了"判断一个二进制位",是JDK 8对HashMap扩容做的一个典型性能优化点,也是面试里"HashMap扩容原理"这个问题能问出深度的地方。
四、面试追问
Q1:HashMap 的 hash() 方法为什么要把 hashCode 的高16位和低16位做异或?
因为定位桶用的 (容量-1) & hash 这个运算,容量通常不大(比如16),容量-1 的二进制只有低几位是1,这意味着只有hash值的低位在参与定位,高位信息完全被浪费。做异或扰动能把高位的信息"混"到低位里,让两个hashCode低位相同、只有高位不同的key也能有更大概率分散到不同的桶,减少哈希冲突。
Q2:为什么 HashMap 的容量必须是2的幂?
因为定位桶用的位运算 (容量-1) & hash,只有在容量是2的幂时,才等价于对哈希值做取模运算、并且分布均匀,同时位运算比取模运算性能更好。这是HashMap用位运算代替取模运算的一个性能优化,前提就是容量必须是2的幂,所以即使构造时传入了不是2的幂的初始容量,JDK内部也会自动调整成最近的、不小于该值的2的幂。
Q3:链表转红黑树的阈值为什么是8,退化回链表又为什么是6而不是8?
链表长度达到8才转红黑树,是因为在哈希分布正常的情况下一个桶挂到8个节点的概率极低,绝大多数场景不需要用到红黑树,只在冲突真的很严重时才值得为查找效率付出额外的内存和维护成本。退化阈值用6而不是8,是为了避免节点数量在8附近反复增删时,链表和树来回抖动转换,6和8之间留出的缓冲区能减少这种无意义的性能损耗。
Q4:HashMap 扩容时,JDK 8 做了什么优化,避免重新计算每个节点的哈希?
扩容后容量翻倍,参与定位的位数多了一位。JDK 8 利用这一点,只需要判断节点hash值里"新增的那一位"是0还是1:是0则新桶下标和原下标相同,节点留在原位置;是1则新桶下标等于原下标加上旧容量。这只需要一次 hash & oldCapacity 的位运算就能判断,不需要把hash值和新容量重新做一次完整定位计算,大幅简化了扩容时每个节点重新分桶的开销。
Q5:HashMap 的默认负载因子为什么是 0.75,不是1或者更低的值?
负载因子是"空间利用率"和"哈希冲突概率"之间的权衡:负载因子设得太高(比如接近1),数组空间利用得更充分,但桶越容易被填满,哈希冲突概率上升,链表变长,查找效率下降;负载因子设得太低,冲突概率低、查找快,但会浪费更多数组空间没被用上、还会更频繁触发扩容。0.75是JDK经过测算后,在这两者之间取得的一个比较均衡的默认值。
下一篇预告
Day23 讲一个很实际的问题:HashMap 为什么线程不安全,多线程并发操作会出现什么具体问题,以及和 ConcurrentHashMap(Day08 讲过)到底差在哪个环节。