Java集合(2)

Java集合(2)

作者:没有四次元口袋的蓝胖

日期:2026-07-01

标签:Java, 集合框架, HashMap


一、HashMap 概述

HashMap 是 Java 中最常用的 Map 实现,基于哈希表实现,存储键值对(key-value),允许 key 和 value 为 null(key 只能有一个 null,value 可以多个)。

特点:

  • 无序(不保证插入顺序)
  • 线程不安全
  • 查询、插入、删除平均时间复杂度 O(1)
  • JDK 1.7:数组 + 链表
  • JDK 1.8:数组 + 链表 + 红黑树

面试地位: HashMap 是 Java 面试的"第一题",底层结构、put 流程、扩容机制几乎必问,必须吃透。


二、底层结构

2.1 JDK 1.7 vs JDK 1.8

版本 底层结构 链表插入方式 扩容时顺序
JDK 1.7 数组 + 链表 头插法 逆序(头插导致)
JDK 1.8 数组 + 链表 + 红黑树 尾插法 正序(保持原顺序)

为什么 1.8 要加红黑树?

当哈希冲突严重时,链表会越来越长,查询效率退化为 O(n)。引入红黑树后,链表长度超过阈值时转红黑树,查询效率维持在 O(log n)。

2.2 JDK 1.8 核心字段

java 复制代码
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    // 默认初始容量:16(必须是 2 的幂)
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16

    // 最大容量:2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默认加载因子:0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 链表转红黑树的阈值:链表长度 >= 8 时考虑转树
    static final int TREEIFY_THRESHOLD = 8;

    // 红黑树退化为链表的阈值:树节点 <= 6 时退化为链表
    static final int UNTREEIFY_THRESHOLD = 6;

    // 链表转红黑树的最小数组容量:数组长度 >= 64 才转树
    // (数组太小的话优先扩容,不转树)
    static final int MIN_TREEIFY_CAPACITY = 64;

    // 哈希桶数组(存储链表/红黑树的头节点)
    transient Node<K,V>[] table;

    // 键值对个数
    transient int size;

    // 扩容阈值 = 容量 × 加载因子
    int threshold;

    // 加载因子
    final float loadFactor;
}

2.3 Node 节点(链表节点)

java 复制代码
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;     // key 的 hash 值
    final K key;        // 键
    V value;            // 值
    Node<K,V> next;     // 下一个节点(链表指针)
}

2.4 整体结构示意图

复制代码
table[] 数组(哈希桶)
├── [0] → Node → Node → Node(链表,长度 < 8)
├── [1] → null
├── [2] → TreeNode(红黑树,链表长度 >= 8 且数组长度 >= 64)
├── [3] → Node
├── ...
└── [15] → null

2.5 红黑树节点(TreeNode)

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;           // 颜色(红/黑)
}

TreeNode 继承自 LinkedHashMap.Entry,保留了链表指针,所以红黑树中仍然可以通过链表遍历。

2.6 几个关键概念

概念 含义
容量(capacity) 哈希桶数组的长度,默认 16,必须是 2 的幂
加载因子(loadFactor) 填充比例,默认 0.75
阈值(threshold) capacity × loadFactor,size 超过这个值就扩容
哈希桶(bucket) 数组的每一个位置叫一个桶

为什么容量必须是 2 的幂? 见下文"寻址优化"。


三、Hash 冲突

3.1 什么是 Hash 冲突?

两个不同的 key,通过哈希函数计算出相同的数组下标(落在同一个桶里),这就是 Hash 冲突。

复制代码
hash("apple") % 16 = 3
hash("banana") % 16 = 3   ← 冲突了!

Hash 冲突不可避免,因为 key 是无限的,数组长度是有限的。

3.2 Hash 冲突的解决方案

方法 原理 代表
链地址法 冲突的元素放在同一个桶里,用链表/红黑树串起来 HashMap
开放地址法 冲突了就找下一个空位置(线性探测、二次探测等) ThreadLocalMap
再哈希法 冲突了就换一个哈希函数重新算 -
公共溢出区 冲突的元素放到另一个溢出表 -

HashMap 用的是链地址法(链表 + 红黑树)。

3.3 HashMap 的哈希函数

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

