HashMap rehash/resize容量

Posted

技术标签:

【中文标题】HashMap rehash/resize容量【英文标题】:HashMap rehash/resize capacity 【发布时间】:2019-03-12 13:35:03 【问题描述】:

HashMap 的文档中有这样一个短语:

如果初始容量大于最大条目数除以负载因子,则不会永远发生重新哈希操作。

注意文档是如何说 rehash,而不是 resize - 即使 rehash 只会在调整大小时发生;那是当桶的内部大小变成两倍大的时候。

当然HashMap 提供了这样一个构造函数,我们可以在其中定义这个初始容量

构造一个具有指定初始容量和默认加载因子 (0.75) 的空 HashMap。

好的,看起来很简单:

// these are NOT chosen randomly...    
List<String> list = List.of("DFHXR", "YSXFJ", "TUDDY", 
          "AXVUH", "RUTWZ", "DEDUC", "WFCVW", "ZETCU", "GCVUR");

int maxNumberOfEntries = list.size(); // 9
double loadFactor = 0.75;

int capacity = (int) (maxNumberOfEntries / loadFactor + 1); // 13

所以容量是13(内部是16 - 二次幂),这样我们保证文档部分不会重复。好的,让我们测试一下,但首先介绍一个方法,该方法将进入 HashMap 并查看值:

private static <K, V> void debugResize(Map<K, V> map, K key, V value) throws Throwable 

    Field table = map.getClass().getDeclaredField("table");
    table.setAccessible(true);
    Object[] nodes = ((Object[]) table.get(map));

    // first put
    if (nodes == null) 
        // not incrementing currentResizeCalls because
        // of lazy init; or the first call to resize is NOT actually a "resize"
        map.put(key, value);
        return;
    

    int previous = nodes.length;
    map.put(key, value);
    int current = ((Object[]) table.get(map)).length;

    if (previous != current) 
        ++HashMapResize.currentResizeCalls;
        System.out.println(nodes.length + "   " + current);
    

现在让我们测试一下:

static int currentResizeCalls = 0;

public static void main(String[] args) throws Throwable 

    List<String> list = List.of("DFHXR", "YSXFJ", "TUDDY",
            "AXVUH", "RUTWZ", "DEDUC", "WFCVW", "ZETCU", "GCVUR");
    int maxNumberOfEntries = list.size(); // 9
    double loadFactor = 0.75;
    int capacity = (int) (maxNumberOfEntries / loadFactor + 1);

    Map<String, String> map = new HashMap<>(capacity);

    list.forEach(x -> 
        try 
            HashMapResize.debugResize(map, x, x);
         catch (Throwable throwable) 
            throwable.printStackTrace();
        
    );

    System.out.println(HashMapResize.currentResizeCalls);


好吧,resize 被调用,因此条目被重新散列,而不是文档所说的。


如上所述,密钥不是随机选择的。这些设置是为了在桶转换为树时触发static final int TREEIFY_THRESHOLD = 8; 属性。不是真的,因为我们还需要点击MIN_TREEIFY_CAPACITY = 64 才能出现树;直到发生resize,或者桶的大小增加了一倍;因此会发生条目的重新散列。

我只能暗示为什么HashMap 文档在那句话中是错误的,因为 java-8 之前,桶没有转换为树;因此该属性将成立,从 java-8 开始,这不再是真的。由于我不确定这一点,因此我不会将其添加为答案。

【问题讨论】:

有趣的发现 - [官方] 文档并不完美;这很可能是文档和实现之间的分歧.. @user2864740 你也可以投票here if you want。这是一个旧的,但仍然没有答案 呃...问题是什么? @JornVernee ups,问题是,这真的是文档缺陷吗?斯图尔特给出的答案是“是” 无关:您给出了正确的提示。我了解到我的“小”助手项目......没有重新编译。我更改了另一个项目,设置为使用 java 7 语言级别,是的,它编译为 java7。所以不幸的是,我的问题没有意义留下来。所以我不得不删除它。我真的很讨厌被赞成的问题,因为我得到了太多的 0 或更少的问题,来自报复性的反对者……但你的问题在这里:很好! 【参考方案1】:

文档中的那一行,

如果初始容量大于最大条目数除以负载因子,则不会发生重新哈希操作。

确实可以追溯到在 JDK 8 (JEP 180) 中添加树箱实现之前。您可以在JDK 1.6 HashMap documentation 中看到此文本。事实上,这篇文章可以追溯到 JDK 1.2,当时引入了 Collections Framework(包括 HashMap)。您可以在网上找到 JDK 1.2 文档的非官方版本,如果您想亲自查看,也可以从 archives 下载版本。

我相信这个文档在添加树箱实现之前是正确的。但是,正如您所观察到的,现在有些情况下它是不正确的。该策略不仅是如果条目数除以负载因子超过容量(实际上是表长度),则可以调整大小。如您所述,如果单个存储桶中的条目数超过 TREEIFY_THRESHOLD(当前为 8)但表长度小于 MIN_TREEIFY_CAPACITY(当前为 64),则可能发生调整大小。

你可以在HashMap的treeifyBin()方法中看到这个决定。

    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) 

