【Java基础】01-HashMap的底层原理

下面以 JDK 8+ 的 java.util.HashMap 源码实现 为主,从底层结构、putget、扩容、红黑树化等角度说明 HashMap 的原理。


1. HashMap 的底层数据结构

JDK 8 之后,HashMap 的底层结构是:

text 复制代码
数组 + 链表 + 红黑树

核心字段大致如下:

java 复制代码
transient Node<K,V>[] table;

transient int size;

int threshold;

final float loadFactor;

其中:

  • table:哈希桶数组
  • Node<K,V>:链表节点
  • size:当前键值对数量
  • threshold:扩容阈值
  • loadFactor:负载因子,默认 0.75

节点结构如下:

java 复制代码
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

每个元素最终都会落到 table 数组的某个位置上。如果多个 key 落到同一个数组下标,就会形成链表;当链表过长时,链表会转换成红黑树。


2. put 的源码流程

调用:

java 复制代码
map.put(key, value);

实际会进入:

java 复制代码
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

2.1 先计算 hash

源码中 hash 方法类似:

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

这里不是直接使用 key.hashCode(),而是做了一次扰动:

java 复制代码
h ^ (h >>> 16)

原因是 HashMap 定位桶下标时主要依赖低位:

java 复制代码
(n - 1) & hash

如果只用原始 hashCode,高位信息可能浪费。通过高 16 位和低 16 位异或,可以让高位信息参与到低位计算中,减少哈希冲突。


3. HashMap 如何定位数组下标

HashMap 的数组长度始终是 2 的幂,例如:

text 复制代码
16, 32, 64, 128 ...

定位下标的方式是:

java 复制代码
index = (n - 1) & hash;

这里 n 是数组长度。

如果 n = 16,那么:

text 复制代码
n - 1 = 15
二进制:0000 1111

所以:

text 复制代码
hash & 0000 1111

相当于取 hash 的低 4 位。

这和取模类似:

java 复制代码
hash % n

但位运算效率更高。

前提是数组长度必须是 2 的幂,否则 (n - 1) & hash 不能等价于取模,也会导致分布不均匀。


4. putVal 核心逻辑

putVal 的核心流程可以概括为:

java 复制代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab;
    Node<K,V> p;
    int n, i;

    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    i = (n - 1) & hash;

    if ((p = tab[i]) == null) {
        tab[i] = newNode(hash, key, value, null);
    } else {
        Node<K,V> e;
        K k;

        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k)))) {
            e = p;
        } else if (p instanceof TreeNode) {
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        } else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);

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

                    break;
                }

                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;

                p = e;
            }
        }

        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            return oldValue;
        }
    }

    ++modCount;

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

    return null;
}

put 的主要步骤

  1. 如果 table 为空,先初始化数组。
  2. 通过 (n - 1) & hash 计算桶下标。
  3. 如果该位置为空,直接插入新节点。
  4. 如果该位置不为空:
    • 如果第一个节点 key 相同,覆盖 value。
    • 如果是红黑树节点,走红黑树插入逻辑。
    • 否则遍历链表:
      • 找到相同 key,覆盖 value。
      • 找不到,则尾插新节点。
  5. 插入后,如果链表长度达到阈值,尝试树化。
  6. 插入成功后,size++
  7. 如果 size > threshold,触发扩容。

5. get 的源码流程

调用:

java 复制代码
map.get(key);

实际会进入:

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

核心逻辑是:

java 复制代码
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)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);

            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }

    return null;
}

get 的主要步骤

  1. 计算 key 的 hash。
  2. 通过 (n - 1) & hash 找到桶下标。
  3. 如果桶为空,返回 null
  4. 先检查桶中的第一个节点。
  5. 如果第一个节点不是目标:
    • 如果是红黑树,按红黑树查找。
    • 否则遍历链表查找。
  6. 找到返回 value,找不到返回 null

6. 为什么 HashMap 的容量必须是 2 的幂

源码中容量会被调整为 2 的幂。

例如构造方法中指定容量:

java 复制代码
new HashMap<>(10)

内部不会真的使用 10,而是会调整为 16。

相关方法是:

java 复制代码
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;
}

它的作用是找到大于等于 cap 的最小 2 的幂。

例如:

text 复制代码
cap = 10  => 16
cap = 17  => 32
cap = 33  => 64

原因主要有两个:

6.1 位运算替代取模

java 复制代码
(n - 1) & hash

比:

java 复制代码
hash % n

更快。

6.2 扩容时迁移更高效

当容量从 oldCap 扩容到 newCap = oldCap * 2 时,一个节点的新位置只有两种可能:

text 复制代码
原下标
原下标 + oldCap

