概述
HahsMap是日常开发中经常用到的一种数据结构,在介绍HashMap的时候会涉及到很多术语,比如时间复杂度O、散列(也叫哈希)、散列算法等,这些在大学课程里都有教过,但是由于某种不可抗力有还给老师了,在深入学习HashMap之前先了解HashMap设计的思路以及一些重要概念,在后续分析源码的时候就能够有比较清晰的认识。
HashMap是什么
在回答这个问题之前先看个例子:小明打算从A市搬家到B市,拿了两个箱子把自己的物品打包就出发了。
image.png
到了B市之后,他想拿手机给家里报个平安,这时候问题来了,东西多了他忘记手机放在哪个箱子了?小明开始打开1号箱子找手机,没找到;再打开2号箱子找,找到手机。当只有2个箱子的时候,东西又不多的情况下,他可能花个2分钟就找到手机了,假如有20个箱子,每个箱子的东西又多又杂,那么花的时间就多了。小明总结了下查找耗时的原因,发现是因为这些东西放的没有规律,如果他把每个箱子分个类别,比如定一个箱子专门放手机、电脑等电子设备,有专门放衣服的箱子等等,那么他找东西花的时间就可以大大缩短了。
其实HashMap也是用到这种思路,HashMap作为一种数据结构,像数组和链表一样用于常规的增删改查,在存数据的时候(put)并不是随便乱放的,而是会先做一次类似“分类”的操作再存储,一旦“分类”存储之后,下次取(get)的时候就可以大大缩短查找的时间。我们知道数组在执行查、改的效率很高,而增、删(不是尾部)的效率低,链表相反,HashMap则是把这两结合起来,看下HashMap的数据结构。
image.png
从上面的结构可以看出,通常情况下HashMap是以数组和链表的组合构成(Java8中将链表长度超过8的链表转化成红黑树)。结合上面找手机的例子,我们简单分析下HashMap存取操作的心路历程。put存一个键值对的时候(比如上图盖伦),先根据键值“分类”,“分类”操作告诉我们,盖伦应该属于14号坑,直接定位到14号坑。接下来有几种情况:
- 14号坑没人,直接存值;
- 14号坑有人,也叫盖伦,替换原来的值
- 14号坑有人,叫老王!插队到老王前面去(单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置)
get取的时候也需要传键值,根据传的键值来确定要找的是那个“类别”。比如找火男,“分类”操作能够告诉我们火男属于2号坑,于是我们直接定位到2号坑开始找,亚索不是...找到火男。
小结
HashMap是由数组和链表组合构成的数据结构,java8中链表长度超过8时会将此链表转化成红黑树;存取时都会根据键值计算出“类别”(hashCode),再根据“类别”定位到数组中的位置并执行操作。
HashCode是什么
还是举个例子:一个工厂有500号人,下图用两种方案来存储厂里员工的信件。
image.png
左右各有27个信箱,左边保安大哥存信的时候不做处理,想放那个信箱就放那个信箱,当员工去找信的时候,只好挨个信箱找,再挨个对比信箱里信封的名字,万一哥们脸黑,要找的放在最后一个信箱的最底下,悲剧....所以这种情况的时间复杂度为O(N);右边采用HashCode的方式将27个信箱分类,分类的规则是名字首字母(第一个箱子放不写名字的哥们),保安大哥将符合对应姓名的信件放在对应的信箱里,这样员工就不用挨个找了,只需要对比一个信箱里的信件即可,大大提高了效率,这种情况的时间复杂度趋于一个常数O(1)。
例子中右图其实就是hashCode的一个实现,每个员工都有自己的hashCode,比如李四的hashCode是L,王五的hashCode是W(这取决于你的hash算法怎么写),然后我们根据确定的HashCode值把信箱分类,hashCode匹配则存在对应的信箱。在Java的Object中可以调用HashCode()方法获取对象hashCode,返回一个int值。那么会出现两个对象的hashCode一样吗?答案时会的,就像上上个例子中盖伦和老王的hashCode就一样,这种情况网上有人称之为“hash碰撞”,出现这种所谓“碰撞”的主力上面已经介绍了解决思路,具体源码后续介绍。
小结
hashCode是一个对象的标识,Java中对象的hashCode是一个int类型值。通过HashCode来制定数组的索引可以快速定位到要找的对象在数组中的位置,之后再遍历链表找到对应值,理想情况下时间复杂度为O(1),并且不同对象可以拥有相同的hashCode。
HashMap的时间复杂度
通过上面信箱找信的例子来讨论下HashMap的时间复杂度,在使用hashCode之后可以直接定位到一个箱子,时间的耗费主要是在遍历链表上,理想的情况下(hash算法写得很完美),链表只有一个节点,就是我们要的。
image.png
那么此时的时间复杂度O(1),那不理想的情况下(hash算法写得很糟糕),比如上面信箱的例子,假设hash算法计算每个员工都返回同样的hashCode
image.png
所有的信都放在一个箱子里,此时要找信就要依次遍历C信箱里的信,时间复杂度不再是O(1),而是O(N),因此HashMap的时间复杂度取决于算法的实现上,当然HashMap内部的机制并不想信箱那么简单,在HashMap内部会涉及到扩容、Java8中会将长度超过8的链表转化成红黑树,这些都在后续介绍。
小结
HashMap的时间复杂度取决于hash算法,优秀的hash算法可以让时间复杂度趋于常数O(1),糟糕的hash算法可以让时间复杂度趋于O(N)。
负载因子是什么
我们知道HashMap中数组长度是16,假设我们用的是最优秀的hash算法,即保证我每次往HashMap里存键值对的时候,都不会重复,当hashMap里有16个键值对的时候,要找到指定的某一个,只需要1次;
之后继续往里面存值,必然会发生所谓的“hash碰撞”形成链表,当hashmap里有32个键值对时,找到指定的某一个最坏情况要2次;当hashmap里有128个键值对时,找到指定的某一个最坏情况要8次
随着hashmap里的键值对越来越多,在数组数量不变的情况下,查找的效率会越来越低。那怎么解决这个问题呢?只要增加数组的数量就行了,键值对超过16,相应的就要把数组的数量增加(HashMap内部是原来的数组长度乘以2),这就是网上所谓的扩容,就算你有128个键值对,我们准备了128个坑,还是能保证“一个萝卜一个坑”。
image.png
其实扩容并没有那么风光,就像ArrayList一样,扩容是件很麻烦的事情,要创建一个新的数组,然后把原来数组里的键值对“放”在新的数组里,这里的“放”不像ArrayList那样用原来的index,而是根据新表的长度重新计算hashCode,来保证在新表的位置,老麻烦了,所以同一个键值对在就数组里的索引和新数组中的索引通常是不一致的。通常,我们也可以看出这是典型的以空间换时间的操作。
说了这么多,那负载因子是个什么东西?负载因子其实就是规定什么时候扩容。上面我们说默认hashmap数组大小为16,存的键值对数量超过16则进行扩容,好像没什么毛病。然而HashMap中并不是等数组满了(达到16)才扩容,它会存在一个阈值(threshold),只要hashmap里的键值对大于等于这个阈值,那么就要进行扩容。阈值的计算公式:
阈值 = 当前数组长度*负载因子
hashmap中默认负载因此为0.75,默认情况下第一次扩容判断阈值是16*0.75-12;所以第一次存键值对的时候,在存到第13个键值对时就需要扩容了;或者另外一种理解思路:假设当前存到第12个键值对:12/16=0.75,13/16=0.8125(大于0.75需要扩容)。肯定有人疑问,负载因子有何用?直接国定超过数组长度再扩容不就行了,还省得每次扩容之后还要重新计算新的阈值,Google说取0.75是一个比较好的权衡,当然我们可以自己修改,HashMap初始化时可以指定数组大小和负载因此,你完全可以改成1。
public HashMap(int initialCapacity, float loadFactor)
我的理解是这负载因子就像人的饭量,有的人吃要7分饱,有的人要10分饱,稳妥起见默认让我们7.5分饱。
小结
在数组大小不变的情况下,存放键值对越多,查找的时间效率会降低,扩容可以解决该问题,而负载因子决定了什么时候扩容,负载因子是已存键值对的数量和总的数组长度的比值。默认情况下负载因子为0.75,我们可在初始化HashMap的时候自己修改。
hash与Rehash
hash和rehash的概念其实上面已经分析过了,每次扩容后,转移旧表键值对对新表之前都要重新rehash,计算键值对在新表的索引。如下图火男这个键值对被存进hashmap后后面扩容,会经过hash和rehash的过程。
image.png
第一次hash可以理解成“分类”,方便后续取、该等操作可以快速定位到具体的“坑”,那么为什么要进行rehash,按照之前元素在数组中的索引直接赋值,例如火男之前3号坑,现在跑到30号坑。
个人理解是,在为扩容前,可以看到如13号链的长度是3,为了保证我们每次查找的时间复杂度O趋于O(1),理想的情况是“一个萝卜一个坑”,那么现在“坑”多了,原来“3个萝卜一个坑”的情况现在就能有效避免了。
源码分析
java7源码分析
先看下java7里的HashMap实现,有了上面的分析,现在在源码中找具体的实现。
//HashMap里的数组
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//Entry对象,存key、value、hash值以及下一个节点
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
//默认数组大小,二进制1左移4位为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//负载因子默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当前存的键值对数量
transient int size;
//阈值 = 数组大小 * 负载因子
int threshold;
//负载因子变量
final float loadFactor;
//默认new HashMap数组大小16,负载因子0.75
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//可以指定数组大小和负载因子
public HashMap(int initialCapacity, float loadFactor) {
//省略一些逻辑判断
this.loadFactor = loadFactor;
threshold = initialCapacity;
//空方法
init();
}
以上就是HashMap的一些先决条件,接着看平时put操作的代码实现,put的时候会遇到3中情况上面已经分析过,看下java7的代码:
public V put(K key, V value) {
//数组为空时创建数组
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//key为空单独对待
if (key == null)
return putForNullKey(value);
//①根据key计算hash值
int hash = hash(key);
//②根据hash值和当前数组的长度计算在数组中的索引
int i = indexFor(hash, table.length);
//遍历整条链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//③情况1.hash值和key值都相同的情况,替换之前的值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
//返回被替换的值
return oldValue;
}
}
modCount++;
//③情况2.坑位没人,直接存值或发生hash碰撞都走这
addEntry(hash, key, value, i);
return null;
}
先看上面key为空的情况(上面画图的时候总要在第一格留个空key的键值对),执行putForNullKey()方法单独处理,会把该键值对放在index0,所以HashMap中时允许key为空的情况。再看下主流程:
步骤①,根据键值算出hash值 -> hash(key)
步骤②,根据hash值和当前数组的长度计算在数组中的索引 -> indexFor(hash,table.length)
static int indexFor(int h, int length) {
//hash值和数组长度-1按位与操作,听着费劲?其实相当于h%length;取余数(取模运算)
//如:h = 17,length = 16;那么算出就是1
//&运算的效率比%要高
return h & (length-1);
}
步骤③情况1. hash值和key值都相同,替换原来的值,并将被替换的值返回。
步骤③情况2. 坑位没人或发生hash碰撞 -> addEntry(hash,key,value,i)
void addEntry(int hash, K key, V value, int bucketIndex) {
//当前hashmap中的键值对数量超过阈值
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容为原来的2倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
//计算在新表中的索引
bucketIndex = indexFor(hash, table.length);
}
//创建节点
createEntry(hash, key, value, bucketIndex);
}
如果put的时候超过阈值,会调用resize()方法将数组大小扩大为原来的2倍,并且根据新表的长度计算在新表中的索引(如之前17%16=1,现在17%32=17),看下resize方法
void resize(int newCapacity) { //传入新的容量
//获取旧数组的引用
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//极端情况,旧数组长度已经是最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
//修改阈值为最大直接返回
threshold = Integer.MAX_VALUE;
return;
}
//步骤①根据容量创建新的数组
Entry[] newTable = new Entry[newCapacity];
//步骤②将键值对转移到新的数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//步骤③将新数组的引用赋给table
table = newTable;
//步骤④修改阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
上面的重点是步骤②,看下它具体的转移操作
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;
}
}
}
这段for循环的遍历会使得转移前后键值对的顺序颠倒(java7和java8的区别),画个图就清楚了,假设石头的key的hash值为5,盖伦的key的hash值为37,这样扩容前后还是在5号坑。第一次:
image.png
第二次
image.png
最后再看下创建节点的方法
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
创建节点时,如果找到这个坑里面没有存值,那么直接把值存进去就行了,然后size++;如果是碰撞的情况
image.png
前面说的以单链表头插入的方式就是这样,总结以下java7 put流程图
image.png
相比put,get操作就没这么多套路,只需要根据key值计算hash值,和数组长度取模,然后就可以找到在数组中的位置(key为空同样单独操作),接着就是遍历链表,源码很少就不分析了。
java8源码分析
基本思路是一样的
//定义长度超过8的链表转化成红黑树
static final int TREEIFY_THRESHOLD = 8;
//换了个马甲还是认识你!!!
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
看下Java8 put的源码
public V put(K key, V value) {
//根据key计算hash值
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//步骤1.数组为空或数组长度为0,则扩容(咦,看到不一样咯)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//步骤2.根据hash值和数组长度计算在数组中的位置
//如果"坑"里没人,直接创建Node并存值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//步骤3."坑"里有人,且hash值和key值都相等,先获取引用,后面会用来替换值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//步骤4.该链是红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//步骤5.该链是链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//步骤5.1注意这个地方跟Java7不一样,是插在链表尾部!!!
p.next = newNode(hash, key, value, null);
//链表长度超过8,转化成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//步骤5.2链表中已存在且hash值和key值都相等,先获取引用,后面用来替换值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//统一替换原来的值
e.value = value;
afterNodeAccess(e);
//返回原来的值
return oldValue;
}
}
++modCount;
//步骤6.键值对数量超过阈值,扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
通过上面注释分析,对比和java7的区别,Java8一视同仁,管你key为不为空的统一处理,多了一步链表长度的判断以及转红黑树的操作,并且比较重要的一点,新增Node是插在尾部而不是头部!!!当然上面的主角还是扩容resize操作
final Node<K,V>[] resize() {
//旧数组的引用
Node<K,V>[] oldTab = table;
//旧数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧数组阈值
int oldThr = threshold;
//新数组长度、新阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//极端情况,旧数组爆满了
if (oldCap >= MAXIMUM_CAPACITY) {
//阈值改成最大,放弃治疗直接返回旧数组
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容咯,这里采用左移运算左移1位,也就是旧数组*2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//同样新阈值也是旧阈值*2
newThr = oldThr << 1; // double threshold
}
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;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//遍历旧数组,把原来的引用取消,方便垃圾回收
oldTab[j] = null;
//这个链只有一个节点,根据新数组长度计算在新表中的位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//红黑树的处理
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//链表长度大于1,小于8的情况,下面高能,单独拿出来分析
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
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;
}
可以看到,Java8把初始化数组和扩容全写在resize方法里了,但是思路还是一样的,扩容后要转移,转移要重新计算在新表中的位置。
下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1(5)和key2(21)两种key确定索引位置的实例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
image.png
图a中key1(5)和key(21)计算出来的都是5,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
image.png
图b中计算后key1(5)的位置还是5,而key2(21)已经变成了21,因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”.
有了上面的分析再回来看下源码
else { // preserve order
//定义两条链
//原来的hash值新增的bit为0的链,头部和尾部
Node<K,V> loHead = null, loTail = null;
//原来的hash值新增的bit为1的链,头部和尾部
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循环遍历出链条链
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;
}
}
为了更清晰明了,还是举个栗子,下面的表定义了键和它们的hash值(数组长度为16时,它们都在5号坑)
Key | Hash |
---|---|
石头 | 5 |
盖伦 | 5 |
蒙多 | 5 |
妖姬 | 21 |
狐狸 | 21 |
日女 | 21 |
假设一个hash算法刚好算出来的存储是这样的,在存第13个元素时要扩容
image.png
那么流程应该是这样的(只关注5号坑键值对的情况),第一次
image.png第二次......
第六次
image.png
两条链找出来后,最后转移一波,大功告成
//扩容前后位置不变的链
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//扩容后位置加上原数组长度的链
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
image.png
总结下java8 put流程图
image.png
对比
- 发生hash冲突时,java7会在链表头部插入,java8会在链表尾部插入
- 扩容后转移数据,java7转移前后链表顺序会倒置,java8还是报纸原来的顺序
- 关于性能可参考美团技术博客,引入红黑树的java8大程度的优化了HashMap的性能。
概述
以上分析了HashMap的设计思想以及java7和Java8源码上的实现,当然还有一些“坑”还没填完,比如大家都知道HashMap是线程不安全的数据结构,多线程情况下HashMap会引起死循环引用,它是怎么产生的?Java8引入了红黑树,那是怎么提高效率?
java7分析
通过上面的整体学习,可以知道当存入的键值对超过HashMap的阈值时,HashMap会扩容,即创建一个新的数组,并将原数组里的键值对“转移”到新的数组中。在“转移”的时候,会根据新的数组长度和要转移的键值对key值重新计算在新数组中的位置。重温下Java中负责“转移”功能的代码
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;
}
}
}
为了加深理解,画个图如下
image.png
这里假设扩容前后5号坑石头、盖伦、蒙多的hash值与新旧数组长度取模运算后还是5。上篇文章也总结了,java7扩容转移前后链表顺序会倒置。当只有单线程操作hashMap时,一切都是那么美好,但是如果多线程同时操作一个hashMap,问题就来了,下面看下多线程操作一个hashMap在Java7源码下是怎样引起死循环引用。
前戏是这样的:有两个线程分别叫Thread1和Thread2,它们都有操作同一个HashMap的权利,假设HashMap中的键值对是12个,石头和盖伦扩容前后的hash值与新旧数组取模运算后还是5。扩容前的模拟堆内存情况如图
image.png
Thread1得到执行权(Thread2被挂起),Thread1往HashMap里put第13个键值对的时候判断超过阈值,执行扩容操作,Thread1创建了一个新数组,还没来得及转移旧键值对的时候,系统时间片切到Thread2(Thread1被挂起),整个过程用图表示
image.png
可以看到Thread1只是创建了个新数组,还没来得及转移就被挂起了,新数组没有内容,此时在Thread1的视角认的是e是石头,next是盖伦;此时的模拟内存图情况
image.png再看下Thread2的操作,同样Thread2往hashMap里put第13个键值对的时候判断超过阈值,执行扩容操作,Thread2先创建一个新数组,不同的是,Thread2运气好,在时间片轮换前转移工作也走完了。
第一次遍历
image.png
第二次遍历
image.png
此时模拟的内存情况
image.png
可以看到此时对于盖伦来说,他的next是石头;对于石头来说,他的next是null,隐患就此埋下。接下来时间片又切到Thread1,先看下Thread1的处境
image.png
结合代码分析如下
第一步:
image.png
第二步:
image.png
第三步:
image.png
第四步:
image.png
第五步:
image.png
第六步:
image.png
第七步:
image.png
第八步:
image.png
第九步:
image.png
第十步:
image.png
到这终于看到盖伦和石头“互指”
image.png
那这会带来什么后果呢?后续操作新数组的5号坑会进入死循环(注意,操作其他坑并不会有问题),例如java7 put操作
image.png
java7 get操作会执行getEntry,同样会引起死循环
image.png
到此,java7多线程操作HashMap可能形成死循环的原因剖析完成。
java8分析
通过上面的学习可知,java7转移前后位置颠倒,而java8转移键值对前后位置不变。同样的前戏,看下代码
image.png
此时模拟堆内存情况
image.png
Thread1的情况
image.png
这时候Thread2获得执行权,扩容并完成转移工作,通过上篇的学习可知,Java8在转移前会创建两条链表,即扩容后位置不加原数组长度的lo链和要加原数组长度的hi链,这里假设石头和盖伦扩容前后都是在5号坑,即这是一条lo链(其实就算不在同一个坑也不影响,原因就是Java8扩容前后链顺序不变)。
Thread2遍历第一次
image.png
第二次
image.png
可以看到Thread2全程是没有去修改石头和盖伦的引用关系,石头.next是盖伦,盖伦.next是null.那么Thread1得到执行权后其实只是重复了Thread2的工作。
总结
通过源码分析,Java7在多线程操作hashmap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系;java8在同样的前提下并不会引起死循环,原因是扩容转移前后链表顺序不变,保证之前节点的引用关系。那是不是意味着Java8就可以把HashMap用在多线程呢?个人感觉即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一面get的时候还是原值,建议使用ConcurrentHashMap.
网友评论