网站公告

本次更新:没啥更新的

“钱塘江上潮信起,今日方知我是我”

Skip to content

HashMap(附源码) ​

字数: 0 字 时长: 0 分钟

Java HashMap ​

字数: 0 字 时长: 0 分钟

HashMap 是 Java 中常用的数据结构之一,用于存储键值对。

在 HashMap 中,每个 Key 对应一个 Value,可以通过 Key 快速访问对应的 Value,理想情况下时间复杂度可以达到:

text
O(1)

下面先来看一个简单的增删改查示例。

1)增加元素

将一个键值对添加到 HashMap 中,可以使用 put() 方法。

例如,将名字和年龄作为键值对保存:

java
HashMap<String, Integer> map =
        new HashMap<>();

map.put(
        "赵敏",
        20
);

map.put(
        "小昭",
        25
);

2)删除元素

从 HashMap 中删除一个键值对,可以使用 remove() 方法:

java
map.remove(
        "赵敏"
);

3)修改元素

修改 HashMap 中某个 Key 对应的 Value,同样可以使用 put() 方法:

java
map.put(
        "小昭",
        30
);

为什么修改和添加使用的是同一个方法?

因为:

text
HashMap 的 Key 是唯一的
    ↓
再次 put 相同的 Key
    ↓
新的 Value 会覆盖原来的 Value

具体原因后面继续分析。

4)查找元素

根据 Key 查找对应的 Value,可以使用 get():

java
int age =
        map.get(
                "小昭"
        );

在实际开发中,HashMap 可以用于:

text
缓存

索引

例如:

text
用户 ID
    ↓
Key


用户信息
    ↓
Value

将用户信息保存到 HashMap 中,就可以根据用户 ID 快速查找。

又比如:

text
关键字
    ↓
Key


文档 ID 列表
    ↓
Value

可以将文档索引保存到 HashMap 中,方便快速搜索文档。

HashMap 的实现原理基于:

text
哈希表

底层是一个数组。

数组中的每一个位置可能保存:

text
一个键值对

链表

红黑树

当添加一个键值对时:

text
Key
    ↓
计算 Hash
    ↓
计算数组下标
    ↓
找到对应桶
    ↓
保存键值对

通过 Key 查找 Value 时也是类似过程:

text
Key
    ↓
计算 Hash
    ↓
计算数组下标
    ↓
找到对应桶
    ↓
查找对应 Value

1. hash 方法的原理 ​

简单了解 HashMap 后,先讨论第一个问题:

HashMap 的 hash 方法到底在做什么?

理解这个方法,对后面理解 HashMap 非常重要。

JDK 8 中 HashMap 的 hash() 方法:

java
static final int hash(
        Object key) {

    int h;

    return (
            key == null
    )
            ? 0
            : (
                    h = key.hashCode()
            ) ^ (
                    h >>> 16
            );
}

这段代码的作用就是:

对 Key 的 hashCode() 进行处理,得到 HashMap 最终使用的哈希值。

例如:

java
HashMap<String, String> map =
        new HashMap<>();

map.put(
        "zhaomin",
        "赵敏"
);

来看一下 put():

java
public V put(
        K key,
        V value) {

    return putVal(
            hash(key),
            key,
            value,
            false,
            true
    );
}

可以看到:

java
put()

内部首先调用了:

java
hash(key)

1.1 hash 方法的作用 ​

前面说过,HashMap 底层使用数组。

第一次真正创建数组后,默认容量为:

text
16

也就是说,向 HashMap 中添加元素时,需要根据 Key 的哈希值,在长度为 16 的数组中找到一个位置。

可以把数组想象成:

text
16 个桶

每一个桶都有自己的数组下标。

image-20250304221845262

现在需要把:

text
Key   = zhaomin

Value = 赵敏

放进这 16 个桶中的一个。

怎么确定位置?

核心计算:

java
(n - 1) & hash

其中:

text
n
    ↓
数组长度


hash
    ↓
通过 hash() 方法处理后的哈希值

例如:

java
map.put(
        "zhaomin",
        "赵敏"
);

计算以后的位置为:

text
8

也就是说:

text
zhaomin → 赵敏

会放到:

text
数组下标 8

对应的桶中。

所以 HashMap 保存键值对时:

text
hash()

对于计算元素的位置非常重要。

再回到源码:

java
static final int hash(
        Object key) {

    int h;

    return (
            key == null
    )
            ? 0
            : (
                    h = key.hashCode()
            ) ^ (
                    h >>> 16
            );
}

解释一下:

text
key
    ↓
需要计算哈希值的 Key

如果:

java
key == null

则:

text
hash = 0

也就是说,Key 为 null 时,对应的哈希值为 0。

否则先执行:

java
key.hashCode()

得到原始哈希值。

然后:

java
h >>> 16

把哈希值无符号右移 16 位。

最后:

java
h ^ (h >>> 16)

将两部分进行异或运算。

其中:

text
^
    ↓
异或运算

规则:

text
相同
    ↓
0


不同
    ↓
1

最终返回经过处理后的哈希值。

理论上,哈希值是一个 int 类型,范围非常大,大约拥有 40 亿级别的映射空间。