判断依据是:

java 复制代码
(e.hash & oldCap) == 0

如果为 0,位置不变;否则位置变成 原下标 + oldCap


7. resize 扩容机制

默认情况下,第一次插入时初始化容量为 16,负载因子为 0.75。

所以默认扩容阈值:

text 复制代码
threshold = 16 * 0.75 = 12

当元素数量超过 12 时,就会扩容到 32。

resize 的核心逻辑

源码核心思想如下:

java 复制代码
if (++size > threshold)
    resize();

扩容时:

  1. 新容量变为旧容量的 2 倍。
  2. 新阈值也通常变为旧阈值的 2 倍。
  3. 旧数组中的节点重新分布到新数组中。

JDK 8 的扩容优化很重要。

对于旧桶中的链表,扩容时不会重新计算完整 hash,而是拆成两组:

java 复制代码
if ((e.hash & oldCap) == 0) {
    // 留在原位置
} else {
    // 移动到 原位置 + oldCap
}

比如旧容量是 16,扩容后是 32:

text 复制代码
旧下标:i
新下标:i 或 i + 16

这是因为扩容后参与下标计算的二进制位多了一位。


8. 链表什么时候变红黑树

JDK 8 引入红黑树,是为了避免极端哈希冲突时链表过长,导致查询退化到 (O(n))。

相关常量:

java 复制代码
static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;

含义:

  • TREEIFY_THRESHOLD = 8
    • 单个桶中链表长度达到 8 时,尝试树化。
  • UNTREEIFY_THRESHOLD = 6
    • 红黑树节点数量减少到 6 以下时,可能退化回链表。
  • MIN_TREEIFY_CAPACITY = 64
    • 只有数组容量至少为 64 时,才允许树化。

注意:链表长度达到 8 并不一定马上树化。

源码中 treeifyBin 会先判断:

java 复制代码
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();
else
    // treeify

也就是说:

  • 如果数组长度小于 64,优先扩容。
  • 如果数组长度至少 64,才真正转红黑树。

原因是:冲突严重有可能是数组太小导致的,此时扩容比树化更划算。


9. 红黑树节点 TreeNode

链表节点是 Node,红黑树节点是 TreeNode

TreeNode 继承自 LinkedHashMap.Entry,大致结构如下:

java 复制代码
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;
}

红黑树查找时,如果 hash 不同,根据 hash 大小走左右子树。

如果 hash 相同,则还需要比较 key:

  1. 如果 equals 相同,说明找到目标。
  2. 如果 key 实现了 Comparable,按比较结果决定方向。
  3. 如果不能比较,则使用 tieBreakOrder 做兜底排序。

10. HashMap 如何处理 hash 冲突

hash 冲突指的是不同 key 计算出的数组下标相同。

处理方式:

text 复制代码
链地址法

即同一个桶下多个节点通过 next 串起来。

JDK 8 之后:

text 复制代码
冲突较少:链表
冲突严重:红黑树

所以复杂度大致是:

场景 时间复杂度
理想情况 (O(1))
链表冲突严重 (O(n))
红黑树化后 (O(\log n))

11. key 相等的判断逻辑

源码中判断 key 是否相等,通常是:

java 复制代码
p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))

也就是说,判断一个 key 是否相同,需要同时满足:

  1. hash 值相同
  2. key 引用相同,或者 equals 返回 true

因此,如果自定义对象作为 HashMap 的 key,必须正确重写:

java 复制代码
hashCode()
equals()

并且要求:

text 复制代码
equals 相等的对象,hashCode 必须相等

否则 HashMap 可能无法正确查找元素。


12. null key 如何处理

HashMap 允许一个 null key。

源码中:

java 复制代码
static final int hash(Object key) {
    int h;
    return key == null ? 0 : ...
}

所以:

java 复制代码
hash(null) = 0

null key 一定会落到:

java 复制代码
index = (n - 1) & 0 = 0

也就是 table[0]

因此,HashMapnull key 存储在第 0 个桶中。


13. JDK 7 和 JDK 8 的主要区别

JDK 7

底层结构:

text 复制代码
数组 + 链表

插入方式:

text 复制代码
头插法

扩容时链表顺序可能反转。

在并发环境下,JDK 7 的 HashMap 扩容可能导致链表形成环,进而出现死循环问题。

JDK 8

底层结构:

text 复制代码
数组 + 链表 + 红黑树

插入方式:

text 复制代码
尾插法

扩容时会保持链表相对顺序。

并且引入红黑树,降低极端冲突情况下的查询复杂度。

不过需要注意:

text 复制代码
HashMap 仍然不是线程安全的

并发场景应使用:

