[Java] 调用 HashMap 的 put(K, V) 方法时,Node 变为 TreeNode 的条件

83 阅读2分钟

调用 HashMapput(K, V) 方法时,Node 变为 TreeNode 的条件

背景

HashMap.java 里的 put(K, V) 方法 调用了 putVal(int, K, V, boolean, boolean) 方法,后者有这样的代码 ⬇️

image.png

我把 TREEIFY_THRESHOLD 这个字段的内容复制到下方了 ⬇️

    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

TREEIFY_THRESHOLD 的值为 88,从它的 javadoc 可以看出,当前 index 中的 entry 数量 8\ge 8 时,list 可能转化为 tree(其实就是把 Node 转化为 TreeNode)。

我把本文开头红色框里的代码复制到下方了 ⬇️

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);

这两行代码的意思是(在尚未添加新的 entry 时)如果当前这个 index 中的 entry 的数量 8\ge 8,则调用 treeifyBin(tab, hash) 方法。

从名称来看,treeifyBin(...) 方法似乎会把指定 indexNode 转化为 TreeNode。但有特殊情况 ⬇️

image.pngtable 字段的 length 小于 MIN_TREEIFY_CAPACITY (=64=64) 时,resize() 方法会被调用,在此过程中,Node 不会 转化为 TreeNode。我们可以写点代码验证一下 Node 变为 TreeNode 的条件。

虽然可以通过打断点来查看 table 字段 的具体内容,但当 HashMap 中的 entry 数量较多时,查看各个 entry 的内容耗时耗力。

可以利用反射将 table 字段 的详细内容展示出来,这样可以直观地查看 table 字段 的变化。考虑到每个 entry 保存到 table 字段 的哪个 index 是由 entrykeyhashCode 来决定的,我们可以定义一个类 CC,在 CC 的构造函数中直接将 hashCode 传进来,这样就可以控制每个 entry 保存到哪个 index 了。

要点

调用 HashMapput(K, V) 方法时,只有同时满足以下两个条件,Node 才会变为 TreeNode

  • (在尚未添加新的 entry 时)当前这个 index 中的 entry 的数量 8\ge 8
  • table.length64table.length\ge 64

正文

用于查看 HashMap 详情的代码

请将以下代码保存为 HashMapInspector.java ⬇️

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

public class HashMapInspector {
    private static final Class<?> hashMapClass = HashMap.class;

    private <K, V> void showBasicInfo(HashMap<K, V> map, Map.Entry<K, V>[] table) throws Exception {
        Field thresholdField = hashMapClass.getDeclaredField("threshold");
        thresholdField.setAccessible(true);

        System.out.println("Basic Info ⬇️");
        System.out.printf("* `size` is: `%s`%n", map.size());
        System.out.printf("* `threshold` is: `%s`%n", thresholdField.get(map));
        if (table == null) {
            System.out.printf("* `table` field is `null`%n");
        } else {
            System.out.printf("* `table.length` is: `%s`%n", table.length);
        }

        System.out.println();
    }

    public <K, V> void showHashMapDetails(HashMap<K, V> map) throws Exception {
        Field tableField = hashMapClass.getDeclaredField("table");
        tableField.setAccessible(true);

        @SuppressWarnings("unchecked")
        Map.Entry<K, V>[] table = (Map.Entry<K, V>[]) tableField.get(map);
        showBasicInfo(map, table);
        if (table == null) {
            return;
        }

        Class<?> nodeClass = Class.forName("java.util.HashMap$Node");
        Field nextField = nodeClass.getDeclaredField("next");
        nextField.setAccessible(true);

        System.out.println("Details for each index in `table` field ⬇️");
        System.out.println("| Index | Entry's class | Node count | Details |");
        System.out.println("| --- | --- | --- | --- |");
        int currentIndex = 0;
        while (currentIndex < table.length) {
            Map.Entry<K, V> entry = table[currentIndex];
            if (entry == null) {
                int rightBound = currentIndex;
                while (rightBound + 1 < table.length && table[rightBound + 1] == null) {
                    rightBound++;
                }
                if (rightBound > currentIndex) {
                    System.out.printf("| `%s` to `%s` | (entry is null) | `0` | `null` |%n", currentIndex, rightBound);
                } else {
                    System.out.printf("| `%s` | (entry is null) | `0` | `null` |%n", currentIndex);
                }
                currentIndex = rightBound + 1;
            } else {
                StringBuilder details = new StringBuilder();
                details.append(entry);
                int nodeCnt = 1;
                while (true) {
                    @SuppressWarnings("unchecked")
                    Map.Entry<K, V> next = (Map.Entry<K, V>) nextField.get(entry);
                    if (next == null) {
                        break;
                    }
                    entry = next;
                    details.append(" -> ");
                    details.append(next);
                    nodeCnt++;
                }
                String classDesc = String.format("`%s`", entry.getClass().getCanonicalName());
                String row = String.format("| `%s` | %s | `%s` | `%s` |", currentIndex, classDesc, nodeCnt, details);
                System.out.println(row);

                currentIndex++;
            }
        }
        System.out.println();
    }
}

