vlambda博客
学习文章列表

JDK源码之HashMap的实现

HashMap,顾名思义,是一种基于哈希函数实现的map,又被称为哈希表、散列表。它是基于快速存取的角度设计的,也是一种典型的空间换时间的做法。map表示一种一对一的映射关系,一个键对应一个值,键不可重复(重复的键只会存储最后一次的值)。它允许使用 null 值和 null 键。除了不同步和允许使用 null 之外,HashMap与Hashtable大致相同。该类是不能保证顺序的,进行某些操作后,键值对的顺序可能会发生变化。由于使用哈希函数定位,所以它的存取非常高效。本篇我们将对HashMap的数据结构及核心方法进行分析。下面开始我们的主题。

Java版本

1
2
3
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

数据结构

JDK8中HashMap底层是基于数组、链表、红黑树实现的。其内部有如下几个主要的成员变量。

1
2
3
4
transient Node<K,V>[] table;
private int size;
int threshold;
final float loadFactor;

table是一个Node<K,V>类型的数组,每个元素指向一个单链表,链表中每个节点存储一个键值对,当链表长度大于8时,将链表转成红黑树;size 记录了实际存储的键值对个数。

loadFactor负载因子表示哈希表中元素的填满的程度。负载因子越大,填满的元素越多,空间利用率越高。但同时冲突几率也会变大,从而导致链表长度会越来越长,查找效率降低。反之,负载因子越小,填满的元素越少,冲突几率减小了。但表中的数据将过于稀疏(很多空间还没用,就开始扩容了),造成了空间浪费。因此,必须在冲突几率与空间利用率之间做一种权衡的选择。如果不特别指定,默认负载因子是0.75。

threshold表示扩容临界值。值为容量与负载因子的乘积。当实际大小超过这个临界值时,会自动扩容。

Node<K,V>是HashMap的一个内建数据类型,用来存储实际的key、value、key的hash值和下一个节点的引用。这里直接存储hash值是为了在比较的时候加快计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}
......
}

table初始值为空,当第一次添加键值对时,初始化为容量16的数组。它随着键值对的添加会自动扩容。大概扩容策略是,当实际存储的键值对个数超过临界值threshold (= capacity * load factor)时,就会重新分配。

实现原理

继承与实现

1
2
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
  • AbstractMap抽象类:对一些通用方法提供默认实现。

  • Map接口:定义了map的一些基本方法。

  • Cloneable接口:标记接口。表示可克隆的。

  • Serializable接口:标记接口。表示可序列化的。

成员属性

以下是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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 默认初始容量,必须是2的整数次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大容量,这里表示的数字是 2的30次幂
//
// 这里为什么这么写,又为什么是这个数字?
// 1.使用位运算效率更高
// 2.如果写成 2 << 31则超过int最大值,而2 << 30 是仅仅比最大值小的整数次幂
//
// 为什么是int最大值?
// 1.因为size、容量等字段数据类型都是int
//
// 为什么一定要是2的整数次幂?
// 1.HashMap定位下标,采用的是(length-1) & hash这样的方式,为了元素更均匀的分布。
static final int MAXIMUM_CAPACITY = 1 << 30;

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

// 当桶中的节点数大于8时,将链表转成红黑树。
static final int TREEIFY_THRESHOLD = 8;

// 红黑树的节点数小于6时,将红黑树转回链表。
static final int UNTREEIFY_THRESHOLD = 6;

// 最小的树化容量临界值。实际容量大于该值时,才允许树化。否则直接扩容。
// 为了避免扩容和树化之间选择的冲突,该值不能小于4 * TREEIFY_THRESHOLD。
static final int MIN_TREEIFY_CAPACITY = 64;

// 实际存储数据的数组。该表在首次使用时初始化,并根据需要调整大小。分配后,长度始终是2的整数次幂。
transient Node<K,V>[] table;

// 键值对集合视图,对其修改会直接修改map本身。
transient Set<Map.Entry<K,V>> entrySet;

// 实际存储的键值对个数
transient int size;

// 对map结构性修改的次数(用于fail-fast机制)
transient int modCount;

// 扩容临界值
int threshold;

// 负载因子变量
final float loadFactor;

构造函数

HasMap有如下四个构造方法。

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
// 构建一个指定初始容量和负载因子的map
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);
}

// 构建指定初始容量的map
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}


// 无参构造,初始容量为16,默认负载因子为0.75。第一次使用时才真正分配存储空间。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}


// 从一个已有的map构建新map
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

