HashMap

HashMap

一、定义

1
2
3
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
}

二、实现

2.1 数据结构

HashMap 的底层数据存储结构是“数组 + 链表 + 红黑树”,红黑树结构是 JDK8 之后进行的优化。

1
transient Node<K,V>[] table;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static class Node<K,V> implements Map.Entry<K,V> {
final int 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 K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }

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

它的实际结构大概是这样子的:

1
2
3
4
5
6
7
8
9
  0    1    2    3    4    5    6    7 
____ ____ ____ ____ ____ ____ ____ ____
| | | 42 | | | 21 | 14 | 15 |
/ \ | |
26 58 22 7
/ \ / \ |
10 34 50 66 6
/ \
2 18

整个数组长度为8,而2那里放的就是红黑树,6、7那边则放的是链表。

实际上HashMap的数组长度这么短时,是不会有红黑树的,这里只是做个例子展示。

2.2 集合操作

1)查找元素

HashMap底层就是“数组 + 链表 + 红黑树”,所以查找也不是很麻烦,只要遍历数组的每一个链表或树就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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;
// 找到指定hash对应的数组位置
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;
}

查找的话,可以分为几步:

  1. 计算hash对应到table的位置first = table[(n - 1) & hash],如果first不为null,则进行下一步;
  2. 如果first就是所要寻找的元素,那么就直接返回;
  3. 如果first是一个树节点(TreeNode),那么就按照树的查找来找目标元素;
  4. 如果first是普通节点,就按照链表的顺序,从头到尾寻找目标元素。

理论上来说,只要知道了数据的结构,遍历寻找还是比较简单的。

2)删除元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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;
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;

// 1. 查找删除节点和它的父节点
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);
}
}

// 2. 找到指定节点的话就删除
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;
}

删除的处理逻辑也不复杂,就是源码写的花里胡哨:

  1. 先找到要删除的节点,以及它的父节点,查找过程和上面的类似;
  2. 如果是红黑树结构,就按照树结构来删除;
  3. 如果是链表结构,直接按照链表结构删除节点,即父节点指向孙子节点。

3)添加元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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) {
// 1. 数组还未初始化,新建数组
n = (tab = resize()).length;
}
if ((p = tab[i = (n - 1) & hash]) == null) {
// 2. hash对应的数组位置还未初始化,直接插入新节点
tab[i] = newNode(hash, key, value, null);
} else {
// 3. hash对应的数组位置已经初始化,即已存在链表或红黑树
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) {
// 3.1 表头或树根节点,即放在数组中的节点(用于性能优化)
e = p;
} else if (p instanceof TreeNode) {
// 3.2 红黑树结构
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
} else {
// 3.3 链表结构
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;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) {
// 超过阈值,扩容
resize();
}
afterNodeInsertion(evict);
return null;
}

添加的话,可以分为几步:

  1. table未初始化时,先初始化table
  2. hash所在的table位置未初始化。计算hash对应到table的位置p = table[(n - 1) & hash],如果p为null,则直接插入新节点到数组上
  3. 添加节点已存在时。如果p在table中已存在,不会新建节点,而是直接更新值
  4. 添加节点不存在时,分2种情况:
  • 插入结构是红黑树,如果p是一个树节点(TreeNode),也就是红黑树结构,那么就按照树结构来添加节点
  • 插入结构是链表,如果p是普通节点,也就是链表结构,把新节点添加到链表末尾。另外如果此时超过了阈值,链表会转化为红黑树

需要说明的一点就是,如果添加的元素在HashMap中已存在,那么就会更新对应节点的值,而不是插入新节点。

总的来说,集合的几个操作并不算复杂,复杂的是这些操作引起的扩容问题以及同hash值节点的数据结构变更(链表变树,树变链表)。

2.3 扩容机制

1)初始容量

HashMap的初始容量是指数组的长度,它要求数组的长度始终是“2的倍数”,和ArrayDueue的要求一样。

所以当传入的参数不符合要求时,就会对它进行修正:

1
2
3
4
5
6
7
8
9
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;
}

也就是说,如果传入的初始化容量不是“2的倍数”,会自动被修正为“2的倍数”。

比如,new HashMap(3)实际上底层的数组长度的初始长度会是 4,new HashMap(14)实际上底层的数组长度的初始长度会是 16。