class C {
    private final int v;
    private final int h;

    C(int val, int hash) {
        this.v = val;
        this.h = hash;
    }

    @Override
    public int hashCode() {
        return h;
    }

    @Override
    public String toString() {
        return "C{v=" + v + ", h=" + h + '}';
    }
}

HashMapInspectorC 的类图如下 ⬇️ (所有泛型和方法签名中的异常均省略)

classDiagram   
    class HashMapInspector {
      -Class hashMapClass$
      -showBasicInfo(HashMap, Map.Entry[]) void
      +showHashMapDetails(HashMap) void
    }

    class C {
        -int v
        -int h
        ~C(int, int)
        +hashCode() int
        +toString() String
    }

用如下的命令可以编译 HashMapInspector.java ⬇️

javac HashMapInspector.java

编译之后会生成以下两个 class 文件

  • HashMapInspector.class  
  • C.class

有了这两个 class 文件后,我们可以方便地查看 HashMaptable 字段的变化。

情形 1: 将 11entry 都保存在 HashMapindex=0 的位置

请将以下代码保存为 Case1.java ⬇️

import java.util.HashMap;

public class Case1 {
    public static void main(String[] args) throws Exception {
        HashMap<C, Integer> map = new HashMap<>();
        
        HashMapInspector inspector = new HashMapInspector();
        for (int i = 1; i <= 8; i++) {
            map.put(new C(i, 0), map.size());
        }
        inspector.showHashMapDetails(map);
        
        map.put(new C(9, 0), map.size());
        inspector.showHashMapDetails(map);
        
        map.put(new C(10, 0), map.size());
        inspector.showHashMapDetails(map);
        
        map.put(new C(11, 0), map.size());
        inspector.showHashMapDetails(map);
    }
}

Case1.javamain(...) 方法里先用默认构造函数创建一个 HashMap mapmap,之后有以下44 步 ⬇️

  • mapmap 中添加 88key-value pair 并通过 HashMapInspector 展示 table 字段的详细内容
  • mapmap 中添加第 99key-value pair 并通过 HashMapInspector 展示 table 字段的详细内容
  • mapmap 中添加第 1010key-value pair 并通过 HashMapInspector 展示 table 字段的详细内容
  • mapmap 中添加第 1111key-value pair 并通过 HashMapInspector 展示 table 字段的详细内容

用如下的命令可以编译 Case1.java 并运行其中的 main(...) 方法 ⬇️

javac -cp . Case1.java
java --add-opens java.base/java.util=ALL-UNNAMED Case1
1 次展示 table 字段的详细内容: 当 mapmap 里有 8 个 entry
Basic Info ⬇️
  • size is: 8
  • threshold is: 12
  • table.length is: 16
Details for each index in table field ⬇️
IndexEntry's classNode countDetails
0java.util.HashMap.Node8C{v=1, h=0}=0 -> C{v=2, h=0}=1 -> C{v=3, h=0}=2 -> C{v=4, h=0}=3 -> C{v=5, h=0}=4 -> C{v=6, h=0}=5 -> C{v=7, h=0}=6 -> C{v=8, h=0}=7
1 to 15(entry is null)0null

mapmap 里有 88entry 时,由于这些 entrykeyhashCode 都是 00,所以这 88entry 都会保存在 index=0index=0 的位置。

2 次展示 table 字段的详细内容: 当 mapmap 里有 9 个 entry
Basic Info ⬇️
  • size is: 9
  • threshold is: 24
  • table.length is: 32
Details for each index in table field ⬇️
IndexEntry's classNode countDetails
0java.util.HashMap.Node9C{v=1, h=0}=0 -> C{v=2, h=0}=1 -> C{v=3, h=0}=2 -> C{v=4, h=0}=3 -> C{v=5, h=0}=4 -> C{v=6, h=0}=5 -> C{v=7, h=0}=6 -> C{v=8, h=0}=7 -> C{v=9, h=0}=8
1 to 31(entry is null)0null

注意,Case1.java 里的 main(...) 方法添加第 9key-value pair 时,index=0index=0Node 并没有变为 TreeNode,因为 table.length64table.length\ge 64 的条件还不成立。

3 次展示 table 字段的详细内容: 当 mapmap 里有 10 个 entry
Basic Info ⬇️
  • size is: 10
  • threshold is: 48
  • table.length is: 64
Details for each index in table field ⬇️
IndexEntry's classNode countDetails
0java.util.HashMap.Node10C{v=1, h=0}=0 -> C{v=2, h=0}=1 -> C{v=3, h=0}=2 -> C{v=4, h=0}=3 -> C{v=5, h=0}=4 -> C{v=6, h=0}=5 -> C{v=7, h=0}=6 -> C{v=8, h=0}=7 -> C{v=9, h=0}=8 -> C{v=10, h=0}=9
1 to 63(entry is null)0null

