JDK1.8 HashMap 学习笔记
前言
本文主要根据jdk1.8的HashMap实现做的一次学习笔记,jdk1.7的HashMap与jdk1.8的实现有一些不同,但大体原理也是一致的。
HashMap实现原理
Map主要应用的场景是Key-Value键值对的存储,如果当Key-Value键值对的数量达到一定数量的时候,必然会影响我们的查询效率,而大家使用HashMap的时候并没有显著的感觉查询效率显著的下降,主要是HashMap的实现方式。通常我们再进行数据查找的时候都是进行Key比对的方式查找,** 这样带来的问题就是如果有100w个Key那么就要查找100w次(最坏情况)。而hashMap的查找方式是将100w个数据分成了N个数据桶,将带有某些特征的数据放到一个桶里(Hash散列), 那么100w个数据分散到每一个数据桶里就成了100w/N个数据(分布均匀的情况下),查找次数就缩减到了100w/N次(最坏情况) **。
如下所示
- 数组方式
arr[]:
- Hash方式
map:
{
arr[0]:
arr[1]:
arr[1]:
}
像arr[0],arr[1],arr[2]我们将他们称为哈希槽,每个哈希槽里面是一个链表结构
在这里也要说明下:HashMap的Hash槽并不是初始化之后数量就不会变化了,HashMap的Hash槽会根据Map中元素数量的变化而进行调整(目前只会变大),这里有一个结论就是相同数据且均匀分布的情况下Hash槽的数量越多查询效率越高
HashMap内部结构
刚才已经粗略的简绍了HashMap的存储结构, 其实jdk1.8的HashMap在arr[x]的位置上使用两种数据结构:** 单向链表和红黑树 **,这里在所属哈希槽数据量不大的时候会使用单向链表,默认每个哈希槽数量大于8的时候会转成红黑数的形式存储,在小于6的时候会有红黑树转换成单向链表(如果是红黑树的情况下)。
整体结构
粗略(因为位置问题,有一定的错误(hash槽最少也是16个,红黑树至少8个节点以上)),但是大体如下
每个Hash槽中元素结构
这里每个元素在jdk1.8中抽象为Node, jdk 1.7中是HashMap.Entry.
下图是jdk1.8 Node的实现(简写省略了很多方法):
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
红黑树的Node是Node的子类 TreeNode
class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
...
}
HashMap属性说明
/**
* Hash槽
*/
transient Node<K,V>[] table;
/**
* 默认Hash槽的大小。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* table的扩容阈值,当table的Node数量大于此值就会进行扩容
*/
int threshold;
/**
* Hash可扩容的最大值。
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 负载因子,这里我们上文说过Hash越多查询效率就越高但是也会带来一个问题,空间上会有一些浪费,负载因子的作用就是在空间和时间上做出一个更好的权衡,这里通常不需要更改,经过测试0.75是通常情况下比较理想的取值了。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
final float loadFactor;
/**
* 当Hash槽中的元素数量,大于了此值将会把链表结构转换成红黑树结构。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当Hash槽中的元素是以红黑树结构,当树的节点小于此值将会将红黑树转变成链表。
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 这里是转换成红黑树对于Map元素的总量限制,如果Map元素的总量小于此值,当进行链表转红黑树的时候只会进行一次resize()扩容。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 每次修改Map时会进行modCount++操作,用于快速检查是否在迭代时有其他线程是否修改过Map元素
*/
transient int modCount;
这里大家有没有注意到一个小细节,就是他的很多默认值都是2的次幂,这里我先把原因告诉大家:主要是因为HashMap的hash方法需要保证尽量减少Hash数量对于hash值得影响,以及HashMap扩容时移动Node的便利。
HashMap方法说明
- 构造方法
- public HashMap(int initialCapacity, float loadFactor)
可以预先设置Hash槽数量和负载因子,这里如果预测Map要存大量数据时可以将initialCapacity设置的高些,不然移动元素会比较耗时。 - public HashMap(int initialCapacity)
- public HashMap()
- public HashMap(Map<? extends K, ? extends V> m)
- public HashMap(int initialCapacity, float loadFactor)
- hash方法
hash方法主要是获得散列值,以确保key的均匀分布。
HashMap是根据hash方法返回的hash值来和(hash槽的数量-1)进行与(&)操作,HashMap为了消除hash槽数量对hash值的影响使每一次扩容的hash槽数量都是2的次幂(最低是16) 。这里为什么2的次幂可以减少影响呢?是因为2的次幂-1,16在二进制表示的话是(0000 1111)与运算是都为1的情况下结果才为1,那面最后影响结果的是hash方法获得的hash值。static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这里大家可能会有一些疑惑为什么要把得出的hash值用后16位异或前16位,这里是因为我们Hash槽通常数量都达不到高16位的数量,很多时候hash值得前16位并没有参加到hash槽定位的运算中,所以这里进行了一次高位与低位的运算来提高hash函数的分散性,以提高hash查找的效率,这里也叫扰动函数
hash函数这里使用的是Object的hashCode方法,应该是属于加法hash。
几种常见的hash函数:加法hash,乘法hash,除法hash。 - tableSizeFor方法
tableSizeFor方法主要用于保证Hash槽的数量是2的次幂。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个方法主要是确保用户初始化HashMap时传进的initialCapacity是2的次幂,即使初始化的时候调用了initialCapacity(30),HashMap也会根据此方法来获取initialCapacity向上最接近的2的次幂,至于为什么要保持2的次幂请参考hash方法,以及put方法和reszie方法。
- put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* onlyIfAbsent : 表示是否可以替换已存在的元素(true不可以,false可以)
* evict: 表示是否是创建模式
*/
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; //判断hash槽是否初始化,如果没初始化则先调用resize方法
if ((p = tab[i = (n - 1) & hash]) == null) //确定Hash槽位置,使用hash槽数量-1 与 hash值
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) //如果此Hash槽的元素是红黑树结构则使用红黑树的put方法(具体暂时忽略)
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) //如果大于指定的红黑树阈值,则将此Hash槽的Node转换成红黑树的形式
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; //增加乐观锁,这个主要用于迭代时,其他线程修改了Map时的快速失败
if (++size > threshold) //判断是否需要扩容
resize();
afterNodeInsertion(evict); //回调,可以重写此方法监听
return null;
}
这里put流程可以参考后面的存储流程图
这里主要的就是key的Hash槽定位(n - 1) & hash,要理解为什么要与hash值?为了定位Hash槽,(n-1)与上任何数都是小于n-1的,并且n是2的次幂的话,结果的值就只跟hash的值有关。
- resize方法
resize方法主要负责hash槽的初始化,扩容,以及node元素在扩容后的迁移
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//查看Hash槽是否初始化
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { //已初始化的情况
if (oldCap >= MAXIMUM_CAPACITY) { //判断hash槽扩容后是否超过了最大值,若超过就不在进行扩容,并且调整阈值。
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 获得新的hash槽的数量值和新的hash槽阈值
}
else if (oldThr > 0) // 初始化用于HashMap(Map map)构造方法的情况
newCap = oldThr;
else { // 初始化赋予HashMap默认值
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;
if (oldTab != null) {//扩容前Hash槽内数据不为空的情况下需要移动Node节点。
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 {
//链表的Node数据的Hash转移。
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//判断扩容后是否需要移动,扩容前是hash值 * (n-1),由于n是2的次幂,那么扩容后Node所属hash槽的位置只能是不变或者加之前的n-1
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;
}
这里可以参考后面的扩容流程图
resize方法比较有困惑的地方就是(e.hash & oldCap) == 0这行代码,这里主要是因为n是2的次幂
举个例子:
假如之前是16个Hash槽 二进制表示是:0001 0000。 n-1则为: 0000 1111
假设这个key的hash值为 0010 1110 那么和n-1 & 的值为 0000 1110。
下面我们把Hash槽扩容为32 二进制表示是: 0010 0000。 n-1则为:0001 1111
那么key的hash值 & n-1 的值为 0000 1110。 这里只要计算二进制的第5位就可以了,因为后面的数字n-1不变,hash值的二进制也不会变。之前是由于n-1只有4位有效数字,现在扩容后n-1有5位有效数字,所以我们只要把hash值的第五位加进来参与运算即可。
这里获得的Hash槽位置只会是不变或者是之前的位置加上(old n-1)。
HashMap存储流程
HashMap扩容流程
写在最后
这篇文章主要是针对HashMap源码解析后的笔记。
学习之后的收获是:
- hashMap对于确定Hash槽位置的整体布局与操作。
- hashMap整体结构的了解。
- hashMap的性能调整(可以基于初始Hash槽和加载因子)。
如果有什么错误,欢迎大家及时指正。