此篇是关于初期的一篇HashMap文章的补充文章:主要涉及两个东西,一、扩容;二、扩容时的线程安全分析。
在上述篇幅里分析了hash过程,put过程和get过程。应该来说还是比较详细的。
一、扩容
扩容应该是HashMap内一个非常常见的问题。此篇还是基于1.7去补充下,1.8的稍微复杂了一些是由于引入了红黑树进去。
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);
}
当put的时候addEntry方法内存在一个扩容的判断:
1.当size>=threshold时(通俗的讲就是当前个数是否大于阈值);
2.当前存在hash冲突了;
这里需要重点分析的是第一种情况的一些特例,比如threshold,这个值的初始值来源于下面:
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
capacity初始默认值是16,loadFactory默认是0.75,也就是threshold默认是16*0.75=12。当个数大于12时,理论上就需要扩容了。
场景1:map中的数组初始大小是16,那么放进去的12个数据都放在了不同的数组内(假设是0-11的位置上),这样,当第13个放进来的时候(如果hash之后的位置是0-11(hash冲突了)),就需要扩容了。
场景2:map中的数组初始大小是16,那么放进去的12个数据都放在了不同的数组内(假设是0-11的位置上),这样,当第13个放进来的时候(如果hash之后的位置是12(hash没有冲突)),那么此时是不需要扩容的。这种情况下的极端例子就是16个数据在放置的时候都依次放在了16位的数组中(0-15),这样当17个数据来的时候才会扩容。
那么在最初最多能存放多少数据而不发生扩容呢?
场景3:场景3更加极端一些,初始大小是16,阈值是12,那么假设前11个值都落到了位置0上,也就是存储到了数组的同一个位置上,后续存入的15个数据都依次存放在1-15中(此时数据虽然大于阈值,但是没有发生哈市冲突,所以不扩容),当第27个数据进来时,已经没有位置了,必定发生冲突导致扩容。,所以最大的数据是11+15=26个数据。
扩容后续代码
resize:
1.扩容有最大值限定,2^30方。
2.transfer就是将原数组的值放入新数组中。
3.最后重新设置threshold(新的阈值)。
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];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
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;
}
}
}
transfer内没什么特殊的东西,就是重新计算hash的值在新数组中的哪一个位置上。
这里就引出了一个新的问题,关于transfer的线程安全问题,也可以说是HashMap的线程安全问题,大家都知道HashMap是线程不安全的,那么提现在哪儿呢?一个就是put的时候,另一个就是扩容里的transfer的时候。
put就不说了,比较容易理解。今天主要分析一下transfer的时候的线程安全问题;
基础前提:
1.数组初始大小为2
2.hash算法取简单的key%length 的大小。
单线程场景:
多线程场景:
多线程存在问题主要会是在哪里呢?看单线程场景中,我们可以看见对于原数组+链表的操作,存在两个指针,一个e,一个e.next。这就是问题所在(对于链表的操作指针e,如果一个线程完整操作之后,后续线程再次操作时,链表的结构已经发生改变,那么线程不安全也就无法避免)。
我们来看一看核心操作:
while(null != e) {
Entry<K,V> next = e.next; //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;
}
问题就出现在步骤1处,假设现在存在两个线程A,线程B同时执行put操作。线程A执行到步骤1时,挂了。线程B正常执行。
因此线程A和线程B会出现下面的场景:
线程A再次被唤醒继续执行扩容:
第一次循环,此时e指向key=3的节点,e.next指向key=7的节点。因此最终的结果就是线程A的位置3指向了key=3的处于线程B中的节点。
第二次循环,注意此时e和e.next的位置变化。这个时候e指向的是key=7,对于线程A来说当前存在指向key=3的数据,因此,key=7的next指向了key=3的节点,而key=7就变成了线程A的头节点。
第三次循环,注意此时e又指向了key=3的节点,而e.next指向了null节点。如果针对key=3的节点再次操作的话,如下关键语句:
e.next = newTable[i];
key=3的next指向了第二次循环时的链表开头数据key=7。所以就形成了一个环形结构,table[3]->key[3]->key[7]->[3]。这就是在多线程下可能出现的场景。