前言
在JDK1.7&1.8源码对比分析【集合】HashMap中我们遗留了一个问题:为什么HashMap在调用resize() 方法时会出现死循环?这篇文章就通过JDK1.7的源码来分析并解释这个问题。
如下,并发场景下使用HashMap造成Race Condition,从而导致死循环,现象是CPU 100%被占用。
final HashMap<String, String> map = new HashMap<String, String>(); for (int i = 0; i < 1000; i++) { new Thread(new Runnable() { @Override public void run() { map.put(UUID.randomUUID().toString(), ""); } }).start(); }
目录
一、问题症状
二、Hash表数据结构
三、HashMap的rehash源代码
1. 正常的rehash的过程
2. 并发下的rehash过程
一、问题症状
我们在程序中会经常使用HashMap来存储键值对,在单线程场景下使用没有任何问题。当程序性能出现瓶颈,我们开始使用多线程来操作HashMap,但因此也带来了问题:发现程序经常占了100%的CPU,查看堆栈,你会发现程序都Hang在了HashMap.get()这个方法上了,重启程序后问题消失。但是过段时间又会来。而且,这个问题在测试环境里可能很难重现。
我们简单的看一下我们自己的代码,我们就知道HashMap被多个线程操作。而Java的文档说HashMap是非线程安全的,应该用ConcurrentHashMap。
接下来我们分析一下具体的原因。
二、Hash表数据结构
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 Collision DoS 问题》)。
所以,Hash表的尺寸和容量非常的重要。一般来说,Hash表这个容器当有数据要插入时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大hash表的尺寸,但是这样一来,整个hash表里的无素都需要被重算一遍。这叫rehash,这个成本相当的大。
三、HashMap的rehash源代码
下面,我们来看一下Java的HashMap的源代码。
put一个key,value对到hash表中:
public V put(K key, V value) { // 判断当前数组是否需要初始化 if (table == EMPTY_TABLE) { inflateTable(threshold); } // 如果 key 为空,则 put 一个空值进去 if (key == null) return putForNullKey(value); // 根据 key 计算出 hashcode int hash = hash(key); // 根据计算出的 hashcode 定位出所在桶 int i = indexFor(hash, table.length); // 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; // 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置 addEntry(hash, key, value, i); return null; }
检查容量是否超标:
void addEntry(int hash, K key, V value, int bucketIndex) { // 判断是否需要扩容 if ((size >= threshold) && (null != table[bucketIndex])) { // 如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位 resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } // 将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表 createEntry(hash, key, value, bucketIndex); }
新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的hash表中:
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } // 创建一个新的hash table Entry[] newTable = new Entry[newCapacity]; // 将old hash table上的数据迁移到new hash table上 transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
迁移的源代码,注意粗体部分:
/** * Transfers all entries from current table to newTable. */ void transfer(Entry[] newTable, boolean rehash) { // 从old table中取一个元素出来,然后放到new table中 int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
好了,这个代码算是比较正常的。而且没有什么问题。
1. 正常的rehash的过程
假设我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
最上面的是old hash 表,其中的Hash表的size = 2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
接下来的三个步骤是hash表 resize成4,然后所有的<key, value> 重新rehash的过程。
2. 并发下的rehash过程
2.1 假设我们有两个线程
我们再回头看一下我们的 transfer代码中的这个细节:
do { Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了 int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null);
而我们的线程二执行完成了。于是我们有下面的这个样子。
注意,因为线程一的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。
2.2 线程一被调度回来执行
先是执行 newTable[i] = e,然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
2.3 一切安好
线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移。
2.4 环形链接出现
e.next = newTable[i] 导致 key(3).next 指向了 key(7),注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
于是,当我们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop。