看到的好文(我的好文都是我能看懂的,说明写的很详细啦),转载记录学习~
不过人家也是转载的,不管啊,好文都要分享哒~~
前言
几个月前,上线了一个版本。但是上线了几个小时之后 CPU 突然暴增至99%,在网上搜了一下,多半是因为出现死循环问题了。就用 jstack dump 了当时的线程快照,发现这次死循环问题的起源是 HashMap 的 get()方法。之后先是迅速重启了服务,这样可以让服务先运行一段时间。然后立即修复了这个 bug并提交到 SVN。
这次事故的原因是因为开发时没有注意到 HashMap 是非线程安全的,而使用 HashMap 的那个地方又是 PV 级别的代码,多线程并发非常容易出现问题。但因为这块代码不是我开发的,我也不清楚具体的细节,就没有过多关注。最近正好在看 HashMap 的源码,突然想起来这事,就正好看看究竟是神马原因造成了 HashMap 的死锁问题。
一、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 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 e : table) {
58 while(null != e) {
59 Entry 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 }
看到最后这个函数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 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 }
上面代码就是重中之重,不过我们可以再简化一下,因为中间的 i 就是判断新表的位置,我们可以跳过。简化后代码:
1 while(null != e) {
2 Entry next = e.next;
3 e.next = newTable[i];
4 newTable[i] = e;
5 e = next;
6 }
去掉了一些与本过程冗余的代码,意思就非常清晰了:
Entry next = e.next;
——因为是单链表,如果要转移头指针,一定要保存下一个结点,不然转移后链表就丢了
e.next = newTable[i];
——e 要插入到链表的头部,所以要先用 e.next 指向新的 Hash 表第一个元素(为什么不加到新链表最后?因为复杂度是 O(N))
newTable[i] = e;
——现在新 Hash 表的头指针仍然指向 e 没转移前的第一个元素,所以需要将新 Hash 表的头指针指向 e
e = next
——转移 e 的下一个结点
好了,代码层面已经全部 ok,下面开始演示:
假设这里有两个线程同时执行了put()操作,并进入了transfer()环节
粉红色代表线程1,浅蓝色代码线程2
1. 初始状态
现在假设线程1的工作情况如下代码所示,而线程2完成了整个transfer()过程,所以就完成了 rehash。
1 while(null != e) {
2 Entry next = e.next; //线程1执行到这里被调度挂起了
3 e.next = newTable[i];
4 newTable[i] = e;
5 e = next;
6 }
那么现在的状态为:
从上面的图我们可以看到,因为线程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,
执行e = next,将 e 指向 next,所以新的 e 是 key(7)
状态图为:
第二步
然后该执行 key(3)的 next 节点 key(7)了:
现在的 e 节点是 key(7),首先执行Entry
,那么 next 就是 key(3)了
执行e = next,将 e 指向 next,所以新的 e 是 key(3)
这时候的状态图为:
第三步
然后又该执行 key(7)的 next 节点 key(3)了:
现在的 e 节点是 key(3),首先执行Entry
,那么 next 就是 null
很明显,环形链表出现了!!当然,现在还没有事情,因为下一个节点是 null,所以transfer()就完成了,等put()的其余过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。
没错,put()过程虽然造成了环形链表,但是它没有发生错误。它静静的等待着get()这个冤大头的到来。
五、启示
通过上面的讲解,我们就弄明白了 HashMap 死锁的原因,其实在很久以前这个 Bug 就被提交给了 Sun,但是 Sun 认为这不是一个 Bug,因为文档中明确说了 HashMap 不是线程安全的。要并发就使用 ConcurrentHashMap。
因为 HashMap 为了性能考虑,没有使用锁机制。所以就是非线程安全的,而 ConcurrentHashMap 使用了锁机制,所以是线程安全的。当然,要知其然知其所以然。最好是去看一下 ConcurrentHashMap 是如何实现锁机制的(其实是分段锁,不然所有的 key 在锁的时候都无法访问)。就像侯捷在《STL 源码剖析》中说的:
源码面前,了无秘密。
对我们的启示在前面的文章踩坑记中就提到过:
使用新类、新函数时,一定一定要过一遍文档
不要望文生义或者凭直觉“猜”,不然坑的不仅仅是自己。
转自:http://github.thinkingbar.com/hashmap-infinite-loop/