这段代码做了什么?------ 扰动函数

  1. 拿到 key 的 hashCode()(32 位 int)
  2. 将 hashCode 的高 16 位与低 16 位做异或h >>> 16 是无符号右移 16 位)
  3. 目的:让高位也参与到下标计算中,减少哈希冲突

为什么要这么做?

因为后面计算数组下标用的是 hash & (n-1)(n 是数组长度),当 n 比较小时(比如 16),只有低几位参与运算,高位完全用不上。扰动函数把高位的特征混入低位,让哈希更均匀。

复制代码
hashCode: 1101 1010 1110 0101  1011 0001 1100 1010
>>> 16:   0000 0000 0000 0000  1101 1010 1110 0101
异或后:   1101 1010 1110 0101  0110 1011 0010 1111
                                    ↑ 高位特征混入低位

3.4 寻址优化:用位运算代替取模

java 复制代码
// 计算数组下标
// 普通写法:hash % n (取模)
// HashMap 写法:hash & (n - 1) (位与运算)

为什么 hash & (n - 1) 等价于 hash % n

前提是 n 必须是 2 的幂

举个例子,n = 16(二进制 10000),n-1 = 15(二进制 01111):

复制代码
hash:   1010 1101
n-1:    0000 1111
& 结果: 0000 1101 = 13

n-1 的低位全是 1,高位全是 0,和 hash 做与运算就相当于只保留低几位,效果等价于取模,但位运算比取模快得多。

这就是容量必须是 2 的幂的原因! 为了用位运算优化寻址效率。

3.5 链表转红黑树的条件

两个条件同时满足:

  1. 链表长度 >= 8(TREEIFY_THRESHOLD)
  2. 数组容量 >= 64(MIN_TREEIFY_CAPACITY)

如果链表长度 >= 8 但数组容量 < 64,会优先扩容而不是转红黑树。

为什么阈值是 8?

根据泊松分布,在哈希函数良好的情况下,链表长度达到 8 的概率约为 0.00000006(千万分之六),概率极低。选 8 是为了让转红黑树的概率足够小,避免频繁转换。

为什么退化阈值是 6 而不是 8?

留一个缓冲区间(8 转树,6 退化),避免在 8 附近频繁地"树化 ↔ 链表化"来回震荡。


四、Put 流程

4.1 总流程图

复制代码
put(key, value)
    ↓
计算 hash 值(扰动函数)
    ↓
table 数组为空?
    ├── 是 → resize() 初始化数组(默认容量16,阈值12)
    └── 否 → 继续
    ↓
计算数组下标:i = (n - 1) & hash
    ↓
table[i] 位置是否为空?
    ├── 空 → 直接 new Node 放进去 → 转第7步
    └── 不为空 → 继续
    ↓
桶里第一个元素的 key 和新 key 相同吗?
    ├── 相同 → 记录这个节点 → 转第6步
    └── 不相同 → 继续
    ↓
是红黑树节点吗?
    ├── 是 → 调用树的插入方法 → 转第6步
    └── 否(链表) → 遍历链表:
              ├── 找到 key 相同的节点 → 记录 → 转第6步
              └── 遍历到末尾都没找到 → 尾插新节点
                         ↓
                    链表长度 >= 8 吗?
                         ├── 是 → treeifyBin() 尝试转红黑树
                         └── 否 → 不用转
    ↓
6. 如果找到了相同 key 的节点 → 用新 value 覆盖旧 value → 返回旧 value
    ↓
7. modCount++(修改次数+1)
    ↓
8. size++,size > threshold 吗?
    ├── 是 → resize() 扩容
    └── 否 → 结束
    ↓
返回 null

4.2 源码级拆解

第一步:put 入口

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

