HashMap是线程不安全的,如果是在单线程下使用,是没有问题的,但是在并发使用的时候就有可能出现死循环的问题。
想要了解这个问题,我们要先了解HashMap的数据结构。简单回顾一下HashMap的数据结构:
HashMap内部使用的是一个 Entry数组来保存key和value,当我们向HashMap中加入数据的时候,会通过一个散列函数,也就是hash算法得到hash值,然后再和内部数组的长度取模计算出要存放的位置,如果这个位置上已经有数据存在,则产生hash冲突,此时就会在这个位置上生成一个链表。
我们假设有这样一种极端情况,就是所有的元素都定位到了同一个位置,形成一个很长的链表,这样我们get一个值的时候,时间复杂度就变成了O(n),所以说元素的hash值算法和HashMap的初始大小很重要。
不管是极端情况还是正常情况下,我们向HashMap中插入新元素的时候,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到了阈值,就会对数组进行扩容(默认是扩大两倍),然后再对数组中每个元素进行rehash。
源码分析
我们先通过源码分析一下HashMap中Entry数组扩容的整体流程:
1.检查容量是否达到阈值(当前元素/数组大小),如果元素的个数已经达到了阈值,则进行扩容,并把原来的元素移动到新的Entry数组中。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//如果达到了阈值,就会进行resize扩容操作,新数组长度是旧数组长度的2倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
2.扩容
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
...
//创建新数组
Entry[] newTable = new Entry[newCapacity];
...
//移动元素到新数组中,
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
3.移动元素到新数组中,并且重新计算每个元素的hash值,拿着新的hash值和新数组的长度做取模操作,找到这个元素在新数组中存放的位置。
void transfer(Entry[] newTable, boolean rehash) {
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;
}
}
}
模拟出现死循环的情况
为了方便,我们假设HashMap初始化的大小为4,如果按照默认的负载因子0.75,那么我们插入第三个元素的时候,就会触发扩容操作,为了更好的验证效果,我们假设负载因子是1,也就是插入第四个元素的时候会扩容,而且这些插入的元素全部都在同一个链表上。
在所有的元素都插入到相同的链表中,并且在多线程情况下,两个线程同时进行扩容和rehash操作,会发生什么呢?下面我们来进行逐步分析。
//扩容和rehash的核心步骤
void transfer(Entry[] newTable, boolean rehash) {
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;
}
}
}
就像上面说的极端情况一样,所有的元素都插入到一个链表上。
假设线程2
在执行到Entry<K,V> next = e.next;
之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。
线程1
继续执行,然后a、b、c节点rehash之后又是在同一个位置7,开始移动节点
第一步,移动节点a
第二步,移动节点b
这里需要注意一下,顺序是反过来的。
这个时候线程1
的时间片用完,内部的table还没有设置成新的newTable, 线程2
开始执行,这时内部的引用关系如下:
这时,在 线程1
中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
执行之后的引用关系如下图
执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系
变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:1、执行完Entry<K,V> next = e.next;
,目前节点a没有next,所以变量next指向null;2、e.next = newTable[i];
其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;3、newTable[i] = e
把节点a放到了数组i位置;4、e = next;
把变量e赋值为null,因为第一步中变量next就是指向null;
所以最终的引用关系是这样的:
节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。
此外,如果线程2
把newTable设置成到内部的table,节点c的数据就丢了,也会产生数据丢失的问题。
总结
所以在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap。