HashMap

认识HashMap

HashMap是平时开发中常用一种数据结构。其底层精妙的设计大大提高了我们开发出来的产品的底层性能。那么它是如何实现的,我们来一探究竟

底层数据结构

JDK1.7及之前 桶数组(bucket) + 链表

JDK1.8之后 当链表达到一定长度时 会转化为红黑树

其实就是哈希散列法中的“拉链法”,只不过当一条链长度过长时,为了增加查询数据的性能,在JDK1.8之后就会将链条树化(红黑树)

image-20220731133023452

构造函数

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
// 默认构造函数 加载因子为默认值0.75f
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}

// 包含另一个“Map”的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

// 指定了容器大小的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}


// 指定“容量大小”和“加载因子”的构造函数
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

一般常用的就是构建一个空的HashMap,别的构造函数涉及到了一些需要知道的概念,借此机会一次性说掉

门限值

门限值Threshold就是扩容机制触发的一个阈值 计算公式为:

门限值 = 数组容量 X 负载因子

其中负载因子会在下面做出说明

初始化容量

指的就是桶数组(bucket)的初始化大小 默认为16

为什么是16呢 简单的解释就是使用2的次方的容量可以在HASH取模运算后使得散列更加均匀 学过数据结构的朋友应该都有印象 书上建议初始化大小为2的次幂 在这里JDK开发者选择了16作为默认的值

这些说明了为什么扩容时都是乘以2 就是为了保证桶数组的容量为2的次幂

在构造函数中若指定了一个初始的大小 则初始容量会进行计算 找到一个最近的2次幂数

1
this.threshold = tableSizeFor(initialCapacity);
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;
}

乍看是不是很懵逼 这个算法十分精妙 来理解一下

比如要求17对应的2次幂数 易得出是32

那么这个算法如何实现的呢 就是先将17 -1 得到 16 二进制就是 10000

只要将二进制数第一个为1的位置开始,后面的数字全变为1 最后再加上1 就是我们要的2次幂数了 10000 -> 11111(31) -> 31 + 1 -> 32

这个算法就是实现将后面数全变为1的操作 那为什么是逻辑右移1,2,4,8,16 就结束了呢

这样思考 一个位置为1 右移1位 就得到了两个1 此时右移2位 就得到了4各个1 以此类推 当右移16位时 刚好一个int类型位数操作完 自然可以得到任何int类型数据的所要求的二次幂数

负载因子

负载因子可以自定义,默认为0.75f

该参数用于调节散列效果 例如当负载因子为1时 数组需要到达容量大小则才开始扩容 不可避免会出现很多HASH冲突 导致某个节点的链条过长 影响查询性能 当负载因子为0.5时 每当数组容量使用了一半时就开始扩容了 虽然会大大降低HASH冲突的概率 但散列效果却会不尽如人意 于是取中间值0.75作为默认的负载因子 开发者可以根据需求 自由调节

扰动函数

HashMap中有这样一个方法专门用于求键值的HASH值

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

可见,在key求hashcode之后又将其与自己逻辑右移16位后的结果做了按位与操作,那么有两个问题

  • 为什么要做按位与
    • 保留hashcode的结果特征 增强散列效果
  • 为什么是16位
    • 因为hashcode表示数据范围是int类型数据范围 众所周知 int由32个bit组成 因此逻辑右移16位(一半距离) 以此结合一个hashcode高位和低位的特征 以降低在对桶数组容量取模运算后的hash冲突

image-20220731135448439

个人理解:这些参数的存在,就是为了数据有一个较好的散列效果,以增强数据查询插入的效率,以达到高性能的效果

HashMap基本操作和遍历

先循环插入一些数

1
2
3
4
5
6
HashMap<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < 14; i++) {
map.put(i, (int) (Math.random() * 100));
}
System.out.println("直接输出HashMap");
System.out.println(map);
1
2
直接输出HashMap
{0=43, 1=14, 2=54, 3=22, 4=75, 5=36, 6=73, 7=37, 8=46, 9=77, 10=69, 11=23, 12=63, 13=85}

这里可见HashMap以键值对的形式返回了数据

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
// ====ForEach循环遍历====
System.out.println("Iterate entries using ForEach loop:");
for (Map.Entry<Integer, Integer> stringStringEntry : map.entrySet()) {
System.out.println("Key = " + stringStringEntry.getKey() + ", Value = " + stringStringEntry.getValue());
}


// ====ForEach迭代键值对方式====
System.out.println("Iterate keys / values using ForEach loop:");
for (Integer key : map.keySet()) {
System.out.println("Key=" + key);
}
for (Integer value : map.values()) {
System.out.println("Value=" + value);
}


// ====迭代器遍历===
System.out.println("Iterate using Iterator with generics=v:");
Iterator<Map.Entry<Integer, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, Integer> entry = iterator.next();
System.out.println("Key=" + entry.getKey() + " Value=" + entry.getValue());
}


// ====Java Lambda表达式遍历====
System.out.println("Iterate using Java 8 Lambda Expression:");
map.forEach((K, V) -> System.out.println("key: " + K + " value:" + V));