如果哈希值能够分布得足够均匀:

text
哈希冲突

出现的概率就会降低。

但显然,我们不可能创建一个长度达到几十亿的数组。

HashMap 第一次真正初始化数组以后:

text
容量通常只有 16

所以原始哈希值不能直接作为数组下标。

必须进一步通过:

java
(n - 1) & hash

将哈希值映射到数组范围之内。

1.2 取模运算 VS 取余运算 VS 与运算 ​

这里顺便梳理一下:

text
取模

取余

位与

之间的关系。

严格来说:

text
Modulo Operation
取模运算


Remainder Operation
取余运算

并不是完全相同的概念。

Java 中:

java
%

通常表示取余。

而:

java
Math.floorMod()

表示取模。

主要区别:

text
操作数都是正数
    ↓
取模和取余结果相同


存在负数
    ↓
结果可能不同

原因:

text
取模
    ↓
商向负无穷靠近


取余
    ↓
商向 0 靠近

另外,当数组长度是 2 的幂时:

text
取模 / 取余

可以通过:

text
位运算

来完成。

来看一个例子:

java
int a =
        -7;

int b =
        3;

// 取余
int remainder =
        a % b;

// 取模
int modulus =
        Math.floorMod(
                a,
                b
        );

System.out.println(
        "数字: a = "
                + a
                + ", b = "
                + b
);

System.out.println(
        "取余 (%): "
                + remainder
);

System.out.println(
        "取模 (Math.floorMod): "
                + modulus
);

// 改变正负情况
a = 7;

b = -3;

remainder =
        a % b;

modulus =
        Math.floorMod(
                a,
                b
        );

System.out.println(
        "\n数字: a = "
                + a
                + ", b = "
                + b
);

System.out.println(
        "取余 (%): "
                + remainder
);

System.out.println(
        "取模 (Math.floorMod): "
                + modulus
);

输出:

text
数字: a = -7, b = 3
取余 (%): -1
取模 (Math.floorMod): 2

数字: a = 7, b = -3
取余 (%): 1
取模 (Math.floorMod): -2

为什么会出现不同的结果?

先看普通除法。

例如:

text
7 ÷ 3

得到:

text
商 = 2

余数 = 1

因为:

text
7 = 3 × 2 + 1

1)取余

余数基于普通除法。

它的商:

text
趋向于 0

例如:

java
-7 % 3

可以考虑两种情况:

text
商 -2
余 -1

或者:

text
商 -3
余 2

因为取余时商趋近于:

text
0

所以选择:

text
-2

最终余数:

text
-1

2)取模

取模同样基于除法,只不过商:

text
趋向于负无穷

例如:

java
Math.floorMod(
        -7,
        3
);

因为:

text
-3

比:

text
-2

更靠近负无穷,所以最终结果:

text
2

需要注意:

text
取模

取余

都建立在除法基础上,因此:

text
除数不能为 0

3)与运算

当操作数都是正数时:

text
取模

取余

结果一样。

例如:

text
7 % 3 = 1

普通除法:

text
7 / 3
    ↓
商 2
余 1

因此在很多资料中会把:

text
取模

取余

混在一起说。

严格来说:

在操作数均为正数时,两者结果一致;出现负数时,两者可能不同。

对于 HashMap 来说,需要根据:

text
hash

和:

text
table.length

计算数组位置。

可以简单理解成:

java
hash % table.length

例如数组长度:

text
3

当:

text
hash = 7

则:

text
7 % 3 = 1

元素落在:

text
index = 1

当:

text
hash = 8

则:

text
8 % 3 = 2

落在:

text
index = 2

当:

text
hash = 9

则:

text
9 % 3 = 0

落在:

text
index = 0

数组三个位置:

text
0

1

2

都可以被利用。

那为什么 HashMap 并没有直接使用:

java
%

而是使用:

java
&

?

因为当数组长度是:

text
2 的 n 次方

时:

text
hash & (length - 1)

和:

text
hash % length

结果一致。

例如:

text
9 % 4 = 1

二进制:

text
9
    ↓
1001


4 - 1 = 3
    ↓
0011

执行:

text
1001
&
0011
----
0001

结果:

text
1

再例如:

text
10 % 4 = 2

二进制:

text
10
    ↓
1010


3
    ↓
0011

执行:

text
1010
&
0011
----
0010

结果:

text
2

但如果数组长度不是:

text
2 的 n 次方

结果就可能不同。

例如:

text
7 % 3 = 1

但:

text
7
    ↓
0111


3 - 1 = 2
    ↓
0010

执行:

text
0111
&
0010
----
0010

结果:

text
2

已经和:

text
7 % 3 = 1

不同。

为什么长度是 2 的幂时会如此巧合?

因为:

text
hash / 2ⁿ

相当于:

text
hash >> n

高位表示商,而被移出去的低位就是:

text
hash % 2ⁿ

例如:

text
8 = 2³

二进制:

text
1000

而:

text
8 - 1 = 7

二进制:

text
0111

这三个低位全部都是:

text
1

所以:

java
hash & 7

实际上就是:

只保留 hash 的最低 3 位。

例如:

text
26
    ↓
