从一个问题引发的探究过程。HashMap底层实现的数据结构是什么?
---- 没错,数组+链表。
为什么要用到这种结构?什么时候会发生从数组到链表的转换?扩容机制呢?HashMap会根据Key值计算出在数组上的位置,但如果Key为null的时候,怎么处理?
这么多内容,笔者自觉一篇文章也讲不清楚,接下来以数篇文章来进行讲解。
1.数组+链表的源码解读(基于JDK1.7,1.8的内容后期再写);
2.put方法做了些什么(根据Key计算键值对在数组中的位置;扩容;发生Hash冲突时进行链表转换);
首先放上一张图,形象地说明一下数组+链表的结构:
主要变量介绍
(1)transient int size :记录HashMap中key-value的个数;
(2)final float loadFactor :加载因子,默认0.75f;
(3)int threshold:临界值,等于capacity(容量) * load factor;
HashMap初始容量大小:16;
/**
* The default initial capacity - MUST be a power of two.
* 默认的初始容量大小
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
HashMap具有扩容机制,默认情况下,当size =threshold =capacity(16) * loadFactory(0.75) = 12 ,即达到扩容条件(HashMap中存储了12个key-value),数组的大小(size)会从16变成32。
下一次扩容时会变成64,依次类推16、32、64、128 … …
现在临界值(threshold) 、容量(capacity) 、 加载因子(loadFactory )的作用已经很明了了。
总结一下:HashMap的容量一旦达到临界值,就会执行扩容操作,而临界值根据容量和加载因子计算得出。HashMap中存储的key-value数量达到容量的75%,即满足扩容条件。
也就是说,HashMap并不是存储到16的时候才进行扩容。
关于初始容量的设置:设置初始容量,必须是2的幂;就算给定的值不是2的幂,HashMap也会计算出大于指定容量最接近的2的幂作为初始容量;比如说设置初始容量为3,则打印出容量结果为4;
由此可见,HashMap还是很任性的,有自己的脾气;
put操作源码探秘
好了,解释了相关变量和概念,现在来看看put操作究竟做了什么。首先贴出源码。
public V put(K key, V value) {
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;
}
-
第一步:putForNullKey() --> 如果key为null,将key-value放入数组的第一个位置;
-
第二步:hash() --> 对key做hash运算,返回一个int类型的值
-
第三步:indexFor() --> 用hash()得到的int值,对数组长度进行取模,计算出key在数组上的位置i。此处jdk源码采用位运算替代取模运算,提高计算效率。
-
第四步:for (Entry<K,V> e = table[i]; e != null; e = e.next) --> 判断数组第i个元素是否为空,如果i不为空,则遍历i位置挂载的单向链表。
-
第五步:for循环内部, if (e.hash == hash && ((k = e.key) == key || key.equals(k))) --> 判断此次插入的key的hash值和已存在元素的hash值是否相同(是否存在hash冲突),若二者hash值相同,且key值相等,则用新值覆盖原来的值。(简单理解为put了相同的key,替换掉value)。
-
第六步:新建一个entry节点,将其放在数组i的位置上,作为链表的表头
此处详解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);
}
1.if逻辑判断是否需要扩容,当key-value的个数达到临界值,且当前数组i位置有值时,执行扩容操作
2.新建一个Entry
扩容操作-resize():耗时操作,二倍扩容,扩容完成后, 重新计算当前key在数组上的位置;
扩容操作过程:
创建一个新的数组,大小是原来的2倍。
- 遍历原数组,将其存储的key值重新hash后,调用indexFor()方法重新计算其在新数组上的位置。
- 将key-value存放到新数组上
具体操作详见代码,数组复制过程:
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;
}
}
}
关于扩容的思考
为什么要有扩容操作?数组+链表不是已经能够解决掉hash冲突了吗?理论上再多的值都能够存储,数组似乎完全没有必要扩容吧?
- 是的,存储肯定是能存储的,但随着put操作增加,HashMap中的key-value数量不断增多,此时每次调用get方法时,都需要在长长的链表上进行遍历,HashMap的get效率大大降低。
所以扩容操作是有必要的。
~~
- ps:分享over,如有错误之处欢迎在评论区指出。
~~