200字
HashMap笔记(一)核心参数与初始化
2025-11-04
2025-12-18

此文会记录我对 HashMap的一些理解,源码均基于JDK8

tableSizeFor(int cap)

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;
}

tableSizeFor这个方法入参是一个int类型的capacity,用于传递hashmap的期望容量,并把“期望容量 cap 向上调整为最近的 2 的幂”作为返回值(举个例子,期望容量是19,则向上最近的2的幂为32)

1.代码一开始有 n = cap - 1,这一步的作用是?

  • 如果 cap 本身已经是 2 的幂,比如 16(二进制 10000),减一得到 01111。后续把这些 1 再“扩散”一遍,最终 n+1 又回到 10000,不会把容量错误地提升到下一个 2 的幂。

2.从二进制角度,如何理解这回事呢

假设 cap = 19,先 n=cap-1=18,二进制 0001 0010

那么如果需要获取容量 cap 向上调整为最近的 2 的幂,则只需要将00010010 -〉00011111,即将最高位1后面的每一位都变为1即可

3.为什么这些移位宽度为1、2、4、8、16?

  • 因为 int 是 32 位,1+2+4+8+16 ≥ 31,足以让最高位的 1 通过多轮扩散覆盖到其右侧的所有位。

  • 每一步都把已有的 1 成倍“传染”到更远的低位,最终形成形如 0b0…0111…11 的数:从最高的那一个 1 开始,右侧全是 1。

4.直观例子

同样假设 cap = 19,先 n=cap-1=18,二进制 0001 0010

  • n |= n>>>1: 0001 0010 | 0000 1001 = 0001 1011

  • n |= n>>>2: 0001 1011 | 0000 0110 = 0001 1111

  • n |= n>>>4: 0001 1111 | 0000 0001 = 0001 1111

  • 后续 >>>8、>>>16 不再改变

  • 结果 n = 0001 1111

  • n + 1 = 0010 0000 = 32,即大于等于 19 的最小 2 的幂

HashMap(int initialCapacity,float loadFactor)

在讨论完tableSizeFor之后,我们接着来讨论HashMap的这个有参构造方法,之所以只讨论这个构造方法,是因为这个方法中调用到了tableSizeFor方法,并且可以更好的了解到HashMap的设计思路,先来看下代码:

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);
    }

1. initialCapacity & loadFactor

  • initialCapacity

期望元素容量

  • loadFactor

加载因子

最大容量=期望元素容量/加载因子

2.threshold

在正常情况下,threshold代表的是HashMap的扩容阈值,但是在这里,使用了tableSizeFor方法来进行了赋值,代表的却是最大容量,乍一看好像是把 threshold 当作容量(capacity)用了,难道是逻辑出现了混乱?

其实并不是,需要注意的是,在 JDK8 的 HashMap 里,底层数组 table 并没有在构造函数里创建。因此在构造函数里,threshold 并不是真正的“扩容阈值”,而是暂存的初始容量大小,等到第一次 put 时再真正初始化数组。JDK8 为了节省资源,采用了懒加载策略:只有当第一次执行 put() 时才创建底层数组。

初见resize()

没错,虽然上面你知道了第一次 put 时才会真正初始化数组,但是接下来我要先讲讲resize方法,因为在第一次调用put时,table为空,我们会默认调用的其实就是resize,那就不如直接跳转吧,由于原代码有些多,我会略过一些和第一次put流程无关的内容:

final Node<K,V>[] resize() {
        Node<K,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
            ……
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            ……
        }
        return newTab;
    }

让我简单概述一下这个代码讲了什么:

  • 1.定义了旧table的最大容量、扩容阈值,新table的最大容量、扩容阈值

  • 2.将新table的最大容量设置为oldThr,即通过第一步tableSizeFor得出的值

  • 3.通过“新table的最大容量 * loadFactor”计算新table的threshold

  • 4.为新table分配内存并返回

putVal

putVal应该算得上整个HashMap逻辑集成度最高的地方了,代码相对较多,而且都是核心逻辑,这里就着代码贴图讲几个关键点:

  • resize的调用位置

  • 桶数组下标计算

  • putTreeVal(往树里插节点)

  • treeifyBin(链表结构转树结构)

  • afterNodeAccess和afterNodeInsertion(LinkedHashMap)

1.resize的调用位置

初见resize中讲到的内容,就是在这个位置的调用,我们知道HashMap构造函数中并不会对table进行任何操作,而是在putVal时使用resize来初始化table,这里也不难理解,因此就不过多展开了。

2.桶数组下标计算——模运算VS与运算

HashMap 的核心是“根据 key 计算出桶数组(table)下标”,在根据key经过hash算法得到hash以后,通常情况下,要判断这个key要落在哪个桶里,就是做一个取模操作:

int index = hash % n;

但是在JDK8中,却是这么做的:

int index = (n - 1) & hash;

我们来进行一个简单的推理,假设:

n = 8 (二进制 1000)
hash = 10110100(十进制180)

我们知道一个数除以2的k幂次,相当于将这个数的二进制数向右移动k位,右移后的值为:00010110

加下来是套公式环节:

n = 2^k
index = hash % n
q = hash / n = hash >>> k
hash = n * q + index

想要从二进制的角度去获取到index,只需要做一件事情:

index = hash - ((hash >>> k) <<< k )
#代入示例的结果:
index = 10110100 - 10110000 = 00000100 = 4