第二步: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;

    // 1. 数组为空 → 初始化(resize 既是初始化也是扩容)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 2. 计算下标,位置为空 → 直接放
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    else {
        Node<K,V> e; K k;

        // 3. 桶头节点的 key 相同 → 记录下来
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;

        // 4. 是红黑树 → 走树的插入逻辑
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 5. 是链表 → 遍历
        else {
            for (int binCount = 0; ; ++binCount) {
                // 遍历到末尾 → 尾插新节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度 >= 8 → 尝试转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因为从0开始
                        treeifyBin(tab, hash);
                    break;
                }
                // 找到 key 相同的节点 → 跳出
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

        // 6. 找到了相同 key → 覆盖 value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // 回调,给 LinkedHashMap 用
            return oldValue;
        }
    }

    ++modCount;  // 7. 修改次数+1

    // 8. size 超过阈值 → 扩容
    if (++size > threshold)
        resize();

    afterNodeInsertion(evict); // 回调
    return null;
}

4.3 判断 key 相同的逻辑

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

两步判断:

  1. 先比 hash:hash 不同,key 一定不同;hash 相同,key 不一定相同
  2. 再比 equals / ==:hash 相同的情况下,再比较 key 是否真的相同

为什么要先比 hash 再比 equals?

因为 hash 是 int,比较非常快;equals 可能比较复杂(比如字符串要逐字符比)。先比 hash 可以快速排除绝大多数不相等的情况,提高效率。

面试题: 为什么重写 equals 必须重写 hashCode?

如果两个对象 equals 返回 true,但 hashCode 不一样,它们在 HashMap 中会被放到不同的桶里,导致"相同"的 key 可以存多次,违反了 Map 的语义。

4.4 JDK 1.7 vs 1.8 put 流程区别

对比项 JDK 1.7 JDK 1.8
插入方式 头插法 尾插法
数据结构 数组 + 链表 数组 + 链表 + 红黑树
扩容时元素顺序 反转(头插导致) 保持原顺序
扩容条件 size >= threshold 且 当前位置不为空 size > threshold
哈希计算 4次位运算 + 5次异或 1次异或(高16位 ^ 低16位)
死循环问题 多线程扩容可能出现 不会(尾插法)

注意: JDK 1.8 的 HashMap 虽然不会出现死循环,但仍然是线程不安全的,多线程场景请用 ConcurrentHashMap。


五、扩容机制(resize)

5.1 什么时候扩容?

两种情况:

  1. 数组为空时,初始化扩容(默认容量 16)
  2. size > threshold 时,扩容(threshold = capacity × loadFactor)

5.2 扩容大小

新容量 = 旧容量 × 2(保持 2 的幂)

java 复制代码
// JDK 1.8 resize 源码片段
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
    oldCap >= DEFAULT_INITIAL_CAPACITY)
    newThr = oldThr << 1; // 阈值也翻倍

5.3 扩容后元素的位置变化

扩容后,元素在新数组中的位置有两种可能:

复制代码
旧容量 n = 16,新容量 = 32

旧下标 = hash & 15(01111)
新下标 = hash & 31(11111)

差别就在第 5 位(从0开始数第4位):
- 如果 hash 的第 5 位是 0 → 新下标 = 旧下标
- 如果 hash 的第 5 位是 1 → 新下标 = 旧下标 + 旧容量

JDK 1.8 的优化: 不需要重新计算 hash,只需要看 hash 值的那一位是 0 还是 1,就能确定新位置。

java 复制代码
// 源码中的判断
if ((e.hash & oldCap) == 0) {
    // 放在原位置的链表
    ...
} else {
    // 放在 原位置 + oldCap 的链表
    ...
}

e.hash & oldCap:oldCap 是 2 的幂,二进制只有一位是 1,和 hash 做与运算就是看 hash 对应的那一位是 0 还是 1。

5.4 JDK 1.7 扩容的死循环问题

JDK 1.7 用头插法,多线程并发扩容时可能导致链表形成环,后续 get 操作会死循环(CPU 100%)。

JDK 1.8 用尾插法,扩容后保持元素原有顺序,不会形成环。

但 HashMap 本来就是线程不安全的! 不要在多线程中使用 HashMap,请用 ConcurrentHashMap。

5.5 为什么加载因子是 0.75?

这是时间和空间的权衡:

  • 加载因子太大(如 1.0):空间利用率高,但冲突概率大,链表变长,查询效率下降
  • 加载因子太小(如 0.5):冲突少,查询快,但空间浪费严重,频繁扩容

0.75 是一个经验值,在时间和空间之间取得了较好的平衡。


