HashMap-博客园版

一文吃透 HashMap:从结构图到 JDK 8 源码

这是博客园专用版:三张结构图已直接内嵌在文中(不用另外上传图片),第八节的交互演示可直接在页面上点击测试。

一句话概括:HashMap = 数组(负责一步定位)+ 链表(负责解决冲突)+ 红黑树(负责防止退化)
本文源码全部取自 JDK 8(java.util.HashMap),为便于阅读做了少量删减。
文末有一个可以在线点击的交互演示,输入 key 就能看到它落在哪个桶、什么时候碰撞、什么时候扩容。


一、先看整体长什么样

HashMap 的整体结构 table 数组的每个桶指向一条链表,空桶为 null HashMap 数组 table[],长度恒为 2 的幂 0 null 1 A 2 B C D 3 null 4 E F 5 null 6 G H I

<text style="font-family:-apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;font-size:12px" x="212" y="418" fill="#CECBF6" dominant-baseline="central">链表长度 ≥ 8 且容量 ≥ 64 → 转成红黑树

只需要记住三个零件:

零件 是什么 作用
table[] 主干数组,每个位置叫一个桶(bucket / bin) 用下标直接访问,O(1) 定位
Node 存 4 个字段:hash / key / value / next 真正装数据的容器
桶里的内容 null、链表头节点、或红黑树根节点 空 / 少量冲突 / 大量冲突 三种形态

Node 的源码(JDK 8):

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;      // 预先算好的 hash,避免每次比较都重算
    final K key;
    V value;
    Node<K,V> next;      // 拉链的下一节

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final boolean equals(Object o) {
        if (o == this) return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

注意 hashfinal 的 —— 节点一旦创建,它的哈希值就不许再变。这也是后面"key 要不可变"要求的根源。


二、先记住这几个常量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;      // 16,默认初始容量
static final int MAXIMUM_CAPACITY         = 1 << 30;     // 2^30,容量上限
static final float DEFAULT_LOAD_FACTOR    = 0.75f;       // 默认负载因子
static final int TREEIFY_THRESHOLD        = 8;           // 链表 → 树  的阈值
static final int UNTREEIFY_THRESHOLD      = 6;           // 树 → 链表 的阈值
static final int MIN_TREEIFY_CAPACITY     = 64;          // 允许树化的最小数组容量
常量 一句话解释
DEFAULT_INITIAL_CAPACITY 16 默认数组长度
DEFAULT_LOAD_FACTOR 0.75 装到 75% 就扩容,空间与时间的折中
TREEIFY_THRESHOLD 8 链表挂到第 9 个节点时申请树化
UNTREEIFY_THRESHOLD 6 树中节点掉到 6 个以下时退回链表
MIN_TREEIFY_CAPACITY 64 数组没到 64,宁可扩容也不树化

为什么树化阈值是 8、退化的阈值是 6 而不是 7?中间隔的 7 就是缓冲带 —— 防止某个桶在阈值附近反复增删时,链表和树来回转换、白白消耗性能。
另外源码注释里提到:在负载因子 0.75 的假设下,hashCode 分布良好时,单个桶达到 8 个元素的概率约为 6×10⁻⁸,属于极端情况。


三、hash 值到底是怎么算出来的

3.1 扰动函数

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

只有两行,但藏着一个关键设计:h ^ (h >>> 16)

定位数组下标时用的是 (n - 1) & hash,而 n 通常只有 16 / 32 / 64,也就是说真正参与运算的只有 hash 的最低几位,高位完全被忽略。如果只拿原始 hashCode() 去定位,高位再怎么变化也影响不了结果,碰撞概率会明显上升。

所以把高 16 位右移下来,跟低 16 位做一次异或 —— 高位的信息被"折"进了低位,这就是所谓的扰动函数。它不保证不碰撞,但让分布更均匀,代价只是一次异或。

顺带一提,key == null 时 hash 固定为 0,这就是为什么 HashMap 允许一个 null key(它永远落在 0 号桶)。

3.2 为什么用 & (n-1) 而不是 % n

n 是 2 的幂时,下面两个式子完全等价

hash % n      ==      hash & (n - 1)

举例:n = 16n - 1 = 15,二进制是 1111hash & 15 就是取 hash 的最低 4 位,正好是 hash % 16 的结果。

位运算比取模快一个数量级,这就是容量必须是 2 的幂的根本原因。

3.3 那我传了 10 怎么办?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;
}

这一串或运算的作用:把最高位 1 右边的所有位全填成 1,最后 +1 就得到大于等于 cap 的最小 2 的幂