11010

计算:

text
26 % 8

因为:

text
8 = 2³

只需要关注最低三位:

text
010

转成十进制:

text
2

所以:

text
26 % 8 = 2

当执行:

java
hash & (length - 1)

时,本质也是保留最低 n 位,其余高位清零。

例如:

text
hash = 14

length = 8

length - 1 = 7

计算:

text
  1110
& 0111
------
  0110

结果:

text
6

也就是:

text
14 % 8 = 6

于是:

text
hash % length

和:

text
hash & (length - 1)

形成闭环。

HashMap 中,这种计算主要会出现在:

text
put

get

过程中。

put() 最终会调用:

java
putVal()

源码片段:

java
final V putVal(
        int hash,
        K key,
        V value,
        boolean onlyIfAbsent,
        boolean evict) {

    // 数组
    HashMap.Node<K, V>[] tab;

    // 当前节点
    HashMap.Node<K, V> p;

    // n 为数组长度
    // i 为数组下标
    int n;
    int i;

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

        // 第一次扩容
        n =
                (
                        tab = resize()
                ).length;
    }

    // 根据 hash 计算桶位置
    if (
            (
                    p = tab[
                            i = (
                                    n - 1
                            ) & hash
                    ]
            ) == null
    ) {

        tab[i] =
                newNode(
                        hash,
                        key,
                        value,
                        null
                );
    }

    // ...
}

其中:

java
(n - 1) & hash

负责计算:

text
数组下标

从 HashMap 中 get() 时,也会再次出现同样的逻辑。

例如 getNode():

java
final Node<K, V> getNode(
        int hash,
        Object key) {

    Node<K, V>[] tab;

    Node<K, V> first;
    Node<K, V> 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
        ) {

            do {

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

                    return e;
                }

            } while (
                    (
                            e = e.next
                    ) != null
            );
        }
    }

    return null;
}

可以看到:

java
(n - 1) & hash

再次出现。

简单理解:

text
put
    ↓
根据 hash 计算桶下标
    ↓
把键值对放入桶


get
    ↓
根据 hash 计算桶下标
    ↓
从桶中查找键值对

1.3 取模运算 % 和位运算 & ​

可能会产生一个疑问:

取模不是应该使用 % 吗?为什么 HashMap 使用 &?

原因之一就是:

text
&

可以直接进行位运算。

并且当:

text
b = 2ⁿ

时:

text
a % b
=
a & (b - 1)

也就是:

text
a % 2ⁿ
=
a & (2ⁿ - 1)

例如:

text
a = 14

b = 8 = 2³

首先:

text
14 % 8 = 6

14 的二进制:

text
1110

8:

text
1000

8 - 1:

text
7
    ↓
0111

进行:

text
1110
&
0111
----
0110

结果:

text
6

和:

text
14 % 8

结果相同。

这也解释了:

为什么 HashMap 数组容量通常保持为 2 的幂。

因为:

text
length - 1

正好可以形成一个:

text
低位掩码

例如:

text
16
    ↓
10000


16 - 1
    ↓
01111

& 的规则:

text
两位都是 1
    ↓
1


否则
    ↓
0

例如:

text
5 & 3

二进制:

text
0101
&
0011
----
0001

结果:

text
1

HashMap 初始数组长度:

text
16

那么:

text
16 - 1
    ↓
15

15 的二进制:

text
00000000 00000000 00001111

假设某个哈希值:

text
10100101 11000100 00100101

和:

text
00000000 00000000 00001111

进行 &:

text
   10100101 11000100 00100101
&  00000000 00000000 00001111
--------------------------------
   00000000 00000000 00000101

高位全部被清零,只保留最低几位:

text
0101

即:

text
5

所以这个 Key 对应的键值对最终会被放到:

text
数组下标 5

的位置。

如果前面的二进制暂时看不懂,也没关系。

记住:

text
(n - 1) & hash
    ↓
计算 HashMap 的数组下标

即可。

1.4 为什么取模运算之前要调用 hash 方法 ​

来看这个图:

image-20250304222502371

假设某个哈希值为:

text
11111111 11111111 11110000 11101010

执行:

java
h >>> 16

相当于把高 16 位移动到低位:

text
00000000 00000000 11111111 11111111

然后:

java
h ^ (h >>> 16)

进行异或。

结果:

text
11111111 11111111 00001111 00010101

异或:

text
XOR
^

规则:

text
相同
    ↓
0


不同
    ↓
1

经过:

java
h ^ (h >>> 16)

之后:

text
原哈希值的高位信息
    ↓
混入低位

这样可以增强低位的随机性。

然后再与:

text
数组长度 - 1

进行 & 运算。

假设数组长度为:

text
16

则:

text
length - 1 = 15

二进制:

text
00000000 00000000 00000000 00001111

最终得到数组下标:

text
00000000 00000000 00000000 00000101

也就是:

text
5

再看之前假设的另外一个哈希值:

text
00000000 10100101 11000100 00100101

执行:

java
h >>> 16

得到:

text
00000000 00000000 00000000 10100101

再执行:

java
h ^ (h >>> 16)

得到:

text
00000000 10100101 00111011 10000000