构造方法中调用了一个名为tableSizeFor的函数,下面一起看下这个方法是做什么的。通过源码注释可以了解,它的作用是根据传入的值,计算返回一个刚好大于这个值的2的整数次幂。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Returns a power of two size for the given target capacity.
*/
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;
}

比如我们指定容量值是10,那么n = cap - 1 = 9,对应的二进制是1001。

n |= n >>> 1

1
2
3
4
0000 1001
0000 0100
---------
0000 1101

n |= n >>> 2

1
2
3
4
0000 1101
0000 0011
---------
0000 1111

n |= n >>> 4

1
2
3
4
0000 1111
0000 0000
---------
0000 1111

n |= n >>> 8

1
2
3
4
0000 1111
0000 0000
---------
0000 1111

n |= n >>> 16

1
2
3
4
0000 1111
0000 0000
---------
0000 1111

最后的n+1,得到结果0001 1111 = 16。刚好大于10的2的整数次幂。

核心方法

添加

HashMap有如下三个添加方法。

1
2
3
public V put(K key, V value) // 添加键值对
public V putIfAbsent(K key, V value) // 添加键值对
public void putAll(Map<? extends K, ? extends V> m) // 将参数m中的键值对全部添加到当前map中

解释一下put方法和putIfAbsent方法的区别:

  • put:如果map中没有该key对应的value,直接添加,并返回null。如果已经存在该key,则覆盖旧值,并返回旧值。

  • putIfAbsent:如果map中没有该key对应的value,直接添加,并返回null。如果已经存在该key,则保留原值,并返回原值。

下面以分析put方法为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 添加键值对。
// 如果不存在相同的key,直接添加,并返回null。
// 存在相同的key,覆盖旧值,并返回旧值。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

// 添加键值对,
// 如果不存在相同的key,直接添加,并返回null。
// 存在相同的key,则保留旧值,并返回旧值。
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}

根据key的hashCode计算得出hash值,连同key、value一同传给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
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
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;

// 这里的 (n-1)& hash 是为了确定具体存储位置。
// 根据参数key的hash值,计算应该存储在哪个索引处
// 如果该位置上没有存储过键值对,则直接新建一个node,存储在该位置。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

// 当前位置已经存储节点了。
else {
Node<K,V> e; K k;
// 判断key是否相同,如果相同,赋值给临时变量e。
// 可以看到这里先比较hash值,然后再用equals方法比较(非null情况)。
// 采用这种比较方式,效率更高。这也是node节点为什么存储hash值的原因。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断是否属于红黑树的节点
// 如果是, 则在红黑树中先查找是否存在该key。
// 如果不存在,直接添加节点到红黑树上,e=null。
// 如果存在,将该节点赋值给临时变量e。
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 新添加的节点,不属于头节点,也不属于红黑树节点
// 遍历链表,如果有key相等的节点,则赋值给临时变量e。
// 如果没有key相等的节点,则在链表尾添加新节点。
// 同时判断链表长度,如果大于等于8,将链表转成红黑树。e=null
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;
}
}
// 表示要添加的key,在map中已经存在了。
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 此处可看出put和putIfAbsent两个方法的区别。
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// HashMap中空实现,为了提供给子类LinkedHashMap重写实现。
afterNodeAccess(e);
return oldValue;
}
}

// 添加方法属于结构性修改,递增modCount值。
++modCount;

// 当实际存储键值对个数大于临界值(= 容量 x 负载因子),则调用扩容方法。
if (++size > threshold)
resize();

// 该方法在HashMap类中是空实现,只要是为了提供给子类LinkedHashMap重写实现。
afterNodeInsertion(evict);
return null;
}

总结一下流程:1、是否空表,空表则扩容;2、定位应该存储的索引位置;3、判断该索引处是否有节点、是否是链表头节点、是否属于红黑树节点;4、map中没有相同的key,直接存放;5、map中有相同的key,根据参数onlyIfAbsent判断是否覆盖原值。

哈希函数

接下来看一下哈希函数,可以从两方面来选择使用哪种哈希函数:一个是数据分布是否均匀,也就是哈希碰撞几率的大小,另一个就是能否高效的计算出hash值。不同的应用场景对哈希函数有着不同的要求,比如用于查找的哈希函数主要考虑它映射到小范围的碰撞率,因为几率越大,链表越长,查询效率也就越低。JDK中,HashMap在权衡了效率与碰撞几率的情况下,实现了自己的哈希函数。

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

解释一下上面的代码,为什么不直接使用key的hashCode作为hash值,而又执行了异或操作?