比如 cap = 10n = 9 (0b1001) → 一路填位得到 0b1111 = 15 → 返回 16。
所以 new HashMap<>(10) 的真实初始容量是 16,不是 10。


四、put 一次,内部到底发生了什么

put 方法的执行流程 从计算 hash 定位下标,到处理碰撞、覆盖,最后判断是否需要扩容 put(key, value) 扰动 hash → 定位下标 i table[i] 是否为空? i = (n-1) & hash 直接放进去 table[i] = 新 Node 逐个比较 key equals 相同 → 覆盖 ++size > 阈值? 0.75 × 容量 否 → 完成 resize() 扩容 2 倍 拆分链表重新定位

put 本身只是一层皮,真正干活的是 putVal

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // ① 懒加载:第一次 put 时才分配数组
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // ② 桶是空的,直接放进去
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    // ③ 桶不为空,说明发生了碰撞
    else {
        Node<K,V> e; K k;

        // ③-a 头节点就是要找的 key,记下来等会儿覆盖
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;

        // ③-b 这个桶已经是一棵树了,走红黑树的插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // ③-c 是链表,尾插法逐个往后挂
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度达到 8,申请树化
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                // 中途发现相同的 key,停止遍历
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

        // ④ 找到了已存在的 key:覆盖 value,返回旧值
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }

    ++modCount;
    // ⑤ 元素个数超过阈值 → 扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

几个容易被忽略的点:

  1. 数组是懒加载的new HashMap<>()table 还是 null,第一次 put 才分配 16 个格子。
  2. 判断 key 相同的完整条件p.hash == hash && (p.key == key || key.equals(k)) —— 先比哈希(快),哈希相同再用 ==equals 确认。这就是为什么重写 equals() 必须同时重写 hashCode(),否则两个逻辑相等的对象会有不同的 hash,永远落不到同一个桶,也永远"找不到"。
  3. JDK 8 用尾插法:JDK 7 是头插法,多线程扩容时会把链表反转成环导致死循环;JDK 8 改成尾插,修掉了这个 bug(但 HashMap 依然线程不安全)。
  4. binCount >= TREEIFY_THRESHOLD - 1binCount 从 0 开始数,所以减 1,实际是链表第 9 个节点时触发。

五、哈希碰撞与树化

链表树化为红黑树 链表长度达到 8 且数组容量不小于 64 时,链表转换为红黑树 链表(长度 ≥ 8) 红黑树 TreeNode Node A Node B Node C Node D 树化 treeify 链表 ≥ 8 且容量 ≥ 64 D B F A C E G

<text style="font-family:-apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;font-size:12px" x="56" y="280" fill="#B4B2A9" dominant-baseline="central">查找从 O(n) 降到 O(log n);节点数 ≤ 6 时退化回链表(untreeify)

注意 treeifyBin 的第一个判断 —— 树化的两个条件是"与"关系,不是"或"

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;

    // 数组还太小?先扩容,不树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();

    // 容量够了,才真的把链表转成红黑树
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null) hd = p;
            else { p.prev = tl; tl.next = p; }
            tl = p;
        } while ((e = e.next) != null);

        if ((tab[index] = hd) != null)
            hd.treeify(tab);   // 真正的建树 + 红黑着色
    }
}

为什么要加"容量 ≥ 64"这一条?因为链表变长的更常见原因是数组太小,扩容一次就能把长链表拆成两条短的,成本远低于维护一棵红黑树(TreeNode 的内存占用约是 Node 的两倍)。只有数组已经足够大、碰撞依然严重(说明 hashCode 本身质量差或遭遇了哈希攻击),才值得动用红黑树把 O(n) 降到 O(log n)。


六、扩容 resize:最精妙的一段

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) {
        if (oldCap >= MAXIMUM_CAPACITY) {          // 已经到顶了,不再扩
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;                  // 容量翻倍,阈值也翻倍
    }
    else if (oldThr > 0)
        newCap = oldThr;                           // 构造时指定了初始容量
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;         // 默认 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  // 12
    }

    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;

                if (e.next == null)                        // 单节点,直接算新下标
                    newTab[e.hash & (newCap - 1)] = e;

                else if (e instanceof TreeNode)            // 树,调用 split
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                else {                                     // 链表:拆成 lo / hi 两条
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {      // 关键判断:这一位是 0 还是 1
                            if (loTail == null) loHead = e;
                            else loTail.next = e;
                            loTail = e;
                        } else {
                            if (hiTail == null) hiHead = e;
                            else hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);

                    if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
                    if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
                }
            }
        }
    }
    return newTab;
}

(e.hash & oldCap) == 0 是全段最漂亮的一句。