然后和:

text
length - 1

进行 &:

text
00000000 00000000 00000000 00001111

最终:

text
00000000 00000000 00000000 00000000

也就是:

text
index = 0

所以:

hash 方法的作用就是对原始 hashCode 进行进一步优化,将高位信息混入低位。

可以简单理解:

text
key.hashCode()
    ↓
h
    ↓
h >>> 16
    ↓
高位移动到低位
    ↓
h ^ (h >>> 16)
    ↓
高低位混合
    ↓
得到新的 hash

这样可以让数据在数组中的分布更加均衡,减少哈希冲突。

1.5 小结 ​

hash() 方法的主要作用:

text
获取 Key 的 hashCode
    ↓
高 16 位右移
    ↓
与原 hashCode 异或
    ↓
得到新的 hash

然后:

text
hash
    ↓
(n - 1) & hash
    ↓
得到 Bucket 下标

HashMap 底层:

text
数组
    ↓
Bucket

桶中可能保存:

text
单个节点

链表

红黑树

整个过程:

text
Key
    ↓
hashCode()
    ↓
hash()
    ↓
(n - 1) & hash
    ↓
数组下标
    ↓
Bucket

这个过程直接影响 HashMap 中键值对的查找效率。

2. HashMap 的扩容机制 ​

数组一旦初始化以后:

text
长度不能改变

所以:

text
ArrayList

需要通过创建新数组完成扩容。

HashMap 的底层同样使用数组。

随着 HashMap 中元素不断增加:

text
数组容量不足
    ↓
需要扩容

扩容以后:

text
桶数量增加
    ↓
部分冲突可能被分散
    ↓
查询效率可能提高

来看一个例子,先把容量看作:

text
16
java
HashMap<String, String> map =
        new HashMap<>();

map.put(
        "zhangwuji",
        "张无忌"
);

map.put(
        "zhaomin",
        "赵敏"
);

map.put(
        "zhouzhiruo",
        "周芷若"
);

map.put(
        "xiaozhao",
        "小昭"
);

map.put(
        "yinyewang",
        "殷野王"
);

map.put(
        "yideng",
        "殷离"
);

map.put(
        "yangxiao",
        "杨逍"
);

map.put(
        "weiyixiao",
        "韦一笑"
);

// 遍历 HashMap
for (
        String key :
                map.keySet()
) {

    int h;

    int n =
            16;

    int hash =
            (
                    key == null
            )
                    ? 0
                    : (
                            h = key.hashCode()
                    ) ^ (
                            h >>> 16
                    );

    int index =
            (
                    n - 1
            ) & hash;

    System.out.println(
            key
                    + "的hash值 : "
                    + hash
                    + " 的索引 : "
                    + index
    );
}

输出:

text
zhangwuji的hash值 : -1460972958 的索引 : 2
yangxiao的hash值 : -431770781 的索引 : 3
weiyixiao的hash值 : 1854843780 的索引 : 4
zhouzhiruo的hash值 : -1262322553 的索引 : 7
yideng的hash值 : -730738309 的索引 : 11
zhaomin的hash值 : -323244979 的索引 : 13
xiaozhao的hash值 : 265727213 的索引 : 13
yinyewang的hash值 : 1055043855 的索引 : 15

可以看到:

text
zhaomin

xiaozhao

最终落到了同一个:

text
index = 13

也就是说发生了:

text
哈希冲突

需要通过:

text
拉链法

把它们放到同一个桶中。

此时查询的时候:

text
不能只定位数组
    ↓
还需要继续查找桶中的节点

如果把数组长度:

text
16

扩容为:

text
32

将前面代码中的:

java
n = 16;

改成:

java
n = 32;

输出:

text
zhangwuji的hash值 : -1460972958 的索引 : 2
yangxiao的hash值 : -431770781 的索引 : 3
weiyixiao的hash值 : 1854843780 的索引 : 4
zhouzhiruo的hash值 : -1262322553 的索引 : 7
yideng的hash值 : -730738309 的索引 : 27
zhaomin的hash值 : -323244979 的索引 : 13
xiaozhao的hash值 : 265727213 的索引 : 13
yinyewang的hash值 : 1055043855 的索引 : 15

可以看到:

text
zhaomin

xiaozhao

仍然都位于:

text
13

数组本身不会自动扩容。

所以 HashMap 扩容需要:

text
创建更大的数组
    ↓
转移旧数组中的元素
    ↓
重新确定元素位置

整个过程本身是比较耗时的。

2.1 resize 方法 ​

HashMap 的扩容通过:

java
resize()

实现。

JDK 8 引入了红黑树:

text
链表节点较多
    ↓
可能树化

源码理解起来会更加复杂。

先来看 JDK 7 的扩容逻辑。

JDK 7 的 resize():

java
/**
 * newCapacity 为新的容量
 */
