第一次在源码里看到DEFAULT_LOAD_FACTOR 0.75f的时候估计很多人和我一样会愣一下为什么不是 1为什么不是 0.50.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 是 16loadFactor 是 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 始终没超过 threshold12没有任何扩容发生。插第 13 个时size 变成 13超过了 12于是 HashMap 申请一块新数组容量从 16 变成 32然后把旧数组里的所有节点重新分布到新数组里。这个重新分布的过程专业叫法叫 rehashJDK 8 之后做了很大优化后面第 3 章再细说。这就是负载因子第一次在工程中真实露面的过程。你在项目里看到 HashMap 突然卡了一下很多时候不是别的原因就是一次扩容引发的数据迁移。2. 为什么偏偏是 0.75空间、时间与概率的三角关系讲完了负载因子是什么问题就来了为什么这个值是 0.75而不是 0.5、0.6、1.0要回答这个问题得先看两个极端情况。2.1 如果负载因子是 1内存省了查询可能遭殃负载因子等于 1意味着“数组装满了才扩容”。从内存利用率来看这是最省内存的方案因为每个桶位都被充分利用。但问题也很明显。假设容量是 16负载因子是 1HashMap 会一直等到塞满 16 个元素才扩容。这 16 个元素分布在 16 个桶里理想情况下是一桶一个但哈希冲突是随机的越接近满载越容易出现多个元素挤在同一个桶里的情况。我举个例子。往一个容量 16 的 HashMap 里插入 16 个随机 key你大概率会看到某些桶的链表长度达到 2、3 甚至更长。链表一长get 操作的平均耗时就会从“走到桶里直接取”退化成“顺着链表一个个对比”。如果业务里查询密集这个退化是能感知到的。负载因子等于 1 的另一个问题是扩容动作太晚。一旦触发扩容一次性要迁移一整批节点迁移时的耗时峰值会很难看。前面说过的那个“仓库塞满才扩建”的问题在扩容时会被放大因为货架上已经堆满了东西搬起来自然也慢。2.2 如果负载因子是 0.5查询爽了内存却受不了反过来如果负载因子设成 0.5HashMap 会“容器装到一半就扩容”。这样做的好处非常直接桶很多、很稀疏冲突概率大幅下降查询基本都是一步到位链表很少有机会被拉长。但代价同样直接内存被大量浪费。容量 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 的注释里说到当负载因子保持默认值时桶内元素数量服从泊松分布各链表长度的出现概率大概是这样的链表长度出现概率00.6065306610.3032653320.0758163330.0126360640.0015795250.0001579560.0000131670.0000009480.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 1232 × 0.75 2464 × 0.75 48128 × 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即使链表已经长到 8HashMap 也不会立刻转树而是先扩容数组。原因很简单数组容量变大之后原本挤在同一个桶里的元素会被拆分到更多桶里链表自然变短用空间换冲突率比转树更划算而且也不需要付出红黑树节点的额外内存开销。负载因子在这个联动里扮演的是调节器的角色。负载因子越大扩容越晚触发桶就越挤链表变长进而转树的概率就越高。树化虽然能保证查询上限不失控但每个树节点比链表节点多了几个引用字段内存占用更高。所以“反正会转树所以负载因子随便调”的想法既忽视了内存成本也忽视了扩容的引擎作用。3.4 一毕业就踩坑初始容量到底该怎么给这是我在实际代码评审里经常看到的问题。很多人知道“要预估容量”于是直接写MapString, String map new HashMap(1000);这段代码的本意是“我要放 1000 个元素所以初始容量给 1000”。但实际不是这样。HashMap 拿到 1000 之后会先用 tableSizeFor 把它对齐到 1024然后第一次扩容后 threshold 是 1024 × 0.75 768。也就是说你还没放满 1000 个元素到第 769 个时就已经扩容了初始容量 1000 的“不扩容”目标根本没达成。正确的做法是让初始容量直接覆盖阈值常见的估算公式是int expectedSize 1000; MapString, String map new HashMap((int) (expectedSize / 0.75f) 1);算出来大约是 1334HashMap 内部对齐到 2048threshold 是 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 的扩容过程走一遍。