HashMap源码分析
HashMap是Map接口使用频率最高的实现类,也是面试比较喜欢考察的内容,无论是面试还是日常开发中,只有了解底层才能正确的使用他们,本文就以JDK1.8为例详细了解HashMap底层到底是怎样实现的。
1、基本属性
HashMap源码中的基本属性或者是非常重要的几个常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// HashMap的默认数组初始化大小(必须是2的整数次幂即2的n次方)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 默认加载因子(决定HashMap的数据密度和计算扩容临界值)
static final int TREEIFY_THRESHOLD = 8;
// Bucket中链表长度大于该默认值,转化为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
// Bucket中的Node被树化时最小的hash表容量
// 当Bucket中Node的数量大到需要变红黑树时,若hash表容量小于MIN_TREEIFY_CAPACITY时,此时应执行 resize() 扩容操作这个MIN_TREEIFY_CAPACITY的值至少是TREEIFY_THRESHOLD的4倍。
transient Node<K,V>[] table;
// 底层Node数组也称为哈希表(保存k-v键值对的数组)
transient Set<Map.Entry<K,V>> entrySet;
// entrySet
transient int size;
// HashMap中元素的数量
int threshold;
// 扩容的临界值(扩容的临界值 = 容量 * 填充因子 --> 16 * 0.75 = 12)
关于加载因子决定HashMap数据密度的理解(加载因子对HashMap有什么影响?)
- 负载因子的大小决定了HashMap的数据密度
- 负载因子越大密度就越大,发生碰撞的几率就越高,数组中的链表就越容易长,造成查询或者插入时比较次数增多,性能会下降
- 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会越高。但是会浪费一定的存储空间,而且经常扩容也会影响性能
- 按照其他语言的参考和研究经验,将负载因子设置为 0.7 ~ 0.75 是最合适的,平均检索长度接近于常数
2、构造函数
public HashMap() // 无参
public HashMap(int initialCapacity) // 指定数组容量
public HashMap(int initialCapacity, float loadFactor) // 指定容量和加载因子
public HashMap(Map<? extends K, ? extends V> m) // 指定map集合转换为HashMap
注意1:当指定数组容量初始化时,initialCapacity
的值并非是数组实际长度,而是大于initialCapacity
的2的n次方数,例如initialCapacity=10
数组初始化长度就是16,initialCapacity=17
数组初始化长度就是32。如
注意2:四个构造方法初始化时并没有进行数组或哈希表table
的初始化,只有当调用putVal()
方法时才会初始化。如
3、存储结构
JDK 8 版本以后HashMap是由数组+链表+红黑树实现,详见下图
关键点:
1、JDK1.8以上底层数组是Node[]
而非Entry[]
2、如果key的hash值不相同,但是计算出来的数组索引相同,那么就用链表来解决这种hash冲突或者是hash碰撞
3、当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64 时,此时此索引位置上的所有数据改为使用红黑树存储
4、put原理
由前面得知4种HashMap构造方法并不会初始化哈希表(Node<K,V>[] table)
而是只有当执行put() / putVal()
方法时才会进行数组的初始化,那么它是如何进行初始化与扩容的?
// put入口1(就是执行putVal()方法)
public V put(K key, V value) {
// key相同则覆盖旧值
return putVal(hash(key), key, value, false, true);
}
// put入口2
public V putIfAbsent(K key, V value) {
// key对应的value不存在才插入
return putVal(hash(key), key, value, true, true);
}
由put
入口得知一定是先计算后得到key对应的hash值才进入putVal()
方法,也就是说计算key的hash值就是put的第一步。而计算hash值的方法也有点特殊。
// 经过异或运算和右移运算才得到最终的hash值(这是为啥???详见以下链接)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap中hash方法的作用https://blog.csdn.net/weixin_52394141/article/details/131987773
4.1、初始化与首次put
主要看我标记的地方即可(以下同理)
再来看resize()
方法是如何进行数组初始化的
为什么采用 i = (n - 1) & hash
来计算key对应数组的索引??
@Test
public void hashMapTest1() {
String key = "name";
Map<String, Object> map = new HashMap<>();
map.put(key, "v");
System.out.println(map.get(key));
}
/*
1.计算性能较高
2.保证索引范围不越界
3.保证每个索引计算出来的概率尽可能相等(均匀分布)
// 以key=name为例
00000000 00000000 001111 15的二进制
11001101 11101010 001011 3373707的二进制(name的hash值=3373707)
00000000 00000000 001011 进行 & 运算后的二进制(转换成十进制就是11,也就是name作为key时存在数组的下标)
*/
4.2、key对应的数组下标存在元素
key对应的数组下标存在元素时分为两种情况,分别是key相同和不相同。key是否相同是通过hash值、key的值、key的引用地址等判断的,从这里也能看出HashMap
的key可以是任何类型的如null / int / 引用数据类型
等。如果key不相同且key对应的数组下标相同说明发生了hash碰撞
(不同的输入产生了相同的输出地址)。
详见以下分析
场景1:key相同
如下例代码,为啥最后输出的的B ???
@Test
public void hashMapTest1() {
String key = "name";
Map<String, Object> map = new HashMap<>();
map.put(key, "A");
map.put(key, "B");
System.out.println(map.get(key)); // B
}
源码分析:
场景2:hash碰撞
源码分析:
扩展内容(后续补上):
TreeNode(红黑树)是如何插入新节点的?
树化过程(链表转红黑树)
4.3、扩容过程
扩容的逻辑是在 resize()
方法上,很多地方也调用了此方法,我们再来看此方法到底是什么逻辑
put完元素后判断当前hash表中的元素数量是否超过阈值threshol
(默认情况下数组初始化容量 * 填充因子 --> 16 * 0.75 = 12
)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
++modCount;
if (++size > threshold) // 默认情况下threshold=16 * 0.75
resize(); // 满足扩容条件时执行 resize() 方法
afterNodeInsertion(evict);
return null;
}
resize()
方法创建新数组过程:
resize()
方法老数组元素迁移至新数组过程:
// resize() 源码截取
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { // 1.遍历老数组 j 就是老数组下标
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 将当前元素赋值给e并判断是否为空
oldTab[j] = null;
if (e.next == null) // 2.如果 e.next == null 说明当前元素既不是链表也不是红黑树
newTab[e.hash & (newCap - 1)] = e; // 3.通过新数组长度(32) & 运算,重新计算 e 元素在新数组所处的索引并插入
else if (e instanceof TreeNode) // 4.判断 e 元素是否是红黑树,如果是就走红黑树迁移逻辑
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 5.当前老数组下标对应的 e 元素是一个链表,以下是链表迁移逻辑
// 注意1:链表的迁移并不是把链表的头放到新数组对应的位置即可,因为新数组长度的改变意味着链表元素不一定都处于原来那个位置
// 注意2:以下逻辑相当于把一组完整的链表,通过hash & 的方式拆分为两个链表,为了让数据更加的分散,分布的更均匀,而不会都挤在同一个链表内
// 注意3:链表元素迁移时的规律:扩充之前和扩容之后的数组下标要么相等要么相差老数组的长度(扩充之后的下标 = 当前下标 + 老数组长度)
// 注意4:了解这个规律后以下代码就是查找下标相同和需要加上老数组长度的链表元素并分别放入新数组中
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); // 遍历链表跳出循环条件(最后一个元素的next == null)
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; // 下标相同
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; // 当前下标 + 老数组长度
}
}
}
}
}
链表元素迁移规律推导:
/*
假设 hashCode 等于 117 而117对应的二进制就是1110101
0000 1111 (16-1)的二进制
0111 0101 117的二进制(hashCode)
&
0000 0101 进行 & 运算后的二进制对应十进制为 5
0001 1111 (32-1)的二进制
0111 0101 117的二进制(hashCode)
&
0001 0101 进行 & 运算后的二进制对应十进制为21
规律1:21-5=16即扩充之前和扩容之后的数组下标相差16,16为老数组的长度
规律2:如果hashCode=10 扩容之前和扩容之后的数组下标是相等的,可自行计算与验证
最终规律:扩充之前和扩容之后的数组下标要么相等要么相差老数组的长度(扩充之后的下标 = 当前下标 + 老数组长度)
/*
红黑树迁移至新数组过程:
// resize() 源码截取
else if (e instanceof TreeNode)
// 红黑树迁移至新数组入口 split() 方法
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
由前面得知当链表的数量大于8时才会转成红黑树,而由于新数组长度的变化,不是所有的红黑树元素都在原来的位置,所以迁移后不一定还满足树化的条件。
注意:红黑树这里扩容前后下标规律依然适用。扩充之前和扩容之后的数组下标要么相等要么相差老数组的长度(新下标 = 当前下标 + 老数组长度)
源码分析:
// HashMap<K,V> map this
// Node<K,V>[] tab 新数组
// int index 老数组下标
// int bit 老数组长度
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this; // 变量b就是这科红黑树
// 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;
// 1.遍历红黑树
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) { // 2.判断是否是低位节点(和老数组下标相同)
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) // 红黑树退化成链表条件(数量 <= 6)
tab[index] = loHead.untreeify(map); // 退化成链表(去除left/right属性创建新的Node)
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);
}
}
}
5、get原理
理解put
过程后,再来看get
已经很简单了,除了红黑树的查找!
get
源码分析:
get
总结:
先通过hashCode()
方法计算key对应的hash值,并根据哈希算法(数组长度 - 1 & hash值)
转换成数组对应的下标,得到数组下标后判断该下标元素是否为空,如果为空就返回null
,如果不为空就比较key是否全等(全等条件为:key的hash值相等并且key不等于空并且key使用==或者是equals比较返回true)
,如果key全等就返回当前Node(Node再.value
就能获取value)。
以上是普遍的情况。
而当前Node的key不全等,并且当前Node的next属性不为空,说明发生了hash碰撞(前面有解释),先判断当前Node是否是红黑树。如果是红黑树就走红黑树的查找逻辑getTreeNode(hash, key)
,如果不是红黑树就是链表,遍历链表依次比较key是否全等,如果其中某一个链表元素的key全等那么这个节点的value就是我们要找的value。
6、remove原理
/**
移除某个节点,根据下面四个条件进行移除
hash - key 的hash值
key - key
matchValue - 如果为true,则仅在值相等时删除;如果是false,则值不管相不相等,只要key和hash值一致就移除该节点
movable - 如果为false,则在删除时不移动其他节点
return - 返回被移除节点,未找到则返回null
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// tab - 当前hash表(Node数组)
// p - hash对应的数组索引index位置上的节点/遍历链表时表示当前遍历到的节点的前一个节点,
// n - 数组长度
// index - hash对应的数组索引
// 这几个值在hashMap的源码中很常见
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 前提判断数组不为空,并且长度大于0并且hash对应的数组索引位置上的节点p也不为null
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// node:被移除的节点,e:当前头节点的下一个节点/遍历链表时表示当前遍历到的节点,
// k:e节点的key,v:被移除节点node 的value
Node<K,V> node = null, e; K k; V v;
// 如果第一个节点p就是目标节点,则将node指向第一个节点p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))){
node = p;
}
// 第一个节点不是,那就看看第一个节点还有没有下一个元素。
// 如果有第二个节点
else if ((e = p.next) != null) {
// 如果刚刚第一个节点是红黑树
if (p instanceof TreeNode){
// 调用红黑树的查询节点的方法,getTreeNode()
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
}
// 第一个节点不是红黑树,并且还有第二个节点,那就说明,这里是链表了
else {
// 那么开始循环链表,从第二个节点开始循环,因为第一个节点已经处理过了
do {
// 判断e节点是不是目标节点,是的话就将node指向e,并且终止循环
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
// e节点不是目标节点,那就将p节点指向e节点,
// 然后while里面e节点后移,在进入循环后发现e是目标节点了,退出循环,退出后此时p节点还是e节点的前一个节点,也就保证了在整个循环的过程中,p节点始终是e节点的前一个节点。
p = e;
} while ((e = e.next) != null);// e指针后移,并且下一个节点不为null则继续遍历,不为null表示没到链表最后。
}
}
// 找到目标节点了 matchValue为true,则仅在值相等时删除。如果是false,则值不管相不相等,只要key和hash值一致就移除该节点。
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果目标节点是红黑树
if (node instanceof TreeNode){
// 调用红黑树的删除节点方法
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
}
// 目标节点是p节点,
// 还记得之前 如果第一个节点p(数组桶中的节点)就是目标节点,则将node指向第一个节点p
else if (node == p){
// 将目标节点的下一个节点作为该索引位置的第一个元素
// 也就是跳过目标节点,指向目标节点的下一位
tab[index] = node.next;
}
// 这里就是遍历链表找到了目标节点
else{
// p节点始终作为node的上一个节点,p.next始终指向目标节点node
// 现在将p.next 指向目标节点node的next,这样跳过了目标节点node,就把node移除掉了
p.next = node.next;
}
// 记录map结构被修改的次数,主要用于并发编程
++modCount;
// 记录table存储了多少键值对,因为移除了一个,所以此处就减一
--size;
// 该方法在hashMap中是空方法,主要是供LinkedHashMap使用,因为LinkedHashMap重写了该方法
afterNodeRemoval(node);
// 返回被移除的节点
return node;
}
}
// 没找到 返回null
return null;
}