void resize(
        int newCapacity) {

    // 保存旧数组
    Entry[] oldTable =
            table;

    // 旧容量
    int oldCapacity =
            oldTable.length;

    // 达到最大容量
    if (
            oldCapacity
                    == MAXIMUM_CAPACITY
    ) {

        threshold =
                Integer.MAX_VALUE;

        return;
    }

    // 创建新的大数组
    Entry[] newTable =
            new Entry[
                    newCapacity
            ];

    // 将旧数组元素转移到新数组
    transfer(
            newTable,
            initHashSeedAsNeeded(
                    newCapacity
            )
    );

    // table 指向新数组
    table =
            newTable;

    // 重新计算阈值
    threshold =
            (
                    int
            ) Math.min(
                    newCapacity
                            * loadFactor,
                    MAXIMUM_CAPACITY
                            + 1
            );
}

这个方法接收:

text
newCapacity

也就是新的容量。

首先:

text
oldTable
    ↓
保存旧数组


oldCapacity
    ↓
保存旧容量

如果旧容量已经达到:

text
MAXIMUM_CAPACITY

即:

text
2³⁰

则:

java
threshold =
        Integer.MAX_VALUE;

HashMap 不再继续扩容。

接着:

java
Entry[] newTable =
        new Entry[newCapacity];

创建一个更大的新数组。

然后调用:

java
transfer()

把旧数组中的元素转移到新数组。

转移完成以后:

java
table =
        newTable;

让 HashMap 内部的 table 指向新的数组。

最后重新计算:

text
threshold
扩容阈值

2.2 新容量 newCapacity ​

那:

text
newCapacity

是怎么计算出来的?

JDK 7 中:

java
int newCapacity =
        oldCapacity * 2;

if (
        newCapacity < 0
                || newCapacity
                >= MAXIMUM_CAPACITY
) {

    newCapacity =
            MAXIMUM_CAPACITY;

} else if (
        newCapacity
                < DEFAULT_INITIAL_CAPACITY
) {

    newCapacity =
            DEFAULT_INITIAL_CAPACITY;
}

也就是说:

text
新容量
    ↓
旧容量 × 2

如果超过:

text
MAXIMUM_CAPACITY

就使用最大容量。

如果小于默认初始容量:

text
16

则使用:

text
16

Java 8 中,新容量的计算方式出现了一些变化:

java
int newCapacity =
        oldCapacity << 1;

if (
        newCapacity
                >= DEFAULT_INITIAL_CAPACITY
                && oldCapacity
                >= DEFAULT_INITIAL_CAPACITY
) {

    if (
            newCapacity
                    > MAXIMUM_CAPACITY
    ) {

        newCapacity =
                MAXIMUM_CAPACITY;
    }

} else {

    if (
            newCapacity
                    < DEFAULT_INITIAL_CAPACITY
    ) {

        newCapacity =
                DEFAULT_INITIAL_CAPACITY;
    }
}

注意:

java
oldCapacity * 2

变成了:

java
oldCapacity << 1

这里出现:

text
左移
<<

例如:

java
int a =
        39;

int b =
        a << 2;

十进制:

text
39

用 8 位二进制表示:

text
00100111

左移两位:

text
10011100

转换回十进制:

text
156

也就是:

text
39 × 4

可以简单记:

text
左移 1 位
    ↓
约等于 ×2


左移 2 位
    ↓
约等于 ×4


左移 3 位
    ↓
约等于 ×8

所以:

java
oldCapacity << 1

表示:

text
容量扩大 2 倍

2.3 transfer 方法 ​

接着来看:

java
transfer()

它负责:

把旧数组中的元素转移到新的大数组。

JDK 7:

java
void transfer(
        Entry[] newTable,
        boolean rehash) {

    // 新容量
    int newCapacity =
            newTable.length;

    // 遍历旧数组
    for (
            Entry<K, V> entry :
                    table
    ) {

        while (
                entry != null
        ) {

            // 保存链表中的下一个节点
            Entry<K, V> next =
                    entry.next;

            // 是否重新计算 hash
            if (rehash) {

                entry.hash =
                        (
                                entry.key
                                        == null
                        )
                                ? 0
                                : hash(
                                        entry.key
                                );
            }

            // 计算在新数组中的位置
            int index =
                    indexFor(
                            entry.hash,
                            newCapacity
                    );

            // 使用头插法
            entry.next =
                    newTable[index];

            // 放到新数组
            newTable[index] =
                    entry;

            // 继续处理链表中的下一个节点
            entry =
                    next;
        }
    }
}

参数:

text
newTable
    ↓
新的 HashMap 数组


rehash
    ↓
是否重新计算 hash

整个过程:

text
遍历旧数组
    ↓
遍历桶中的链表
    ↓
计算新数组下标
    ↓
把节点放到新数组

其中:

java
entry.next =
        newTable[index];

newTable[index] =
        entry;

使用的是:

text
链表头插法

2.4 拉链法 ​

注意:

java
entry.next =
        newTable[index];

说明 JDK 7 转移链表节点时使用:

text
头插法

也就是说:

text
同一个桶中新转移的节点
    ↓
放到链表头部

因此原来链表中的顺序可能发生变化。

同时,扩容以后重新计算数组位置:

text
原来同一个桶中的节点
    ↓
可能进入新数组不同的桶

Java 8 对这里进行了比较大的优化。

2.5 Java 8 扩容 ​

JDK 8 的 resize():

