2026-10-22 HashMap 扩容机制实测

看 JDK 源码时注意到 HashMap.putVal 最后有这么一句:if (++size > threshold) resize();。一句判断决定整个表什么时候搬家,我想把它什么时候触发、要搬多少、代价多大测出来。

一、先看那句判断

if (++size > threshold)
    resize();

threshold 是「容量 × 负载因子」。默认容量 16、负载因子 0.75,所以阈值是 12。

注意顺序:先 ++size 再比较。也就是说第 13 个元素放进去之后才扩,不是放之前。这一点我一开始理解反了,测了才知道。

二、跑一遍看它什么时候动

不指定初始容量,一个一个 put,用反射把 table 的长度和 threshold 读出来:

put次数    table长度    阈值       备注
1        16         12       首次 put 才初始化
2        16         12
...
12       16         12
13       32         24       扩容!
...
24       32         24
25       64         48       扩容!

有两个细节值得一提。

第一,new HashMap<>() 的时候表根本没建,table 是 null,要等第一次 put 才分配 16。所以空 HashMap 占的那点内存是假的。

第二,扩容发生在 13 和 25,不是 12 和 24。因为它判断的是 ++size > threshold,而 threshold 是 12,装到 13 才越界。

三、搬家的时候元素去哪了

容量从 16 变 32,原来的槽位要不要重算?

key      容量16时        容量32时        变化
0        0            0            不变
1        1            1            不变
...
15       15           15           不变
16       0            16           +16
17       1            17           +16
18       2            18           +16
19       3            19           +16
33       1            1            不变

规律很清楚:要么留在原处,要么往后挪一个「原容量」。

原因在索引的算法上。索引是 hash & (cap - 1),容量 16 时掩码是 1111,容量 32 时是 11111。多出来的那一位(第 5 位)如果是 0,新索引和旧索引一样;如果是 1,新索引正好是「旧索引 + 16」。

这就是 JDK 8 对 rehash 的优化:扩容时不用重新算 hash,只判断这一位。

四、那预设容量到底省多少

开发手册里都写着「创建 HashMap 时指定初始容量」。我拿 200 万条数据试了三种写法:

构造方式                               耗时(ms)       扩容次数           最终容量
new HashMap<>()                    67           18             4194304
new HashMap<>(N)                   76           1              4194304
new HashMap<>(N/0.75+1)            19           0              4194304

中间那个结果有点意外:new HashMap<>(N) 比默认构造还慢。

原因是构造函数的参数是「期望存放的元素数」,不是「容量」。HashMap 会把 N 向上取到 2 的幂,也就是 2097152,但阈值是它的 0.75 倍,只有 1572864。装到 200 万还是要扩容一次——而这一次要把 200 万个节点全部搬一遍。

所以正确的写法是把负载因子先除掉:(int)(N / 0.75f) + 1。这样阈值才真的够。三种写法里它最快,19ms,是默认构造的三分之一左右。

五、搬了多少次,摊到每次是多少

new HashMap<>() 塞 100 万条,一共扩容 17 次,扩容时平均已经存了 9.2 万个元素。

搬运总量大约是 16 + 32 + ... + 524288,合计一百多万个节点,和元素总数是同一个数量级。所以从整体看,每次 put 的摊还成本还是 O(1);但某一次 put 会突然变成 O(n),把一百多万个节点搬一遍。

这就是为什么在「要求单次操作延迟稳定」的地方,要考虑它。

六、结论

扩容的触发点是 size 超过阈值,而且是元素放进去之后才触发,所以实际扩容点在 13、25、49 这些位置,不是 12、24、48。

扩容时元素的落点只有两种:原地不动,或者往后挪一个原容量。原因就是掩码多了一位。

预设容量不是写 new HashMap<>(N) 就完事,要写成 new HashMap<>((int)(N / 0.75f) + 1)。实测 200 万条差 3.5 倍——而且中间那种"看起来预设了"的写法反而最慢。

附:完整代码

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.HashMap;

public class HashMapResize {

    static final Field TABLE_FIELD;
    static final Field THRESHOLD_FIELD;

    static {
        try {
            TABLE_FIELD = HashMap.class.getDeclaredField("table");
            TABLE_FIELD.setAccessible(true);
            THRESHOLD_FIELD = HashMap.class.getDeclaredField("threshold");
            THRESHOLD_FIELD.setAccessible(true);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    static int capacity(HashMap<?, ?> m) throws Exception {
        Object t = TABLE_FIELD.get(m);
        return t == null ? 0 : Array.getLength(t);
    }

    static int threshold(HashMap<?, ?> m) throws Exception {
        return THRESHOLD_FIELD.getInt(m);
    }

    static int indexOf(Object key, int cap) {
        int h = key.hashCode();
        h ^= (h >>> 16);
        return h & (cap - 1);
    }

    public static void main(String[] args) throws Exception {
        HashMap<Integer, Integer> m = new HashMap<>();
        int lastCap = -1;
        for (int i = 1; i <= 26; i++) {
            m.put(i, i);
            int cap = capacity(m);
            if (cap != lastCap) {
                System.out.printf("%d -> cap=%d threshold=%d%n", i, cap, threshold(m));
                lastCap = cap;
            }
        }
    }
}

跑的时候要加 --add-opens java.base/java.util=ALL-UNNAMED,
否则 JDK 9 以后的模块封装不让反射读 table。

posted @ 2026-10-22 21:58  呱哒  阅读(8)  评论(0)    收藏  举报