深入理解 Java HashMap

一. HashMap简介

1.1 什么是 HashMap

HashMap 是 Java 集合框架中基于哈希表(Hash Table)实现的 Map 接口实现类,自 JDK 1.2 引入。它以 key-value(键值对) 的形式存储数据,通过 key 的哈希值快速定位存储位置,在理想情况下实现 O(1) 时间复杂度的插入、查询和删除。

一句话总结:HashMap 是一个以哈希函数为核心、用空间换时间的键值对容器。

1.2 HashMap 的特点

使用之前,需要先明确它的几个核心特点:

  • key 唯一,value 可重复:不允许重复的 key,对已存在的 key 再次 put 会覆盖旧值;value 则没有这个限制。
  • 允许 null:允许 null key 和 null value。null key 最多只能有一个(JDK 8 中它的哈希值为 0,落在 table0 位置),null value 可以有多个。
  • 不保证顺序:HashMap 不保证元素的存储顺序,且扩容(resize)后顺序还可能发生改变。需要有序时可选 LinkedHashMap(保持插入顺序)或 TreeMap(按键排序)。
  • 非线程安全:多线程并发读写会引发数据不一致,JDK 7 甚至在并发扩容时可能出现死循环。并发场景应使用 ConcurrentHashMap。
  • 性能:平均时间复杂度 O(1);极端哈希冲突下,JDK 8 之前会退化为 O(n),JDK 8 之后链表过长会自动转红黑树,最坏为 O(log n)。

1.3 基本用法

java 复制代码
Map<String, Integer> map = new HashMap<>();

// 插入
map.put("apple", 1);
map.put("banana", 2);

// 重复 key 会覆盖旧值
map.put("apple", 3);                    // apple 的值变为 3

// 查询
Integer value = map.get("apple");       // 3
boolean has = map.containsKey("banana"); // true

// 删除
map.remove("banana");

// 遍历方式一:entrySet
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println(entry.getKey() + " = " + entry.getValue());
}

// 遍历方式二:forEach(JDK 8+)
map.forEach((k, v) -> System.out.println(k + " = " + v));

// 其他常用方法
int size = map.size();                  // 键值对数量
boolean empty = map.isEmpty();
map.clear();

适用场景

  1. 缓存:按 ID 缓存用户信息、配置项;
  2. 计数与去重:词频统计、元素去重;
  3. 索引映射:将业务对象与唯一标识互相映射;
  4. 作为基础组件:HashSet 的底层实现就是 HashMap,很多框架源码也大量使用它。

二. HashMap 的底层数据结构

JDK 8 的 HashMap 底层采用 数组 + 链表 + 红黑树 的三层结构,这也是它最核心的设计:

各部分职责:

  • 数组(table):Node<K,V>\[\] table,每个位置称为一个桶(bucket),默认初始容量 16
  • 链表(Node):当多个 key 哈希后落到同一个桶,就以链表形式串起来,解决哈希冲突;
  • 红黑树(TreeNode):当链表过长时转换为红黑树,把查询性能从 O(n) 优化到 O(log n)。

2.2 数据是如何落位的

插入时,HashMap 通过 key 的哈希值计算桶下标,核心两步:

java 复制代码
// 第一步:扰动 hash,让高位也参与运算(后面源码章节详解)
hash = (h = key.hashCode()) ^ (h >>> 16);

// 第二步:用位运算代替取模,得到桶下标
index = (n - 1) & hash;   // n 为数组长度,恒为 2 的幂

两个 key 的哈希值相同(或计算出的下标相同),就发生了哈希冲突,此时数据以链表(或红黑树)的形式挂在同一个桶下。

2.3 为什么引入红黑树

JDK 8 之前,HashMap 只有「数组 + 链表」。一旦哈希函数设计不好或 key 的 hashCode() 分布不均匀,链表会越来越长,查询退化成 O(n),性能断崖式下降。

JDK 8 引入红黑树的思路是:链表过长时,用自平衡二叉查找树替换链表,把最坏情况下的查询、插入、删除从 O(n) 降到 O(log n)。

但树化不是无条件的,必须同时满足两个阈值:

阈值常量 含义
TREEIFY_THRESHOLD 8 链表长度达到 8 时尝试树化
MIN_TREEIFY_CAPACITY 64 数组长度必须 ≥ 64 才真正树化,否则先扩容

2.4 JDK 7 与 JDK 8 的结构差异

