HashMap的循环链表图解
@author:Jingdai
@date:2021.03.20
复习HashMap的知识点,总是看到jdk1.7前在多线程操作时可能会出现循环链表问题,不是很理解,于是研究源码并画图终于搞懂,记录一下。
由于本人电脑只有jdk1.5和jdk1.8,所以以下分析均基于jdk1.5,jdk1.7应该一样。
循环链表发生的地方
循环链表发生在多个线程同时对HashMap进行put操作使HashMap 扩容的时候。下面看代码。
public V put(K key, V value)
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
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;
}
代码前面的循环是用来判断插入的 Entry
是否是已有的 key
,是就进行替换,这里不是我们关注的,加入元素主要发生在倒数第二行的 addEntry 方法。下面看这个方法。
void addEntry(int hash, K key, V value, int bucketIndex)
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
见代码最后两行,如果加入元素到达了扩容阈值,就进行 resize 操作,传入新的容量为 2倍的数组长度,继续进入这个方法。
void resize(int newCapacity)
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);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
代码的前几行判断 oldCapacity 是否已经是最大了,如是则不能扩容,这里看下面 Entry[] newTable = new Entry[newCapacity];
这行代码,当满足扩容条件时,就创建一个大小为2倍数组长度的新的HashMap,然后调用 transfer 方法将旧的元素传递到新的HashMap中,最后将新HashMap传给table变量。主要的扩容操作就在 transfer 方法中,也是发生循环链表的地方,接着看。
void transfer(Entry[] newTable)
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
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);
}
}
}
代码并不长,找到了循环链表发生的地方,我们具体分析怎么产生的循环链表。
循环链表产生分析
要了解循环链表如何产生,我们首先看 transfer 方法在单线程中是如何正常工作的,下面利用图解分析。
如图,为了简单,这里我们假设HashMap原本的数组大小为2,扩容后大小为4。实际中这种情况不会发生,但是原理一样。图左为原始HashMap,图右是新的HashMap,我们只关注下标为1的那个桶,其他不关心,同时假设元素1、2、3都会插入到新的HashMap的1号桶中。下面一行一行代码分析。
进入循环前,先执行 Entry<K,V> e = src[j];
,这时 e
就指向元素1,然后使 src[j] = null;
,再将 next
指向 e
的下一个元素,即元素2。下面那一行 int i = indexFor(e.hash, newCapacity);
是根据 hash 算出插入桶的下标,我们都假设算出来是1,即插入到新的1号桶中,后面我就不分析这行代码了。同时,由于src[j] = null
,对这三个元素来说,原始HashMap已经用不到了,后面的图也不画了(画图挺累的^ _ ^)。
然后将 e.next
指向 newTable[i]
, 注意这里 newTable[i]
里面还没有元素,所以是 null
,即使 e.next
指向 null
,然后使 newTable[i] = e;
,就将新HashMap的1号桶指向 e
,这时就已经把元素1插入到新HashMap中了,接着看。
如上图,接着就是将 e
和 next
更新一下。
如上图,接着就会将元素2插入到列表中,然后更新e和next将元素3也插入到里面。插入元素2和元素3步骤一样,我就不画了。更新的和最后的图如下。
好了,懂了在单线程中如何扩容元素就可以分析循环链表了。还有要注意一点,在插入元素的时候使用的是头插法,所以我们在旧HashMap中元素的顺序是123,而到了新的HashMap中就变成了321。
继续多线程,假设两个线程都要插入一个元素,且都进入到了执行了resize方法,即执行到 addEntry 方法中的 if (size++ >= threshold)
时都判断为true,然后在各自的线程中创建一个新的 newTable
,然后进入到 transfer
方法。还是上面的例子,假设两个线程一起执行,最开始的样子如下图。
我们用 e1
、e2
分别代表线程1和线程2中的局部变量 e
,用 next1
、next2
分别代表线程1和线程2中的局部变量 next
。
如上图,假设线程1和线程2执行到这步时,线程2失去了时间片,线程1继续执行直到结束,那么线程1执行结束后,就成了下面这样。
然后线程2获得了时间片,开始执行,接着一步一步分析。
上面图是一步一步按代码走的,走到最后一步 e
为 null
了,就退出了循环,此时已经有环了,对应于线程2,它对应的图就是下面这样。
如果线程2后将 newTable
写回 table
,环就产生了,同时发现元素3也丢失了,再也找不到了。
以上就是关于jdk1.7版本前HashMap生成环的一个例子,希望对大家有帮助。如果上面说的有什么错误也欢迎指正,一起学习进步。