容量从 16 扩到 32,掩码从 0b1111 变成 0b11111,多出来的那一位就是 0b10000(正好等于 oldCap)。所以判断新下标只需要看 hash 的这一位:

  • 这一位是 0 → 新下标 = 旧下标,留在原地;
  • 这一位是 1 → 新下标 = 旧下标 + oldCap。

也就是说:不需要重新计算 hash,不需要逐个取模,遍历一次链表就能把它拆成两条。而且 loHead / hiTail 是保持原顺序拼接的,不会像 JDK 7 那样反转链表 —— 这也是修复死循环的另一半原因。


七、get 是怎么找到的

get 就是 put 的逆向操作,同样先定位桶,再在桶里找:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {

        // 先检查头节点(命中率最高)
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;

        if ((e = first.next) != null) {
            if (first instanceof TreeNode)          // 树:O(log n)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {                                    // 链表:O(n)
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

时间复杂度一目了然:无碰撞 O(1),链表 O(n),红黑树 O(log n)

顺带一个高频问题:HashMapget 返回 null,既可能是"key 不存在",也可能是"key 存在但 value 就是 null"。要区分请用 containsKey()


八、动手试一试:在线模拟桶、碰撞与扩容

下面的演示完全复刻 Java 的规则String.hashCode() 的 31 倍累加 → 扰动函数 h ^ (h>>>16) → 定位 (n-1) & hash → 负载因子 0.75 → 超阈值扩容 2 倍。

点击「随机 5 个」可以多放几次,观察链表变长、以及放满 6 个元素后自动扩容、所有 key 下标被打乱重排的过程。

九、高频问题速查

问题 答案
默认容量 / 负载因子 16 / 0.75,阈值 12
什么时候扩容 ++size > threshold,即元素数超过 容量 × 0.75
扩容变成多少 2 倍(左移 1 位)
什么时候树化 链表长度 ≥ 8 数组容量 ≥ 64;否则优先扩容
什么时候退化 扩容或删除后树中节点 ≤ 6
为什么容量是 2 的幂 为了用 & (n-1) 代替 % n,并且扩容时能靠一位判断拆分链表
允许 null 吗 允许 1 个 null key(hash 固定为 0)、任意多个 null value
线程安全吗 不安全。多线程用 ConcurrentHashMap,或 Collections.synchronizedMap
JDK 7 的死循环 JDK 7 扩容用头插法会反转链表,并发下成环;JDK 8 改尾插修复
有序吗 无序,遍历顺序不保证,也不保证长期不变

实践建议:

  1. 已知元素数量时预设初始容量new HashMap<>(expected / 0.75 + 1),避免反复扩容和 rehash。
  2. key 用不可变对象StringInteger、或 final 字段的自定义类)。放进 HashMap 后再修改参与 hashCode() 的字段,这个元素就永久失联了。
  3. 自定义 key 必须同时重写 hashCode()equals(),且遵守"equals 相等则 hashCode 必相等"。
  4. 不要在遍历过程中 remove —— 用 iterator.remove(),否则 ConcurrentModificationException

十、发布到博客园(cnblogs)的注意事项

这篇里的三张图已经直接内嵌在 Markdown 当中(就是那三段 <svg>...</svg>),不需要另外上传图片文件;第八节的交互演示是 HTML + JS,同样内嵌在本文件里。

发布步骤

  1. 进博客园后台 → 随笔 → 新建,编辑器切到 Markdown 模式
  2. 把本文件全文复制粘贴进去
  3. 勾选编辑器的「允许 HTML」;如果交互演示没反应,需要在「设置 → 博客设置」里开通 JS 代码 权限
  4. 保存 / 发布

出问题时的备选方案

现象 解决办法
三张图不显示 用编辑器工具栏的「上传图片」把 images/ 里的三个 PNG 传上去,再把对应的 <svg>...</svg> 整段替换成博客园生成的 ![](https://img.../xxx.png) 链接
交互演示出不来 账号未开通 JS 权限,或平台过滤了 <script>。可把 hashmap-demo.html 传到任意静态托管,然后在文末放一行「在线演示地址:xxx」
代码块格式乱 确认代码块用的是三个反引号 + 语言名(java / html / text

小结

回过头看,HashMap 的全部设计其实就是围绕一个矛盾展开的:

数组定位快但怕冲突,链表解决冲突但查找慢。

于是它用 & (n-1) 把定位做到极致(O(1)),用拉链法兜住冲突(O(n)),再用红黑树给极端情况兜底(O(log n)),最后用 0.75 的负载因子在时间和空间之间取平衡。

理解了这一条主线,剩下的源码细节都是它的自然延伸。

posted @ 2026-09-08 22:58  白鹿为溪  阅读(9)  评论(0)    收藏  举报