目录
Map存储键值对,并且实现方式相对于List来说要复杂很多,使用比较多的Map是HashMap。从JDK1.8开始,HashMap的实现发生了比较大的变化,本文主要针对JDK1.8的实现介绍HashMap的实现原理,并介绍与JDK1.7的主要差异。
接下来将从5个方面对HashMap进行介绍:
1、hash函数的实现;
2、HashMap内部哈希数组的长度为什么要取2的幂;
3、哈希数组的扩容;
4、HashMap存储原理;
5、与JDK1.7的差异
一、hash函数实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
对于key是null的情况,那么哈希值为0,否则,哈希函数由key的哈希值h与h无符号右移16位异或得到。
二、HashMap内部哈希数组的长度为什么要取2的幂
hashmap内部用一个节点数组存储存放的键值对,利用了哈希原理将存放的键值对散列到数组的不同索引位置,这个过程是通过hash(key)返回的哈希值hValue与数组长度-1按位相与得到。只有当数组的长度是2的幂时,数组长度-1的二进制低位均为1,当hValue与与数组长度-1相与,则直接得到hValue的低位值,此即为键值对在数组中的索引位置。
i = (n - 1) & hash
三、哈希数组的扩容
扩容涉及两个方面的问题:1、什么时候触发扩容?2、扩容做什么事情?JDK1.8中执行扩容的方法为resize()。
1、什么时候触发扩容?
hashmap有两个影响其性能的参数:loadFactor和初始容量。其中初始容量定义了新创建的hashmap实例初始hash数组的大小,而loadFactor用于衡量在扩容之前,hashmap内部hash数组能够装多满。初始容量与loadFactory的乘积则表示触发扩容的阈值,当hahsmap内部存储的键值对超过这个阈值值将触发扩容。
在JDK1.8实现的hashmap中,哈希数组的初始化延迟到了第一次put键值对时,这时同样会调用扩容方法resize()。
综上触发扩容有两种场景:1、第一次执行put操作;2、hashmap存储键值对超过阈值。且看触发扩容的两种场景要做什么事情。
2、扩容做什么事情?
先贴出resize的源码,看起来非常的长,但是我们可以把它拆为两个部分:1)新hash数组初始化;2)已存储键值对重新散列。也就是说触发扩容的两种场景,第一步都是初始化新hash数组,第二步则是对已存储键值对重新散列,分配存储位置。
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
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
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
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;
//----------新hash数组初始化到此结束-----------------
//-------------已存储键值对再散列------------------
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;
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;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
1)新hash数组初始化
哈希数组初始化比较简单。当旧hash 数组长度不为0时(表示已经存储元素,是存储操作超过阈值触发的扩容),则根据旧的hash数组容量计算新的hash数组容量,否则根据初始化hahsmap设置的容量或者是默认值初始化hash数组。对于第一次put的场景,到这个地方就结束了扩容过程,resize的重点在于存储键值对超过阈值触发扩容的场景。把resize()方法拆开,即是前半部分代码:
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
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
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
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];
2)已存储键值对重新散列
重新散列的过程是从数组为0的索引开始依次遍历hash数组,将存储的键值对依次存放到新的扩容后的hash数组中。这里又涉及三种场景:a)旧hash数组某个索引位置只存储了一个节点;b)旧hash数组某个索引存储的为树节点;c)旧hash数组某个索引存储的为普通节点。
a)旧hash数组某个索引位置只存储了一个节点
由于旧hash数组索引位置只有一个节点,那么直接根据该节点key的hash值与新数组长度-1按位相与,计算在新数组中的位置,并存放到新数组即可。
b)旧hash数组某个索引存储的为树节点
当旧数组索引存储的节点为树节点时,则需要对该位置节点构造的树中的每个节点重新分配在新数组中的位置。调用((TreeNode<K,V>)e).split(this, newTab, j, oldCap)方法实现;具体看看,在spit()方法中声明了四个树节点类型的变量,分别表示原位置的头节点、尾节点,新位置的头节点、尾节点:
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
从根节点开始依次用节点hash值与旧hash数组长度进行按位相与操作:
if ((e.hash & bit) == 0)
这行代码非常关键,它确定了节点在新数组中是在原位置还是要存放到新的索引位置。当if条件为true时,表示节点在新数组中仍然在旧数组的索引位置,为false则表示该节点在新数组中的索引位置为旧数组索引+旧数组容量。依次遍历树中所有节点,并顺序添加到lo节点链或者hi节点链中,维护原来节点顺序,并且对于hashmap中的树,至少要有6个节点,因此在遍历过程中还统计了lo节点链和hi节点链中节点个数,当遍历完整棵树后,若节点链中的节点个数小于6,还要将该链中的树节点转换为普通节点。实现代码如下:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
那么为什么通过e.hash&oldCap来确定节点在新数组中的位置呢?先明确三点:数组长度是2的幂;扩容是创建旧数组长度2倍长度的新数组;键值对在数组中的位置是通过e.hash与数组长度-1按位相与得到。
节点旧数组中位置是通过e.hash&(oldCap-1)得到,也就是说,假设oldCap转换为二进制,其低n位的值位0,第n+1位为1,即100xx0,那么oldCap-1转换为低n位为1,即011xx1。在旧数组的情况下是e.hash&011xx1得到旧数组中的位置,相当于保留的是e.hash的低n位,保留的低n位的十进制就是在旧数组中的索引位置,而当数组扩容后,其容量newCap是旧数组的2倍,转换为二进制是1000xx0,那么newCap-1的二进制是0111xx1。此时,利用e.hash&0111xx1计算在新数组中的位置,相当于保留的是e.hash的低n+1位,即在新数组中的索引位置。也就是说节点在新数组中的位置在于这第n+1位为0还是为1,而这第n+1位为0还是为1,只需要e.hash与旧数组容量直接按位相与即可取得,因为旧数组容量是2的幂,那么转换为二进制其低n为均为0,只有第n+1位为1。当为0时,那么计算得到的值与旧数组一样,则位置不变,为1时,则表示为节点在新数组中的位置为旧索引值加上旧数组容量。
c)旧hash数组某个索引存储的为普通节点
节点再散列的过程类似于b),由于是普通节点链,只需要确定节点链中的每个节点在新数组中的位置即可,相对于b)更简单一点,不再赘述。
以上,即为resize扩容的原理。 resize对旧数组再散列的代码如下:
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;
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;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
四、HashMap存储原理
讲述完hashmap实现的三个关键点,再来介绍hashmap存储原理应该比较容易懂了。hashmap基于hash表实现,内部由一个节点构成的hash数组保存键值对。节点保存了键、值、hash值以及下个节点的引用。对于存储来说,存在几种场景:1、hash数组索引位置为null;2、索引位置第一个节点key与要存储的键值对key相等;3)要存储的位置已经是树节点;4)普通节点
1、put操作
1)hash数组索引位置为null
表示该数组索引位置尚未存储元素,则直接创建一个新节点即可。
2)索引位置第一个节点key与要存储的键值对key相等
表示已经存放过相同的key,那么替换对应的值,并返回旧值即可。
3)要存储的位置已经是树节点
表示该位置由于hash冲突导致节点链已经超过阈值8,链式结构转换为了树,(hashmap中是红黑树),则将新节点存入树中。
4)普通节点
存储的位置仍然为普通节点,那么需要从该数组位置头部开始遍历,并且记录已经存储的节点个数,如果在遍历过程中找到相同的key,则替换旧值并返回,如果遍历到节点链末尾仍未找到相同的key,则在节点链末尾添加新的节点存储,并且此时如果节点个数已经超过阈值,那么需要将该节点链转换为树形结构。红黑树暂时不展开。
put的主要实现代码如下:
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)
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;
}
2、get操作
get操作就非常简单了,通过key的哈希值定位到数组位置,然后在该数组位置依次遍历,通过equal()方法或者==查找key,返回对应的值。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
五、与JDK1.7的差异
JDK1.8中hashmap实现相较于1.7有重大的变化,主要体现在:1、1.8中hashmap会在hash数组某个索引位置hash碰撞导致形成的节点链超过一定值(8)时,会将该索引位置转换为树形节点(红黑树);2、在put键值对时,是在节点末尾插入元素而非头部;3、在扩容时,维护了元素插入顺序,而在1.7中,节点链中的节点位置会反转,因此JDK1.8也解决了1.7中resize造成的死循环问题。