1、HashMap神么时候开辟bucket
数组占用内存?
在HashMap第一次put的时候,jdk7和jdk8都是这样实现的
2.数组的大小都是2的正整数幂?
-
计算出来的哈希值足够散列,能够有效减少哈希碰撞
-
能够快速计算得出,
HashMap
每次调用get
和put
的时候都会调用hash
方法,index =(table.length - 1) & key.hash();
3.Jdk8 put()方法的步骤:
-
调用
key
的hashCode
方法计算哈希值,并据此计算出数组下标index -
如果发现当前的桶数组为
null
,则调用resize()
方法进行初始化 -
如果没有发生哈希碰撞,则直接放到对应的桶中
-
如果发生哈希碰撞,且节点已经存在,就替换掉相应的
value
-
如果发生哈希碰撞,判断出桶中存放的是树状结构,则挂载到树上
-
如果碰撞后为链表,添加到链表尾,如果链表长度超过
TREEIFY_THRESHOLD
默认是8,则将链表转换为树结构 -
数据
put
完成后,如果HashMap
的总数超过threshold
就要resize
public V put(K key, V value) { // 调用上文我们已经分析过的hash方法 return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) // 第一次put时,会调用resize进行桶数组初始化 n = (tab = resize()).length; // 根据数组长度和哈希值相与来寻址,原理上文也分析过 if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有哈希碰撞,直接放到桶中 tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 哈希碰撞,且节点已存在,直接替换 e = p; else if (p instanceof TreeNode) // 哈希碰撞,树结构 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 哈希碰撞,链表结构 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 链表过长,转换为树结构 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 如果节点已存在,则跳出循环 break; // 否则,指针后移,继续后循环 p = e; } } if (e != null) { // existing mapping for key // 对应着上文中节点已存在,跳出循环的分支 // 直接替换 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) // 如果超过阈值,还需要扩容 resize(); afterNodeInsertion(evict); return null; }
相比之下
Java 7
中的put
方法就简单不少public V put(K key, V value) { // 如果 key 为 null,调用 putForNullKey 方法进行处理 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; } 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); // ② }
这里有一个小细节,
HashMap
允许put
key为null
的键值对,但是这样的键值对都放到了桶数组的第0个桶中。
resize()
resize
是整个HashMap
中最复杂的一个模块,如果在put
数据之后超过了threshold
的值,则需要扩容,扩容意味着桶数组大小变化,我们在前文中分析过,HashMap
寻址是通过index =(table.length - 1) & key.hash();
来计算的,现在table.length
发生了变化,势必会导致部分key
的位置也发生了变化,HashMap
是如何设计的呢?
这里就涉及到桶数组长度为2的正整数幂的第二个优势了:当桶数组长度为2的正整数幂时,如果桶发生扩容(长度翻倍),则桶中的元素大概只有一半需要切换到新的桶中,另一半留在原先的桶中就可以,并且这个概率可以看做是均等的。
通过这个分析可以看到如果在即将扩容的那个位上key.hash()
的二进制值为0,则扩容后在桶中的地址不变;否则,扩容后的最高位变为了1,新的地址也可以快速计算出来newIndex = oldCap + oldIndex;
(简单说:是原来的hash值对应扩容的位是0还是1)
JDK8的实现:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果oldCap > 0则对应的是扩容而不是初始化
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没有超过最大值,就扩大为原先的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 如果oldCap为0, 但是oldThr不为0,则代表的是table还未进行过初始化
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 如果到这里newThr还未计算,比如初始化时,则根据容量计算出新的阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
// 遍历之前的桶数组,对其值重新散列
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 如果原先的桶中只有一个元素,则直接放置到新的桶中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 如果原先的桶中是链表
Node<K,V> loHead = null, loTail = null;
// hiHead和hiTail代表元素在新的桶中和旧的桶中的位置不一致
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
// loHead和loTail代表元素在新的桶中和旧的桶中的位置一致
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 新的桶中的位置 = 旧的桶中的位置 + oldCap, 详细分析见前文
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
Java 7
中的resize
方法相对简单许多:
-
基本的校验之后
new
一个新的桶数组,大小为指定入参
总结
-
HashMap
内部的bucket
数组长度为什么一直都是2的整数次幂答:这样做有两个好处,第一,可以通过
(table.length - 1) & key.hash()
这样的位运算快速寻址,第二,在HashMap
扩容的时候可以保证同一个桶中的元素均匀的散列到新的桶中,具体一点就是同一个桶中的元素在扩容后一般留在原先的桶中,一般放到了新的桶中。 -
HashMap
默认的bucket
数组是多大答:默认是16,即时指定的大小不是2的整数次幂,
HashMap
也会找到一个最近的2的整数次幂来初始化桶数组。 -
HashMap
什么时候开辟bucket
数组占用内存答:在第一次
put
的时候调用resize
方法 -
HashMap
何时扩容?答:当
HashMap
中的元素数量超过阈值时,阈值计算方式是capacity * loadFactor
,在HashMap
中loadFactor
是0.75 -
桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?
答:当同一个桶中的元素数量大于等于8的时候元素中的链表转换为红黑树,反之,当桶中的元素数量小于等于6的时候又会转为链表,这样做的原因是避免红黑树和链表之间频繁转换,引起性能损耗
-
Java 8
中为什么要引进红黑树,是为了解决什么场景的问题?答:引入红黑树是为了避免
hash
性能急剧下降,引起HashMap
的读写性能急剧下降的场景,正常情况下,一般是不会用到红黑树的,在一些极端场景下,假如客户端实现了一个性能拙劣的hashCode
方法,可以保证HashMap
的读写复杂度不会低于O(lgN)public int hashCode() { return 1; }
-
HashMap
如何处理key
为null
的键值对?答:放置在桶数组中下标为0的桶中