至于为什么要取“2的倍数”,应该和ArrayDueue的一样,其实就是计算从hash值映射到数组时可以更高效:

  1. 计算机对于2倍数计算是很快的,直接通过移位操作(n << 1)就可以实现;
  2. 计算hash对应的数组位置时,一般需要取模,但如果容量是2的倍数,那么取模就可以用掩码(table[i = (n - 1) & hash]))来实现,会更高效一些。

2)负载因子

对于扩容,有一个问题,就是什么时候才需要重新调整数组容量?

因为理论上来说,“数组 + 链表 + 红黑树”是可以放置无数个节点的,是不是一直都不需要调整呢?

实际上不是,一旦数据多了起来,不论是链表,还是红黑树,它的节点都会变得越来越多,查找也会越来越慢,这个时候就需要减短它的节点数量,来提高性能。

HashMap 中,使用了负载因子来表示需要数组扩容的时机:

1
final float loadFactor;

为了了解负载因子的作用,先解释一下负载的意义。举个例子说明,比如当前结构如下:

1
2
3
4
5
6
7
  0    1    2    3    4    5    6    7 
____ ____ ____ ____ ____ ____ ____ ____
| 0 | 1 | 42 | 11 | | 21 | 14 | 15 |
| |
22 7
|
6

此时的元素数量是 10,数组长度是 8,那么实际负载就是 10 / 8 = 1.25

负载因子在一定意义上,代表了当前数据结构的饱满程度

假设HashMap的负载因子是0.75,那么对于上面的例子而已,它的负载值 1.25 > 0.75,实际上早就已经需要扩容了。

也就是说,在设定好指定的负载因子后,如果不断添加元素,实际负载也会不断增大,直到大于负载因子时,就需要进行扩容。

另外,HashMap为了减少负载的计算,一般都用阈值threshold来提前计算好会超过负载因子的最大元素数量。

1
threshold = table.length * loadFactor;

当元素的数量size > threshold时,就是实际负载超过负载因子的时候,这个时候就要进行扩容。

1
2
3
4
if (size > threshold) {
// 超过阈值,扩容
resize();
}

实际上,阈值threshold并不是一个必要的变量,只是为了提高性能,减少负载的计算(除法计算),而添加的。

3)扩容容量

HashMap 为了保持“2的倍数”容量,每次扩容都是之前的2倍。也就是 “新容量 = 旧容量 * 2”。

直接看一下扩容代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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;
}
// 正常情况下,新容量 = 旧容量 * 2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
} else if (oldThr > 0) {
// 数组第一次初始化时,如果初始化容量大于0,就用初始化容量,它也是2的倍数
newCap = oldThr;
} else {
// 数组第一次初始化时,如果初始化容量为0,就用默认的初始化容量DEFAULT_INITIAL_CAPACITY
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;

// 略掉后面的代码,在后面的4)中贴有
}

扩容容量的计算还算简单,主要可以分为几步:

  1. 如果不是第一次初始化数组,就对旧容量进行扩容,在没有达到最大容量的情况下,新容量 = 旧容量 * 2
  2. 如果是第一次初始化数组,并且初始化容量大于0,那么就用初始化容量作为新容量
  3. 如果是第一次初始化数组,并且初始化容量为0,那么就用默认初始化容量作为新容量

当然,代码中间还加上了一些溢出边界的判断,但是总体逻辑差不多就是这样。

4)数据迁移

扩容数组之后,还需要把旧数组的数据迁移到新数组中。先看它的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
final Node<K,V>[] resize() {
// 略掉前面的代码,在前面的3)中贴有

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, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 把旧的链表拆分成2条新链表,同时保留旧链表的顺序
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;
}
}
}
}
}

迁移过程可以分为几个步骤:

  1. 遍历旧数组的所有元素,重新计算里面每个元素在新数组中的位置,并把它移动到新数组中
  2. 因为是扩容了2倍,所以在旧数组中同位置的元素,映射到新数组上面时,会映射到2个位置
  3. 如果是红黑树结构,映射到新数组时,也会被拆分成2部分
  4. 如果是链表结构,映射到新数组时,也会被拆分成2条链表

举个例子说明一下,假设旧的数据结构如下:

1
2
3
4
5
6
7
  0    1    2    3    4    5    6    7 
____ ____ ____ ____ ____ ____ ____ ____
| | 9 | | 11 | | 21 | 14 | 15 |
| | |
1 22 7
|
6

