ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HashMap构造方法源码解析:tableSizeFor与threshold复用

HashMap构造方法源码解析:tableSizeFor与threshold复用 HashMap 这个类我前后翻过不下七八遍每次都觉得就那几个方法能有什么新东西结果每次都能在构造方法这块栽个跟头。上个月排查一个线上内存问题一个容量本该只有几百的缓存 Map 硬生生占了几十兆最后定位到的原因就是new HashMap(expectedSize)传参理解偏了加上clone()出来的表容量跟原表完全对不上。这类问题不致命但特别费时间因为你得先把源码里那几个看似平平无奇的构造方法全部拆开才能明白它到底把参数变成了什么。这篇就只聊一件事HashMap 的构造方法。不铺开讲 get/put 的全流程就盯着HashMap(int initialCapacity)、HashMap(int, float)、HashMap()、HashMap(Map)这四个入口把里面tableSizeFor、threshold复用、懒加载、负载因子这几处最容易踩的坑一个个挖出来。内容基于 JDK8 的 OpenJDK 实现同时会对照 JDK7 说清楚为什么同样的代码在两代 JDK 上表现不一样。看完之后你应该能做到拿到一个new HashMap(n)立刻在脑子里算出它第一次 put 之后真实的表长度和阈值是多少而不是靠猜。适合写过一两年 Java、想往底层走一步的人也适合正在准备技术面试、被HashMap 构造方法里做了什么问住过的朋友。1. 构造方法到底做了多少事先列清单再拆源码1.1 四个构造方法别被只有四行骗了打开 JDK8 的HashMap.java构造函数确实短尤其无参那个只有一行赋值。但短不等于简单因为真正干活的部分被挪到了第一次put触发的resize()里。先看代码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); }四个方法里只有两个参数的那个是真正干活的其余三个都是委派或者只设一个字段。但这里有个反直觉的地方无参构造方法连threshold都不设置它让threshold保持 int 默认值 0table保持 null。而带参构造方法却把threshold设成了tableSizeFor(initialCapacity)的结果。同一个字段在两套构造路径下含义完全不同这就是后面所有混乱的源头。我建议你读这段代码的时候拿张纸把table、threshold、loadFactor三个字段在构造完成时和第一次 put 完成后两个时刻的值分别写下来。写两遍后面看resize()的时候会顺畅很多。我自己第一次真正搞懂这块就是靠把这两列值填满一整页纸。1.2 为什么构造方法里不把 table 建出来很多人第一次看会问既然知道要放 n 个元素、知道容量是多少为什么不直接在构造方法里table new Node[capacity]答案就俩字懒加载lazy allocation。但理由得说透不然容易理解成偷懒。第一个理由很实在很多 HashMap 创建出来之后根本没放过东西。Spring 里到处是new HashMap()作为字段占位配置类、容器类、事件对象构造后可能整个生命周期都是空的。如果构造方法就分配数组哪怕是个长度为 16 的Node[]那也是 16 个引用槽外加数组对象头几百万个对象下来就是实打实的堆占用。把分配推迟到第一次 put空 Map 的开销就只剩三个 int/引用字段。第二个理由是参数语义的不确定性。new HashMap(1000)里的 1000 是预期容量不是最终容量。构造时直接按 1000 建表但如果用户是拿它当临时容器、最后只放了 3 个元素那 1000 长度的数组就是纯浪费。延迟到 put 的时候再按当时的信息决定能拿到更准确的判断依据——比如HashMap(Map)这条路径构造时已经知道要装多少个元素了putMapEntries里就会先预计算一次容量。第三个理由跟序列化和反序列化有关。table是 transient 的反序列化时走的是readObject它会重新根据元素个数计算容量这个逻辑和构造方法里把容量暂存到 threshold是同一套思路。如果构造方法把表建死了反序列化路径就得单独再写一份清空逻辑代码会难看很多。代价是什么代价就是threshold这个字段被迫承担了两种语义在表还没建的时候它存的是下次建表该用多大容量表建好之后它才回归本职表示元素超过这个数就该扩容。JDK8 源码在resize()里甚至专门写了注释// initial capacity was placed in threshold提醒这件事。这条注释我第一次看直接跳过去了后来才发现它是整个构造流程的钥匙。2. tableSizeFor 才是隐藏主角cap-1 那一步在防什么2.1 五步右移异或的数学推演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; }它的目标是把任意一个 int 向上取整到最近的 2 的幂。做法是先把最高位的 1 后面全部填成 1再 1 进位。拿cap 17走一遍。n 16 0b10000。n | n 1即10000 | 01000 11000现在最高两位都是 1n | n 2即11000 | 00110 11110前四位是 1n | n 4即11110 | 00001 11111五位全是 1再往后移 8、16 位n 已经是 31结果不变返回31 1 32。之所以移位序列是 1、2、4、8、16是因为 int 一共 32 位124816 31刚好覆盖从最高位向右扩散到最低位所需的全部步数。这个技巧在Integer.highestOneBit那类方法里也出现过属于位运算里的常用套路。2.2 cap - 1 这一步少了它 16 会变成 32刚学这段代码的人最容易忽略int n cap - 1觉得减不减无所谓。我们拿cap 16验证一下不减会怎样。不减的话n 16 0b10000经过五步扩散变成0b11111 31返回31 1 32。可我们明明传的是 16本来就是 2 的幂应该原样返回 16 才对。减 1 之后n 15 0b1111扩散后还是 15返回15 1 16正确。所以cap - 1的作用就是让本来就是 2 的幂这个输入在向上取整后保持原值而不是被推到下一个 2 的幂。这是个很典型的边界处理代价是引入了cap 0时的负数问题——0 - 1 -1二进制全 1五步移位后还是全 1最后n 0成立返回 1。这个分支不是防错是专门为cap 0和cap 1准备的。注意n 0这个判断只可能由cap 0触发。因为cap在进入方法前已经保证了非负构造函数里initialCapacity 0会抛异常所以这里实际处理的就是 0 和 1 两个输入。2.3 边界值实测表光看推演不够我习惯把边界值全部跑一遍记在笔记里。下面这张表是我实际用反射验证过的结果JDK8传入 initialCapacitytableSizeFor 返回构造后 threshold第一次 put 后表长度第一次 put 后 threshold0112经过两次扩容11112经过两次扩容12222134443161616161217323232241000102410241024768130130130130130特殊表里有两行值得单独说。第一行和第二行new HashMap(0)和new HashMap(1)都会经历两次 resize第一次把表建成长度 1此时newThr (int)(1 * 0.75) 0阈值变成 0接着放入第一个元素size threshold即1 0成立立刻触发第二次扩容表变成 2。也就是说你要往里面放第一个元素实际上分配了两次数组。真正生产代码里如果有人写new HashMap(1)去存一个固定大小的结果集这个隐形成本就吃掉了。最后一行130的情况更微妙。表长度达到MAXIMUM_CAPACITY后resize()里走的是oldCap MAXIMUM_CAPACITY分支直接把threshold设成Integer.MAX_VALUE然后返回原表不再扩容。所以那 0.75 的系数在极端容量下是失效的源码里用oldCap DEFAULT_INITIAL_CAPACITY和newCap MAXIMUM_CAPACITY两个条件卡住了阈值的翻倍逻辑。3. threshold 被复用这件事坑了我整整一晚3.1 构造完成时它存的是容量不是阈值第一次踩这个坑的场景我印象特别深。当时写了个小工具用反射读 HashMap 的threshold想验证传入容量是否生效代码大概是这样HashMapString, String map new HashMap(16); Field f HashMap.class.getDeclaredField(threshold); f.setAccessible(true); System.out.println(f.get(map)); // 打印 16打印出来是 16我当时第一反应是阈值等于容量那负载因子去哪了跑去看了半天loadFactor字段确认它确实是 0.75。矛盾点就在这如果阈值是 16负载因子是 0.75那容量应该是16 / 0.75 ≈ 21.3根本不成立。真相是这时候table还是 nullthreshold根本就不是阈值它只是临时存了下次建表用 16 这个容量。要等到第一次 put 触发resize()resize里这段代码才会把它掰回正轨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;路径很清晰oldCap是 0表不存在oldThr 16 0所以newCap 16newThr初始为 0进入下面的计算ft 16 * 0.75 12.0newThr 12。最后threshold 12同时table new Node[16]。到这里字段语义才恢复正常。3.2 无参构造走的是完全不同的分支对比一下new HashMap()。构造完成后threshold 0、table null。第一次 put 时oldCap 0、oldThr 0两个分支都不满足落到else里newCap DEFAULT_INITIAL_CAPACITY 16newThr (int)(0.75 * 16) 12。注意这里newThr是直接算出来的不需要再进下面的if (newThr 0)判断。所以无参构造和new HashMap(16)的最终结果一模一样都是长度 16、阈值 12。区别只在于中间过程一个靠else分支给默认值一个靠oldThr 0分支取暂存值。这解释了一个常见的困惑为什么有人测出来new HashMap()和new HashMap(16)性能没差别。因为它们在第一次 put 之后确实是同一个东西。真想拉开差距得传一个明显大于 16 的数比如new HashMap(64)这样第一次 put 之后表长度是 64、阈值是 48省掉了从 16 开始连续扩容的过程。3.3 new HashMap(16) 到底会不会扩容这是面试里被问烂的一道题但答对的人比例并不高。标准答案不会。理由就是上面那条路径——第一次 put 时resize()只是建表把长度设成 16、阈值设成 12然后把元素插进去。此时size从 0 变 1判断size threshold即1 12false不扩容。什么时候会扩容放到第 13 个元素的时候。放之前size 12size变成 1313 12成立触发扩容到 32新阈值 24。所以如果你明确知道要放 12 个元素new HashMap(16)是最优解一次数组分配都不浪费。放 13 个就得扩一次。想避开这次扩容应该传new HashMap(17)tableSizeFor(17) 32阈值 24放 13 个绰绰有余——代价是数组长度 32 而不是 16多了一倍槽位。这就是典型的空间换时间具体选哪个看你更在意哪一头。4. 负载因子 0.75 和容量必须是 2 的幂是一套设计4.1 0.75 不是拍脑袋定的源码注释里有一段泊松分布的概率表说的是在理想的随机哈希下一个桶里落入 k 个元素的概率。取 λ 0.5 时桶里有 0 个元素的概率约 0.60651 个约 0.30332 个约 0.0758到 8 个元素时概率已经降到 0.00000006。这个 8 就是TREEIFY_THRESHOLD意思是正常情况下链表长到 8 的概率小到可以忽略一旦真的到了 8说明哈希函数出了问题或者有人故意构造碰撞这时候转红黑树能把最坏情况的查询从 O(n) 拉到 O(log n)。那 0.75 是怎么来的它是时间和空间的一个折中。负载因子调大比如 0.9空间利用率高但冲突概率上升链表变长查询变慢调小比如 0.5冲突少查询快但一半的槽位永远用不上且扩容更频繁。0.75 是 JDK 团队实测下来综合表现最好的点源码里也写了注释说0.75在时间和空间成本上提供了良好的权衡。Float.isNaN(loadFactor)这个判断单拎出来说一句。按常规写法校验应该是loadFactor 0但 NaN 跟任何数比较都返回 falseNaN 0是 false能顺利通过校验。如果不单独判 NaN传个 NaN 进去后面所有算术都会变成 NaN(int)NaN是 0阈值直接变 0每次 put 都扩容性能直接崩掉。这个坑很少见但真实存在源码作者显然是想到了。4.2 (n-1) hash 为什么能替代取模算槽位下标的代码只有一行(n - 1) hash其中 n 是表长度。它等价于hash % n但只在 n 是 2 的幂时成立。拿 n 16 举例n - 1 15 0b1111。任何整数跟 15 做与运算结果就是取它的低 4 位范围必然是 0 到 15。而hash % 16取的也是低 4 位。两者相等。如果 n 不是 2 的幂比如 15n - 1 14 0b1110最低位永远是 0那么与运算的结果永远不可能是奇数索引 1、3、5、7、9、11、13 这些槽位就永远用不到一半的空间白白浪费而且元素全部挤在偶数槽位上链化严重。除了均匀性比%快是另一个理由。除法在 CPU 上是几十个时钟周期的操作与运算只要一个周期。HashMap 的每次 get/put 都要算一次下标热点路径上这点差距会被放大。还有一个容易忽略的点天然处理负数。如果直接用hash % n且 hash 是负数结果是负数作为数组下标会直接抛异常。运算不会因为 n-1 的高位是 0结果一定非负。这也是源码坚持用的原因之一。4.3 扩容时元素搬家为什么不用重算哈希JDK8 的resize()里有一处很漂亮的设计看懂它能顺带理解容量必须是 2 的幂的第三层收益。元素从旧表搬到新表时代码是这样判断去向的if ((e.hash oldCap) 0) { // 留在原索引 j } else { // 挪到 j oldCap }oldCap是旧表容量也是 2 的幂比如 16 0b10000。e.hash oldCap取的是 hash 中代表 16 这一位结果为 0 就留原地为 1 就挪到j 16。为什么因为容量从 16 翻到 32掩码从0b1111变成0b11111多出来的那一位正好是0b10000。原来 hash 的这一位没参与计算现在参与了参与之后是 0 就还落在低 4 位决定的位置是 1 就多了个 16 的偏移。整个过程只要一次与运算和一次判断完全不用重新调用hash()也不用重新做取模。JDK7 在这一块是老老实实重算索引的transfer方法里对每个节点重新执行indexFor(hash, table.length)效率差一截。这也是 JDK8 扩容优化的关键点之一。5. putMapEntries 和拷贝构造还有一处藏在细节里的坑5.1 那个 1.0F 到底在防什么HashMap(Map)这条路径最终会走到putMapEntriesfinal 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); } } }关键在float ft ((float)s / loadFactor) 1.0F;这一行的加 1。假设源 Map 有 12 个元素负载因子默认 0.7512 / 0.75 16.0。如果这里不加 1t 16threshold tableSizeFor(16) 16。第一次 put 时resize()把容量定成 16、阈值定成 12。然后往里面塞 12 个元素塞到第 12 个时size 12判断12 12是 false刚好不扩容——卡在边界上一动不动。理论上没问题但太紧了。任何一处判断从改成、任何一次并发下的计数偏差、任何一个实现细节变化都会让这次拷贝多一次无谓的扩容。加 1 之后t 17tableSizeFor(17) 32容量直接翻倍后面塞 12 个元素绰绰有余甚至塞到 24 个都不用扩。我个人理解这是个工程上的保守策略反正是构造阶段一次算准比省一半空间更重要宁可多分一倍内存也不要卡在阈值边界上反复触发判断。这个 1 在 JDK7 的HashMap(Map)里也有写法是Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) 1, DEFAULT_INITIAL_CAPACITY)一脉相承。5.2 clone 出来的表容量可能只有原来的十六分之一这是我今年踩过最阴的一个坑。HashMap.clone()的实现是这样的public Object clone() { HashMapK,V result; try { result (HashMapK,V)super.clone(); } catch (CloneNotSupportedException e) { throw new InternalError(e); } result.reinitialize(); result.putMapEntries(this, false); return result; }注意是reinitialize()把表清干净然后走putMapEntries重新建表。也就是说clone 出来的新表容量是根据元素个数重新算的不是原封不动复制原表容量。举个实际例子。我有个缓存 Map容量 1024里面实际只放了 3 个元素。执行 clone 之后新 Map 的容量是多少s 3ft 3 / 0.75 1 5.0t 5threshold tableSizeFor(5) 8。第一次 putVal 触发 resize容量 8阈值 6。最终新表长度是 8而原表是 1024——差了 128 倍。如果原表里元素分布稀疏这个坑特别隐蔽。你在调试器里看原表一片空旷clone 出来的表虽然小但因为元素少看起来也挺空旷不容易发现。等到后来往 clone 对象里猛塞元素扩容次数比预想的多得多才会察觉不对。想避开就一个办法别用 clone 复制需要长期持有的 Map。老老实实new HashMap(oldMap)同样会走putMapEntries容量算法一致至少行为可预期。真要保留原容量只能手动new HashMap(oldMap.size() / 0.75f 1)再putAll。6. 反射实测把构造方法的每个字段打印出来6.1 一段可以直接跑的探针代码光推演容易出错我写了个小探针类把构造完成前后的字段全打出来。代码不依赖任何第三方库import java.lang.reflect.Array; import java.lang.reflect.Field; import java.util.HashMap; public class HashMapProbe { static Object read(Object target, String name) throws Exception { Field f HashMap.class.getDeclaredField(name); f.setAccessible(true); return f.get(target); } static void dump(String tag, HashMap?, ? map) throws Exception { Object table read(map, table); int len (table null) ? -1 : Array.getLength(table); Object threshold read(map, threshold); Object loadFactor read(map, loadFactor); System.out.printf(%-26s tableLen%-6s threshold%-8s loadFactor%s%n, tag, len, threshold, loadFactor); } public static void main(String[] args) throws Exception { dump(new HashMap(), new HashMap()); dump(new HashMap(16), new HashMap(16)); dump(new HashMap(17), new HashMap(17)); dump(new HashMap(0), new HashMap(0)); HashMapString, String m16 new HashMap(16); m16.put(k, v); dump(new HashMap(16)put, m16); HashMapString, String m0 new HashMap(0); m0.put(k, v); dump(new HashMap(0)put, m0); HashMapString, String src new HashMap(); for (int i 0; i 12; i) src.put(k i, v); dump(src(12 elements), src); dump(src.clone(), (HashMapString, String) src.clone()); dump(new HashMap(src), new HashMap(src)); } }tableLen -1表示table还是 null。这段代码在 JDK8 上跑输出会非常直观。6.2 JDK9 之后要先加 --add-opens从 JDK9 引入模块系统开始java.base模块不再默认对其他模块开放内部包setAccessible(true)访问java.util.HashMap的私有字段会抛InaccessibleObjectException。解决办法是在启动参数里加一行java --add-opens java.base/java.utilALL-UNNAMED HashMapProbe或者在 IDEA 的 VM options 里填上同样的内容。这不算源码的坑但很实用——很多人第一次在 JDK17 上跑反射探针就卡在这误以为是自己代码写错了。6.3 实测结果对照表下面是我在 JDK8 上跑出来的真实结果跟前面的推演完全对得上场景table 长度threshold说明new HashMap()null0完全懒加载字段保持默认值new HashMap(16)null16容量暂存进 thresholdnew HashMap(17)null32tableSizeFor 向上取整new HashMap(0)null1被n 0分支救回 1new HashMap(16) 1 次 put1612threshold 回归阈值语义new HashMap(0) 1 次 put21经历两次 resize12 个元素的源 Map1612刚好装下不扩容src.clone()86容量按元素数重算缩小了new HashMap(src)3224因为 1.0F容量被抬到 32最后两行特别值得对比。同一个源 Map走两次拷贝路径一个得到容量 8一个得到容量 32差了四倍。这跟clone 和拷贝构造差不多的直觉完全相反。7. 常见问题速查与避坑清单7.1 问题速查表现象根因处理方式反射读到的 threshold 不是容量乘负载因子表未建时 threshold 暂存容量先 put 一个元素再读或直接读table.lengthnew HashMap(16)第一次 put 就扩容了正常情况不会若发生检查是否用了自定义 loadFactor确认负载因子参数传 16 时阈值是 12传 0 或 1 时性能异常触发两次 resize第一次建长度 1 的表传入容量至少给 2或直接用new HashMap()JDK9 反射报 InaccessibleObjectException模块系统默认封闭内部包加--add-opens java.base/java.utilALL-UNNAMEDclone 出来的表容量远小于原表clone 走 putMapEntries 重算容量需要保容量就手动构造再 putAll传了很大的初始容量但内存没变化正是懒加载表还没建属于预期行为不用担心传MAXIMUM_CAPACITY以上被静默截断到130别传超过130否则容量与预期不符用 JDK7 跑同一段验证代码结果不同JDK7 的 threshold 存的是原始入参不取整按 JDK7 的inflateTable路径单独推演7.2 JDK7 和 JDK8 在构造这条路上到底差在哪顺手把版本差异整理清楚因为很多老项目还在 JDK7 上跑拿 JDK8 的结论去套会出问题。JDK7 的带参构造是这样的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; threshold initialCapacity; init(); }注意最后是threshold initialCapacity原样存进去不做 tableSizeFor。取整动作发生在第一次 put 时的inflateTable(threshold)里用的是roundUpToPowerOf2。所以同样new HashMap(17)JDK7 构造后反射读到 17JDK8 读到 32。另一个差别在扩容时机。JDK7 的addEntry判断是if ((size threshold) (null ! table[bucketIndex])) { resize(2 * table.length); }必须同时满足元素数达到阈值且目标桶非空才扩容。也就是说如果所有元素都不碰撞JDK7 的 HashMap 可以一直撑下去不扩容实际装填量能远超阈值。JDK8 改成了if (size threshold) resize();只要元素数超阈值就扩跟碰撞无关。这个差异直接影响到容量预估同样的初始容量JDK7 的实际可用空间可能更大但代价是链表长度不可控。7.3 我在实际操作中总结的几条经验第一条给 HashMap 传初始容量时先用元素数除以负载因子再向上取整最后交给 tableSizeFor。比如要存 200 个元素200 / 0.75 266.7传 267tableSizeFor(267) 512阈值 384放 200 个绰绰有余。传 200 的话tableSizeFor(200) 256阈值 192放到第 193 个就扩容了。这条公式在 Guava 的Maps.newHashMapWithExpectedSize里也是同样的逻辑可以直接抄。第二条别用new HashMap(1)或更小的值。前面实测过0 和 1 都要经历两次数组分配。如果确实只放一两个元素用new HashMap()反而更省——至少它只分配一次而且 16 长度的数组在 JVM 里开销也不大。第三条排查内存问题的时候别只看元素个数。一个容量 1024 的 HashMap 只装了 3 个元素光表本身就占了 1024 个引用槽。我那次线上问题就是一堆这样的 Map 堆出来的每个都以为我就放几个元素能占多少内存。真要省内存Collections.singletonMap或者干脆换成ArrayList存键值对可能更合适。第四条序列化场景下不要指望容量被保留。table、threshold、size全是 transient只有loadFactor和元素内容会被写出去。readObject里会根据mappings重新算容量而且会把负载因子夹在 0.25 到 4.0 之间防止恶意的流数据把负载因子设成极端值导致性能雪崩。如果你的缓存依赖序列化前后容量一致这条路走不通。第五条putMapEntries里的evict参数别忽略。构造和 clone 时传的是 false正常 put 传的是 true它决定了afterNodeInsertion会不会被真正调用。LinkedHashMap 覆写了这个方法做 LRU 淘汰构造阶段如果误传 true可能在 head/tail 还没初始化的时候就去做淘汰判断。虽然(first head) ! null这个短路条件挡住了大部分问题但把语义弄清楚总没坏处。
返回列表