尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

HashMap构造方法源码解析:threshold与懒加载

HashMap构造方法源码解析:threshold与懒加载 有人在一个技术群里抛出过这么个问题new HashMap(16)写完反射把table打出来一看是null是不是 JDK 有 bug底下一堆人跟着懵因为绝大多数人对 HashMap 构造方法的印象就是分配桶数组、算好阈值结果真去翻源码发现构造方法压根没干这事。这个问题我前后被问过不下五次每次都能看到有人把threshold的值当成capacity * loadFactor来理解然后被 16 这个数字整不会。我自己的习惯是每隔一段时间就把 HashMap 的源码重新过一遍重点永远是那几个构造方法——因为整个类的字段初始化策略、懒加载时机、容量对齐规则全都藏在这几十行里。这篇就把四个构造方法拆开讲透顺带把tableSizeFor的位运算、threshold的双重身份、putMapEntries里那个莫名其妙的1.0F都掰开揉碎最后给出可以用反射自己验证的完整代码和一份常见误判清单。适合已经会写 HashMap、但读源码时总觉得这里为什么这么写的同学。1. 四个构造方法逐一过入参校验之外字段到底被写成了什么先把 JDK 8 里四个构造方法的原文摆出来这是后面所有讨论的地基。很多人读源码的习惯是扫一眼参数校验就跳过去恰恰漏掉了最后那一两行赋值。public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity 0) throw new IllegalArgumentException(Illegal initial capacity: initialCapacity); if (initialCapacity MAXIMUM_CAPACITY) initialCapacity MAXIMUM_CAPACITY; if (loadFactor 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(Illegal load factor: loadFactor); this.loadFactor loadFactor; this.threshold tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor DEFAULT_LOAD_FACTOR; // all other fields defaulted } public HashMap(Map? extends K, ? extends V m) { this.loadFactor DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }相关的常量是DEFAULT_INITIAL_CAPACITY 1 4也就是 16、MAXIMUM_CAPACITY 1 30、DEFAULT_LOAD_FACTOR 0.75f。1.1 无参构造唯一一个不碰 threshold 的new HashMap()只写了一行this.loadFactor DEFAULT_LOAD_FACTOR。注意源码里那行注释all other fields defaulted意思是table保持null、threshold保持0、size保持0。这是四个构造方法里唯一一个不计算tableSizeFor的因为默认容量 16 是编译期就知道的常量没必要再跑一遍位运算反正第一次put时resize()的兜底分支会自己算出来。这里有个容易被忽略的细节为什么不用this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR)转发如果在 JDK 7 里确实就是这么写的。JDK 8 改成直接赋值是因为走转发路径会让threshold被写成tableSizeFor(16) 16而这个值在之后的resize()里会被当成容量来用语义上多绕了一层。直接留 0让resize()走零初始阈值代表使用默认值那条分支逻辑更干净。1.2 单参构造一个纯粹的转发器HashMap(int initialCapacity)什么都不做直接转发给双参版本。这种写法在整个 JDK 里很常见好处是校验逻辑只维护一份。你在读的时候不要在这里停下真正的活儿全在下面。1.3 双参构造三条校验 一次 tableSizeFor双参构造是整个类的入口。三条校验里有两处值得说第一容量为负直接抛IllegalArgumentException但容量超过MAXIMUM_CAPACITY只是静默截断不抛异常。这个不对称是刻意的——你要一个比 int 数组上限还大的哈希表本身就没意义截断到1 30已经足够没必要让调用方崩掉。第二if (loadFactor 0 || Float.isNaN(loadFactor))里那个isNaN检查不是多余的。因为NaN 0在 IEEE 754 里的结果是false如果只写loadFactor 0传入Float.NaN会直接绕过校验然后loadFactor参与后续所有(int)(capacity * loadFactor)计算最后得到一堆 0 阈值和无限扩容。这个坑我在一次代码审查里真见过有人踩传参是从配置中心读的浮点数配置漏填变成了NaN。校验完之后只有两行赋值而this.threshold tableSizeFor(initialCapacity)这一行就是全篇最大的误会源头第 3 节专门讲。1.4 拷贝构造loadFactor 被你交出去了HashMap(Map? extends K, ? extends V m)有个很硬的行为loadFactor被写死成默认的 0.75你没有机会指定。如果你原来的 map 是new HashMap(1024, 0.5f)用它去构造一个新 map新 map 的负载因子会变回 0.75。另外传null进来不会得到友好的提示而是从m.size()处直接抛 NPE。这个在链式代码里偶尔会咬人比如new HashMap(someService.queryConfig())服务返回null的时候堆栈会指向 HashMap 内部而不是你的调用处。2. tableSizeFor 的位运算减一、五次或移位、加一少一步都不行tableSizeFor是构造方法里唯一一处真正算东西的代码也是面试被问烂但很多人只会背结论的地方。static final int tableSizeFor(int cap) { int n cap - 1; n | n 1; n | n 2; n | n 4; n | n 8; n | n 16; return (n 0) ? 1 : (n MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n 1; }一句话概括它的目标返回大于等于cap的最小 2 的幂。下面拆开看为什么必须是这个形状。2.1 减一的用意让已经是 2 的幂的输入保持原样假设去掉cap - 1直接对cap做位扩散。传入cap 16二进制是0001 0000。扩散之后低位全被抹成 1得到0001 1111也就是 31加一变成 32。可 16 本身就是 2 的幂正确的答案应该是 16。这就是典型的多扩了一倍。加上减一就对了n 15 0000 1111扩散还是 15加一回到 16。而传入cap 17时n 16 0001 0000扩散成0001 1111 31加一得 32正确。所以cap - 1这一步的作用是把 2 的幂的边界往里收一格避免恰好命中边界时翻倍。2.2 五次右移或运算把最高位的 1 抹满整个低位n | n 1做的事是把最高位的 1 复制到相邻的右一位此时最高位连续两个 1。n | n 2把这两个 1 再复制两格变成四个连续 1。接着 4 格、8 格、16 格五次之后从最高位 1 开始往下的所有低位全部变成 1n就成了2^k - 1这种全 1 掩码。加一自然进位成2^k。以cap 100走一遍更直观步骤n 的二进制十进制初始n 990110 001199n | n 10111 1011123n | n 20111 1111127n | n 40111 1111127后续两次不变127返回n 11000 0000128至于为什么只做到 16 位就停int一共 32 位无符号右移 1、2、4、8、16 逐级翻倍覆盖总和刚好是 31 位足以把最高位之后的全部低位填满再多做一次纯属浪费。2.3 边界情况0、负数、超大值cap 0时n -1全部是 1五次扩散之后还是-1命中n 0分支返回1。cap 1时n 0扩散无效返回0 1 1。所以传入 0 和 1 得到的都是容量 1这个结果后面会引出一个非常隐蔽的扩容行为第 4 节会详细说。超大值那边(n MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n 1是防溢出的。如果直接n 1当n接近Integer.MAX_VALUE时会翻成负数数组长度变成负的直接抛NegativeArraySizeException。不过实际调用路径上双参构造已经先把initialCapacity截断到1 30了所以这条分支更多是给内部其他调用点兜底。顺便说一句为什么容量非得是 2 的幂。因为定位桶用的是(n - 1) hashn - 1正好是一个全 1 掩码按位与等价于对 2 的幂取模但快得多而且resize()拆分链表时判断元素落在原位置还是原位置 oldCap靠的也是位运算容量不是 2 的幂这套逻辑全崩。3. threshold 的双重身份构造方法执行完之后它其实还不是阈值这是全文最想讲清楚的一点也是我在群里看到翻车最多的地方。threshold这个字段名直译过来是阈值语义上应该是capacity * loadFactor。但在双参构造方法执行完之后它存的是tableSizeFor(initialCapacity)也就是容量不是阈值。为什么会这样因为这个时候table还是null容量没地方存只能先借threshold这个字段占个位。证据在resize()里final NodeK,V[] resize() { NodeK,V[] oldTab table; int oldCap (oldTab null) ? 0 : oldTab.length; int oldThr threshold; int newCap, newThr 0; if (oldCap 0) { // 常规路径容量翻倍阈值翻倍 } else if (oldThr 0) // initial capacity was placed in threshold newCap oldThr; else { // zero initial threshold signifies using defaults newCap DEFAULT_INITIAL_CAPACITY; newThr (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr 0) { float ft (float)newCap * loadFactor; newThr (newCap MAXIMUM_CAPACITY ft (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold newThr; // ... 分配数组、迁移数据 }看到那句英文注释没有——initial capacity was placed in thresholdJDK 作者自己都在提醒你这里读到的oldThr其实是容量。newCap oldThr是直接赋值没有任何loadFactor参与这就是铁证。3.1 构造完成后的字段快照我把常见调用的结果整理成表方便你对照着看自己有没有理解错构造调用tablethresholdloadFactornew HashMap()null00.75new HashMap(16)null160.75new HashMap(17)null320.75new HashMap(100)null1280.75new HashMap(1)null10.75new HashMap(0)null10.75new HashMap(16, 0.5f)null160.5new HashMap(另一个非空 map)null预估值或 00.75注意最后一行new HashMap(16, 0.5f)容量是 16 所以tableSizeFor(16) 16跟负载因子一点关系都没有。如果你按阈值 容量 × 负载因子 8去理解就会得出错误的预期。3.2 第一次 put 之后它才变成真正的阈值第一次put走putVal进去发现table null调resize()。此时oldCap 0、oldThr 16命中else if (oldThr 0)newCap 16因为newThr初始为 0后面那个if (newThr 0)用newCap * loadFactor算出12写回threshold。到这里threshold才第一次具备阈值的含义。构造调用首次 put 后 capacity首次 put 后 thresholdnew HashMap()1612new HashMap(16)1612new HashMap(17)3224new HashMap(100)12896new HashMap(1)21new HashMap(0)21那两行 1 和 0 的诡异结果下一节解释。3.3 一个可以直接复现的读数实验如果你想亲手确认最省事的办法是反射读字段。注意 JDK 9 之后模块系统会拦你需要加启动参数import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; public class ThresholdDemo { public static void main(String[] args) throws Exception { Field tableField HashMap.class.getDeclaredField(table); Field thrField HashMap.class.getDeclaredField(threshold); tableField.setAccessible(true); thrField.setAccessible(true); MapString, String map new HashMap(16); System.out.println(构造后 table tableField.get(map) , threshold thrField.get(map)); map.put(k, v); System.out.println(put 后 table 长度 ((Object[]) tableField.get(map)).length , threshold thrField.get(map)); } }运行命令里带上--add-opens java.base/java.utilALL-UNNAMED否则setAccessible会抛InaccessibleObjectException。这个参数坑我踩过一次当时在一个 Java 17 的环境里跑一段网上抄来的验证代码报错信息很长一眼没看出是模块化的锅以为是反射 API 写错了白白折腾了半小时。4. 构造方法为什么不 new 数组懒加载省了什么又欠了什么理解了threshold的占位性质就能回答那个经典疑问构造方法为什么不把数组分配好4.1 常见误判与真实意图大部分人的心理模型是构造方法负责准备好一切但 HashMap 的设计是构造方法只记录意图第一次写入时才兑现。这么做最直接的好处是内存。一个 64 位 JVM 上开了压缩指针的情况下长度 16 的引用数组至少占 64 字节对象头 16 字节 16 × 4 字节如果默认构造函数就立刻分配那么代码里千千万万个new HashMap()空对象全都要背上这 64 字节。而这个场景在真实项目里极其普遍Spring 的BeanFactory、MyBatis 的参数映射、各种 DTO 转换里的临时容器、Collections工具方法内部的缓存结构大量 map 从创建到销毁一个元素都没放过。懒加载把这些开销全部省掉了。代价也有就是第一次put要多走一次resize()多一次分支判断和一次数组分配。但这笔账很划算绝大多数 map 是要么为空、要么至少写一次而写一次的额外成本只是几十纳秒级别跟省下的内存比起来完全值。4.2 new HashMap(0) 和 new HashMap(1) 为什么会得到容量 2回到第 3 节表格里那两行。new HashMap(0)和new HashMap(1)的构造后threshold都是 1。第一次put时oldThr 1 0所以newCap 1然后newThr (int)(1 * 0.75f) (int)0.75 0。阈值是 0。元素插入后size变成 11 0成立于是立刻触发第二次resize()容量翻到 2阈值算成(int)(2 * 0.75) 1。也就是说new HashMap(1)放一个元素最终容量是 2 而不是 1中间白白多做了一次扩容和数据迁移。new HashMap(0)同理。我个人的结论是永远不要传 0 或者 1 进去。要么直接new HashMap()要么按实际规模算一个像样的值。4.3 从 JDK 7 到 JDK 8init() 钩子为什么被删掉顺手对比一下旧版本能看出设计上的演进。JDK 7 的构造方法是这样的public HashMap(int initialCapacity, float loadFactor) { // ... 校验 this.loadFactor loadFactor; threshold initialCapacity; // 注意存的是原始容量没对齐到 2 的幂 init(); }两个差异。第一JDK 7 存的是原始的initialCapacity对齐到 2 的幂这件事推迟到inflateTable()里用roundUpToPowerOf2做JDK 8 改成在构造时就对齐好。第二JDK 7 的构造方法会调用init()这是一个空方法专门留给LinkedHashMap之类的子类覆写。问题就出在这里在父类构造方法里调用一个允许被子类重写的方法是典型的危险动作。子类的构造方法还没执行实例字段全是默认值父类构造方法却已经开始回调子类的逻辑很容易读到半成品状态。JDK 8 把init()整个删了换成afterNodeAccess、afterNodeInsertion、afterNodeRemoval三个回调这些只在真正的增删改查时触发绕开了构造期的时序问题。这个改动的思路值得记一下自己写框架的时候别犯同样的错。5. putMapEntries 里的容量预估算术1.0F 和 s/loadFactor 的来历拷贝构造只写了一行putMapEntries(m, false)但这个方法的算术是很多人的盲区。final void putMapEntries(Map? extends K, ? extends V m, boolean evict) { int s m.size(); if (s 0) { if (table null) { // pre-size float ft ((float)s / loadFactor) 1.0F; int t ((ft (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); if (t threshold) threshold tableSizeFor(t); } else if (s threshold) resize(); for (Map.Entry? extends K, ? extends V e : m.entrySet()) { K key e.getKey(); V value e.getValue(); putVal(hash(key), key, value, false, evict); } } }5.1 为什么是除法而不是乘法看到s / loadFactor会有人本能地觉得别扭——负载因子不是用来乘容量的吗这里的思路是反解。我们已知要装s个元素还想一次扩容都不触发那就需要capacity * loadFactor s把capacity解出来就是capacity s / loadFactor。所以是除以负载因子不是乘。举个例子s 100、loadFactor 0.75需要capacity 133.33向上取到 2 的幂是 256。如果反过来算成100 * 0.75 75那容量会被算成 128而 128 的实际阈值是 96装不下 100 个元素至少在拷贝完成前会多触发一次扩容。5.2 那个 1.0F 到底防的是什么理论上s / loadFactor如果是整数直接用它做容量就够了resize()里的判断是size threshold才扩容等于阈值时不会触发。那1.0F是不是多余的在精确算术下确实可以去掉但它是防御性的第一浮点除法存在舍入误差比如某些组合下s / loadFactor会算出11.999999而不是12.0(int)截断后变成 11容量就少了一档。加 1 能把这个误差吃掉。第二(int)强转是向下截断而非四舍五入任何小数部分都会被丢掉。加 1 相当于给截断做了一次保守补偿。第三1之后还要过一遍tableSizeFor往上取整到 2 的幂所以这个 1 在大多数情况下并不改变最终结果只在s / loadFactor恰好落在 2 的幂边界时才起作用。但边界恰恰是最容易出事的地方多花一次加法换确定性值得。5.3 拷贝构造出来的 map容量到底是多大把整条链路串一遍。new HashMap(bigMap)且bigMap.size() 100loadFactor被写成 0.75。putMapEntries里s 100table nullft 100 / 0.75 1 134.33t 134。t threshold134 0成立threshold tableSizeFor(134) 256。注意此时没有分配数组threshold存的还是容量 256。循环里第一次putVal触发resize()oldThr 256newCap 256newThr 192。100 个元素装进阈值 192 的表里一次扩容都没有。另外两个细节if (t threshold)是只增不减的如果threshold已经更大了就不会缩回去table ! null时走的是else if (s threshold) resize()这是给putAll用的路径逻辑不一样。还有evict false这个参数是留给LinkedHashMap的淘汰回调用的构造阶段不应该触发淘汰。最后提醒一句如果源 map 是空的s 0整个if块直接跳过新 map 的threshold留在 0跟无参构造出来的状态一模一样。6. 反射验证与坑位清单把构造函数的结果打印出来看前面讲了一堆推理但源码阅读这件事我一直坚持一个原则能跑出来看的绝不靠脑补。下面给一套完整的验证代码再附上我自己整理的误判清单。6.1 一次跑完所有构造方法的对照脚本import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; public class CtorProbe { static final Field TABLE; static final Field THRESHOLD; static final Field LOAD_FACTOR; static { try { TABLE HashMap.class.getDeclaredField(table); THRESHOLD HashMap.class.getDeclaredField(threshold); LOAD_FACTOR HashMap.class.getDeclaredField(loadFactor); TABLE.setAccessible(true); THRESHOLD.setAccessible(true); LOAD_FACTOR.setAccessible(true); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } static void probe(String label, HashMapString, String map, boolean doPut) throws Exception { if (doPut) { map.put(probe-key, probe-value); } Object tab TABLE.get(map); int cap (tab null) ? 0 : ((Object[]) tab).length; System.out.printf(%-28s cap%-5d threshold%-5d lf%s%n, label, cap, THRESHOLD.getInt(map), LOAD_FACTOR.get(map)); } public static void main(String[] args) throws Exception { probe(new HashMap()【构造后】, new HashMap(), false); probe(new HashMap(16)【构造后】, new HashMap(16), false); probe(new HashMap(17)【构造后】, new HashMap(17), false); probe(new HashMap(100)【构造后】, new HashMap(100), false); probe(new HashMap(0)【构造后】, new HashMap(0), false); probe(new HashMap(1)【构造后】, new HashMap(1), false); System.out.println(---- 各放一个元素之后 ----); probe(new HashMap()【put 后】, new HashMap(), true); probe(new HashMap(16)【put 后】, new HashMap(16), true); probe(new HashMap(17)【put 后】, new HashMap(17), true); probe(new HashMap(100)【put 后】, new HashMap(100), true); probe(new HashMap(0)【put 后】, new HashMap(0), true); probe(new HashMap(1)【put 后】, new HashMap(1), true); MapString, String src new HashMap(100); for (int i 0; i 100; i) { src.put(k i, v i); } probe(new HashMap(src100)【put 后】, new HashMap(src), true); } }编译运行的时候记得带上模块开放参数javac CtorProbe.java java --add-opens java.base/java.utilALL-UNNAMED CtorProbe跑出来的结果会和我前面表格里的数字完全对齐。我自己第一次跑通的时候看到new HashMap(100)构造后cap 0、threshold 128脑子里那个阈值就是容量乘负载因子的模型才彻底碎掉。6.2 换 JOL 看内存布局会更直观如果你还想看懒加载到底省了多少内存可以引 JOLJava Object Layout看一眼对象图System.out.println(org.openjdk.jol.info.ClassLayout .parseInstance(new HashMap()).toPrintable());你会看到table字段是空引用整个对象的大小只有对象头加三个字段非常紧凑。等put之后再打一次table指向一个长度 16 的数组对象图立刻胖了一圈。这个对比比任何文字描述都有说服力。6.3 我整理的五条高频误判清单误判真相后果构造后就分配好了桶数组table一直是 null第一次 put 才分配反射读到 null 以为是 bugthreshold就是cap × 0.75构造后存的是容量put 之后才变阈值容量估算算错new HashMap(16)阈值是 12构造后是 16put 后才是 12提前判断扩容时机出错new HashMap(0)等于无参构造得到容量 1还会多一次扩容小表频繁扩容拷贝构造能指定负载因子loadFactor被写死 0.75高频冲突场景性能下降6.4 期望容量到 initialCapacity 的换算写法实际项目里最常见的需求是我要放 N 个元素别扩容。换算公式就是N / loadFactor 1再交给tableSizeFor对齐int expectedSize 1000; int initialCapacity (int) (expectedSize / 0.75f) 1; MapString, Object cache new HashMap(initialCapacity);如果你用 GuavaMaps.newHashMapWithExpectedSize(1000)内部走的就是同一套算术直接用它更省事。但有两个边界要注意一是expectedSize特别小的时候比如小于 12直接new HashMap()就行默认容量 16 的阈值 12 足够用算来算去反而绕二是千万别为了保险把容量往大了报比如实际 100 个元素写成new HashMap(65536)那是白白占着 256KB 内存不放而且这个表可能长期只有个位数元素扩容永远不会发生内存却一直挂着。还有一点容易忘HashMap不是线程安全的。构造方法加后续的put并不构成原子操作多线程共享必须换成ConcurrentHashMap这个跟构造方法是两码事但经常一起出现在踩坑现场。我自己现在的习惯是看到new HashMap(某个魔数)这类代码就会多看一眼尤其是那个数字明显不是 2 的幂、也不是按size / 0.75 1算出来的时候。大部分情况作者只是随手写了个 16 或者 100并不知道构造完之后threshold里躺着的其实是容量。搞清楚这一点之后容量估算这件事就从凭感觉变成了有公式可依代码评审的时候也能一眼看出问题在哪儿。
返回列表