java 复制代码
ConcurrentHashMap

14. 为什么负载因子默认是 0.75

负载因子影响两个方面:

  • 空间利用率
  • 查询效率

如果负载因子太小,例如 0.5

text 复制代码
优点:冲突少,查询快
缺点:数组更稀疏,占用空间更多

如果负载因子太大,例如 1.0

text 复制代码
优点:空间利用率高
缺点:冲突概率增加,查询可能变慢

0.75 是时间和空间之间的折中选择。

默认容量 16,负载因子 0.75:

text 复制代码
16 * 0.75 = 12

即插入第 13 个元素时触发扩容。


15. remove 的源码流程

remove(key) 最终会调用:

java 复制代码
removeNode(hash(key), key, null, false, true)

主要逻辑:

  1. 根据 hash 找到桶下标。
  2. 在桶中查找目标节点:
    • 第一个节点
    • 红黑树节点
    • 链表节点
  3. 找到后删除:
    • 链表中修改 next
    • 红黑树中删除树节点并重新平衡
  4. size--
  5. modCount++

其中 modCount 用于支持 fail-fast 机制。


16. fail-fast 机制

HashMap 的迭代器不是线程安全的。

迭代时会记录当前的 modCount

java 复制代码
int expectedModCount = modCount;

如果遍历过程中发现:

java 复制代码
modCount != expectedModCount

就会抛出:

java 复制代码
ConcurrentModificationException

例如:

java 复制代码
for (String key : map.keySet()) {
    map.put("x", "y");
}

这类结构性修改会触发 fail-fast。

注意,fail-fast 不是并发安全机制,只是一种快速失败检测。


17. HashMap 的整体 put 示例

假设:

java 复制代码
HashMap<String, Integer> map = new HashMap<>();
map.put("abc", 1);

流程如下:

text 复制代码
1. table 初始为空
2. 调用 put
3. 计算 hash("abc")
4. table 为空,调用 resize 初始化为 16
5. index = (16 - 1) & hash
6. table[index] 为空
7. 创建 Node 放入该桶
8. size 变为 1
9. size <= threshold,不扩容

如果继续插入另一个 key,算出的 index 相同:

text 复制代码
1. 找到同一个桶
2. 发现桶不为空
3. 判断第一个节点 key 是否相同
4. 不相同,则遍历链表
5. 插入到链表尾部
6. 如果链表长度达到 8,尝试树化

18. 总结

HashMap 的底层原理可以概括为:

text 复制代码
HashMap 使用数组作为主体结构,通过 key 的 hash 定位桶位置。
当多个 key 落到同一个桶时,使用链表解决冲突。
JDK 8 之后,当单个桶中链表过长并且数组容量足够大时,会转换为红黑树。
put 时先计算扰动 hash,再通过 (n - 1) & hash 定位桶。
get 时使用同样的方式定位桶,然后在桶中比较 hash 和 equals。
当元素数量超过 threshold 时触发 resize,容量扩为原来的 2 倍。
扩容时节点只会留在原位置或移动到 原位置 + oldCap。

面试中可以简洁回答:

JDK 8 的 HashMap 底层是数组、链表和红黑树。它先对 key 的 hashCode 做扰动运算,然后通过 (table.length - 1) & hash 定位桶。桶为空就直接插入,桶不为空则比较 hash 和 equals,相同则覆盖,不同则接到链表尾部;当链表长度达到 8 且数组容量至少 64 时,链表转红黑树。默认容量 16,负载因子 0.75,元素数量超过阈值时扩容为原来的 2 倍。扩容时节点的新位置要么不变,要么变为原下标加旧容量。HashMap 允许一个 null key,但不是线程安全的。

相关推荐
幼儿园技术家2 小时前
实现 GEO 监控:从多引擎探测到优化闭环
前端·后端
掘金者阿豪2 小时前
微信小程序虚拟支付与广告转化回传实战记录
后端
ping某3 小时前
专栏-null 和 undefined 到底是什么?
前端·javascript·后端
神奇小汤圆3 小时前
别再只会用ArrayList了!Java集合框架的性能天花板到底在哪?
后端
神奇小汤圆3 小时前
Dubbo 的 SPI 和 JDK 的 SPI 有什么区别?
后端
叫我少年3 小时前
C# 字符串基础
后端
道友可好4 小时前
从今天开始:你的第一个 Harness Engineering 实践
前端·人工智能·后端
其实是白羊4 小时前
CoderTools 1.5.3:让 AI 帮你看懂代码调用链路
后端·ai编程·vibecoding
妙码生花4 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二):目录结构、初始化 GIT、设计并开发配置系统
前端·后端·go