一、HashMap 的底层实现
这个可以参考上一篇文章:HashMap 源码剖析,具体介绍了 HashMap 的底层实现:
数组:充当索引
链表:处理碰撞
简单地说一下:
HashMap通常会用一个指针数组(假设为 table[])来做分散所有的 key,当一个 key 被加入时,会通过 Hash 算法通过 key 算出这个数组的下标 i,然后就把这个 key, value 插到 table[i]中,如果有两个不同的 key 被算在了同一个 i,那么就叫冲突,又叫碰撞,这样会在 table[i]上形成一个链表。
我们知道,如果 table[]的尺寸很小,比如只有2个,如果要放进10个 keys 的话,那么碰撞非常频繁,于是一个 O(1)的查找算法,就变成了链表遍历,性能变成了 O(n),这是 Hash 表的缺陷。
所以,Hash 表的尺寸和容量非常的重要。一般来说,Hash 表这个容器当有数据要插入时,都会检查容量有没有超过设定的 thredhold,如果超过,需要增大 Hash 表的尺寸,但是这样一来,整个 Hash 表里的无素都需要被重算一遍。这叫 rehash,这个成本相当的大。
二、源码剖析
首先来猜下,神马情况会造成死锁呢?
我们知道,如果要造成死循环,肯定和链表链表有关,因为只有链表才有指针。但是在源码剖析中我们知道,每次添加元素都是在链表头部添加元素,怎么会造成死锁呢?
其实,关键就在于rehash过程。在前面我们说了是 HashMap 的get()方法造成的死锁。既然是 get()造成的死锁,一定是跟put()进去元素的位置有关,所以我们从 put()方法开始看起。
1 public V put(K key, V value) {
2 if (table == EMPTY_TABLE) {
3 inflateTable(threshold);
4 }
5 if (key == null)
6 return putForNullKey(value);
7 int hash = hash(key);
8 int i = indexFor(hash, table.length);
9 //如果该 key 存在,就替换旧值
10 for (Entry<K,V> e = table[i]; e != null; e = e.next) {
11 Object k;
12 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
13 V oldValue = e.value;
14 e.value = value;
15 e.recordAccess(this);
16 return oldValue;
17 }
18 }
19
20 modCount++;
21 //如果没有这个 key,就插入一个新元素!跟进去看看
22 addEntry(hash, key, value, i);
23 return null;
24 }
25
26 void addEntry(int hash, K key, V value, int bucketIndex) {
27 //查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
28 if ((size >= threshold) && (null != table[bucketIndex])) {
29 resize(2 * table.length);
30 hash = (null != key) ? hash(key) : 0;
31 bucketIndex = indexFor(hash, table.length);
32 }
33
34 createEntry(hash, key, value, bucketIndex);
35 }
36
37 //新建一个更大尺寸的hash表,把数据从老的Hash表中迁移到新的Hash表中。
38 void resize(int newCapacity) {
39 Entry[] oldTable = table;
40 int oldCapacity = oldTable.length;
41 if (oldCapacity == MAXIMUM_CAPACITY) {
42 threshold = Integer.MAX_VALUE;
43 return;
44 }
45
46 //创建一个新的 Hash 表
47 Entry[] newTable = new Entry[newCapacity];
48 //转移!!!!跟进去
49 transfer(newTable, initHashSeedAsNeeded(newCapacity));
50 table = newTable;
51 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
52 }
53
54 //高能预警!!!!重点全在这个函数中
55 void transfer(Entry[] newTable, boolean rehash) {
56 int newCapacity = newTable.length;
57 for (Entry<K,V> e : table) {
58 while(null != e) {
59 Entry<K,V> next = e.next;
60 if (rehash) {
61 e.hash = null == e.key ? 0 : hash(e.key);
62 }
63 int i = indexFor(e.hash, newCapacity);
64 e.next = newTable[i];
65 newTable[i] = e;
66 e = next;
67 }
68 }
69 }
- 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
看到最后这个函数transfer(),就算到达了问题的关键。我们先大概看下它的意思:
- 对索引数组中的元素遍历
- 对链表上的每一个节点遍历:用 next 取得要转移那个元素的下一个,将 e 转移到新 Hash 表的头部,因为可能有元素,所以先将 e.next 指向新 Hash 表的第一个元素(如果是第一次就是 null),这时候新 Hash 的第一个元素是 e,但是 Hash 指向的却是 e 没转移时候的第一个,所以需要将 Hash 表的第一个元素指向 e
- 循环2,直到链表节点全部转移
- 循环1,直到所有索引数组全部转移
经过这几步,我们会发现转移的时候是逆序的。假如转移前链表顺序是1->2->3,那么转移后就会变成3->2->1。这时候就有点头绪了,死锁问题不就是因为1->2的同时2->1造成的吗?所以,HashMap 的死锁问题就出在这个transfer()函数上。
三、单线程 rehash 详细演示
单线程情况下,rehash 不会出现任何问题:
假设hash算法就是最简单的 key mod table.length(也就是数组的长度)。
最上面的是old hash 表,其中的Hash表的 size = 2, 所以 key = 3, 7, 5,在 mod 2以后碰撞发生在 table[1]接下来的三个步骤是 Hash表 resize 到4,并将所有的 key,value 重新rehash到新 Hash 表的过程
如图所示:
四、多线程 rehash 详细演示
首先我们把关键代码贴出来,如果在演示过程中忘了该执行哪一步,就退回来看看:
1 while(null != e) {
2 Entry<K,V> next = e.next;
3 if (rehash) {
4 e.hash = null == e.key ? 0 : hash(e.key);
5 }
6 int i = indexFor(e.hash, newCapacity);
7 e.next = newTable[i];
8 newTable[i] = e;
9 e = next;
10 }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
上面代码就是重中之重,不过我们可以再简化一下,因为中间的 i 就是判断新表的位置,我们可以跳过。简化后代码:
1 while(null != e) {
2 Entry<K,V> next = e.next;
3 e.next = newTable[i];
4 newTable[i] = e;
5 e = next;
6 }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
去掉了一些与本过程冗余的代码,意思就非常清晰了:
Entry<K,V> next = e.next;
- 1
- 2
——因为是单链表,如果要转移头指针,一定要保存下一个结点,不然转移后链表就丢了
e.next = newTable[i];
- 1
- 2
——e 要插入到链表的头部,所以要先用 e.next 指向新的 Hash 表第一个元素(为什么不加到新链表最后?因为复杂度是 O(N))
newTable[i] = e;
- 1
- 2
——现在新 Hash 表的头指针仍然指向 e 没转移前的第一个元素,所以需要将新 Hash 表的头指针指向 e
e = next
- 1
- 2
——转移 e 的下一个结点
好了,代码层面已经全部 ok,下面开始演示:
假设这里有两个线程同时执行了put()操作,并进入了transfer()环节
粉红色代表线程1,浅蓝色代码线程2
1. 初始状态
现在假设线程1的工作情况如下代码所示,而线程2完成了整个transfer()过程,所以就完成了 rehash。
1 while(null != e) {
2 Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了
3 e.next = newTable[i];
4 newTable[i] = e;
5 e = next;
6 }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
那么现在的状态为:
从上面的图我们可以看到,因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。
第一步
然后线程1被唤醒了:执行e.next = newTable[i],于是 key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null,
- 执行newTable[i] = e,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。
执行e = next,将 e 指向 next,所以新的 e 是 key(7)
状态图为:第二步
然后该执行 key(3)的 next 节点 key(7)了:现在的 e 节点是 key(7),首先执行
Entry<K,V> next = e.next
,那么 next 就是 key(3)了- 执行e.next = newTable[i],于是key(7) 的 next 就成了 key(3)
- 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)
执行e = next,将 e 指向 next,所以新的 e 是 key(3)
这时候的状态图为:第三步
然后又该执行 key(7)的 next 节点 key(3)了:现在的 e 节点是 key(3),首先执行
Entry<K,V> next = e.next
,那么 next 就是 null- 执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)
- 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
- 执行e = next,将 e 指向 next,所以新的 e 是 key(7)
这时候的状态如图所示:
很明显,环形链表出现了!!当然,现在还没有事情,因为下一个节点是 null,所以transfer()就完成了,等put()的其余过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。
没错,put()过程虽然造成了环形链表,但是它没有发生错误。它静静的等待着get()这个冤大头的到来。
- 死锁吧,骚年!!!
现在程序被执行了一个hashMap.get(11),这时候会调用getEntry(),这个函数就是去找对应索引的链表中有没有这个 key。然后。。。。悲剧了。。。Infinite Loop~~
五、启示
通过上面的讲解,我们就弄明白了 HashMap 死锁的原因,其实在很久以前这个 Bug 就被提交给了 Sun,但是 Sun 认为这不是一个 Bug,因为文档中明确说了 HashMap 不是线程安全的。要并发就使用 ConcurrentHashMap。
因为 HashMap 为了性能考虑,没有使用锁机制。所以就是非线程安全的,而 ConcurrentHashMap 使用了锁机制,所以是线程安全的。当然,要知其然知其所以然。最好是去看一下 ConcurrentHashMap 是如何实现锁机制的(其实是分段锁,不然所有的 key 在锁的时候都无法访问)。就像侯捷在《STL 源码剖析》中说的:
源码面前,了无秘密。
对我们的启示在前面的文章踩坑记中就提到过:
使用新类、新函数时,一定一定要过一遍文档
不要望文生义或者凭直觉“猜”,不然坑的不仅仅是自己。