对比项 JDK 7 JDK 8
存储结构 数组 + 链表 数组 + 链表 + 红黑树
节点类型 Entry<K,V> Node<K,V> / TreeNode
新节点插入方式 头插法 尾插法
哈希函数 4 次扰动 1 次扰动(>>> 16
扩容后 rehash 重新计算每个 key 的 hash 利用 2 的幂特性高位拆分,效率更高

其中最有故事性的是头插法改尾插法:JDK 7 在并发扩容时,链表反转可能形成环,导致 get 陷入死循环(CPU 100%)。JDK 8 改成尾插法后,这个问题从结构上被消除了,但 HashMap 依旧不是线程安全的------并发下仍有数据丢失、覆盖等问题。

三. HashMap 的继承体系

3.1 继承 AbstractMap 的意义

AbstractMap<K,V> 是一个模板抽象类,它实现了 Map 接口,并基于唯一抽象方法 entrySet() 提供了大多数操作的默认实现:

  • get()、containsKey():遍历 entrySet() 查找;
  • put():默认直接抛 UnsupportedOperationException,要求子类必须重写;
  • size()、isEmpty()、containsValue() 等:都有基于 entrySet() 的骨架实现。

也就是说,写一个新的 Map 实现类时,只要实现 entrySet() 和 put(),就能获得一整套基础功能。HashMap 虽然几乎把所有方法都重写成了高性能版本,但骨架复用的设计思想值得借鉴。

3.2 实现的三个接口

  • Map<K,V>:定义键值对容器的契约,包括 put、get、remove、entrySet 等核心方法,以及内部接口 Map.Entry<K,V>(键值对节点);
  • Cloneable:标记接口,表示支持克隆。HashMap 重写了 clone(),返回一个浅拷贝:新 Map 拥有独立的 table 结构,但 key 和 value 对象本身是共享的;
  • Serializable支持序列化。注意 HashMap 的 table 字段声明为 transient,并自定义了 writeObject / readObject------因为桶的下标由哈希函数和容量共同决定,直接序列化内部数组既浪费空间又容易受 JVM 版本影响,所以改为只序列化键值对,反序列化时再重建哈希表。

3.3围绕 HashMap 的两个重要类

  1. LinkedHashMap(继承)
java 复制代码
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>

LinkedHashMap 在 HashMap 基础上额外维护了一条双向链表记录插入顺序(或访问顺序),并重写了 newNode()、afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval() 等钩子方法来实现。这些方法在 HashMap 源码里几乎都是空实现,设计目的就是留给子类扩展------这也是为什么 HashMap 内部节点被称为 Node、而树节点 TreeNode 继承自 LinkedHashMap.Entry。

  1. HashSet(组合,而非继承)
java 复制代码
public class HashSet<E> extends AbstractSet<E> {
    private transient HashMap<E,Object> map;   // 内部持有一个 HashMap
    private static final Object PRESENT = new Object(); // 所有 value 共用的占位对象
}

HashSet.add(e) 本质就是 map.put(e, PRESENT)。所以 HashSet 的迭代顺序、性能特性都完全取决于底层的 HashMap,这也是理解集合框架时「Set 与 Map 相通」的关键。

四. 核心源码深度解析

4.1 成员变量

先总览全部成员变量:

类型 成员变量 值/默认值 作用
常量 serialVersionUID 362498820763181265L 序列化版本号
常量 DEFAULT_INITIAL_CAPACITY 1 << 4(16) 默认初始容量
常量 MAXIMUM_CAPACITY 1 << 30 容量上限
常量 DEFAULT_LOAD_FACTOR 0.75f 默认加载因子
常量 TREEIFY_THRESHOLD 8 链表转红黑树阈值
常量 UNTREEIFY_THRESHOLD 6 红黑树转链表阈值
常量 MIN_TREEIFY_CAPACITY 64 允许树化的最小数组容量
字段 table null 存储节点的数组(桶)
字段 entrySet null entrySet() 视图缓存
字段 size 0 键值对数量
字段 modCount 0 结构性修改次数
字段 threshold 0 扩容临界值
字段 loadFactor 0.75f 加载因子(final
4.1.1 序列化版本号
java 复制代码
private static final long serialVersionUID = 362498820763181265L;

serialVersionUID 是 Java 序列化机制要求的版本号。序列化和反序列化时,JVM 会比对两边的版本号,不一致会抛出 InvalidClassException。HashMap 自定义了序列化逻辑(table 是 transient,见下文),所以需要显式声明它,保证版本升级后仍能反序列化旧数据。

4.1.2 容量相关:为什么必须是 2 的 n 次幂
java 复制代码
// 默认初始容量 16:1 << 4 相当于 1 * 2^4
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 容量上限:2 的 30 次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

容量上限是 2^30 而不是 2^31,是因为 int 是带符号的,最高位是符号位。

为什么容量必须是 2 的 n 次幂? 因为 HashMap 定位桶下标时用的不是取模运算 hash % length,而是位运算

index = hash & (length - 1);

两者在 length 是 2 的幂时完全等价(hash & (length - 1) == hash % length),但位运算效率远高于取模。更深一层,length - 1 的二进制形式是「n 个 1」,这样 hash 的低位都能参与定位,结果可以覆盖 0 ~ length-1 的全部位置,数据分布更均匀

举个反例对比:

length = 8(2^3),length - 1 = 7 = 0111

hash=3: 0011 & 0111 = 0011 → 下标

3 hash=2: 0010 & 0111 = 0010 → 下标 2

(落在不同位置,不碰撞)

length = 9(非 2 的幂),length - 1 = 8 = 1000

hash=3: 0011 & 1000 = 0000 → 下标 0

hash=2: 0010 & 1000 = 0000 → 下标 0

(都落在 0,碰撞)

可以看到,length 不是 2 的幂时,length - 1 的低位存在恒为 0 的位,导致部分桶永远不会被使用,既浪费数组空间,又加剧哈希冲突、拉长链表,性能随之下降。这就是「容量必须是 2 的 n 次幂」的根本原因。

如果用户传入的不是 2 的幂呢? 比如 new HashMap(10),HashMap 不会报错,而是通过 tableSizeFor() 把它"修正"为大于等于该值的最小 2 的幂:

java 复制代码
public HashMap(int initialCapacity) {          // initialCapacity = 10
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 构造方法内部:
this.threshold = tableSizeFor(initialCapacity); // 10 → 16

static final int tableSizeFor(int cap) { // cap = 10

int n = cap - 1; // n = 9

n |= n >>> 1; // 9 → 13

n |= n >>> 2; // 13 → 15

n |= n >>> 4; // 15 → 15

n |= n >>> 8; // 不变

n |= n >>> 16; // 不变

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

}

这个算法的精妙之处在于:

  • 先 cap - 1:防止 cap 本身是 2 的幂时结果翻倍。比如 cap = 8,如果不减 1,最终会得到 16;减 1 后得到 8。
  • 连续无符号右移 + 按位或:把最高位的 1 不断向右"复制",让二进制变成「连续的 1」,即 2^k - 1 的形式,最后 + 1 得到 2^k。
4.1.3 树化相关:为什么是 8、6、64
java 复制代码
// 桶上链表长度达到 8 时,尝试转红黑树(JDK 8 新增)
static final int TREEIFY_THRESHOLD = 8;

// 桶上红黑树节点数降到 6 时,转回链表
static final int UNTREEIFY_THRESHOLD = 6;

// 只有数组长度 ≥ 64 时才允许树化,否则先扩容
static final int MIN_TREEIFY_CAPACITY = 64;

为什么选 8? 官方注释给出的理由是概率统计。在加载因子 0.75、哈希函数良好的前提下,桶内链表长度服从泊松分布,长度为 k 的期望出现次数为:

(exp(-0.5) * pow(0.5, k)) / k!

代入 k = 8,概率约为 0.00000006,即千万分之六,属于几乎不可能的事件。也就是说:链表长到 8 说明哈希冲突已经极其严重,此时牺牲一点空间换性能是值得的。

补充一个工程视角的解读:红黑树平均查找长度为 log(n),链表为 n/2。长度为 8 时,log(8)=3 < 8/2=4,转树划算;而长度为 6 时,log(6)≈2.6 与 6/2=3 差距不大,转树收益有限,还要承担构建树的开销。8 和 6 之间留出 1 的缓冲,可以避免在阈值附近反复「树化 ↔ 退化」造成的性能抖动。

为什么树化还要看数组长度? MIN_TREEIFY_CAPACITY = 64,且官方要求它不能小于 4 * TREEIFY_THRESHOLD。含义是:即使某个桶的链表长度到了 8,只要数组长度还小于 64,就先走扩容而不是树化------因为扩容会把链表拆散,往往比建树更划算,两者不能互相干扰。

4.1.4 加载因子与扩容临界值
java 复制代码
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 实例字段:加载因子,构造时指定后不可修改
final float loadFactor;

// 扩容临界值:threshold = capacity * loadFactor
int threshold;

loadFactor 用来衡量 HashMap 的"疏密程度",实时加载因子 = size / capacity(注意分子是键值对总数,而不是被占用的桶数)。

  • 加载因子太大(趋近 1):数据越密,链表变长,查找效率下降;
  • 加载因子太小(趋近 0):数据稀疏,数组利用率低,频繁扩容;
  • 默认 0.75 是官方经过大量测试得出的平衡点。

hreshold 就是「容量 × 加载因子」算出的临界值。当 size >= threshold 时触发 resize() 扩容,新容量是旧容量的 2 倍。扩容涉及 rehash、复制数据,非常消耗性能,所以开发中可以通过指定初始容量来减少扩容次数:

java 复制代码
// 预估存储 1000 条数据时,直接给足容量,避免中途多次扩容
Map<String, Integer> map = new HashMap<>(1000 / 0.75 + 1);

4.2 构造方法

HashMap 一共有 4 个构造方法,初看都很简单,但有两个共同点值得先记住:

  • 所有构造方法都不初始化数组 table,真正的数组是在第一次 put 时才创建(懒加载);
  • 构造阶段的主要工作只有两件:校验参数、设置 loadFactor 和 threshold。
构造方法 初始容量 加载因子 适用场景
HashMap() 16(默认) 0.75(默认) 无法预估数据量
HashMap(int initialCapacity) 指定值(自动修正为 2 的幂) 0.75 能预估数据量
HashMap(int, float) 指定值 指定值 需要自定义加载因子
HashMap(Map<? extends K, ? extends V> m) 由传入 Map 决定 0.75 拷贝/转换已有 Map
4.2.1 无参构造方法
java 复制代码
public HashMap() {
    // 只把默认加载因子 0.75 赋值给 loadFactor,并没有创建数组
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

无参构造只做一件事:设置 loadFactor = 0.75。此时 table = null、threshold = 0、size = 0,数组留到第一次 put 时才初始化。

4.2.2 指定初始容量
java 复制代码
// 指定"初始容量大小"的构造函数
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

它没有自己的逻辑,直接委托给下面的 HashMap(int, float) 构造方法,加载因子沿用默认值 0.75。

4.2.3 指定初始容量和加载因子

这是校验逻辑最完整的构造方法,也是面试常考的一个:

java 复制代码
public HashMap(int initialCapacity, float loadFactor) {
    // 1. 容量不能为负数
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 2. 容量超过上限时,直接截断为最大值 2^30
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 3. 加载因子必须大于 0 且是合法数值
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    // 4. 赋值加载因子
    this.loadFactor = loadFactor;
    // 5. 把容量修正为 2 的幂,并"暂时"存入 threshold
    this.threshold = tableSizeFor(initialCapacity);
}

三步校验分别对应三种非法输入:负容量、超大容量、非法加载因子。这里有个小细节:为什么单独判断 Float.isNaN(loadFactor)?因为 NaN 与任何数比较结果都是 false,loadFactor <= 0 根本拦不住它。

经典疑问:为什么结果是tableSizeFor(initialCapacity),而不是tableSizeFor(initialCapacity) * loadFactor?

按照 threshold = 容量 × 加载因子 的定义,很多人第一反应是这里写错了,应该乘上 loadFactor才对。其实这是 JDK 8 的刻意设计: 构造方法不初始化 table,数组初始化被推迟到第一次 put 时执行的 resize() 中;

resize() 的第一件事就是 newCap = oldThr,把 threshold 里"暂存"的容量值取出来当新数组长度;然后再重新计算真正的 threshold = newCap * loadFactor。

所以这里的 threshold 只是临时借宿了一下容量值,等 resize() 执行时就会"物归原主"。

4.2.4 包含另一个 Map 的构造方法
java 复制代码
// 构造一个映射关系与指定 Map 相同的新 HashMap
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

加载因子用默认值 0.75,核心逻辑在 putMapEntries() 里:

java 复制代码
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();                       // 源 Map 的元素个数
    if (s > 0) {
        // 情况一:table 尚未初始化(当前就是通过构造方法调用进来的)
        if (table == null) {                // pre-size
            // 预估容量:元素数 ÷ 加载因子,+1.0F 向上取整,避免容量不够
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            // 算出的容量比当前 threshold 大,才需要更新(防止覆盖构造时已设好的值)
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        // 情况二:table 已初始化(通过 clone/其他途径调用),元素数超过阈值则扩容
        else if (s > threshold)
            resize();
        // 逐个把元素放入新 HashMap
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

为什么 ft 要加 1.0F?

(int)ft 是向下取整,而 s / loadFactor 得到的是"刚好装下 s 个元素"的理论容量。如果恰好是个 2 的幂(比如 6 / 0.75 = 8),tableSizeFor(8) = 8,容量 8、阈值 8 × 0.75 = 6------插入 6 个元素后 size 就顶到阈值了,下次再 put 立刻触发扩容,白白损失性能。

加 1.0F 做一次向上取整,能保证容量留出余量:s = 6

不加 +1.0F:ft = 8.0 → tableSizeFor(8) = 8 → 容量 8,阈值 6,马上又要扩容

加上 +1.0F:ft = 9.0 → tableSizeFor(9) = 16 → 容量 16,阈值 12,余量充足

(evict 参数与 LinkedHashMap 的"最近最少使用"淘汰钩子有关,普通使用场景传 false,可以忽略。)

4.3 成员方法

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

两个关键点:

  1. 支持 null key。 key 为 null 时哈希值固定为 0,所以 null key 会落在 table0 这个桶里,且只能有一个。而 Hashtable 直接调用 key.hashCode(),key 为 null 直接抛 NullPointerException。
  2. 扰动函数:高 16 位与低 16 位异或。 为什么多此一举?因为桶下标的计算是:

index = (n - 1) & hash;

当数组很小时(比如默认 16),n - 1 只有低 4 位是 1,意味着只有 hash 的低 4 位真正参与了定位,高位信息全部浪费。如果某个自定义对象的 hashCode() 高位变化很大、低位却几乎不变,就会大量碰撞到同一个桶。

举个例子(n = 16,未扰动):

hashCode(): 1111 1111 1111 1111 1111 0000 1110 1010

n - 1 = 15: 0000 0000 0000 0000 0000 0000 0000 1111

& 结果: 0000 0000 0000 0000 0000 0000 0000 1010 → 下标 10

只要低位是 1010,无论高位怎么变,算出来都是下标 10。而 h ^ (h >>> 16) 把高位"混入"低位后,高位的变化就能影响最终下标,让分布更均匀。相比 JDK 7 的多次扰动,JDK 8 只做一次异或,配合红黑树已经足够,兼顾了性能与散列质量。

4.3.2 put() / putVal():插入元素

对外只暴露 put,真正干活的是私有的 putVal:

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

putVal 的完整流程可以归纳为五步:

  1. 定位:i = (n - 1) & hash 算出桶下标;

  2. 桶为空:直接 newNode 放入;

  3. 桶非空(冲突):桶首节点 key 相同 → 记录旧节点;

    • 桶是红黑树 → 调用 putTreeVal() 插入树中;
    • 桶是链表 → 尾插遍历,链表长度达到 8 调用 treeifyBin() 转树;
  4. 存在相同 key:替换 value,返回旧值;

  5. 新增完成:++size > threshold 时调用 resize() 扩容。

完整源码解读:

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;

    // ① table 为空:第一次 put,触发 resize() 完成数组初始化(懒加载)
    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;

        // 桶首节点:hash 和 key 都相等,说明是同一个 key
        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) {
                // 到达链表尾部,说明没有重复 key,插入新节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度达到 8(binCount = 7 时恰好第 8 个节点)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        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);          // LinkedHashMap 钩子
            return oldValue;             // 覆盖不算结构性修改,modCount 不加
        }
    }

    // ⑤ 真正新增了一个键值对
    ++modCount;                          // 结构性修改 +1
    if (++size > threshold)              // 超过阈值则扩容
        resize();
    afterNodeInsertion(evict);           // LinkedHashMap 钩子
    return null;                         // 新增返回 null
}

几个容易忽略的细节:

  • 尾插法(JDK 8):新节点一律追加到链表末尾,而 JDK 7 是头插法。头插法在并发扩容时会把链表反转成环,导致死循环,尾插法从结构上规避了这个问题(但 HashMap 依然非线程安全);
  • binCount 从 0 开始:桶首节点算第 1 个,binCount = 7 时插入的是第 8 个节点,所以"链表长度到 8 触发树化";
  • 覆盖旧值不算结构性修改:modCount 不变,所以遍历过程中"更新已有 key"不会抛 ConcurrentModificationException;
  • onlyIfAbsent 对应 putIfAbsent():为 true 时只在 key 不存在时写入;evict 是留给 LinkedHashMap 的淘汰钩子。
4.3.3 treeifyBin():链表转红黑树
java 复制代码
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,先串成双向链表
        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);
        // 交给 TreeNode.treeify() 真正构建红黑树
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

注意这里再次体现了 4.1 讲过的权衡:数组长度 < 64 时,先扩容而不是树化。因为扩容后哈希被重新分散,链表大概率会被拆短,比建树更划算;只有数组足够长还发生严重冲突,才值得用红黑树兜底。

4.3.4 resize():扩容机制(重点)

先回答两个问题:

什么时候扩容? 当 size > threshold 时(threshold = capacity × loadFactor,默认 16 × 0.75 = 12)。扩容是什么? 容量翻倍,并把所有元素重新分配到新数组------这一步遍历全部元素,非常耗时,所以开发中应尽量预估容量、减少扩容次数。

HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。

不用重新计算 hash,又能把一条链表均匀拆成两条,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成"原索引+oldCap(原位置+旧容量)"

下面看源码:

java 复制代码
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) {
        // 已达上限:不再扩容,阈值放大到 int 最大值
        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;
    // 无参构造首次 put:使用默认值
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;   // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
    }

    // ② 补充计算阈值(如 oldCap < 16 或走 oldThr 分支时)
    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) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;          // 置空便于 GC
                // 桶上只有一个节点:直接按新下标放入
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 红黑树:split 拆分(节点数降到 6 会退化为链表)
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 链表:按 (e.hash & oldCap) 拆成 lo/hi 两条
                else {
                    Node<K,V> loHead = null, loTail = null;   // 留在原下标
                    Node<K,V> hiHead = null, hiTail = null;   // 移到 j + oldCap
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            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;
}