当单个存储桶中的条目数超过 TREEIFY_THRESHOLD 时,就会到达代码中的这一点。如果表大小等于或大于 MIN_TREEIFY_CAPACITY,则此 bin 被树化;否则,表格只是调整大小。

请注意,在较小的表大小下,这可能会留下比 TREEIFY_THRESHOLD 更多的条目。这并不难证明。首先,一些反射HashMap-dumping代码:

// run with --add-opens java.base/java.util=ALL-UNNAMED

static Class<?> classNode;
static Class<?> classTreeNode;
static Field fieldNodeNext;
static Field fieldHashMapTable;

static void init() throws ReflectiveOperationException 
    classNode = Class.forName("java.util.HashMap$Node");
    classTreeNode = Class.forName("java.util.HashMap$TreeNode");
    fieldNodeNext = classNode.getDeclaredField("next");
    fieldNodeNext.setAccessible(true);
    fieldHashMapTable = HashMap.class.getDeclaredField("table");
    fieldHashMapTable.setAccessible(true);


static void dumpMap(HashMap<?, ?> map) throws ReflectiveOperationException 
    Object[] table = (Object[])fieldHashMapTable.get(map);
    System.out.printf("map size = %d, table length = %d%n", map.size(), table.length);
    for (int i = 0; i < table.length; i++) 
        Object node = table[i];
        if (node == null)
            continue;
        System.out.printf("table[%d] = %s", i,
            classTreeNode.isInstance(node) ? "TreeNode" : "BasicNode");

        for (; node != null; node = fieldNodeNext.get(node))
            System.out.print(" " + node);
        System.out.println();
    

现在,让我们添加一堆字符串,它们都落入同一个桶中。选择这些字符串时,它们的哈希值(由 HashMap 计算)均为 0 mod 64。

public static void main(String[] args) throws ReflectiveOperationException 
    init();
    List<String> list = List.of(
        "LBCDD", "IKBNU", "WZQAG", "MKEAZ", "BBCHF", "KRQHE", "ZZMWH", "FHLVH",
        "ZFLXM", "TXXPE", "NSJDQ", "BXDMJ", "OFBCR", "WVSIG", "HQDXY");

    HashMap<String, String> map = new HashMap<>(1, 10.0f);

    for (String s : list) 
        System.out.println("===> put " + s);
        map.put(s, s);
        dumpMap(map);
    

从 1 的初始表大小和荒谬的负载因子开始,这会将 8 个条目放入单独的存储桶中。然后,每次添加另一个条目时,表都会调整大小(加倍),但所有条目最终都在同一个存储桶中。这最终会产生一个大小为 64 的表,其中一个桶具有长度为 14 的线性节点链(“基本节点”),然后添加下一个条目最终将其转换为树。

程序的输出如下:

===> put LBCDD
map size = 1, table length = 1
table[0] = BasicNode LBCDD=LBCDD
===> put IKBNU
map size = 2, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU
===> put WZQAG
map size = 3, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG
===> put MKEAZ
map size = 4, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ
===> put BBCHF
map size = 5, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF
===> put KRQHE
map size = 6, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE
===> put ZZMWH
map size = 7, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH
===> put FHLVH
map size = 8, table length = 1
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH
===> put ZFLXM
map size = 9, table length = 2
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM
===> put TXXPE
map size = 10, table length = 4
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM TXXPE=TXXPE
===> put NSJDQ
map size = 11, table length = 8
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM TXXPE=TXXPE NSJDQ=NSJDQ
===> put BXDMJ
map size = 12, table length = 16
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM TXXPE=TXXPE NSJDQ=NSJDQ BXDMJ=BXDMJ
===> put OFBCR
map size = 13, table length = 32
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM TXXPE=TXXPE NSJDQ=NSJDQ BXDMJ=BXDMJ OFBCR=OFBCR
===> put WVSIG
map size = 14, table length = 64
table[0] = BasicNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM TXXPE=TXXPE NSJDQ=NSJDQ BXDMJ=BXDMJ OFBCR=OFBCR WVSIG=WVSIG
===> put HQDXY
map size = 15, table length = 64
table[0] = TreeNode LBCDD=LBCDD IKBNU=IKBNU WZQAG=WZQAG MKEAZ=MKEAZ BBCHF=BBCHF KRQHE=KRQHE ZZMWH=ZZMWH FHLVH=FHLVH ZFLXM=ZFLXM TXXPE=TXXPE NSJDQ=NSJDQ BXDMJ=BXDMJ OFBCR=OFBCR WVSIG=WVSIG HQDXY=HQDXY

【讨论】:

我喜欢代码,我喜欢你的回答,但是现在不应该从 HashMap 文档中删除这句话吗? @Eugene 是的,文档可能应该进行调整。这似乎是一件小事,即只是一个小的措辞更改,不是树箱与调整大小策略的完整文档等。另一个错误 JDK-8211831 刚刚出现在请求 HashMap 文档更新中,所以我会修复这个同时。在那里看我的 cmets。

以上是关于HashMap rehash/resize容量的主要内容,如果未能解决你的问题,请参考以下文章

HashMap与TreeMap

VMware虚拟机预留内存分别与HA接入控制磁盘使用容量的关系

HashMap的初始容量和加载因子

为什么HashMap建议初始化容量,且容量为2的次幂?

hashMap记录

关于HashMap容量的初始化,还有这么多学问。