# 为什么 Java 中 HashMap 的默认负载因子是 0.75?
7 min read
Table of Contents
为什么 Java 中 HashMap 的默认负载因子是 0.75?
【核心定义】 HashMap 的默认负载因子 0.75 是空间成本与时间成本之间的一种经验性折中,旨在平衡哈希表的空间利用率和哈希冲突的概率。
【关键要点】
- 空间与时间的权衡:负载因子决定了哈希表在何时进行扩容。0.75 意味着当哈希表容量使用率达到 75% 时触发扩容,这是一个在避免过早浪费空间和防止过晚导致冲突激增之间的平衡点。
- 泊松分布与冲突概率:根据统计学中的泊松分布,在理想的随机哈希函数下,当负载因子为 0.75 时,哈希桶中元素数量达到特定阈值的概率非常低。这为 JDK 8 中链表树化(TREEIFY_THRESHOLD=8)的阈值设定提供了数学依据。
- 2的幂次方扩容的便利性:HashMap 的容量始终是 2 的幂。0.75 这个数值(3/4)可以与 2 的幂进行高效的乘法运算(
newCap = oldCap << 1),计算扩容阈值threshold = capacity * loadFactor相对高效。
【深度推导/细节】
-
为什么不是 0.5 或 1.0?
- 如果设为 0.5:空间利用率过低。例如,默认初始容量 16,当存放 8 个元素时就会扩容,导致大量空闲空间,内存浪费严重。
- 如果设为 1.0:空间利用率最高,但时间风险极大。这意味着哈希表必须等到完全填满才扩容。在填满前的最后阶段,哈希冲突的概率会急剧上升,导致链表过长或树化频繁,严重降低查询和插入性能(接近 O(n) 或 O(log n))。0.75 的设置使得在性能出现显著恶化之前,就通过扩容来重新分散元素。
-
与树化阈值(8)的关联: 在 JDK 8 中,一个桶的链表长度达到 8 时会可能转化为红黑树。这个数字的选择基于泊松分布的计算:在负载因子 0.75 的假设下,单个哈希桶中元素个数达到 8 的概率约为 0.00000006。这是一个极小概率事件,如果发生,通常意味着哈希函数不合理或发生了严重的哈希碰撞。将这种小概率的长链表转化为树,是一种针对异常情况的“性能保护机制”,在绝大多数正常使用场景下不会触发。
【关联/对比】
- 与
Hashtable对比:古老的Hashtable默认负载因子是 0.75,HashMap 沿用了这一经验值,但两者的线程安全实现完全不同(Hashtable全表锁 vsConcurrentHashMap分段锁/桶锁)。 - 与
ConcurrentHashMap对比:ConcurrentHashMap(JDK 8)在默认负载因子上与 HashMap 保持一致(0.75),因为它底层数据结构类似。但它的扩容机制更复杂,涉及多线程协助迁移数据。
『面试官追问』
- 负载因子可以设为大于 1 吗?
可以,但极度不推荐。大于 1 意味着允许元素数量超过桶数组容量(
size > capacity),这必然导致大量的哈希冲突,所有多出的元素都会以链表或树的形式挂在桶中,性能会严重退化,完全失去了数组随机访问 O(1) 的优势。 - 为什么初始容量是 16?
16 是 2 的幂(
1<<4),作为一个较小的默认值,既能减少初始内存开销,又能满足大多数小规模场景的需求。使用 2 的幂可以通过位运算(n - 1) & hash高效替代取模运算hash % n,提升计算性能。 - 扩容的具体过程是怎样的?
Step 1:判断触发。当
size > threshold(capacity * loadFactor)时,触发扩容。 Step 2:创建新数组。新容量为旧容量的 2 倍(newCap = oldCap << 1),并重新计算新阈值。 Step 3:重新哈希(Rehash)。遍历旧数组的每个桶(桶中的每个元素),根据其新的哈希值(由于容量翻倍,参与(n-1) & hash运算的n变了)计算其在新数组中的位置。 Step 4:JDK 8 优化:元素在新表中的位置要么是原索引j,要么是j + oldCap。这是因为容量翻倍后,掩码(n-1)的高位多了一个 1。通过判断元素哈希值对应这个新增位是 0 还是 1,就可以无计算地确定新位置,极大提升了扩容性能。 Step 5:迁移完成,将内部数组引用指向新数组。
【版本差异】
- JDK 7 及之前:扩容时,采用头插法将旧链表元素迁移到新桶中。这在多线程环境下可能产生环形链表,导致死循环。
- JDK 8 及之后:
- 采用尾插法,避免了环形链表问题(但 HashMap 本身仍非线程安全,会有数据覆盖等其他问题)。
- 引入了红黑树优化长链表性能(树化阈值 8,退化阈值 6)。
- 优化了扩容时元素重哈希的逻辑,利用高位判断,性能更优。