java
final Node<K, V>[] resize() {

    Node<K, V>[] oldTab =
            table;

    int oldCap =
            (
                    oldTab == null
            )
                    ? 0
                    : oldTab.length;

    int oldThr =
            threshold;

    int newCap;
    int 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;

        newThr =
                (
                        int
                ) (
                        DEFAULT_LOAD_FACTOR
                                * DEFAULT_INITIAL_CAPACITY
                );
    }

    // 计算新的扩容阈值
    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;

                if (
                        e.next == null
                ) {

                    newTab[
                            e.hash
                                    & (
                                    newCap - 1
                            )
                    ] = e;

                } else if (
                        e
                                instanceof
                                TreeNode
                ) {

                    (
                            (
                                    TreeNode<K, V>
                            ) e
                    ).split(
                            this,
                            newTab,
                            j,
                            oldCap
                    );

                } else {

                    Node<K, V> loHead =
                            null;

                    Node<K, V> loTail =
                            null;

                    Node<K, V> hiHead =
                            null;

                    Node<K, V> hiTail =
                            null;

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

整体过程:

text
1. 获取旧数组 table、oldCap 和 oldThr

2. 根据旧容量计算 newCap 和 newThr

3. 如果旧数组为空但 oldThr 不为 0
   将 oldThr 作为新数组容量

4. 如果旧数组和 oldThr 都为 0
   使用默认容量 16
   使用默认负载因子 0.75 计算阈值

5. 计算新的 threshold

6. 创建 newTab,并让 table 指向它

7. 遍历 oldTab,将元素转移到 newTab

8. 返回 newTab

JDK 7 中定位元素的位置:

java
static int indexFor(
        int h,
        int length) {

    return h
            & (
                    length - 1
            );
}

其实就可以理解成:

text
hashCode % table.length

假设:

text
table.length = 2

Key 的哈希值分别为:

text
3

7

5

经过计算以后都发生了哈希冲突,进入:

text
table[1]

扩容之前:

image-20250304231546152

数组容量:

text
2

Key:

text
3

7

5

全部落在:

text
table[1]

因此通过:

text
拉链法

解决冲突。

假设:

text
loadFactor = 1

也就是说,元素数量超过数组长度时发生扩容。

扩容后:

text
容量 2
    ↓
容量 4

重新计算位置:

text
3 % 4
    ↓
3


7 % 4
    ↓
3


5 % 4
    ↓
1

所以:

text
3
    ↓
table[3]


7
    ↓
table[3]


5
    ↓
table[1]

示意图:

image-20250304231607645

由于 JDK 7 使用:

text
头插法

所以:

text
7

跑到了:

text
3

前面。

对应代码:

java
e.next =
        newTable[i];

同时:

text
5

被重新分配到了:

text
index = 1

JDK 8 对扩容过程进行了优化。

JDK 8 中可以简单看:

java
Node<K, V>[] newTab =
        new Node[
                newCapacity
        ];

for (
        int j = 0;
        j < oldTab.length;
        j++
) {

    Node<K, V> e =
            oldTab[j];

    if (e != null) {

        int hash =
                e.hash;

        int newIndex =
                hash
                        & (
                        newCapacity - 1
                );

        newTab[newIndex] =
                e;
    }
}

新位置仍然根据:

java
hash & (newCapacity - 1)

确定。

JDK 8 的 hash():

java
static final int hash(
        Object key) {

    int h;

    return (
            key == null
    )
            ? 0
            : (
                    h = key.hashCode()
            ) ^ (
                    h >>> 16
            );
}

也就是:

text
32 位 hashCode
    ↓
无符号右移 16 位
    ↓
与原 hashCode 异或

JDK 7 的 hash 算法:

java
final int hash(
        Object key) {

    int h =
            hashSeed;

    if (
            h != 0
                    && key
                    instanceof
                    String
    ) {

        return sun.misc.Hashing
                .stringHash32(
                        (
                                String
                        ) key
                );
    }

    h ^=
            key.hashCode();

    h ^=
            (
                    h >>> 20
            ) ^ (
                    h >>> 12
            );

    return h
            ^ (
                    h >>> 7
            )
            ^ (
                    h >>> 4
            );
}

再来看 JDK 8 扩容后的索引规律。

假设扩容前:

text
数组长度 = 16

则:

text
n - 1
    ↓
15
    ↓
0000 1111

假设:

text
key1 = 5
    ↓
0000 0101


key2 = 21
    ↓
0001 0101

扩容前:

text
key1 & 15
    ↓
0000 0101
    ↓
5


key2 & 15
    ↓
0000 0101
    ↓
5

发生:

text
哈希冲突

现在 HashMap 扩容:

text
16
    ↓
32

新的:

text
n - 1
    ↓
31
    ↓
0001 1111

重新计算:

text
key1
    ↓
0000 0101
&
0001 1111
    ↓
0000 0101
    ↓
5

而:

text
key2
    ↓
0001 0101
&
0001 1111
    ↓
0001 0101
    ↓
21

也就是:

text
21
=
5 + 16

示意图:

image-20250304231720975

所以在 JDK 8 扩容时,元素新的位置:

text
要么保持原索引

要么
原索引 + 旧容量

示意图:

image-20250304231742561

这个规律离不开:

text
hash 算法

数组长度始终保持 2 的幂

两者共同作用。

2.6 小结 ​

不断向 HashMap 中添加元素时:

text
元素数量达到扩容阈值
    ↓
触发 resize()

扩容阈值和:

text
loadFactor

有关。

扩容时:

text
旧数组
    ↓
容量扩大
    ↓
创建新数组
    ↓
重新分配元素

HashMap 通常将容量扩展为原来的:

text
2 倍

JDK 8 中,原桶中的节点在扩容以后:

text
要么保持原索引

要么移动到:
原索引 + oldCap

3. 加载因子为什么是 0.75 ​

前面扩容机制中提到了:

text
Load Factor
加载因子 / 负载因子

那么为什么 HashMap 默认选择:

text
0.75

而不是:

text
0.6

0.8

?

HashMap 底层结构:

text
数组
+
链表 / 红黑树

添加或查找元素时:

text
Key
    ↓
hash()
    ↓
计算数组下标

先计算:

java
static final int hash(
        Object key) {

    int h;

    return (
            key == null
    )
            ? 0
            : (
                    h = key.hashCode()
            ) ^ (
                    h >>> 16
            );
}

然后:

java
index =
        (
                n - 1
        ) & hash;

这种结构需要面对两个问题:

text
数组容量太小
    ↓
容易产生哈希冲突


数组容量太大
    ↓
空间利用率降低

加载因子就是用来表示:

HashMap 中数据的填充程度。

可以理解:

text
加载因子
=
哈希表中的数据数量
/
哈希表长度

所以:

text
加载因子越小
    ↓
元素相对越少
    ↓
冲突概率降低
    ↓
但空间浪费更多
    ↓
扩容也更容易触发

反过来:

text
加载因子越大
    ↓
数组填得更满
    ↓
空间利用率提高
    ↓
但哈希冲突概率增加

因此必须在:

text
哈希冲突

空间利用率

之间寻找平衡。

HashMap 通过:

text
拉链法

解决哈希冲突。

为了降低冲突概率,当 HashMap 的元素数量达到一个临界值时:

text
触发扩容

这个临界值:

text
临界值
=
容量 × 加载因子

默认容量:

java
static final int
        DEFAULT_INITIAL_CAPACITY =
        1 << 4;

也就是:

text
16

默认加载因子:

java
static final float
        DEFAULT_LOAD_FACTOR =
        0.75f;

所以:

text
16 × 0.75
=
12

也就是说,当元素数量达到对应阈值时,就会触发扩容机制。

为什么选择:

text
0.75

?

这里会涉及统计学中的:

text
泊松分布
Poisson Distribution

泊松分布是一种常见的离散概率分布,用于描述一定时间、面积、空间等范围内随机事件发生次数的概率。

阮一峰老师曾经专门介绍过泊松分布和指数分布:

泊松分布和指数分布:10分钟教程

泊松分布公式:

image-20250305095354283

等号左侧:

text
P
    ↓
概率


N
    ↓
某种函数关系


t
    ↓
时间


n
    ↓
数量

HashMap 的源码注释中曾有一段描述:

Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:

text
0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

为了理解这段话,先回顾 HashMap 的:

text
拉链法

红黑树

Java 8 之前:

text
HashMap
    ↓
数组 + 链表

发生哈希冲突时:

text
多个 Key
    ↓
进入同一个桶
    ↓
形成链表

链表越来越长:

text
查询时需要遍历
    ↓
效率下降

Java 8 中:

text
链表节点数量达到一定阈值
    ↓
可能转换为红黑树

红黑树可以提高大量节点情况下的查询效率。

上面的源码说明大致表达的是:

TreeNode 的大小大约是普通 Node 的两倍,所以只有桶中的节点足够多时才会使用树结构。节点减少以后,还可能重新转换为普通结构。在 hashCode 分布良好的情况下,树结构很少真正出现。理想情况下,随机 hashCode 在桶中的节点数量近似符合泊松分布。

这段内容中提到了:

text
TREEIFY_THRESHOLD = 8

Load Factor = 0.75

这里还可以结合:

text
二项分布
Binomial Distribution

理解。

假设一次实验只有两个结果:

text
成功

失败

类似:

text
抛硬币
    ↓
正面 / 反面

如果进行:

text
N 次实验

每一次:

text
互相独立

并且成功概率相同,就可以建立相应概率模型。

对于 HashMap:

text
向哈希表放入数据
    ↓
没有发生哈希冲突
        ↓
成功


发生哈希冲突
        ↓
失败

可以假设 hash 值是随机的。

HashMap 容量为:

text
16

时,可以想象成:

text
掷一个 16 面骰子

每次 put:

text
Key
    ↓
随机落在一个桶中

如果两个结果一样:

text
发生冲突

我们的目标就是:

text
连续放入若干数据
    ↓
尽可能降低冲突概率

相关概率计算:

image-20250305095542871

希望这个概率:

text
> 0.5

于是:

image-20250305095600568

进一步希望求:

text
当数组长度为 s 时
    ↓
放入多少个元素 n
    ↓
应该发生扩容

负载因子:

text
n / s

继续推导:

image-20250305095617633

得到:

image-20250305095633614

其中:

image-20250305095703540

这是一个:

text
∞ · 0

形式的函数极限问题。

令:

text
s = m + 1

m → ∞

转化为:

image-20250305095720047

再令:

text
x = 1 / m

x → 0

得到:

image-20250305095736991

所以:

image-20250305095752547

另外 HashMap 容量要求保持:

text
2 的 n 次幂

当加载因子为:

text
0.75

时:

text
容量 × 负载因子

可以得到整数:

text
16 × 0.75 = 12

32 × 0.75 = 24

在:

text
0.5 ~ 1

之间还可以看到:

text
0.625
5 / 8


0.875
7 / 8

从中间值的角度来看:

text
0.75

也是一个比较合适的选择。

相关资料中也提到,拉链法的加载因子最好控制在:

text
0.7 ~ 0.8

左右。

超过一定范围以后,查表时 CPU Cache Miss 的情况也可能明显增加。

所以:

0.75 是 HashMap 在空间利用率与哈希冲突之间选择的一个平衡值。

3.1 小结 ​

HashMap 的加载因子:

text
Load Factor

表示:

text
哈希表中元素数量
/
桶数量

当元素数量达到:

text
容量 × Load Factor

时:

text
触发扩容

如果加载因子太大:

text
元素更密集
    ↓
空间利用率提高
    ↓
冲突概率提高
    ↓
查找、插入、删除效率可能下降

如果加载因子太小:

text
桶数量相对更多
    ↓
冲突减少
    ↓
但空间利用率下降

所以:

text
0.75

是在:

text
时间成本

空间成本

之间做出的平衡。

4. 线程不安全 ​

HashMap 是:

text
线程不安全

主要可以从三个方面理解:

text
多线程扩容可能出现问题

多线程 put 可能导致元素丢失

put 和 get 并发可能出现异常结果

4.1 多线程下扩容会死循环 ​

HashMap 通过:

text
拉链法

解决哈希冲突。

也就是:

text
多个 Key
    ↓
落到同一个桶
    ↓
通过链表保存

JDK 7 中:

text
链表采用头插法

新的冲突节点会插入到旧节点之前。

扩容过程中重新转移链表节点时,多线程并发修改链表结构,就可能造成:

text
环形链表

从而产生:

text
死循环

4.2 多线程下 put 会导致元素丢失 ​

正常情况下,发生哈希冲突时:

image-20250305095951393

如果多个线程同时执行:

java
put()

并且计算出的数组位置相同,就可能出现:

text
线程 A 准备写入节点

线程 B 同时准备写入节点

两个线程操作相同桶
    ↓
其中一次修改可能覆盖另一次修改
    ↓
导致元素丢失

4.3 put 和 get 并发时会导致 get 到 null ​

假设:

text
线程 1
    ↓
执行 put
    ↓
达到扩容阈值
    ↓
触发 resize

同时:

text
线程 2
    ↓
执行 get

就有可能出现问题。

示意图:

image-20250305100026677

线程 1 执行:

java
table = newTab;

以后,线程 2 看到的 table 也可能已经发生变化。

但此时:

text
旧元素
    ↓
还没有全部完成转移

线程 2 此时执行:

java
get()

就可能得到:

text
null

4.4 小结 ​

HashMap 线程不安全,主要是因为:

text
插入

删除

扩容

都会改变内部:

text
数组

链表

树

的结构。

如果一个线程正在操作 HashMap,而另外一个线程同时修改其内部结构:

text
可能造成数据丢失

可能得到错误结果

可能破坏内部结构

多线程环境下,如果需要线程安全的 Map,可以使用:

text
ConcurrentHashMap

ConcurrentHashMap 的实现会在单独的:

text
ConcurrentHashMap.md

中继续分析。

5. 小结 ​

HashMap 是 Java 中最常用的集合之一。

它是一种:

text
Key → Value

形式的键值存储结构,可以通过 Key 快速访问对应的 Value。

主要特点:

text
HashMap
    ↓
数组
+
链表 / 红黑树

理想情况下:

text
添加

删除

查找

都可以达到很高的效率。

HashMap:

text
线程不安全

多线程场景需要考虑:

text
ConcurrentHashMap

HashMap 的扩容机制:

text
元素不断增加
    ↓
达到 threshold
    ↓
resize()
    ↓
数组容量扩大
    ↓
旧元素重新分配

Java 8 中:

text
数组

链表

红黑树

共同组成了 HashMap 的主要底层结构。

HashMap 中:

text
Key 唯一

如果重复:

java
put(
        相同Key,
        新Value
);

后面的 Value 会覆盖前面的 Value。

HashMap 的容量和加载因子都可以设置。

默认:

text
初始容量
    ↓
16


加载因子
    ↓
0.75

HashMap 本身:

text
不保证遍历顺序

如果需要根据 Key 的顺序进行排序,可以使用:

text
TreeMap

综上:

HashMap 是一种高效的键值存储结构,核心在于 hash、数组下标计算、哈希冲突处理、扩容机制以及线程安全问题。

Noctiflorous Garden · MIT License