注意,Case1.java 里的 main(...) 方法添加第 10key-value pair 时,index=0index=0Node 并没有变为 TreeNode,因为 table.length64table.length\ge 64 的条件还不成立。

4 次展示 table 字段的详细内容: 当 mapmap 里有 11 个 entry
Basic Info ⬇️
  • size is: 11
  • threshold is: 48
  • table.length is: 64
Details for each index in table field ⬇️
IndexEntry's classNode countDetails
0java.util.HashMap.TreeNode11C{v=3, h=0}=2 -> C{v=1, h=0}=0 -> C{v=2, h=0}=1 -> C{v=4, h=0}=3 -> C{v=5, h=0}=4 -> C{v=6, h=0}=5 -> C{v=7, h=0}=6 -> C{v=8, h=0}=7 -> C{v=9, h=0}=8 -> C{v=10, h=0}=9 -> C{v=11, h=0}=10
1 to 63(entry is null)0null

注意,Case1.java 里的 main(...) 方法添加第 11key-value pair 时,index=0index=0Node 并变为 TreeNode 了,因为 table.length64table.length\ge 64 的条件成立。

小结

Case1.javamain(...) 方法,在创建 HashMap mapmap 后,会调用 put(K, V) 方法将新的 entry 保存至 table 字段中 index=0index=0 的位置。下表列举了当 entry 的数量是 8,9,10,118,9,10,11 时,table.lengthtable.length 的值和 index=0index=0 处的对象的类型 ⬇️

mapmapentry 的数量table.lengthtable.lengthindex=0index=0 处的对象的精确类型
881616java.util.HashMap.Node
993232java.util.HashMap.Node
10106464java.util.HashMap.Node
11116464java.util.HashMap.TreeNode

情形 2: 用 new HashMap(64) 创建 map 后,将 9entry 都保存在 index=0 的位置

请将以下代码保存为 Case2.java ⬇️

import java.util.HashMap;

public class Case2 {
    public static void main(String[] args) throws Exception {
        HashMap<C, Integer> map = new HashMap<>(64);
        
        HashMapInspector inspector = new HashMapInspector();
        for (int i = 1; i <= 8; i++) {
            map.put(new C(i, 0), map.size());
        }
        inspector.showHashMapDetails(map);
        
        map.put(new C(9, 0), map.size());
        inspector.showHashMapDetails(map);
    }
}

Case2.javamain(...) 方法里先用 new HashMap(64) 的方式创建一个 HashMap mapmap,之后有以下 22 步 ⬇️

  1. mapmap 中添加 88key-value pair 并通过 HashMapInspector 展示 table 字段的详细内容
  2. mapmap 中添加第 99key-value pair 并通过 HashMapInspector 展示 table 字段的详细内容

用如下的命令可以编译 Case2.java 并运行其中的 main(...) 方法 ⬇️

javac -cp . Case2.java
java --add-opens java.base/java.util=ALL-UNNAMED Case2
1 次展示 table 字段的详细内容: 当 mapmap 里有 8 个 entry
Basic Info ⬇️
  • size is: 8
  • threshold is: 48
  • table.length is: 64
Details for each index in table field ⬇️
IndexEntry's classNode countDetails
0java.util.HashMap.Node8C{v=1, h=0}=0 -> C{v=2, h=0}=1 -> C{v=3, h=0}=2 -> C{v=4, h=0}=3 -> C{v=5, h=0}=4 -> C{v=6, h=0}=5 -> C{v=7, h=0}=6 -> C{v=8, h=0}=7
1 to 63(entry is null)0null
2 次展示 table 字段的详细内容: 当 mapmap 里有 9 个 entry
Basic Info ⬇️
  • size is: 9
  • threshold is: 48
  • table.length is: 64
Details for each index in table field ⬇️
IndexEntry's classNode countDetails
0java.util.HashMap.TreeNode9C{v=3, h=0}=2 -> C{v=1, h=0}=0 -> C{v=2, h=0}=1 -> C{v=4, h=0}=3 -> C{v=5, h=0}=4 -> C{v=6, h=0}=5 -> C{v=7, h=0}=6 -> C{v=8, h=0}=7 -> C{v=9, h=0}=8
1 to 63(entry is null)0null

可见当 index=0index=0 的位置的 entry 的数量超过 88 个时,Node 的确变成了 TreeNode

小结

Case2.javamain(...) 方法,用 new HashMap(64) 的方式创建一个 HashMap mapmap 后,会调用 put(K, V) 方法将新的 entry 保存至 table 字段中 index=0index=0 的位置。下表列举了当 entry 的数量是 8,98,9 时,table.lengthtable.length 的值和 index=0index=0 处的对象的类型 ⬇️

mapmapentry 的数量table.lengthtable.lengthindex=0index=0 处的对象的精确类型
886464java.util.HashMap.Node
996464java.util.HashMap.TreeNode

参考资料