打印结果太多 就不展示了 可以自己试一下 个人觉得Lambda表达式简直爱了好吗 Elegant And Concise 优雅且简洁

底层数据结构的构建

下面就来看看所谓的桶数组+链表+红黑树到底是怎么构建 让我们一起撕开神秘的面纱

节点类

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

public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}

public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}

这便是HashMap中最基本的一个节点的构成 它实现了Map.Entry<K,V> Entry是Map的一个静态内部类,用于存放键值对

值得注意的是,这里Node的equals方法,是比较Key和Value的equals方法,在开发中,如果有需求要进行个性化的比较,就需要重写我们自己的Object类的equals方法了

插入节点的putVal()方法

此方法是一个内部方法,仅在内部调用。在我们插入一个节点时,就会调用该方法

1
2
3
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

putVal方法也详细的展示了怎么构建这么一个精妙的数据结构的

先上源码 前方高能~~~

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
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;
if ((p = tab[i = (n - 1) & hash]) == 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) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

头疼的来了 看看怎么拆解 怎么理解

做好预备工作

image-20220731145208320

这里进行一系列的初始化 tab即是桶数组容器 p就是插入的单个节点 若一上来table为null 则说明HashMap仅仅是做了初始化 还没有一个初始化的大小空间 那么就resize()方法分配初始空间 这里只要知道resize方法就是用来分配空间的 具体细节后面会做分析

image-20220731145500014

第一种情况(当前位置为null)

HASH分配到的偏移位置还没有插入节点,那么二话不说,直接塞入新节点

1
2
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

值得注意的是 数组偏移是通过 (n - 1) & hash 得到的 其实 这就是将hash对数组容量做了一个取默运算 只不过数组偏移是从0开始的 所以是n-1 非常精妙

第二种情况(hash分配的位置相同且Key相同)

说明这是节点更新,直接将当前节点更新

1
2
3
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;

第三种情况(加入红黑树节点)

1
2
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
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
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
dir = tieBreakOrder(k, pk);
}

TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}

具体怎么加入的,需要先了解红黑树,这里不做多说明

第四种情况(加入链表节点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}

遍历链表,若hash值Key值均一样,则直接退出循环

若链表长度即将大于树化的门槛值,则要靠考虑进行树化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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<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);
}
}

首先会判断桶数组容量是否达到可以树化的最低标准,没有则先进行桶数组的扩容再HASH,达到了则将链表转化为红黑树

HashMap的resize()方法

在HashMap中,采用二次方扩容法

当HashMap在元素达到负载因子阈值时,便会自动扩容,以保证HASH得到很好的散列

1
2
3
4
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;

resize()方法的一开始,进行一系列的初始化。下面分种情况来讨论

无参构造的空HashMap

由于是一个空的HashMap,oldTable oldCap oldThr均为空

image-20220819114543226

于是直接初始化容量为16,门限值为0.75*16 = 12

达到门限值需要扩容

image-20220819135919766

  • 如果原来的桶数组大小已经超过最大限定容量(1 << 30)了,那么将门限值设定为Integer数据类型的最大值(1 << 31)
  • 否则,就直接按照2次幂扩展规则,将桶数组容量和门限值都扩大为原先的2倍

接着,不可避免的,需要rehash

大概结构如下:遍历每个节点,进行调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 { // preserve order
Node<K,V> loHead = null, loTail = null;
// 一系列操作
......
}
}
}
数组的调整
1
2
3
4
5
6
7
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 这一步将原数组对应位置的节点设为NULL
if (e.next == null) // 如果该节点的下一个为空,则说明此处既不是链表,也不是红黑树,直接进行哈希再分配
newTab[e.hash & (newCap - 1)] = e;
......
}
}
链表的调整
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
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 单个节点
......
// 红黑树
......
// 链表
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, 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;
}
}
}

乍一看很复杂,是的。

首先,要明白高低位链表是什么:

将每个节点HASH值与oldCap按位与,如果为0,则加入低位链表,如果高位为1,则加入高位链表

为什么会这样呢,举个例子,oldCap为16

若7(00111) & 16(10000) => 00000 则加入低位链表

若17(10001) & 16(10000) => 10000 则加入高位链表

下面看看怎么通过循环构建两根链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);

loHead loTail分别指向低位链表的头尾节点

hiHead hiTail分别指向高位链表的头尾节点

接着将两条链表加入新的桶数组中

1
2
3
4
5
6
7
8
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}

最后,借互联网上的一张图来帮助理解

image-20220819144720573

红黑树的调整
1
2
3
4
5
6
7
8
9
10
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 单节点
......
// 红黑树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表
......
}

其中split函数细节为:

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
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}

if (loHead != null) {
if (lc <= 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)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}

看起来有些复杂,其实和链表一样的。根据HASH将红黑树分出高低位链表来。再将两条链表树化塞入新的桶数组中。其中,有很多细节和特殊情况,暂不做讨论。关于红黑树的理论和实现,这里也不再详细赘述。

这样,对于HashMap是如何进行扩容的,我们有了一个基本的认识