这段代码同时回答了两个伏笔:

  • 构造方法里 threshold = tableSizeFor(initialCapacity) 暂存的容量,正是在 else if (oldThr > 0) newCap = oldThr 这里"物归原主";
  • UNTREEIFY_THRESHOLD = 6 则是在红黑树 split() 时决定是否退化为链表。
4.3.5 remove():删除元素

remove 的实现在 removeNode 中:

java 复制代码
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 定位桶,桶为空直接返回 null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 桶首节点就是要删的 key
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            // 红黑树:从树中查找
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            // 链表:遍历查找
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 找到节点(且 value 匹配,仅 remove(key, value) 双参版本校验)
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)          // 删的是头节点
                tab[index] = node.next;
            else                          // 删的是链表中间节点
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

删除分三种情况:头节点直接 tabindex = node.next;中间节点通过 p.next = node.next 摘除;树节点走 removeTreeNode(节点数降到 6 以下会自动退化为链表)。

4.3.6 get():查找元素
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) {
            // 红黑树:getTreeNode → find 折半查找,O(log n)
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 链表:逐个比较,O(n)
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

查找逻辑与插入完全对称:先比 hash,hash 相同再比 key(地址相同或 equals 相等)。红黑树的 find() 按 hash 大小走左/右子树,基本是折半查找,效率远高于链表。这也提醒我们:自定义对象作为 key 时必须正确重写 hashCode() 和 equals(),否则相同的业务 key 会算成不同的桶或永远匹配不上。

4.3.7 遍历方式
方式 代码 适用场景
分别遍历 key / value for (K k : map.keySet()) / map.values() 只需要 key 或 value
entrySet for (Map.Entry<K,V> e : map.entrySet()) 同时需要 key 和 value(首选)
Iterator Iterator<Map.Entry<K,V>> it = map.entrySet().iterator() 遍历中需要 remove()
forEach map.forEach((k, v) -> ...) JDK 8+,最简洁
相关推荐
霸道流氓气质1 小时前
Java开发者AI编程常用MCP、Skills、Rules全景汇总
java·开发语言·ai编程
天涯醉青楼1 小时前
Spring Boot 接口返回 Bean 时字段名“变形”的深度剖析与解决方案
java
步行cgn2 小时前
MyBatis 高级映射与延迟加载完全指南
java·tomcat·mybatis
thefool1122662 小时前
从中序与后序遍历序列构造二叉树
java
吴声子夜歌2 小时前
Java面试题——JVM(二)
java·开发语言·jvm
CRMEB2 小时前
商城大促活动策划全流程:从定目标到复盘四阶段
java·开发语言·人工智能·ai·开源·php
denglinqings2 小时前
Java开发从入门到精通所有课程
java·开发语言
星空2 小时前
maven镜像
java·maven
chuan.bai3 小时前
Java RAG 实战附录:qwen3 与 bge-m3 模型切换指南
java·人工智能·算法