第一次在源码里看到DEFAULT_LOAD_FACTOR = 0.75f的时候,估计很多人和我一样会愣一下:为什么不是 1?为什么不是 0.5?0.75 这种不好记又不好看的数字,到底是怎么定下来的?我当年为了搞清楚这个问题,把扩容相关的源码翻了几遍,又把各种资料里的说法都过了一遍,最后发现这件事并没有网上传的那么玄,它就是一组数学概率和工程经验碰撞出来的结果。
这篇文章主要写给两类人。第一类是刚开始接触 Java 集合、正在准备面试的开发者,你会发现 0.75 引出来的其实是整个 HashMap 的扩容逻辑和冲突处理逻辑,弄懂它比背十个面试题都划算。第二类是已经在写业务代码、偶尔被内存和性能问题折腾的老手,理解了这个数字,你设计初始容量的时候就不会再随手写个 100 了,也能解释清楚为什么公司规范里总要求“预估容量再创建 Map”。
下面我把这个数字从“是什么”讲到“为什么”,再讲到项目里怎么用,全程不罗列晦涩公式,尽量用大白话和真实例子,保证你读完能给别人讲明白。
1. 先搞清楚负载因子到底在管哪件事
想理解 0.75,得先知道它是干什么的。负载因子不是一个孤立的概念,它和 HashMap 的底层结构、扩容时机、性能表现是绑在一起的,所以先从底层结构说起。
1.1 HashMap 的底层结构:从数组到链表再到红黑树
JDK 8 之后的 HashMap,底层是“数组 + 链表 + 红黑树”三件套。你可以把这个结构想象成一个大仓库,仓库里有很多编号固定的货架,货架上的位置就叫“桶”(bucket)。每次往 HashMap 里放一个键值对,先用 key 的 hashCode 做一次哈希运算,再用计算结果和数组长度做一次位运算,决定这个键值对应该放到几号货架上。
如果两个不同的 key 算出来的结果落到了同一个桶上,这就叫“哈希冲突”。冲突发生的时候,HashMap 不会丢掉数据,而是把后到的数据以链表的形式挂在同一个桶后面。链表一长,查询就从 O(1) 退化成 O(n),所以 JDK 8 又加了红黑树:当链表长度大于等于 8 并且数组长度大于等于 64 时,链表会转成红黑树,把最坏情况下的查找时间压回 O(log n)。
这个结构说明一件事:HashMap 的性能,很大程度上取决于“桶到底有多挤”。桶越空,冲突越少,查询越快;桶越满,冲突越多,链表和树就越臃肿。
1.2 负载因子是什么,它和“扩容”是什么关系
负载因子,英文叫 load factor,它表示的是“当前已经存储的元素个数”和“底层数组容量”之间的比值。在 HashMap 里有个固定公式:
扩容阈值 threshold = 当前容量 capacity × 负载因子 loadFactor默认情况下 capacity 是 16,loadFactor 是 0.75,所以 threshold 一开始就是 12。也就是说,当 HashMap 里的元素数量超过 12 个时,它就会触发扩容,把数组长度翻倍,变成 32,然后 threshold 变成 32 × 0.75 = 24。
所以负载因子的本质是一个“扩容开关”:它决定了 HashMap 会在装到多大比例的时候,自动把仓库扩大。这个值设得越小,HashMap 越早扩容、越“浪费”空间;设得越大,HashMap 越晚扩容、越容易挤在一堆。
这里有个容易混淆的细节:HashMap 扩容是在插入元素之后检查的,不是插入之前。也就是说,当元素个数 size 从 12 变成 13 时,先成功插入第 13 个元素,再判断 size > threshold,然后才触发扩容。这个顺序看源码的 putVal 方法体会最深,先++size,再判断是否要resize()。
1.3 一次 put 是怎么把负载因子带出来的
为了把流程串起来,我拿一个容量 16、负载因子 0.75 的 HashMap 举个例子。
假设你往里面插入 13 个键值对。前 12 个插入时,size 始终没超过 threshold=12,没有任何扩容发生。插第 13 个时,size 变成 13,超过了 12,于是 HashMap 申请一块新数组,容量从 16 变成 32,然后把旧数组里的所有节点重新分布到新数组里。这个重新分布的过程,专业叫法叫 rehash,JDK 8 之后做了很大优化,后面第 3 章再细说。
这就是负载因子第一次在工程中真实露面的过程。你在项目里看到 HashMap 突然卡了一下,很多时候不是别的原因,就是一次扩容引发的数据迁移。
2. 为什么偏偏是 0.75:空间、时间与概率的三角关系
讲完了负载因子是什么,问题就来了:为什么这个值是 0.75,而不是 0.5、0.6、1.0?要回答这个问题,得先看两个极端情况。
2.1 如果负载因子是 1:内存省了,查询可能遭殃
负载因子等于 1,意味着“数组装满了才扩容”。从内存利用率来看,这是最省内存的方案,因为每个桶位都被充分利用。
但问题也很明显。假设容量是 16,负载因子是 1,HashMap 会一直等到塞满 16 个元素才扩容。这 16 个元素分布在 16 个桶里,理想情况下是一桶一个,但哈希冲突是随机的,越接近满载,越容易出现多个元素挤在同一个桶里的情况。
我举个例子。往一个容量 16 的 HashMap 里插入 16 个随机 key,你大概率会看到某些桶的链表长度达到 2、3 甚至更长。链表一长,get 操作的平均耗时就会从“走到桶里直接取”退化成“顺着链表一个个对比”。如果业务里查询密集,这个退化是能感知到的。
负载因子等于 1 的另一个问题是扩容动作太晚。一旦触发扩容,一次性要迁移一整批节点,迁移时的耗时峰值会很难看。前面说过的那个“仓库塞满才扩建”的问题,在扩容时会被放大,因为货架上已经堆满了东西,搬起来自然也慢。
2.2 如果负载因子是 0.5:查询爽了,内存却受不了
反过来,如果负载因子设成 0.5,HashMap 会“容器装到一半就扩容”。这样做的好处非常直接:桶很多、很稀疏,冲突概率大幅下降,查询基本都是一步到位,链表很少有机会被拉长。
但代价同样直接:内存被大量浪费。容量 16 的 HashMap,放进 8 个元素就要扩容到 32;放进 16 个,又要扩容到 64。每一轮扩容,数组里都有将近一半的桶是空的。假设你要存 10000 条数据,负载因子 0.5 意味着底层数组可能要到 32768 甚至更高,白白多占了近一倍的内存。
在服务端场景里,内存往往比 CPU 更贵。HashMap 占着几百万个空桶不干活,这种浪费在堆内存紧张的时候会让 GC 压力明显上升。所以 0.5 的问题是“空间效率太低”,多数业务场景根本扛不住这种浪费。
2.3 0.75 的底气:来自泊松分布的概率支撑
既然 1 和 0.5 都不好,0.75 是不是拍脑袋拍的?答案不是。JDK 8 的源码注释里直接给过一组数据,这组数据是 0.75 最硬气的支撑。
HashMap 的注释里说到,当负载因子保持默认值时,桶内元素数量服从泊松分布,各链表长度的出现概率大概是这样的:
| 链表长度 | 出现概率 |
|---|---|
| 0 | 0.60653066 |
| 1 | 0.30326533 |
| 2 | 0.07581633 |
| 3 | 0.01263606 |
| 4 | 0.00157952 |
| 5 | 0.00015795 |
| 6 | 0.00001316 |
| 7 | 0.00000094 |
| 8 | 0.00000006 |
注意看最后一行的含义。在理想散列情况下,某个桶里出现 8 个元素的概率只有千万分之六,几乎不可能发生。这就是为什么 HashMap 里链表转红黑树的阈值被定成“链表长度大于等于 8”。换句话说,8 这个树化阈值,本身就是建立在默认负载因子 0.75 之上的。
反过来想,如果负载因子被调大到 1.5 甚至 2.0,泊松分布的参数会变大,长链表出现概率会明显上升,8 这个阈值就不一定够用了,树化会更频繁发生。而树化本身不是免费的,红黑树的节点比普通链表节点大不少,左旋右旋也要消耗 CPU。所以 0.75 能让“冲突概率”和“空间占用”同时处在一个工程上很舒服的区间。
2.4 另一个容易被忽略的原因:容量必须是 2 的幂
HashMap 有一个设计约束:底层数组容量永远是 2 的幂。16、32、64、128,不可能是别的数字。这样设计是为了能用(n - 1) & hash代替取模运算,位运算比取模快得多。
这个约束直接影响负载因子的取值。2 的幂乘以 0.75,恰好永远得到一个整数:16 × 0.75 = 12,32 × 0.75 = 24,64 × 0.75 = 48,128 × 0.75 = 96。这样 threshold 永远是整数,计算起来没有精度损失,也不会出现“扩容阈值是 12.5,结果到底是超过 12 还是超过 13 才扩容”这种边界问题。
如果负载因子是 0.7 或者 0.6,容量乘以负载因子就会得到小数。虽然代码里可以做取整处理,但这种“天然对齐”的特质,让 0.75 这类 2 的幂友好型数值显得特别合适。0.75 本质就是 3/4,和 HashMap 底层的二进制位运算体系完美契合。
我常跟朋友开玩笑说:0.75 既是概率论的选择,也是二进制强迫症的选择。这两个理由叠在一起,它就很难被替换掉了。
3. 源码层面看 0.75 是怎么落地的
理论说完了,接下来看代码。这一章我会把 HashMap 里和负载因子相关的几个关键点拆开,包括默认值从哪来、扩容阈值怎么算、JDK 8 扩容做了什么优化,以及一个很多人毕业入职后都会踩的坑。
3.1 默认负载因子与阈值初始化
在 HashMap 的源码里,负载因子的声明是这样的:
static final float DEFAULT_LOAD_FACTOR = 0.75f;构造方法里,如果不传任何参数,就用这个默认值:
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; }如果你传了 initialCapacity,构造方法会更隐晦一点,因为它不会立刻创建数组,只是把 threshold 临时设置成一个“大于等于 initialCapacity 的最小 2 的幂”。比如你写new HashMap<>(8),threshold 临时就是 8;你写new HashMap<>(10),threshold 临时就是 16。真正把 threshold 变成“容量 × 负载因子”,要等到第一次 put 触发 resize 的时候。
这个细节容易把人绕晕,我画个简单的时间线:
- 创建 HashMap:只存 loadFactor,数组还是 null
- 第一次 put:触发 resize,创建容量 16 的数组,threshold 变成 16 × 0.75 = 12
- 后续扩容:容量翻倍,threshold 跟着用新容量重新计算
所以面试里如果有人问“new HashMap<>(100) 之后 threshold 是多少”,正确答案不是 75,也不是 100,而是 128。因为 100 向上对齐到 2 的幂是 128,第一次扩容后 threshold 才会变成 128 × 0.75 = 96。这里藏着一个非常容易误判的初始容量问题,我在第 3.4 节专门展开。
3.2 JDK 8 的扩容流程:旧数组如何迁移到新数组
扩容时最核心的动作是“迁移旧节点”。JDK 7 的实现是逐个节点重新计算 hash、重新定位;JDK 8 做了一个很有名的优化,叫“高位拆分”。
因为新容量是旧容量的 2 倍,所以每个节点在新数组里的索引,要么保持不变,要么就是“旧索引 + 旧容量”。判断条件非常巧妙:
if ((e.hash & oldCap) == 0) { // 留在原索引 } else { // 移动到 原索引 + oldCap }这里有点绕,我用一个例子解释。假设旧容量 oldCap 是 16,二进制是 10000。某个节点的 hash 和 16 做按位与运算,结果取决于 hash 二进制里的第 5 位:第 5 位是 0,就留在原位置;第 5 位是 1,新位置就是原索引加 16。
这个优化的厉害之处在于,整个过程不需要重新计算 hash,也不需要取模,只要一次位运算,就能把一条链表拆成“低位链表”和“高位链表”两条,最后分别接在新数组的对应桶上。既快又稳。
在 JDK 7 时代,一个常见的风险是:多线程并发扩容时,链表可能因为头插法被翻转成环,导致下一次 get 的时候死循环。JDK 8 改成尾插法并且做了拆分后,这个问题在实际中几乎不再出现,但要注意,这不代表 HashMap 并发安全了,并发写的数据丢失等问题依然存在,该用 ConcurrentHashMap 还是得用。
3.3 负载因子和红黑树化阈值是怎么联动的
JDK 8 引入红黑树之后,很多人有个误解:觉得负载因子设大一点没关系,反正链表长了会转树。这个认知不全面。
链表转红黑树有两个条件,必须同时满足:
- 链表长度大于等于 8
- 底层数组容量大于等于 64
注意,如果容量不到 64,即使链表已经长到 8,HashMap 也不会立刻转树,而是先扩容数组。原因很简单:数组容量变大之后,原本挤在同一个桶里的元素会被拆分到更多桶里,链表自然变短,用空间换冲突率,比转树更划算,而且也不需要付出红黑树节点的额外内存开销。
负载因子在这个联动里扮演的是调节器的角色。负载因子越大,扩容越晚触发,桶就越挤,链表变长进而转树的概率就越高。树化虽然能保证查询上限不失控,但每个树节点比链表节点多了几个引用字段,内存占用更高。所以“反正会转树所以负载因子随便调”的想法,既忽视了内存成本,也忽视了扩容的引擎作用。
3.4 一毕业就踩坑:初始容量到底该怎么给
这是我在实际代码评审里经常看到的问题。很多人知道“要预估容量”,于是直接写:
Map<String, String> map = new HashMap<>(1000);这段代码的本意是“我要放 1000 个元素,所以初始容量给 1000”。但实际不是这样。HashMap 拿到 1000 之后,会先用 tableSizeFor 把它对齐到 1024,然后第一次扩容后 threshold 是 1024 × 0.75 = 768。也就是说,你还没放满 1000 个元素(到第 769 个时)就已经扩容了,初始容量 1000 的“不扩容”目标根本没达成。
正确的做法是让初始容量直接覆盖阈值,常见的估算公式是:
int expectedSize = 1000; Map<String, String> map = new HashMap<>((int) (expectedSize / 0.75f) + 1);算出来大约是 1334,HashMap 内部对齐到 2048,threshold 是 1536,放 1000 个元素绰绰有余,全程不扩容。我在项目里基本都是按这个公式来给初始容量的,实测下来能避免一整轮扩容迁移,对大 Map 效果尤其明显。
如果有人嫌公式麻烦,可以在项目里封装一个静态方法,比如Maps.newHashMapWithExpectedSize(1000),底层逻辑就是“除以负载因子”再补 1。这个技巧在很多写工具类的团队里已经是标配了。
4. 项目里到底要不要改负载因子
默认的 0.75 适合绝大多数的通用场景,但“适合绝大多数”不等于“适合所有”。这一章说说我实际调优时怎么判断,以及哪些场景值得动它。
4.1 哪些场景值得把负载因子调小
负载因子调小的核心动机是“用内存换查询速度”。
我见过比较典型的场景是读密集型缓存。某个服务启动时加载一批白名单数据,之后大部分请求都在做 get 查询,几乎不再 put。这种 Map 常年存活在堆里,如果查询命中率很关键,把负载因子调到 0.5 甚至 0.6,桶会更稀疏,冲突更少,get 的耗时会更稳定。
另一个场景是 hash 分布本身不均匀。比如 key 是某个具有强规律性的业务字段,无法很好地控制 key 的 hashCode 质量,此时调小负载因子能缓解局部桶过挤的问题。不过更推荐的做法是先检查 key 的 hashCode 实现,而不是一味加内存。
调小负载因子有个隐含成本:HashMap 会提前扩容,内存占用上升。所以只有在你确认“这块内存压力可以接受”的时候才值得做。我一般会先算账,比如原来容量 4096、负载因子 0.75、threshold 是 3072,改成 0.5 后容量可能要 8192 才能装下同样多的数据,内存翻了一倍,值不值要看线上指标。
4.2 哪些场景可以把负载因子调大
调大负载因子的动机正好相反:省内存,牺牲一点冲突容忍度。
当内存相当紧张,或者 Map 的规模非常大(比如几百万条),并且 get 操作并不频繁时,把负载因子调到 0.9 甚至 1.0 是可行的。你相当于告诉 HashMap:“仓库可以挤一点,满了再扩建。”
但我必须提醒一句,调大负载因子要特别注意扩容迁移的连锁反应。负载因子变大意味着扩容触发得更晚,可一旦触发,迁移的数据量也比正常情况更多,扩容瞬间的耗时峰值更高。对于流量敏感的服务,这个峰值可能比扩容本身更致命。我倾向于调大之前,先确认 Map 的规模是不是真的需要长期维持,如果只是偶尔大,维持默认值更省心。
还有一种情况是“配合自定义容量”使用。比如你明确知道 Map 最多只会放固定数量的元素,初始容量给一个足够大的 2 的幂,负载因子稍微调大一点,让 threshold 刚好覆盖最大值,之后永远不扩容。这种组合用法是合法的,但必须经过容量测算,不能拍脑袋。
4.3 调负载因子的几个容易踩的坑
把我在实际开发里踩过、也见过别人踩的坑列一下。
第一个坑是只调负载因子不调初始容量。很多人new HashMap<>(16)然后loadFactor改成 0.5,结果容量 16 的 threshold 变成 8,放 10 个元素就扩容了,还不如用默认值。负载因子和容量必须一起算,单独调哪个都很容易失衡。
第二个坑是迷信“负载因子越大效率越高”。负载因子影响的是扩容阈值,不是哈希算法本身。哈希冲突的次数主要取决于 hash 函数质量和元素分布,负载因子只是决定了“桶密度”。所以在排查 HashMap 性能问题时,先看 hash 分布,再看是否需要调负载因子,顺序别反。
第三个坑是忽略树化条件。有人把负载因子调到 2,想着反正能转红黑树,查询不会爆炸。但正如前面说的,数组容量不到 64 时根本不会树化,而是先扩容;就算树化了,红黑树节点更占内存,左旋右旋也是开销。真遇到高频查询,红黑树只是兜底方案,不是性能利器。
第四个坑是把负载因子调整当成并发问题的解药。HashMap 在并发环境下丢失数据、覆盖数据的问题,和负载因子无关。负载因子调得再合理,也不能让 HashMap 变成线程安全集合。并发场景请直接拥抱 ConcurrentHashMap。
5. 高频疑问速查与面试应答思路
这一章把常见的疑问集中回答一遍,再给一套面试表达思路,方便你复习和临场组织语言。
5.1 这些高频问题,值得收藏
| 问题 | 简洁回答 |
|---|---|
| 负载因子是什么? | 元素个数与容量的比值,超过阈值就扩容。 |
| 默认值为什么是 0.75? | 空间和时间权衡的结果,配合泊松分布能让冲突概率很低,且与 2 的幂容量天然对齐。 |
| 为什么不设置成 1? | 空间利用率高但冲突多,链表边长,查询退化,扩容峰值大。 |
| 为什么不设置成 0.5? | 查询快但内存浪费严重,扩容频繁,多数业务扛不住。 |
| 0.75 是计算出来的吗? | 是工程经验与概率分析的结合,不是某个唯一数学公式的产出。 |
| 扩容阈值怎么算? | threshold = capacity × loadFactor,默认 16 × 0.75 = 12。 |
| 初始容量应该怎么给? | 用 expectedSize / 0.75f + 1 估算,交给 HashMap 对齐成 2 的幂。 |
| 调大负载因子能省多少内存? | 要看具体容量,但代价是冲突率上升和扩容峰值变大,需评估。 |
| 负载因子影响线程安全吗? | 不影响,并发问题要换并发集合。 |
这套问答基本把 HashMap 负载因子相关的面试点都覆盖了,背结论不难,难的是能解释清楚每个结论背后的权衡,下面给一套组织思路。
5.2 面试问起 0.75,怎么答显得你有源码功底
我不太建议一上来就背“泊松分布”这个词,先从一个小的定义切入会更自然。结构大致可以这样:
先一句话点明作用:负载因子是用来控制扩容时机的,执行时机是元素数量超过 容量 × 负载因子 时扩容。然后说默认值是 0.75。
再说权衡:如果负载因子太低,比如 0.5,内存利用率差,HashMap 会频繁扩容,空桶多;如果太高,比如 1,空间利用率高,但冲突概率大,链表边长,查询会退化成链表扫描。0.75 是两者之间的经验平衡点。
然后说概率依据:在默认负载因子下,理想散列时,桶内链表长度出现 8 的概率低于千万分之一,所以树化阈值才敢定为 8。这句话能体现你看过源码注释,而不是只会死记硬背。
再补充实现细节:HashMap 容量必须是 2 的幂,0.75 = 3/4,容量乘负载因子一定是整数,threshold 毫无误差。如果面试官有兴趣,还能顺便提一句 JDK 8 扩容时的位运算优化,就是e.hash & oldCap判断去留的那个技巧。
最后收个尾:默认值适合大多数场景,但如果是读多写少的缓存、或者内存极敏感的应用,可以结合初始容量重新设置。这里能体现工程判断力,而不是把 0.75 当成不可变教条。
整个过程我觉得控制在 1 到 2 分钟内比较合适,逻辑链路就是“是什么 → 权衡什么 → 概率支撑 → 实现配合 → 工程调整”。
5.3 用一个小实验验证负载因子的影响
如果你也想亲手验证一下,方法很简单。准备同一批 key,用一个固定 seed 的随机数生成器造数据,分别用负载因子 0.5、0.75、1.0 创建 HashMap,插入同样多的数据,然后统计两点:每次扩容发生时的 size,以及最终所有桶的链表长度分布。
我试过用 50 万个 key 做实验,结论非常直观:负载因子 1.0 的 Map 里,出现链表长度为 3 到 5 的桶数明显更多;负载因子 0.5 的 Map 里,空桶比例很高,内存多占了快一倍;0.75 卡在中间,冲突分布和内存占用都比较收敛。这个实验不需要高级工具,一个 main 函数加一段统计代码就能跑出来,建议有兴趣的人自己执行一次,比干看文章记结论要扎实得多。
回到前面说的那个公式,如果你现在正要在代码里创建一个大 Map,我最后再说一句实在话:先把初始容量算好,通常比纠结负载因子调成 0.6 还是 0.8 更有效。0.75 这个数字本身,背后是概率、性能和工程的综合取舍,理解它最好的方式,不是背下结论,而是亲手把 HashMap 的扩容过程走一遍。