六、Get 流程

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

    // 1. 数组不为空且桶头不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {

        // 2. 桶头就是目标节点
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;

        if ((e = first.next) != null) {
            // 3. 是红黑树 → 树中查找
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);

            // 4. 是链表 → 遍历查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

流程总结:

  1. 计算 hash 值
  2. 数组为空 → 返回 null
  3. 找到对应桶,桶头就是目标 → 直接返回
  4. 是红黑树 → 树中查找(O(log n))
  5. 是链表 → 遍历查找(O(n))
  6. 找不到 → 返回 null

七、思维导图速览

复制代码
HashMap 核心
├── 底层结构
│   ├── JDK 1.7:数组 + 链表(头插法)
│   └── JDK 1.8:数组 + 链表 + 红黑树(尾插法)
│       ├── 链表转红黑树:链表>=8 且 数组>=64
│       ├── 红黑树退链表:节点<=6
│       └── 默认容量 16、加载因子 0.75、容量必须 2 的幂
├── Hash 冲突
│   ├── 定义:不同 key 算到同一个下标
│   ├── 解决方案:链地址法(链表+红黑树)
│   ├── 扰动函数:高16位 ^ 低16位(减少冲突)
│   └── 寻址优化:hash & (n-1) 等价于 hash % n(位运算更快)
├── Put 流程
│   ├── 1. 计算 hash(扰动函数)
│   ├── 2. 数组为空 → resize 初始化
│   ├── 3. 位置为空 → 直接插入
│   ├── 4. key 相同 → 覆盖 value
│   ├── 5. 红黑树 → 树插入
│   ├── 6. 链表 → 尾插,长度>=8尝试转树
│   ├── 7. modCount++
│   └── 8. size > threshold → resize 扩容
├── 扩容机制
│   ├── 扩容时机:size > threshold
│   ├── 扩容大小:容量 × 2、阈值 × 2
│   ├── 元素重定位:
│   │   ├── hash & oldCap == 0 → 原位置
│   │   └── hash & oldCap == 1 → 原位置 + oldCap
│   ├── JDK 1.7 头插法 → 死循环问题
│   └── JDK 1.8 尾插法 → 保持顺序,不会死循环
└── Get 流程
    ├── 计算 hash → 定位桶 → 判断桶头
    ├── 红黑树 → 树查找 O(log n)
    └── 链表 → 遍历查找 O(n)

八、写在最后

学习建议

  1. put 流程必须能手写出来:这是 HashMap 面试的核心,说不清楚直接挂
  2. 扩容机制要理解透:为什么是 2 倍、为什么用位运算、扩容后元素位置怎么变
  3. 红黑树转换条件要记准 :链表长度 >= 8 数组容量 >= 64,两个条件缺一不可
  4. JDK 1.7 和 1.8 的区别:头插 vs 尾插、数据结构变化、死循环问题,都是高频考点
  5. 建议读源码:putVal 和 resize 两个方法加起来也不长,对着注释读一遍比背十遍都管用

面试高频题(附答题思路)

Q1:HashMap 的底层结构?

JDK 1.8 是数组 + 链表 + 红黑树。数组是主体,冲突用链表解决,链表长度 >= 8 且数组容量 >= 64 时转红黑树优化查询效率。

Q2:HashMap 的 put 流程?

按上面的流程图说,8步说清楚,重点说清楚链表和红黑树的分支判断。

Q3:为什么容量必须是 2 的幂?

为了用 hash & (n-1) 位运算代替取模,效率更高,而且只要 n 是 2 的幂,这个等式就成立。

Q4:为什么重写 equals 必须重写 hashCode?

HashMap 判断 key 是否相同的逻辑是:先比 hash,再比 equals。如果 equals 相同但 hashCode 不同,会被放到不同的桶里,导致同一个 key 可以存多次,违反 Map 语义。

Q5:HashMap 是线程安全的吗?

不是。JDK 1.7 多线程扩容会有死循环问题,JDK 1.8 虽然不会死循环但仍有数据丢失等问题。多线程用 ConcurrentHashMap。

Q6:加载因子为什么是 0.75?

时间和空间的权衡。太大冲突多、查询慢;太小浪费空间、扩容频繁。0.75 是经验值,综合性能最好。