当前的数组大小是 8,那么按照前面的规则,扩容后的新数组长度应该是 16,重新分配后的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  0    1    2    3    4    5    6    7 
____ ____ ____ ____ ____ ____ ____ ____
| | 9 | | 11 | | 21 | 14 | 15 |
| | |
1 22 7
|
6

|
v

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____
| | 1 | | | | 21 | 22 | 7 | | 9 | | 11 | | | 14 | 15 |
|
6

其实重新hash的计算也很简单,因为是2倍扩容,实际上重新分配时,一部分节点还是在原来的位置,而另一部分会被分出去。

1
2
3
4
5
if ((e.hash & oldCap) == 0) {
// 原来的索引位置 i
} else {
// 扩容后的索引位置 i + oldCap.length
}

另外,HashMap 重新分配后的链表顺序还是和之前一样的。

这一点 HashMapHashTable 不一样,HashTable 扩容后链表结构会反过来。

这是链表的重分配,实际上红黑树的重分配也是一样的,只不过调整树结构稍微麻烦一些,毕竟是有顺序的红黑树。

2.4 树转化

除了加上“红黑树”结构以外,HashMapHashTable 的结构都差不多,一般情况下都是“数组 + 链表”。

HashMap 为了提高效率,增加了“红黑树”的功能,当然也给代码加了很多复杂操作~~

什么时候需要红黑树呢?这里先说明一下相关的几个属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 转化树的数组长度阈值
*/
static final int MIN_TREEIFY_CAPACITY = 64;