因为我们平时使用map的时候,存储键值对数量绝大多数都在 2^16 以内。在取余定位的时候,使用的是(n-1)& hash,这个&运算,只有hash值的低16位(甚至更低)参与运算,如果两个key的hash只有高16位有变化,那么此时也会映射到同一索引,产生碰撞。

所以 h >>> 16 取到hashCode高16位,再与hashCode本身异或运算,可以得到更加随机的效果,达到了散列的目的。这里使用异或也是为了更加随机,如果使用 & 、| 都会令结果偏向0或1。

为什么是 (n - 1) & hash?

在定位存储位置的时候,使用的是 (n - 1) & hash。那么为什么要这么写呢?

我们想要把一个数映射到一个给定的范围,通常可以采用除数取余的方式,假设数组长度为16,可以使用 hash % 16,结果在0 ~ 15 之间。这种方法虽然可以将哈希值映射到一个给定范围的确切位置,但不够高效。有一种更加高效的方式 – 位运算。也就是说 (n - 1) & hash 与hash % n是等价的,但这个等价有个前提,那就是n一定是2的整数次幂。可以表示为x%2^n = x&(2^n-1)

下面举个例子演示一下为什么一定要是2的整数次幂。假设数组长度分别为15和16,分别用来存储hash值0 ~ 14 和 0 ~ 15,那么&运算后的结果如图所示。

JDK源码之HashMap的实现

可以看到,当数组长度为2的整数次幂时,与hash % 16的结果一样。分布均匀,碰撞几率小,且空间利用合理。具体原因是,n-1的低位二进制全是1,经过(n - 1) & hash的计算结果,可能是偶数,也可能是奇数(这取决于原值的奇偶)。这样便可以保证散列的均匀性。再加上hash函数对key的hashCode高位异或运算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。

当数组长度为奇数时,偶数索引处产生哈希碰撞,而且是不同的哈希值碰撞在一起。需要用链表存储他们。这就大大降低了将来查询的效率。而且有近一半的空间被浪费掉。为什么会产生这种现象,当数组长度为奇数时,那么n-1对应的二进制最后一位一定是0,表示成xxx0,当任何一个数同它 & 运算后得到的结果最后一位仍然是0。看出什么问题没有?索引值二进制最后一位是1的位置(也就是奇数索引),永远不会被用到,造成了空间的浪费。

既然奇数不可以,那么我们能不能采用一个不是2的整数次幂的偶数呢?也是不可以的,我们看个例子。假设数组长度为14,我们向数组中添加元素0 ~ 13(这些key对应的hash仍然是其本身),那么这些元素经过计算后的存储位置如图所示。

当数组长度是非2的整数次幂的偶数时,同一范围下出现很大的碰撞概率的同时,又浪费了很多空间。使用链表解决碰撞问题,又带来查询效率问题。

所以说,当数组长度为2的整数次幂时,不同的key定位到相同的索引几率很小,数据分布均匀。相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就高了。

扩容

下面我们来看一下HashMap的扩容机制。扩容方法主要做了这几件事:1、计算新容量和新临界值;2、基于新容量创建新数组;3、转移数据。首次put元素需要进行扩容为默认容量16,临界值16*0.75=12,以后扩容后的数组长度和临界值都变为原来的两倍。

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 旧数组不为null
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; // double threshold
}

// 这种情况是在调用两个传递负载因子、初始容量的构造方法产生的。
// 还记得前面分析过这个方法么,this.threshold = tableSizeFor(initialCapacity);
// 这时候,临界值记录的是刚好大于initialCapacity的2的整数次幂
// 此时数组还没初始化,用这个值作为新数组容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 调用无参构造,容量和负载因子都是默认值。
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;

// 按照扩容的容量新建数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// table变量指向新数组
table = newTab;
// 旧数组不为空,可能存储数据,将数据转移到新数组上
if (oldTab != null) {
// 遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 旧数组第j位索引处的节点没有后置节点
if (e.next == null)
// 不用重新计算key的hash值
// 使用节点中存储的hash值,按照hash & (newCap-1)在新数组中定位存储位置
newTab[e.hash & (newCap - 1)] = e;
// 旧数组第j位索引处的节点,如果属于红黑树的节点
// 先拆分红黑树再映射
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 只剩最后一种情况:这是一个多于一个节点的链表
// 先对链表进行分组,然后再映射。
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表,根据(e.hash & oldCap) == 0对链表分两组
// 每组一条链表,相对位置不变(旧链表在前边的节点,在新链表一样在前)。
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;
}

解释一下对链表的分组操作。首先旧链表会被分成两组,按照(e.hash & oldCap) == 0这个规则去分,一组等于0,一组不等。上面的loHead、lotTail、hiHead、hiTail就是分别指向两条新链表的头尾。

