近期学习Map相关的基础源码,有所收获进行些必要的笔记,重点针对面试中HashMap1.7可能常问的点通过源码方式进行解析。本文之后还会继续总结JDK1.7 ConcurrentHashMap,JDK1.8的HashMap及ConcurrentHashMap底层相关源码知识。下面进行QA问答式解析。
1.HashMap是什么数据结构,类似的还有哪些数据结构?
/ -----
* @param <K> the type of keys maintained by this map
* @param <V> the type of mapped values
*
* @author Doug Lea
* @author Josh Bloch
* @author Arthur van Hoff
* @author Neal Gafter
* @see Object#hashCode()
* @see Collection
* @see Map
* @see TreeMap
* @see Hashtable
* @since 1.2
*/
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
/**
* An empty table instance to share when the table is not inflated.
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
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;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}
可以看到HashMap内部是一个采用数组+链表的数据结构。其中数组是Entry[k,v]类型的table属性,链表是内部定义的静态Entry类,所有的数据都封装为一个个的Entry数据,Entry包含了自身hash值,对应key,对应value,下一个Entry对象,体现出了链表的特点。
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash;
同时关注到HashMap实现了Map接口,除此之外还有HashTable,TreeMap两种数据结构也是实现了Map接口,但是与HashMap存在一些差异。
2.HashMap内部有哪些内部属性需要关注;HashMap初始化流程?
HashMap内部有许多重要核心属性,下面挑出必须要了解的一些属性加以说明:
/**
* 默认的HashMap容器的容量,必须为2的幂次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 容器的最大容量数,必须满足小于等于1<<30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 加载因子:用于辅助扩容默认为0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* HashMap数据存储的基本数据单元Entry类,实现了Entry接口
*/
static class Entry<K,V> implements Map.Entry<K,V>
/**
* 空数组对象
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* 数组对象。第一次使用或者扩容时初始化,数组长度总是2的幂次方。
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* 缓存所有Entry数据的Set缓存对象,可以使用keySet()和values()来查询键值数据
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 记录hashmap容器中数据键值对个数
*/
transient int size;
/**
* 下一次进行扩容的阈值= (capacity * load factor).
* 阈值=容器当前最大容量*加载因子,当数据添加个数达到阈值时准备进行扩容操作
*/
int threshold;
/**
* 记录hashmap被创建后修改次数。每次hashmap添加,删除,修改数据该数值都会加1,
* 用于如果for循环遍历删除数据时可以快速报错ConcurrentModificationException
*/
transient int modCount;
/**
* hash种子,可以用于影响hash值的生成规则,使hash生成更加自定义散列,默认不使用
*/
transient int hashSeed = 0;
HashMap初始化过程,对于hashmap有多种构造函数:
//传递初始化容器大小,自定义加载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
//传递初始化容器大小,加载因子默认0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//不传递参数,默认容器大小16,加载因子0.75
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//传递已存在的map参数,将map参数数据复制到新构造hashmap中
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
从上可以看出,hashmap有四种构造方法,实际上意义都只是对hashmap内部的容器大小和加载因子属性进行前期赋值操作,大多数时候我们都使用不传参数的构造方法HashMap() 只初始化默认容器大小和加载因子。
3.put操作的流程?
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
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;
}
首先结论如下,再逐条解析:
(1)先判断当前table是否为空数组,如果是空数组则计算出最接近当前阈值的2幂次方数值,作为数组容量进行table初始化,同时根据JVM是否配置了misc来决定hashseed种子是否需要参与到hash计算,如果开启则hashseed值将不为0;
if (table == EMPTY_TABLE) { inflateTable(threshold); }
private void inflateTable(int toSize) { // Find a power of 2 >= toSize //根据阈值,查询比阈值大的,最近的2幂次数值,作为容量值 int capacity = roundUpToPowerOf2(toSize); //计算新的阈值=capacity * loadFactor,不超过数字最大值 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); //table初始化 table = new Entry[capacity]; //判断后续是否启用hashseed的种子参与hash计算 initHashSeedAsNeeded(capacity); }
final boolean initHashSeedAsNeeded(int capacity) { //初始hashseed=0,这里currentAltHashing=false boolean currentAltHashing = hashSeed != 0; //如果jvm启用misc参数,则useAltHashing=true boolean useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); //进行异或操作,当useAltHashing为true,则开启 boolean switching = currentAltHashing ^ useAltHashing; //一旦开启,hashSeed 则会进行计算得到一个大于0的值,此后该hashseed就可以参与hash计算 if (switching) { hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0; } return switching; }
(2)判断当前key是否为null,可以支持存储K-V为空的数据,放置位置为第一个数组table[0]位置或内部链表中。如果第一个数组链表中存在key=null的数据则直接返回,如果不存在则封装一个key=null,hash=0的空数据存入容器并返回null:
if (key == null) return putForNullKey(value);
/** * Offloaded version of put for null keys */ private V putForNullKey(V value) { //遍历第一个数组,如果数组位置不为空则遍历该数组下的链表 for (Entry<K,V> e = table[0]; e != null; e = e.next) { //找到key=null的数据,则返回源value值,同时新value覆盖 if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } //如果不存在key为空的链表数据,则新增一个key=null,hash=0的entry对象 modCount++; addEntry(0, null, value, 0); return null; }
(3)如果K-V不为空,通过key计算一个尽可能散列的hash值,然后通过该hash值和数组大小进行与操作,得到该数据要写入哪个数组下标:
int hash = hash(key);
int i = indexFor(hash, table.length);
final int hash(Object k) { int h = hashSeed; //如果是Value是字符串则直接使用jdk字符串自身hashcode函数 if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } //hashseed种子和对象的hashCode()进行异或操作 h ^= k.hashCode(); // 下面的操作为了让得到的hash值进一步位移操作,保证得到的hash尽可能散列 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
/** * Returns index for hash code h. */ 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); }
(4)先判断put进来的数据,是否已经在计算好的数组下标链表中已经存在,如果已经存在则进行覆盖更新并返回源value值;
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; } }
(5)如果put进来的数据,在计算好的数组下标链表不存在,则封装成Entry对象。如果该数组还不存在链表则直接存在数组位置,如果已存在链表则以头插法方式添加到链表中,链表新增数据前同时判断数据个数是否已经达到阈值且该下标处的数组是有数据的,则会进行扩容,扩容后再将新数据添加到链表中:
modCount++; addEntry(hash, key, value, i); return null;
void addEntry(int hash, K key, V value, int bucketIndex) { //判断容器大小是否达到阈值,且该下标位置的数组存在数据,则进行扩容 if ((size >= threshold) && (null != table[bucketIndex])) { //扩容规则是:容器大小为原大小的2倍。并进行源数据转移到新数组中 resize(2 * table.length); //对key重新hash hash = (null != key) ? hash(key) : 0; //计算新数组下标 bucketIndex = indexFor(hash, table.length); } //将新数据以头插法方式新增到源数组链表或扩容后的数组链表中 createEntry(hash, key, value, bucketIndex); }
4.put操作中怎么计算数组位置,为什么数组长度要为2的幂次方?
HashMap进行put数据时,根据key计算出一个散列后的hash值后,计算对应数组位置是通过和table数组长度进行与操作:
static int indexFor(int h, int length) {
return h & (length-1);
}
取若干个测试数据进行与操作(相同则为1):
(1)
hash=12: 0000 1100
length-1=15: 0000 1111
hash&(length-1):0000 1100 = 12 = 12%16
(2)
hash=20: 0001 0100
length-1=15: 0000 1111
hash&(length-1):0000 0100 = 4 = 20%16
(3)当容器大小length=32
hash=50: 0011 0010
length-1=31: 0001 1111
hash&(length-1):0001 0010 = 18 = 50%32
可以看到这种情况下,不管hash值是多少,通过上述&操作就能得到不越界的数组下标,并且与操作和直接使用(hash%数组大小)的取余操作效果是一样的。但是为什么要与操作呢?原因是与操作时位操作,运行速度更快。而为了达到上述不越界数组下标的目的,还有一个前提条件,那就是数组容器大小length值必须是2的幂次方才成立。
5.put的元素会存储到哪里,是否可以存储Key为Null的数据?
如果新增的数据key为null,则默认放置在第一个数组table[0]位置或内部链表中,且封装数据为hash=0,key=null,value=value,next=table[0]。
if (key == null) return putForNullKey(value);
/**
* Offloaded version of put for null keys
*/
private V putForNullKey(V value) {
//遍历第一个数组,如果数组位置不为空则遍历该数组下的链表
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//找到key=null的数据,则返回源value值,同时新value覆盖
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//如果不存在key为空的链表数据,则新增一个key=null,hash=0的entry对象
modCount++;
//hash=0,key=null, value=value,数组index=0,next=table[0]
addEntry(0, null, value, 0);
return null;
}
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++; }
6.put的链表数据存储流程是怎么样的,采用头插法还是尾插法,有什么区别?
put操作采用头插法方式。首先解释下头插法和尾插法。
(1)头插法:
新增第1个元素时,该元素放置在数组位置里。
新增第2个数据时,以头插法方式,第2个数据的next属性指向第1个数据。
但是当新增第3个数据,这就出现一个问题了,那就是我们进行put操作,都是根据key进行hash后,再计算出数组下标,在该数组下标开始位置开始向上找到链表最头部进行头插新增。由于所有的Entry数据本身没有一个prev属性记录上一个节点对象,这样就导致第3个数据无法挂到第2个数据前面了。
观察到每次计算到数组下标的位置后,可以每次都将这个位置就作为整个链表的头结点,因此就只需要每次put之后,将整个链表向下再移动一个位置即可,如下:
这样,就变种成我们都非常熟悉的数组+链表结构了。那么这样的操作在源码中是怎么实现的呢?主要思路就是新数据的next属性指向源数组下标的数据,并且当前新数据放在table数组下标位置处,即完成了链表的头插和向下移动。
void createEntry(int hash, K key, V value, int bucketIndex) { //取出源数组下标处的上一个数据 Entry<K,V> e = table[bucketIndex]; //上一个数据作为下一个对象,由新数据的next指向;同时新数据放在table下标处 table[bucketIndex] = new Entry<>(hash, key, value, e); //数据个数+1 size++; }
(2)尾插法:
尾插法就比较好理解了,就是定位到数组下标位置后,每次遍历整个链表,找到该链表中next属性为null的数据,该数据就是当前最末尾数据,该最末尾数据的next属性指向新数据,新数据的next=null即完成尾插。
两者区别:
很明显,尾插法每次都需要对整个链表进行遍历,找到最后一个节点;而头插法则只需要对数组下标第一个链表元素进行链表操作即可,在执行效率上头插法要比尾插法要高。
6.HashMap什么情况下会进行扩容,扩容的策略是什么,扩容的流程是什么
- HashMap的扩容指的就是数组的扩容,当table存储的数据量大于等于阈值,且当前数组下标处有数据,表明这次put操作在table添加新数据前就要先进行扩容了。
- 因为数组占用的是连续内存空间,所以数组的扩容其实只能新开一个新的数组,然后把老数组上的元素转移到新数组上来,这样才是数组的扩容;
- 先新建一个2被数组大小的新数组;然后遍历老数组上的每一个位置,如果这个位置上是一个链表,就把这个链表上的元素转移到新数组上去;
- 在这个过程中就需要遍历链表,jdk7就是简单的遍历链表上的每一个元素,然后按每个元素的hashcode结合新数组的长度重新计算得出一个新数组下标然后进行头插法新增,而重新得到的这个数组下标很可能和之前的数组下标是不一样的,这样子就达到了一种效果,就是扩容之后,某个链表会变短,这也就达到了扩容的目的,缩短链表长度,提高了查询效率。
- 扩容后的新数组中链表的数据,和老数组链表中的数据,存在顺序倒过来的特点。
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { //1.进行2倍容器大小扩容 resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } //2.初始化新数组,大小为2倍源数组长度 Entry[] newTable = new Entry[newCapacity]; //源数组的数据转换到新数组 transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //遍历源table数据 for (Entry<K,V> e : table) { while(null != e) { //每个源数据 Entry<K,V> next = e.next; //如果需要重hash 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; } } }
8.get操作流程?
public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
get操作相对比较简单,首先如果key=null则直接导下标为0的数组处链表找key为null的数据;如果key不为空,则根据key计算hash值,找到对应数组下标,如果就在数组下标头结点位置直接返回,否则继续遍历整个链表根据hash值和key值匹配所有数据元素,找到则返回。
9.remove方法能否在for循环移除数据,如何安全移除?
(1)如果在for循环中直接调用remove移除数据,则会抛出 java.util.ConcurrentModificationException异常,原因分析如下:
那么为什么在for循环删除就会出现异常呢?那是因为上面那段循环删除的代码,编译之后实际上是使用了Iterator,编译后的运行class文件如下:
看关键的next()方法:
private final class KeyIterator extends HashIterator<K> { public K next() { return nextEntry().getKey(); } }
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
可以看到有一行关键代码:
if (modCount != expectedModCount) throw new ConcurrentModificationException();
这行代码会判断modCount修改次数与expectedModCount是否一致,如果不一致就会抛这个异常。我们知道modCount是hashmap进行新增,删除,更新时都会自动+1,而expectedModCount则是在构造Iterator的时候默认与modCount保持一致了。
但为什么在for循环remove时两个数值就不一样了呢,这个很简单,让我们看下remove方法内部逻辑,就会发现每次remove其实会将modCount++,这样就导致了modCount != expectedModCount情况的发生,进而抛出该异常。
(2)那想要安全循环删除数据,应该采用什么方式呢?那就是使用Iterator自身的remove方法:
public void remove() {
if (current == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Object k = current.key;
current = null;
HashMap.this.removeEntryForKey(k);
expectedModCount = modCount;
}
可以看到Iterator自身的remove方法每次移除数据之后,都会手动将expectedModCount值保持与modCount一致,这样就避免抛ConcurrentModificationException异常了,但是也要注意要先使用next()方法获取到current数据后才能执行remove,否则会抛IllegalStateException。
综上所述:
(1)如果在for循环中直接调用remove移除数据,则会抛出 java.util.ConcurrentModificationException异常原因是使用Iterator获取数据时会检查modCount != expectedModCount是否相等,如果不相等就抛异常,而直接remove操作会让modCount自增从而导致大于expectedModCount;
(2)要想安全删除数据不抛异常,则可以使用Iterator的remove方法,删除之前先获取要删除的当前数据即可。
10.为什么说HashMap线程不安全,在多线程扩容时的循环链表产生过程?
HashMap本身是线程不安全的集合数据结构,下面演示一下两个线程运行操作同一个HashMap在扩容情况下可能会产生循环链表,进而导致出现程序死循环。
(1)当两个线程同时进入resize时,可以看到两个线程都可能在自己线程内部创建出新的扩容数组对象 Entry[] newTable = new Entry[newCapacity]; 然后对同一个源hashmap的旧数据进行transfer。
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);
}
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);
}
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; //假设两个线程情况下,线程2在这里阻塞但是线程1正常
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)当两个线程都执行transfer时,假设数据都转移到同一个新数组下标。通过下面源码代码演练,可以知道两个线程都有自己的e对象和next对象,分别命名为e1,e2,next1,next2。假设线程1和线程2同时进入resize扩容方法,但是由于线程2没有获取到CPU资源一直阻塞在Entry<K,V> next = e.next,而线程1可以正常执行下去。那么e1,e2,next1,next2四个变量初始状态下指向关系如下
(3)当线程1执行e.next = newTable[i];,相当于源数组第一个数据作为新数组的头插节点
然后向下移动一位newTable[i] = e;就将第一个源数据转移到新数组下了,然后再滑动到下一个待转移的源数据上继续转移。
(3)上述线程1正常执行完成最终转移完之后的新hashmap如下所示,根据前面扩容结论可知扩容后的数据排列和源数据排列整好是倒过来的。同时也可以看到虽然key1,key2转移到新的数组了,但是对于e2,next2而言引用是依然存在的,也就是说e2,next2还是会指向线程A扩容后的key1及key2数据:
(4)那么这时如果线程2开始恢复执行,那么接下来也会开始进行转移,只不过转移的源hashmap就是线程1转移后的新hashmap。下面执行e.next = newTable[i];
继续执行newTable[i] = e;向下移动一位:
接着继续新一轮while循环,还是上面源码的移动步骤,直接用(1)(2)(3)(4)展示:
执行完后,再执行下一轮while循环:
Entry<K,V> next = e.next; //此时的next2指向一个null
接下来执行 e.next = newTable[i]; 就出现了循环链表。原因是e2指向的下一个元素,与e2的前一个元素,整好是同一个。这样后面假如新数据put到这个下标位置就会出现程序死循环问题。
综上,出现循环链表的根本原因是在扩容的时候,扩容后的链表元素顺序,和扩容前的链表元素顺序整好的反过来的导致的。怎么解决这个问题,在JDK1.8中有所体现,后面还会继续总结。