/**
* 链表转树的阈值
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* 树转链表的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;

这里有3个变量和链表与树转化有关。

其中 MIN_TREEIFY_CAPACITYTREEIFY_THRESHOLD 是链表转红黑树用到的,UNTREEIFY_THRESHOLD 是红黑树转回链表时用到的。

2.4.1 链表转到红黑树

HashMap 不是随便就把链表转到红黑树的,而是需要满足某些条件:

  • 数组长度达到阈值 MIN_TREEIFY_CAPACITY,即64
  • 链表长度达到阈值 TREEIFY_THRESHOLD,即8

注意,要同时满足这2个条件,才会将链表转成红黑树。

MIN_TREEIFY_CAPACITY 是转化树需要的数组长度阈值,这是用来干嘛的?

其实就是当 table 数组太小时,HashMap 宁愿进行扩容,也不愿意把链表转成树。

这个可能是考虑到性能问题,数组比较小时,维护红黑树反而比扩容的性能低。

下面是链表转成红黑树的逻辑判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 这个部分的源码上面添加元素写有备注了,就不重复写了
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// 忽略部分代码
if ((p = tab[i = (n - 1) & hash]) == null) {
// 忽略部分代码
} else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) {
// 忽略部分代码
} else if (p instanceof TreeNode) {
// 忽略部分代码
} 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;
}
// 忽略部分代码
}
}
// 忽略部分代码
}
// 忽略部分代码
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;

// 如果table很小时,先直接扩容table,而不要把链表转成树
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);
}
}

就比如说,当前数组长度是 16,然后其中某个位置的链表很长了,需要转成红黑树。

但此时数组长度还比较小 16 < 64,所以它就不转化成红黑树了,而是直接把数组扩容为 32,然后把长链表拆分,并重新分配到不同位置。

2.4.2 红黑树转回链表

当红黑树的节点数量变少了,把它转成链表来维护,反而性能会更好。不过,这个转换回来也要满足某些条件才行:

  1. 删除节点后,如果此时红黑树节点数量已经很少了,红黑树将会转回链表
  2. 进行扩容时,此时红黑树会拆分成2棵子树,如果拆分后的2棵子树的节点数量低于某个阈值,即 UNTREEIFY_THRESHOLD,红黑树将会转会链表

注意,满足其中1个条件,就会将红黑树转成链表。

首先是“红黑树的节点数量很少”的情况。

这种情况只会出现在删除节点的时候,具体看代码:

1
2
3
4
5
6
7
8
9
10
11
12
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) {
// 忽略部分代码
// 移除节点...

// 当红黑树的节点数量很少时,将会转回链表
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // too small
return;
}
// 忽略后面很多代码...
}

节点数量很少,究竟多少算少呢?按照代码的判断,它有几种情况:

  1. 红黑树节点完全没有了
  2. 红黑树右子树没有了
  3. 红黑树左子树没有了
  4. 红黑树的左子树的左子树没有了

这几种情况下,就认定为红黑树的节点数量很少了。

这是为什么?红黑树本质上是一棵平衡树,如果某棵子树没了,另一边的子树肯定没几个节点,那就意味着整棵树就没几个节点了(至于更深层的原因,可以去深入了解红黑树的结构)。

其次是“进行扩容时,如果红黑树节点数量低于 UNTREEIFY_THRESHOLD”。

这种情况只会出现在增加节点导致扩容的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
// 忽略部分代码
// 重新分配节点,分成2部分...

if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
// 如果红黑树的节点数量低于UNTREEIFY_THRESHOLD,就会将其转回链表
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
// 如果红黑树的节点数量低于UNTREEIFY_THRESHOLD,就会将其转回链表
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}

可以看到,扩容后,会将之前的节点拆分成2部分,如果某部分的节点数量不大于阈值 UNTREEIFY_THRESHOLD,就会将红黑树转回链表。

2.4.3 测试代码

针对链表红黑树相互转换的几种情况,写个测试代码来测试一下。

测试的情况分为3种:

  1. 添加节点时,链表转红黑树
  2. 删除节点时,红黑树节点很少,转回链表
  3. 扩容时,红黑树节点少于阈值,转回链表

测试思路是这样的:

  1. 明确链表转红黑树时,table 必须要达到阈值 MIN_TREEIFY_CAPACITY,即64。所以一开始就设定容量为64,这样是为了避免中间出现扩容的情况,一旦出现扩容情况就复杂了。

  2. 为了测试链表和红黑树的转换时机,需要一直在同一个位置进行增删数据,同时打印 HashMap 的结构。

  3. 第3种情况是必须要扩容的,所以为了在同一个位置添加可以快速扩容,把负载因子改小了。但是这个负载因子也不是随便写的,因为必须要使得扩容后红黑树的节点数量不大于阈值 UNTREEIFY_THRESHOLD,即6。同时为了看到红黑树转回链表和不转回链表的情况,最好就是一个大于6,一个小于6,所以这里取的是13,重新分配后刚好满足这样要求(7和6),然后这个负载因子就是根据 13/64 --> 0.2 来算的。

因为 HashMap 的很多属性外部都访问不到,比如 table 成员,然后测试时又需要用到,所以这里通过反射拿到它里面的 table 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class PrintHashMap extends HashMap<Integer, Integer> {

public PrintHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("{map size: ").append(size());
Object[] table = getTable();
if (table == null) {
return sb.toString();
}
sb.append(", table size: ").append(table.length);
if (table.length == 0) {
return sb.toString();
}
sb.append(", nodes: {");
boolean hasNotNullNode = false;
for (int i = 0; i < table.length; i++) {
Object node = table[i];
if (node == null) {
continue;
}
if (hasNotNullNode) {
sb.append(", ");
}
hasNotNullNode = true;
sb.append(i).append(": ").append(getNodeTypeStr(node));
}
sb.append("}");
sb.append("}");
return sb.toString();
}

private Object[] getTable() {
try {
Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true);
return (Object[]) tableField.get(this);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

private String getNodeTypeStr(Object node) {
StringBuilder sb = new StringBuilder();
String nodeType = node.getClass().getName();
sb.append("{type: ").append(nodeType).append(", value: ").append(node).append("}");
return sb.toString();
}
}

然后下面是具体的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static void main(String[] args) {
int index = 1;
int size = 64;

System.out.println("测试添加节点时,链表转红黑树");
PrintHashMap map = new PrintHashMap(size, 1);
testPut(map, size, index, 10);

System.out.println("测试删除节点时,红黑树节点很少,转回链表");
testRemove(map, size, index, 10);

System.out.println("测试扩容时,红黑树节点少于阈值,转回链表");
map = new PrintHashMap(size, 0.20f);
testPut(map, size, index, 13);
}

private static void testPut(PrintHashMap map, int size, int index, int n) {
for (int i = 0; i < n; i++) {
map.put(index, index);
System.out.println(i + " -> put " + index);
System.out.println(map);
index += size;
}
System.out.println();
}

private static void testRemove(PrintHashMap map, int size, int index, int n) {
index = index + n * size;
for (int i = 0; i < n; i++) {
index -= size;
map.remove(index);
System.out.println(i + " -> remove " + index);
System.out.println(map);
}
System.out.println();
}

最后输出结果是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
测试添加节点时,链表转红黑树
0 -> put 1
{map size: 1, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
1 -> put 65
{map size: 2, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
2 -> put 129
{map size: 3, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
3 -> put 193
{map size: 4, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
4 -> put 257
{map size: 5, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
5 -> put 321
{map size: 6, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
6 -> put 385
{map size: 7, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
7 -> put 449
{map size: 8, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
8 -> put 513
{map size: 9, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
9 -> put 577
{map size: 10, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}

测试删除节点时,红黑树节点很少,转回链表
0 -> remove 577
{map size: 9, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
1 -> remove 513
{map size: 8, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
2 -> remove 449
{map size: 7, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
3 -> remove 385
{map size: 6, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
4 -> remove 321
{map size: 5, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
5 -> remove 257
{map size: 4, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 65=65}}}
6 -> remove 193
{map size: 3, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 65=65}}}
7 -> remove 129
{map size: 2, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 65=65}}}
8 -> remove 65
{map size: 1, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
9 -> remove 1
{map size: 0, table size: 64, nodes: {}}

测试扩容时,红黑树节点少于阈值,转回链表
0 -> put 1
{map size: 1, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
1 -> put 65
{map size: 2, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
2 -> put 129
{map size: 3, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
3 -> put 193
{map size: 4, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
4 -> put 257
{map size: 5, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
5 -> put 321
{map size: 6, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
6 -> put 385
{map size: 7, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
7 -> put 449
{map size: 8, table size: 64, nodes: {1: {type: java.util.HashMap$Node, value: 1=1}}}
8 -> put 513
{map size: 9, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
9 -> put 577
{map size: 10, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
10 -> put 641
{map size: 11, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
11 -> put 705
{map size: 12, table size: 64, nodes: {1: {type: java.util.HashMap$TreeNode, value: 193=193}}}
12 -> put 769
{map size: 13, table size: 128, nodes: {1: {type: java.util.HashMap$TreeNode, value: 129=129}, 65: {type: java.util.HashMap$Node, value: 193=193}}}

测试结果基本和上面的源码分析一致:

  • 增加节点,直到节点数量大于阈值 TREEIFY_THRESHOLD 时,即8时,链表就会转成红黑树。

  • 删除节点,直到节点数量很少时,这里是3,红黑树就会转回链表。

  • 扩容时,重新分配得节点数量不大于阈值 UNTREEIFY_THRESHOLD 时,即6时,红黑树就会转回链表。

从这里可以看出了,实际上添加第9个元素时,即节点数量 > 8 时,链表才会转为红黑树。

小结

链表转到红黑树需要满足的条件:

  • 添加节点后,数组长度达到阈值 MIN_TREEIFY_CAPACITY,即64
  • 添加节点后,链表长度达到阈值 TREEIFY_THRESHOLD,即8
  • 注意,要同时满足这2个条件,才会将链表转成红黑树

红黑树转回链表需要满足的条件:

  • 删除节点后,红黑树节点数量很少时,红黑树就会转回链表
  • 扩容时,重新分配得节点数量不大于阈值 UNTREEIFY_THRESHOLD 时,即6时
  • 注意,满足其中1个条件,就会将红黑树转回链表

总结

数据结构

  • 数据结构是“数组 + 链表 + 红黑树”

扩容机制

  • 负载因子负责控制扩容的时机
  • 当满足“大小 > 容量 * 负载因子”时,才触发扩容
  • 每次扩容是2倍,这是为了维持hash的快速定位
  • 扩容时的重新分配位置,倒序插入

树转化机制

  • 链表转到红黑树
    • 添加节点后,数组长度达到阈值 MIN_TREEIFY_CAPACITY,即64
    • 添加节点后,链表长度达到阈值 TREEIFY_THRESHOLD,即8,但是实际上是下一次再添加时才会转为红黑树,即长度为9时
    • 注意,要同时满足这2个条件,才会将链表转成红黑树
  • 红黑树转回链表
    • 删除节点后,红黑树节点数量很少时,红黑树就会转回链表
      1. 红黑树节点完全没有了
      2. 红黑树右子树没有了
      3. 红黑树左子树没有了
      4. 红黑树的左子树的左子树没有了
    • 扩容后,重新分配得节点数量不大于阈值 UNTREEIFY_THRESHOLD 时,即6时
    • 注意,满足其中1个条件,就会将红黑树转回链表
作者

jiaduo

发布于

2021-10-16

更新于

2023-04-03

许可协议