Skip to content

负载因子 0.75:空间与时间的完美平衡

你有没有想过这个问题:

HashMap 的负载因子为什么是 0.75,而不是 0.5、0.8 或 1.0?

有人说「这是经验值」,有人说「这是数学推导」。

让我来告诉你真相。

负载因子是什么?

负载因子(Load Factor)是 HashMap 的一个关键参数:

java
public class HashMap<K, V> {
    // 负载因子
    final float loadFactor;
    
    // 扩容阈值 = capacity * loadFactor
    int threshold;
}

扩容阈值 = 数组容量 × 负载因子

size > threshold 时,触发扩容。

容量 16,负载因子 0.75
→ threshold = 12
→ 当元素数量超过 12 时,数组扩容到 32

负载因子与空间利用率

负载因子直接影响 HashMap 的空间利用率

负载因子 = 0.5  → 50% 空间利用率 → 扩容早 → 空间浪费
负载因子 = 1.0  → 100% 空间利用率 → 扩容晚 → 空间利用充分但碰撞多

空间浪费 vs 哈希碰撞

┌─────────────────────────────────────────────────────┐
│                 负载因子 0.5                        │
│  容量 16,threshold = 8                             │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐    │
│  │ ██ │    │ ██ │    │    │ ██ │    │    │    │    │
│  │ 2  │ 0  │ 2  │ 0  │ 0  │ 2  │ 0  │ 0  │    │    │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┘    │
│  空间利用率:50%,但 8 个位置是空的,浪费!          │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│                 负载因子 1.0                        │
│  容量 16,threshold = 16                           │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐    │
│  │ ██ │ ██ │ ██ │ ██ │ ██ │ ██ │ ██ │ ██ │ ██ │    │
│  │ 2  │ 2  │ 2  │ 2  │ 2  │ 2  │ 2  │ 2  │    │    │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┘    │
│  空间利用率:100%,但每个桶平均 2 个元素,碰撞严重! │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│                 负载因子 0.75                       │
│  容量 16,threshold = 12                            │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐    │
│  │ █  │    │ ██ │ █  │    │ █  │ █  │    │    │    │
│  │ 1  │ 0  │ 2  │ 1  │ 0  │ 1  │ 1  │ 0  │    │    │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┘    │
│  空间利用率:75%,每个桶平均 0.75 个元素,平衡!     │
└─────────────────────────────────────────────────────┘

泊松分布:数学上的证明

HashMap 的哈希碰撞可以用泊松分布来建模。

泊松分布的概率质量函数:

P(k) = (λ^k * e^(-λ)) / k!

对于 HashMap:

  • λ = loadFactor:平均每个桶的期望元素数
  • k = 链表长度

当负载因子 = 0.75 时:

链表长度 k概率 P(k)
00.472(47.2% 的桶是空的)
10.354(35.4% 的桶有 1 个元素)
20.133(13.3% 的桶有 2 个元素)
30.033(3.3% 的桶有 3 个元素)
40.006(0.6% 的桶有 4 个元素)
50.001(0.1% 的桶有 5 个元素)
60.00013
70.000015
80.0000013
> 8< 0.00000006

链表长度 > 8 的概率小于千万分之六!

这就是为什么 HashMap 选择 链表长度 > 8 作为树化条件——这个阈值在数学上极小概率会被触发。

不同负载因子的对比

负载因子链表长度 > 8 的概率空间利用率扩容频率
0.510^-8 级别50%
0.7510^-7 级别75%
1.010^-6 级别100%

负载因子太小,链表几乎不会超过 8,但空间浪费严重。

负载因子太大,链表可能很长,但空间利用率高。

0.75 是数学上的最优平衡点。

时间复杂度分析

HashMap 的操作复杂度取决于链表/红黑树的长度

状态时间复杂度
没有哈希冲突O(1)
链表查找O(1 + k),k 是链表长度
红黑树查找O(log k),k 是节点数

