HashMap是我们在项目中用的比较多的一个键值对集合,但是在高并发的场景下如果使用不当会产生意想不到的问题,本篇文章主要讲HashMap在高并发场景下最为经典的两个问题---死链问题和对象丢失问题
本文会结合图示及原码来讲诉这两个问题的原理及应该如何去解决
死链问题
这个问题算是我们比较常见的问题了,不管是面试也好还是看其他的博客也好,只要一谈到HashMap,都会讨论到这个问题的,因此,也正式因为这样,死链这个问题也就成为了HashMap的一个经典问题了
死链问题的产生是在JDK1.7以前,我们都知道HashMap底层的数据结构是一个数组加链表的形式,每当有元素插入的时候,HashMap就会根据元素的key通过hash取模的方式来定位到数组中的具体位置,如果在插入的过程中定位到的数组位置与其他的元素冲突了就会形成一条链表
这其实不难理解,我举一个例子,假如我初始化一个HashMap,我往HashMap里 put 了一个A元素 ,HashMap根据A元素的key通过取模的方式计算出的数组的索引位置是 1 ,那么此时A元素就会存放在数组索引是 1 的位置,此时我又 put 了一个B元素,刚巧不巧,HashMap根据B元素的key通过取模的方式计算出的数组索引位置也是 1 (我们把这种情况称之为hash碰撞或hash冲突),而HashMap在存放B元素的时候并不会把A元素给覆盖掉,而是A元素通过一个指针指向B元素,这样就形成了一条链表
如果我们通过 get 根据 key 去获取某个值的时候,HashMap会根据这个 key 还是通过取模的方式来定位到数组的索引位置,然后再遍历这个位置的链表,找到对应的值再将其返回
这个数组的长度不可能是固定的,在存放的数据达到一定的量的时候就会对这个数组进行扩容,HashMap是规定了一个 0.75 的扩容因子,当数组索引的位置存放元素的个数达到这个扩容因子的比例的时候,HashMap就会对数据进行扩容,比如数组的长度是10,数组索引位置存放元素的个数到达了 7 时,在下一次插入元素的指后,数组就会进行扩容
那么死链问题就是产生在扩容的过程中
HashMap在扩容的时候是使用的头插法的方式将元素一个一个存放到扩容后的数组里的
头插法就是在迁移元素的过程中,将元素从扩容后数组的索引位置的头部插入,然后再将插入的元素下移到数组所对应的索引位置
为了让大家更好的了解头插法,我来举一个例子
我在上面的那个例子的基础之上再往集合里 put 一个C元素、D元素和E元素,假如我在 put E元素的时候触发了扩容(这里需要注意一下,在JDK1.7的时候,会在插入元素之前判断下数组是否需要扩容),此时会创建一个新数组,新数组的长度是原来数组的两倍,我们把原来老数组称为oldTab,新数组称为newTab,如图所示
此时会遍历oldTab里每个索引位置的链表,从链表的第一个元素开始一个一个搬迁到新数组里
这里搬迁的时候会将元素的指针指向新数组的链表的第一个元素(这里新数组的索引位置会根据key重新做一下计算),此时oldTab索引 1 的位置链表里的元素通过对key的重新计算得到的newTab索引位置是 4 ,假如已经将A元素迁移到了newTab索引4的位置,那么在迁B元素的时候此时是这个样子的
这里会将B元素的指针指向A元素,然后再将B元素下移到数组索引位置处,如图所示
这就是头插法的全过程
在全部迁移完成之后我们再来看看与原数组有什么不一样
可以看出来,除了数组的长度不一样之外,其链表的元素指针所指向的顺序都反了过来,也正是因为头插法造成的元素指针的顺序反了才会导致死链问题
为什么会这么说呢,我们现在来模拟一个多线程环境下的数组扩容
假如有两个线程同时触发了扩容,那么此时这两个线程会分别创建一个新的数组,我们暂且将这两个新数组称为newTab1和newTab2
此时newTab1所对应的线程率先对oldTab完成数据的迁移,而newTab2所对应的线程才迁移了两个元素,如图所示
那么当newTab2所对应的线程要去迁移C元素的时候,本来是根据oldTab里B元素的指针找到C元素,但是由于newTab1所对应的线程是率先对oldTab里的数组完成迁移,所以oldTab里元素所对应的指针指向全部反过来了,所以当newTab2所对应的线程根据oldTab里B元素的指针找到C元素的时候,实际上获取到的是A元素,如图所示
A指向B,B又指向A,因此就造成了一个死链
这里来总结下,HashMap是通过头插法的形式将元素迁移到新数组里,但正是由于头插法这种形式将链表里元素的指针本末倒置了,所以在并发环境下才会导致死链问题
为了解决这种链表元素指针本末倒置问题,JDK1.8将头插法改成了尾插法
尾插法顾名思义就是直接从链表元素的尾部插入,我们来看看尾插法的插入
假设我们往Map集合里 put 了A、B、C、D四个元素,在 put 到D元素的时候触发了扩容,此时扩容后的新数组如图所示
与头插法不同的是,尾插法保证了链表元素指针指向顺序问题,从而就解决了元素指针指向的本末倒置问题,通过上图就知道了,新数组链表元素A、B、C、D的指针指向的顺序与原数组链表元素A、B、C、D的指针指向的顺序是一样的
说完了尾插法,我们来把上面多线程环境下扩容的例子改成用尾插法的方式来看看
操作newTab1所对应的线程率先完成迁移,而此时操作newTab2所对应的线程才迁移了两个元素,如图所示
那么当newTab2所对应的线程要去迁移C元素的时候,本来是根据oldTab里B元素的指针找到C元素,但是newTab1所对应的线程是并没有改变oldTab里的数组里链表元素所对应的指针指向,所以当newTab2所对应的线程根据oldTab里B元素的指针找到C元素的时候,实际获取的任然是C元素,而不会像上面头插法那样获取到A元素了
下面我们来结合源码来看看HashMap的扩容机制
我们先来看看头插法的,在此之前我们需要将JDK的版本改成1.7的
下面这段源码是 put 方法的源码
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key); // 代码一
int i = indexFor(hash, table.length); // 代码二
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++;
addEntry(hash, key, value, i); //代码三
return null;
}
在代码一和代码二的这个位置,put 方法通过计算下key的hash值,然后再根据hash值与数组的长度去做一个 & 的操作,我们可以来看下这个 indexFor 方法的
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
通过 & 操作返回的值就是我们 put 的值在数组中的索引位置,在获取完索引位置之后,接下来就是一个for 循环,这个 for 循环的作用实际上就根据所获取的索引位置找对应的元素,如果找到了的话就会将我们 put 的value值替换掉对应老的value替换掉,并将老的value值返回
如果没有找到的话会去新增,在代码三的位置调用了 addEntry 方法,我们可以来看看这个方法的源码
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) { // 代码一
resize(2 * table.length); // 代码二
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex); // 代码三
}
我们看到代码一位置的这个判断,在新增元素之前会做一下判断看看是不是需要对数组进行扩容,如果需要进行扩容的话就会调用 resize 这个方法,可以看到 resize 方法传参是 2*tabel.length,也就是扩容的大小是原数组的两倍
在看这个 resize 方法之前我们先来看看代码三这个位置的 createEntry 方法,看看是如果通过头插法来插入的,下面是 createEntry 方法的源码
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++;
}
就很简单的几行代码,并没有那么复杂
首先看下第一行代码,这行代码之际上就是获取到数组对应索引位置的链表
这里肯定有人想问,这里获取的就是一个 Entry 对象,为啥是一个链表呢
我们可以看下这个 Entry 对象的结构
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
........
}
这个对象里除了有我们 put 的 key 和 value 之外,还有一个 next 变量,这个变量就是我们上文所说的元素指针,这个指针会指向下一个 Entry 对象(或者元素)
在上面我们所说的第一行代码,虽然获取到的是一个 Entry 对象,但是我们可以根据这个 Entry 对象去获取到下一个 Entry 对象,然后还可以根据下一个 Entry 对象获取到下下个 Entry 对象,这就是我们所说的链表,在第一行代码获取到一个 Entry 对象,就是链表的第一个元素
到了这里你现在知道了第一行代码为什么获取到的是链表了,我们再来看看第二行代码
第二行代码就是将我们 put 的值封装成了一个新的 Entry 对象,然后将新的 Entry 对象所对应的 next 指针指向了在第一行代码所获取到 Entry 对象,这里就完成了从头部插入,在上面讲头插法的时候就说到了,元素除了从头部插入之外还需要将元素下移到数组索引的位置
那么在第二行代码除了 new 了一个 Entry 对象之外,还有将这个新的 Entry 对象赋值给了数组中的索引所对应的位置,最终完成下移
讲完了元素如何插入的,我们再来看看扩容对应的源码,在上面就说了,扩容会调用 resize 方法,下面就是 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 = newTable; // 代码四
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
看到代码二位置这里是创建一个新的数组,并且新数组的长度是老数组的两倍
到了代码三位置这里调用了 transfer 方法,这个方法就是对老数组做数据迁移的方法,也是死链问题产生的源头
下面是 transfer 方法源码
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 循环就是循环老数组里每个索引位置的链表,第二个循环就是循环每个链表的元素
代码一和代码二位置就是将老数组的元素通过头插法的方式插入到数组里,这两个位置对应的代码与 createEntry 方法里第一、二行代码其实是大同小异的
那么我们主要来看看代码三位置的代码,这个行代码我们可以将其理解为是一个偏移指针,用来记录下一个将要迁移的元素,这个可能会有点抽象,我们来通过上面讲的一个例子来说明下
在上面的数组迁移的例子中,假如对A元素进行了迁移,那么偏移指针会指向B元素,如图
紧接着就会根据偏移指针所指向的B元素,对B元素开始做迁移的操作
迁移完B元素之后,偏移指针就会指向C元素,如图
紧接着就会根据偏移指针所指向的C元素开始做迁移的操作,就这样依次偏移,直到链表的最后一个元素
为什么这里要着重讲下这个偏移指针呢,因为正是这种偏移指针才会造成HashMap在扩容过程中的另外一个经典问题---对象丢失,这也是在下一节要讲的问题
到了这里我们JDK1.7关于HashMap的扩容、添加元素、头插法所涉及到源码就说完了
接下来就来看看JDK1.8关于HashMap的源码,在此之前我们需要将JDK的版本从切换到1.8
我们此时往集合里 put 元素的时候,put 方法最终会调用 putVal 方法,下面是 putVal 方法源码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 代码一
if ((p = tab[i = (n - 1) & hash]) == null) // 代码二
tab[i] = newNode(hash, key, value, null); // 代码三
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) // 代码四
e = p; // 代码五
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) { // 代码六
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); // 代码七
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
相比于JDK1.7版本的HashMap来讲,这里面的逻辑还是比较的复杂,其实在JDK1.8除了将头插法改成尾插法这个优化之外,还有一个优化就是当数组中链表达到一定的长度之后,会将链表转换成红黑树,这个优化主要就是为了查询效率
我们刚开始第一次 put 值得时候,在代码一的位置会调用 resize 方法,注意此时调用 rsize 并不是扩容,而是初始化数组
在JDK1.7的时候,当我们创建HashMap对象的时候就会去初始化数组,而JDK1.8会在我们 put 值得时候才会去初始化数组,类似于一种懒加载,这里是是需要做下区分的
也就是说在JDK1.8,resize 方法除了扩容之外,还有初始化数组的功能
初始化完数组之后,就会来到代码二位置,这个判断其实就是判断初始之后的数组所对应的索引位置有没有值,如果没有就直接创建 Node 对象并将这个对象存放到这个索引位置
刚初始化出来的数组是没有任何值的,所以当我们刚开始第一次 put 值的时候会直接走代码三位置所对应的代码,这样也是为了插入效率
当我们第二次 put 值的时候,此时呢,数组里是有我们第一次 put 的值的,那么就会直接来到代码四位置,这个判断会判断数组对应的索引位置的链表的第一个元素是不是刚好就是我们第二次 put 的那个值,如果是的话,就说明了我们第二次 put 的值在链表中存在并且位于链表的第一个元素,就会来到代码五位置做了一个赋值的操作,紧接着就直接来到了代码九位置,将第二次 put 的值所对应的value覆盖老的value并将老的value返回,同样的逻辑在代码八处也体现了,代码八位置是处在一个循环体中,也就是说在循环链表的过程中如果发现元素已经存在了,那么就会退出循环然后又来到代码九位置,将我们 put 的新的value值替换成老的value值
如果我们第二次 put 的值在链表的第一个元素不存在的话就会直接来到代码七位置,可以看到这个循环没有判断条件,因此这是一个死循环
这个死循环就是用来循环链表的每个元素,如果我们 put 的值在链表中不存在的话,就会循环到链表的最后一个元素,并将最后一个元素的 next 指针指向新插入的元素,这里尾插法就充分的体现出来了,在代码六位置处,在这里就不再是将 put 的值封装成 Entry 对象了,而是封装成了一个 Node 对象
在看了如何通过尾插法插入的源码之后,我们再来看下 resize 方法,下面是 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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
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);
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;
}
看起来代码还挺多的,但是我们只需要看分割线以下的代码,分割线以上的代码都是设置一些初始值
在代码一处初始化了一个新的数组,我们上面所讲的刚开始第一次 put 的值的时候会去初始化一个数组,就是在这里初始化的
说到这里你们肯定有有人要问了,如果初始化数组走的是这里,难道就不会直接执行下面扩容的代码吗
这里需要注意,刚开始 put 值的时候,在代码二处 oldTab 是为 null 的,因此不会走里面的逻辑
来到扩容这里,也就是代码二处判断逻辑里面的代码
这里也是有两个循环,一个是 for 循环,循环的是数组,一个是 do-while 循环,循环的是链表
在做数据迁移的时候,在代码三和代码四位置处定义了 loHead、loTail、hiHead、hiTail四个变量,这四个变量所对应的类型是 Node, 我们称之为高低位链表
这个高低位链表在上面讲扩容的时候并没有讲到,那么这个高低位链表又是怎么回事呢
高低位链表是HashMap的又一种优化,在迁移数据的时候,为了避免频繁的访问数组,所以就先将有冲突的元素拉出一条链表,循环完之后,再将链表放到数组对应的位置上
在扩容的时候,新数组会扩容一倍,以容量为8的HashMap为例,那么在扩容的时候就会扩容至16,那么在数组区间 [0,7] 为低位,[8,15] 为高位,低位链表对应的就是loHead,高位链表对应的就是hiHead,hiTail和loTail又分别对应高位指针和低位指针
为了更好的理解高低位链表和高低位指针,用一个例子在说明下
我有一个HashMap对象,我往这个对象里 put 了A元素、B元素、C元素和D元素,这四个元素都在同一条链表上,假设在 put D元素的时候触发了扩容,那么此时就会来到代码五位置的 do-while 循环这里
如果正在迁移的是A元素,那么此时会通过计算下A元素是落在高位还是地位,在代码六位置处就体现了是如何计算的,如果是落在低位那么就将元素保存在 loHead,同时低位指针会指向这个迁移的元素,如果是落在高位,元素就会保存在 hiHead,同时高位指针指向这个元素,假设A元素通过计算落在了低位,那么结果如图所示
紧接着就会迁移B元素,刚好B元素通过计算也是落在了低位,那么此时结果就是这样的
假设C元素和D元素通过计算是落在了高位,那么此时就是如图所示
既然已经有了高地位链表了,为什么还要搞个高低位指针呢,这里就主要是为了能够快速的找到链表的最后的一个元素,所以可以看到高低位指针指向的永远都是链表的最后一个元素
当原来的链表迁移完成之后,此时就会将高低位链表存放到新数组的高低位区间,在代码九和代码十的位置
这里做一下小结,在JDK1.8中,HashMap为了解决死链问题,除了将头插法改成尾插法之外,还引入了高低位链表,如果在迁移的过程中没有哈希冲突,那么就直接重新hash取模,并copy到新数组对应的位置,如果有哈希冲突就会采用高低位链表处理
看完了JDK1.8的HashMap的尾插法、扩容相关源码之后,我们再来看看HashMap另外的一个经典问题,那就是对象丢失
对象丢失
我们都知道在并发环境下做扩容的操作,每个线程可能都会创建一个新数组,那么在每个线程扩容完成并迁移完数据之后就会将自己创建的数组替换原来老的数组,我们通过源码就知道,下图分别是JDK1.8和JDK1.7的源码
HashMap底层的数组最终由这个 table 变量来引用着,只要这个 table 变量的所引用的数组被替换了那么就有可能造成对象丢失
还得的上面讲的指针吗,我在讲JDK1.7源码的时候讲了个偏移指针,偏移指针在每迁移完一个元素就会在老的数组里指向下一个将要迁移的元素,如图所示
这个偏移指针在JDK1.8中也存在
为什么偏移指针会造成对象的丢失呢
我来举一个例子,假如有个HashMap正在扩容,其中HashMap里有A、B、C、D四个元素,并且这四个元素在不同的链表上,在数据迁移的过程中偏移指针刚好指向了D元素,如图所示
此时,对应的 table 变量还是指向的老数组,刚巧不巧,在迁移的过程中又插入进来了E元素,而这个E元素通过计算又放到了老数据的B元素的后面,如图所示
此时的偏移指针已经偏移到了D元素了,所以不会再去偏移前面链表里的元素,因此在迁移的过程中会将E元素漏掉,在迁移完最后一个D元素之后,就会将 table 变量指向新的数组,如图所示
可以看到在整个迁移的过程中E元素被丢失了
那么既然JDK1.8解决了死链问题,那么有没有解决这个对象丢失的问题呢
答案是没有,我们通过它扩容的逻辑就知道了,对象丢失是不可避免的,在对于数组或链表的循环的过程中,偏移指针只对链表里的元素做一次扫描,因此后面新加进来的元素错过了就错过了
最后,来做一下小结,虽然JDK1.8结局了死链问题,但是HashMap任然是一个线程不安全的类,比如对象丢失,数据被并发修改等,在高并发场景下如果需要用到键值对集合,要自己先评估下是否涉及到多个线程并发访问,如果涉及到的话切记不要直接使用HashMap,可以用Hashtable、ConcurrentHashMap或 Collections.synchronizedMap来替代
好了,本文对于HashMap两个经典问题的讲解就结束了,码字不易,还希望能多点点👍