看着很复杂是吧,但是一句话来说:其实取模的结果就是hash的低k位。

那么继续看int index = (n - 1) & hash这个操作,由于我们知道n必须是2次幂,因此n-1对应的二进制全是连续的 k 个 1。

还是拿 n=8 来举例,n-1的二进制表达式为 00000111,那么(n - 1) & hash 的结果,其实也是hash的低k位,因此hash % n(n - 1) & hash在n是2次幂的时候其实是等价的,但是为什么选择与运算,而不是模运算呢?下面是几个原因:

  • 更快的效率

CPU 执行一次除法的耗时 ≈ 几十个时钟周期;执行一次位与只需 1 个时钟周期

  • 简化扩容计算

当桶扩容时, 桶容量从n变为2n,桶数组下标的计算从(n-1) & hash变为(2n-1)&hash,从二进制层面来说,当哈希值重新分桶时, 不需要重新取模,只需判断哈希的新增加的那一位是 0 还是 1

还是举上面的例子:

(n-1) & hash = 00000111 & 10110100

(2n-1)&hash = 00001111 & 10110100

也就是说:元素要么“留在原桶”,要么“移动到原桶 + n”的位置。(运气不错,这个情况下留在了原桶)

3.putTreeVal

我们知道HashMap会对桶中元素进行链表和红黑树的转换,假如在插入一个元素时,桶中的元素保存的数据结构已经是树形了(p instanceof TreeNode),那么就会调用putTreeVal,主要的功能是往红黑树插入节点,并没有太多需要关注的,不过由于putTreeVal的源码比较晦涩,我在其中加上了一些注释。

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                               int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    // 若本节点有父,则取真正root;否则this就是root
    TreeNode<K,V> root = (parent != null) ? root() : this;

    // 从根开始向下找插入位置或已存在的key
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;

        // 1) 先比 hash,决定左右
        if ((ph = p.hash) > h)
            dir = -1;                  // 往左
        else if (ph < h)
            dir = 1;                   // 往右
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;                  // 同一key,返回旧节点
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) {
            // hash相等且key不可比较(或比较结果为0)
            if (!searched) {
                // 只做一次:尝试在左右子树里再找同key
                TreeNode<K,V> q, ch;
                searched = true;
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    return q; // 找到了就直接返回
            }
            // 仍找不到,用平局打破策略确定左右
            dir = tieBreakOrder(k, pk);
        }

        TreeNode<K,V> xp = p;
        // 2) 若对应子树为空,落位插入
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            Node<K,V> xpn = xp.next; // 桶内链表的下一个
            // 同时创建树结点,并预置链表next指向xpn
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);

            // 接到树上
            if (dir <= 0) xp.left = x;
            else          xp.right = x;

            // 接到桶内双向链表上
            xp.next = x;
            x.parent = x.prev = xp;
            if (xpn != null)
                ((TreeNode<K,V>)xpn).prev = x;

            // 红黑树平衡 & 把根移到桶表头
            moveRootToFront(tab, balanceInsertion(root, x));
            return null;               // 新插入,返回null
        }
        // 否则继续向下
    }
}

4.treeifyBin

treeifyBin的工作其实就是将桶中的元素链表转换为红黑树,这里重点需要看的是套在treeifyBin外面的这个for循环,首先理解一下两个变量

binCount:表示当前正在遍历的桶中链表的节点位置(从头算起)

p:一个指向当前桶中元素链表的指针(默认指向第0个元素)。

然后理解for循环中干了什么:

  • 如果p是最后一个元素了,那么将需要插入的节点插入到p.next

    • 如果此时链表长度大于等于TREEIFY_THRESHOLD,则需要将链表转红黑树

    • 插入完毕,break

  • p不是最后一个元素,but发现p.next的key正好和我们要插入的元素key相同,break

  • 移动p指针指向下一个链表中的元素

5.afterNodeAccess和afterNodeInsertion

afterNodeAccess(e) 和 afterNodeInsertion(evict) 这两个方法在普通 HashMap 中几乎没存在感,但它们的设计意义非常重要 —— 是为子类扩展(如 LinkedHashMap)预留的“钩子(hook)”。

👉 在 HashMap 中,这两个方法是 空实现(do nothing)。

👉 但它们被设计成模板方法(template methods),供子类(比如 LinkedHashMap)重写(override)

例如LinkedHashMap, 它是 HashMap 的子类,维护了一个 双向链表 来记录插入/访问顺序。

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        // 把被访问的节点移到链表尾部,保持“最近访问的在后面”
        LinkedHashMap.Entry<K,V> p = e.prev, n = e.next;
        if (p != null) p.next = n;
        if (n != null) n.prev = p;
        if (tail == e) tail = p;
        if (head == e) head = n;
        e.next = null;
        e.prev = tail;
        if (tail != null)
            tail.next = e;
        else
            head = e;
        tail = e;
    }
}

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first))
        removeNode(first.hash, first.key, null, false, true);
}

需要注意的是,这两个方法在 LinkedHashMap 里被重写成维护访问顺序LRU 淘汰策略的关键机制。在本篇文章中不会展开,但这是一个很有趣的东西,所以值得提及。

写在最后

本文是近几天我在回顾HashMap内容时做的一些笔记,由于篇幅和时间限制,仅记录了我认为值得学习借鉴和比较有意思的内容,如果后面有新的想法和理解,将会开一篇新的文章继续记录。

Comment