当负载因子过高时:

  • 哈希冲突增多
  • 链表变长
  • 查找从 O(1) 退化到 O(n)

当负载因子过低时:

  • 空间浪费严重
  • 扩容频繁
  • 每次扩容都需要重新哈希

实际测试数据

java
public class LoadFactorTest {
    public static void main(String[] args) {
        int n = 1_000_000;
        
        // 测试不同负载因子
        int[] capacities = {16, 16, 16};
        float[] lfs = {0.5f, 0.75f, 1.0f};
        
        for (int i = 0; i &lt; 3; i++) {
            HashMap&lt;Integer, Integer> map = new HashMap&lt;>(16, lfs[i]);
            long start = System.nanoTime();
            for (int j = 0; j &lt; n; j++) {
                map.put(j, j);
            }
            long time = (System.nanoTime() - start) / 1_000_000;
            System.out.printf("LF=%.2f: %dms, 扩容次数=%d%n", 
                lfs[i], time, getResizeCount(map));
        }
    }
}

典型结果:

负载因子耗时扩容次数最终容量
0.5180ms多次2,097,152
0.75150ms适中1,048,576
1.0140ms最少524,288

负载因子 0.75 在性能和空间之间取得了平衡。

2 的幂次的数学原理

HashMap 要求容量是 2 的幂次,这与负载因子密切相关:

java
// 数组下标计算
index = (n - 1) & hash

// 当 n = 2^k 时
n - 1 = 0b00...011...1  // k 个 1

2 的幂次保证了 n - 1 的二进制全是 1,这样 & 运算才能充分利用 hash 的每一位。

为什么不是其他数字?

java
// n = 15 时
// n - 1 = 14 = 0b00001110
// 用 & 运算时,hash 的某些位被「屏蔽」了

// 例如:
hash1 = 0b00010000  // 16
hash2 = 0b00010010  // 18
(14 & hash1) = 0b00010000 & 0b00001110 = 0
(14 & hash2) = 0b00010010 & 0b00001110 = 0b00010010 & 0b00001110 = 0
// 碰撞!

// n = 16 时
// n - 1 = 15 = 0b00001111
// 所有位都参与运算
(15 & hash1) = 0b00010000 & 0b00001111 = 0
(15 & hash2) = 0b00010010 & 0b00001111 = 0b00010010 & 0b00001111 = 2
// 没有碰撞!

面试追问方向

  1. 负载因子 0.75 是怎么来的?

JDK 作者 Josh Bloch 和 Neal Gafter 在《Effective Java》中解释:0.75 是在时间和空间成本之间的平衡点。太高会降低查询性能,太低会浪费空间。泊松分布分析也支持这个选择。

  1. HashMap 允许自定义负载因子吗?

允许。HashMap 有构造函数 HashMap(int initialCapacity, float loadFactor),可以自定义。但除非有特殊需求,否则不要改。

  1. ConcurrentHashMap 的负载因子是多少?

ConcurrentHashMap 不使用负载因子,而是直接用 sizeCtl 控制容量。JDK 8 中,如果某个桶的链表长度超过 TREEIFY_THRESHOLD(8),就会树化,而不是扩容。

  1. 负载因子是 1.0 时会怎样?

容量 16 的 HashMap 可以容纳 16 个元素而不扩容。但此时哈希碰撞严重,链表变长,查询性能从 O(1) 退化到 O(n)。

  1. 为什么 HashMap 要用泊松分布来分析?

因为哈希是伪随机的,元素落入每个桶的概率是独立的,符合二项分布。当样本足够大时,二项分布近似泊松分布。泊松分布可以准确预测不同长度链表的概率分布。


负载因子 0.75 不是拍脑袋想出来的,它是空间利用率和时间性能的数学平衡点。理解了这个设计决策,你就能更好地理解 HashMap 的行为,也能在面试中给出更有说服力的答案。

下一节,我们来探讨一个更深入的问题:HashSet 是如何判断元素是否重复的?

基于 VitePress 构建