下面解释一下e.hash & oldCap这个表达式的意思。假设现在有一个容量为16的数组,和两个hash值,如下。

(n-1) & hash &运算结果
0000 1111 & 1101 1001 0000 1001
0000 1111 & 1010 1001 0000 1001

两个hash映射到同一位置,用一条链表存储。此时数组扩容为32。

(n-1) & hash &运算结果
0001 1111 & 1101 1001(hash1) 0001 1001 (原数组容量 + 原位置)
0001 1111 & 1010 1001(hash2) 0000 1001 (原位置)

由上面的例子,可以得出一个公式:在新数组的位置 = 原数组容量 + 原位置。

扩容后,n-1 = 32 - 1= 0001 1111 ,此时参与计算的位数多了一个1。如果hash值对应位置上也是1,则位置需要变化(也就是根据上面的公式变)。而如果hash值对应位置上是0,则计算结果完全没变化,还在原位置。基于上面的思路,JDK中想出了这样的写法,用来区分需要变位置和不需要变位置的节点。

JDK1.8重写了扩容方法resize,取代了1.7中的rehash操作,避免了rehash引起的死循环及低效的问题。这里简单提一句,死循环是在多线程环境下,扩容时,put操作导致了环形链表,但此时并不会发生错误,只是埋下了祸根。一旦调用get方法,获取这条链表上的节点值的时候,就会一直循环下去。系统CPU占用100%。

查找

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
// 根据key获取value,如果不存在返回null
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

// 根据key获取value,如果不存在返回默认值defaultValue
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
}

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) {
// 比较头节点的key,如果相等直接将头节点返回
if (first.hash == hash && // always check first node
((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、通过key的hash值定位到某个索引;2、如果该索引处存在节点,判断key是否相等;3、如果该头节点是红黑树的根节点,则去红黑树中查找;4、以上情况没查到,则遍历该索引处整个链表,先比较key的hash值,再用equals方法比较key。

删除

有两个删除方法,和一个清空方法。清空操作比较简单,我们重点看删除操作。

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
// 根据指定key删除对应键值对。
// 如果存在,则返回对应的value;如不存在,则返回null。
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}

// 根据指定key删除对应键值对。只有当value值等于key对应的值才会执行删除操作。
// 删除成功,返回true。
// 删除失败,返回false。这里有两点可能,一是没有找到key对应的键值对;二是value不等于key对应的值。
public boolean remove(Object key, Object value) {
return removeNode(hash(key), key, value, true, true) != null;
}

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;
// 比较头节点的key,如果相等,则将头节点赋值给临时变量node
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)
// 从红黑树中按key查找,赋值给变量node
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 以上情况都不是,则遍历整个链表查找,并赋值给变量node
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 这里可以看到两个remove方法,传递参数不同的分支判断
// remove(Object key, Object value)方法,传递过来一个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);
// 待删除节点如果是链表的头节点,直接将node.next放到数组中
else if (node == p)
tab[index] = node.next;
// 待删除节点既不是红黑树节点,也不是链表的头结点。
// p是node的前置节点,令p的next指针指向node的下一节点
else
p.next = node.next;
// 递增modCount,递减size
++modCount;
--size;
// HashMap中空实现,为了LinkedHashMap重写
afterNodeRemoval(node);
return node;
}
}
return null;
}

总结

  • 基于数组、链表、红黑树和哈希函数实现。

  • 允许使用 null 值和 null 键。

  • 键值对是无序的,因为hash值随机。想要保证插入顺序可以使用LinkedHashMap。

  • 除了不同步和允许使用 null 之外,HashMap与Hashtable大致相同。

  • 虽然Hashtable是同步的,但在多线程下建议使用ConcurrentHashMap替代。

  • 因为使用哈希函数定位,存取的效率都很高,时间复杂度为O(1)。

写在最后

其实HashMap的原理很简单,就是通过哈希函数将key映射到数组上,如果出现碰撞,则用链表解决,JDK1.8又新加了一条,为了查询效率,当链表长度大于8转成红黑树。这个想必大家都懂。但Java实现里面有好多细节,比如通过位运算代替取余运算来定位;再比如在存取的过程中,都有比较操作,先比较hash值,再用equals方法比较等等,这些为了性能而设计的点,只有看了源码才能体会到有多精妙。由于时间和能力有限,本篇源码分析并没有面面俱到,讲到的也难免出现错误,希望大家批评指正,一起学习。本文先写到这里了,谢谢各位观看。