HashMap源码分析
数组结构
HashMap由数组+链表/红黑树 组成。当数组某一元素Hash冲突达到8时,会转变为红黑树。当长度小于6时,又会转变为链表
基本属性
// 实际存储的key-value键值对的个数
transient int size;
// 阈值,当table == {}时,该值为初始容量(16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory
// HashMap在进行扩容时需要参考threshold
int threshold;
// 负载因子,代表了table的填充度有多少,默认是0.75。加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了
final float loadFactor;
// HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;
// HashMap的主干数组,初始值为空数组{},主干数组的长度一定是2的次幂。也是拉链的Head节点
transient Node<K,V>[] table;
Node链
HashMap的内部类Node——节点,实现了Map.Entry接口
static class Node<K,V> implements Map.Entry<K,V> {
// key的哈希值
final int hash;
// 节点的key,类型和定义HashMap时的key相同
final K key;
// 节点的value,类型和定义HashMap时的value相同
V value;
// 该节点的下一节点
Node<K,V> next
// ...
}
值得注意的是其中的next属性,记录的是下一个节点本身,也是一个Node节点,这个Node节点也有next属性,记录了下一个节点,于是,只要不断的调用Node.next.next.next……,就可以得到:
Node-->下个Node-->下下个Node……-->null
对于一个HashMap来说,只要明确记录每个链表的第一个节点,就能顺序遍历链表上的所有节点
// 红黑树节点 继承自LinkedHashMap.Entry,而LinkedHashMap.Entry继承自HashMap.Node
static final 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;
// 看其父继承关系,其属性与LinkedHashMap顺序访问性有关
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
重要API分析
构造方法
HashMap有4个构造器,如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值
注意:构造函数中不会初始化HashMap中最重要的属性table,而是在第一次put时通过resize来完成初始化
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
看下最复杂的一个
public HashMap(int initialCapacity, float loadFactor) {
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// JDK11在这里存在变动,确保 threshold 为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;
}
PUT方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
// 这里为什么要采用 异或 运算,而不是 与/或 运算之类的,因为只有异或运算能够保存0/1出现的概率各为50%(四种情况:00、01、10、11),可以让值分布的更加均匀
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算
在JDK1.8的实现中,优化了高位运算算法,通过hashCode()的高16位异或低16位实现的,这样做可以在数组table的length比较小的时候,也能保证考虑到高低Bit参与到Hash计算中,同时不会有太大的开销
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab为全局变量table的副本
// p为待节点节点构造值
// n为table数组长度
// i为待插入元素所在数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
// <1> 第一次调用put时,tab为空,n为0 完成变量tab/n的赋值
if ((tab = table) == null || (n = tab.length) == 0)
// 通过resize方法来创建一个新的数组
n = (tab = resize()).length;
// <2> 判断插入位置是否冲突,如果不冲突就直接newNode,插入到数组中即可 完成变量p的赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// <3> 插入元素hash值冲突,处理冲突
else {
// e保存待插入元素信息 k保存链表中某一节点的key信息
Node<K,V> e; K k;
// <3.1> 判断table[i]中head是否与插入的key相同,若相同那就直接使用p替换掉旧的值e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// <3.2> 判断插入的数据结构是红黑树,那就直接putTreeVal到红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
// <3.3> 遍历链表,若不存在就直接newNode
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 若长度达到8个,则达到转换为红黑树的条件
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;
// <4> 判断一下实际存在的键值对数量size是否大于阈值threshold。如果大于那就开始扩容了
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
最后附上一张图:
扩容
扩容分两步:
-
将数组的长度变为原来的两倍
-
将已经hash分布到数组中的所有元素重新计算hash值,分配到新的数组中
第一步操作只需增加一块存储区域而已,而第二步操作则需要消耗巨大的计算资源。如果扩容前已经存在5万个元素,则需要把这5万个元素的hash值重新计算一遍,并根据新的结果移动它的位置。该操作叫做重哈希操作,是一次代价极高的操作
注意:此步操作可能会导致提前触发Full GC,特别是对于CMS这种标记清除的GC可能会触发二次Full GC。所以可以确认长度,最好提前设定
长度设定标准为:长度/loadFactor 向上取整
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 初始时,oldTab为空
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;
}
// 如果没有超过最大容量,就扩容为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// *** 有参构造器,在这里完成赋值,newCap为16
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;
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)
// 重新计算元素在新table中的位置
// 注意:元素所处位置只有可能在 j 与 j+oldCap 处 ①
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 树元素重hash
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表元素重hash
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);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
①使用的是2次幂的扩展。所以,元素的位置要么是在原位置,要么是在原位置+初始长度的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两key确定索引位置的示例,图(b)表示扩容后key1和key2两key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”
这个设计非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突节点分散到新的bucket中。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置
针对JDK1.8中新增的API可以自行分析一下,具体使用可以参考
getOrDefault
replace
replaceAll
compute
computeIfAbsent
computeIfPresent
putIfAbsent
merge
小结
-
扩容是一个特别耗性能的操作,所以当coder在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容
-
负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊
-
HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap
OrDefault
replace
replaceAll
compute
computeIfAbsent
computeIfPresent
putIfAbsent
merge
小结
-
扩容是一个特别耗性能的操作,所以当coder在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容
-
负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊
-
HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap
-
JDK1.8引入红黑树大程度优化了HashMap的性能