本文接上篇:
《HashMap最基本的实现流程(源码角度)》
上篇主要说了HashMap的put操作实现的主流程,分析了源码中某些关键步骤之所以那样写的原因;对于一些分支流程没有过多展开,这里就填一下扩容的坑(注意:本文还是基于jdk1.7)
这里先把涉及到扩容的地方的源码贴一下:
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);
}
如上图代码,这是HashMap添加元素的函数,上一篇中我们假设还没到扩容的条件,先走了下面的主流程,这里我们就来看一下分支流程——扩容;
即什么时候会触发扩容?回答这个问题时这里可能会有一个误区(不怕笑话,我之前就是按误区这么想的),就是当 数组存的元素个数大于阈值的时候 会触发扩容,并且回答时脑海中浮现的比例图是下图:
其实直到我真的去看了HashMap的源码我才意识到我的错误。上一篇我一开始也特别强调了下size的含义;其实触发扩容的条件应该是这样的:当哈希表中存的元素个数(size:数组+链表存的元素)超过了阈值(threshold)时。
这样就对了嘛?对了一半,看源码中判断的条件,是一个且运算,即后面还有半句,这就表示并不是size>threshold时就会扩容,什么时候size>threshold但还不扩容呢?——当要存的下一个元素的索引并没有冲突时。如下图,假设现在的参数情况如下:
capacity=4(2^2)
loadfactor=0.75
threshold=capacity*loadFactor=3
size=3(满足扩容第一个条件)
存储情况如下图:
这时要添加的元素的索引被计算出来是3(或者2),因为此时2或者3的位置都没有元素(即table[index]==null),即没有冲突,所以直接放进去,此时这种情况不会触发扩容。
所以,最终触发扩容的条件(其实源码中很清楚):当存储的元素个数超过了设定的阈值并且即将添加的元素发生冲突。
PS:这里之所以展开说,是因为今天恰巧看见有面经里描述有面试官这样问过。不然我也没在意这里(面试官都是魔鬼吗??!peace)。
从字面意思很容易理解为:因为容量不够了。可是我们刚了解到,hashmap的存储结构时数组加链表,数组会存满,但是理论上将值要JVM的空间足够,链表不会满,能一直往下放元素。
这里就要说一下HashMap的get操作了:
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方法中的主要部分是getEntry函数,可以看到,其实就是遍历链表,所以虽然链表可以无限存下去,但是将严重影响查询的效率。通过扩容可以使链表变短,因为因发生冲突而被放在同一条链上的entry有可能通过扩容而分在不同链上。
这里说一下hashMap中key为对象时的注意点:
- 作为key的对象要重写equals方法:hashmap判断key相等的条件:== 或者 equals 两个有一个为true即可。对于原始类型,== 就可以判断相等;对于引用类型,不同的对象==始终为false,所以只能依赖equals方法。
- 作为key的对象要重写hashcode方法:因为hashmap根据key的hashcode计算索引。所以业务上相等的对象其hashcode也要相等
首先从整体上了解扩容的流程:
可以看到,扩容的实质其实就是复制(我第一次知道这个实质是震惊的,扩容多高大上的词,万万没想到有着这么朴实无华的实现),并且扩容规律是二倍扩容(2*table.length),
这里重新计算index好理解,因为毕竟表的长度变了,但是有一个 疑问①:为什么扩容后要再次hash? key还是原来那个key,其hashcode也不会变啊,rehash会有变化?稍后会说
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);
}
根据代码可以看到,在创建新数组前会有一个判断分支:当hashmap已经扩容到允许的最大值(MAXIMUN_CAPACITY;230)时,阈值就变成Integer.MAX_VALUE(231-1),并直接返回。这意思就很明确了,capacity=230后就不会再扩容了,因为size永远也达不到threshold了。
下面可以看到使用新长度(原长度的2倍,符合2的次幂)new一个新的数组。接下来就是把数组中的元素一个个复制到新数组中(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;
}
}
}
这里可以看到,使用了一个双层循环去遍历哈希表,外层循环遍历数组,内层循环遍历链表,然后将遍历到的元素(entry)是哟个头插法插入到新的数组中。
这里有一个值得注意的点,就是这个函数的入参中有一个布尔变量rehash,通过这个变量来判断是否需要重新计算所复制元素的key的hash值。这个变量的入参是一个函数的返回值;
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
这里我们先不具体去了解函数的逻辑及意义,我们先知道一点,这个函数中操作了hashseed,而我们的key的hash也会受这个hashseed 的影响;所以这里就可以解答上面的疑问①了——因为在扩容的操作中调用了initHashSeedAsNeeded函数,而这个函数在特定条件下会影响hashseed的值,而hashSeed的值会影响key 的hash值,所以要重新hash。
解决了疑问①,我们再来看这个initHashSeedAsNeeded函数到底做了什么?以及目的是什么?我这里不展开讲了,可以看下以下两个文章:
文章1
文章2
这里直接给出结论:hashseed以这个函数设计的目的就是可以让用户去影响HashMap 中当key类型为String类型的hash方法,以期望达到降低碰撞的目的。而我们一般情况下hashseed的值一直是为0的,这个函数的返回一直是false的。
回到transfer函数,rehash=false,下面就是计算索引,头插法插入元素了。
看一下索引的计算:
我们把第一篇文章中的例子先拿过来
0101 0011(h)
&0000 1111(length-1=15)
=0000 0011(3)
这里hash之不变,length变为2倍,重新计算索引:
0101 0011(h)
&0001 1111(length-1=31)
=0001 0011(19)
认真的同学可能已经发现了,相比于扩容前,与运算的结果就是倒数第五位从0变为1,对应到十进制刚好就是原来的值加一个扩容的长度的值;
newIndex=oldindex+oldCapacity
要得出这个结论还有一个条件——原hash值的倒是第五位为1,如果是0,则新的索引和老的所以保持一致;
利用这个这个结论,就不需要再通过与运算计算索引了,可以直接根据hash值那一位(newLength-1的二进制第一个1那一位)判断索引是否改变(增加一个oldCapacity的长度).【jdk1.7并没有用到这一特性,1.8用到了】
到这里扩容就结束了。
可以看到HashMap的扩容效率还是很低的,这也是为什么编码规范会提示你在创建HashMap的时候最好赋一个容量大小。因为用户根据实际需求赋值的容量,可以减少甚至避免(如果用户完全知道自己的Map要存多少东西)扩容操作,从而提高效率。
PS:之前HashMap这种官方的,我们拿来就用的东西,一直对我来说很神秘,很高大上,感觉遥不可及,他肯定使用了什么了不得的东西,可是当你看下去的时候,你会发现,原来就这啊,不就是复制嘛?他原来也是会使用双层循环的啊。当你再仔细看的时候,就会再次发现,他的那些精巧的构思是多么妙不可言!
以上