HashMap 源码分析
本文共7511字,阅读本文大概需要15~25分钟
了解 HashMap
简单操作
Map<Integer, String> map = new HashMap<>();
map.put(1,"张三");
map.put(2,"李四");
System.out.println(map.get(1));
当进行如上操作的时候,即调用put()方法的时候,会对key进行一个hashCode()方法的运算,获取key的hash值。常规的一个做法,就是用这个hash值对数组的长度进行取模,根据取模的结果,将key-value对放在数组中的某个元素上
map.get(1)
这个方法,同理的,会对key获取一个hash值,根据hash值对数组长度的取模,就知道这个key对应的key-value对在哪里,就可以直接根据hash值定位到数组中的元素,然后就返回了,性能很高
如果说,某两个key,对应的hash值,是一样的,怎么办呢?
如果说两个值的hash值是一样的,但是这两个key值不一样,hash值一样会导致他们放到同一个数组的索引位置上,在 jdk1.8 以前,采用链表。如果说有很多的 hash 冲突,也就是说多个 key 的 hash 值是一样的,或者也可能是多个 key 的 hash 值不一样,但是不同的 hash 值对一个数组的 length 取模,获取到的这个数组的 index 位置,是一样的比如说map.put(1,"zs")
取模以后 index 的位置是 5,而map.get(4,"ls")
取模后 index 同样为 5,此时会导致它的这个元素挂在数组上,形成一个链表;jdk1.8以后,优化了一下,如果一个链表的长度超过了8,就会自动将链表转换为红黑树,查找的性能O(logN),比链表O(N)要高,若长度小于了6,又会退化为链表
总结:jdk1.8之前,hashmap 的数据结构是 数组 + 链表;jdk1.8及以后,hashmap的数据结构是 数组 + 链表/红黑树
几个关键点
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
数组的默认初识大小,是16static final float DEFAULT_LOAD_FACTOR = 0.75f;
这个参数是负载因子,如果你在数组里的元素个数达到了数组大小(16) * 负载因子(0.75),默认是达到12个元素,就会进行数组的扩容
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
这是一个很关键的内部内,它其实是代表了一个key-value对,里面包含了 key 的hash 值,key,value,还有就是可以有一个 next 的指针指向下一个Node,也就是指向单向链表中的下一个节点,通过这个指针就可以形成一个链表
transient Node<K,V>[] table;
Node<K,V>[],这个数组就是所谓的 map 里的核心数据结构的数组,数组的元素就可以看到是 Node 类型的,天然就可以挂成一个链表(单项链表),Node里面只有一个 next 指针transient int size;
这个 size 代表的就是当前 hashmap 中有多少个 key-value 对,如果这个数量达到了指定大小 * 负载因子,那么就会进行数组的扩容int threshold;
threshold = capatity * loadFactory,threshold表示当HashMap的size大于threshold时会执行resize操作。final float loadFactor;
默认就是负载因子,默认的值是 0.75f,也可以直接指定,如果你指定的值越大,一般就越是拖慢扩容的速度,一般不要修改
hash 算法
使用 map.put(key,value)
,对 key 进行 hash 算法,通过 hash 获取到对应的数组中的 index 位置。hash 算法是怎么来玩的,是简单的 key 的 hash值即 key.hashCode()
方法返回一个值吗?答案是否定的。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash(key),对 key 进行 hash 获取一个对应的 hash 值,key、value传入到 putVal() 方法里面去,将 key-value 对根据其 hash 值找到对应的数组位置
hash(key)方法,里面的算法是什么?JDK 源码里面,涉及到了大量的位运算,下面这个方法计算出了 key 的 hash 值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
eg:
1111 1111 1111 1111 1111 1010 0111 1100
# h >>> 16,这个是位运算的操作,这个东西是把 32 位的二进制的数字,所有的 bit 往右侧右移了 16 位
1111 1111 1111 1111 1111 1010 0111 1100
0000 0000 0000 0000 1111 1111 1111 1111
^ 得到: 1111 1111 1111 1111 0000 0101 1000 0011
怎么做的目的,其实是考虑到,将它的高 16 位和低 16 位进行一个异或运算,结论:后面在用到这个 hash 值定位到数组的 index 的时候,也有一个位运算,但是呢,一般那个后面的位运算,一般都是用低 16 位在进行运算,所以说如果你不把 hash 值的高16位和低16位进行运算的话,那么就会导致你后面在通过 hash 值找到数组 index 的时候,只有 hash 值的低16位参与了运算
一个结论:提前在 hash() 函数里面,把高16位和低16位进行一下异或运算,就可以保证说,在hash值的低16位里面,可以同时保留它的高16位和低16位的特征。相当于是,在后面定位到数组index的位运算的时候,哪怕只有低16位参与了运算,其实运算的时候,它的hash值的高16位和低16位的特征都参与到了运算定位到那个数组的index
这么做的好处:为什么要保证同时将高16位和低16位的特征同时纳入运算,考虑到数组index的定位中去呢?因为这样子可以保证降低hash冲突的概率,如果说直接用hash值的低16位去运算定位到数组index的话,可能会导致一定的hash冲突。有很多的key,可能值不同,但是hash值可能是相同的,如果key不同,但是hash值相同,或者是hash值不同,但是到数组的index相同,那么会出现hash冲突,通过上面的这个操作,计算出来非hash值可以降级hash冲突概率
put操作原理&hash的寻址算法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
假设 hashmap 是空的,数组大小就是默认的16
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
刚开始 table数组是空的,所以会分配一个默认大小的一个数组,数组大小是16,负载因子是0.75,threshold是12
对于hash的寻址算法,并不是说用hash值数组大小取模,取模就可以将任意一个hash值定位到数组的一个index,取模的操作性能不是很高。对于hash的寻址算法,采用的是位运算,通过位运算来达到取模的效果,
对于 hashmap,它的初始值以及未来每次扩容的值,都是2的N次方,也就是说他后面每次扩容,数组的大小就是2的N次方,只要保证数组的大小是2的N次方,也就可以保证说,hash & (n-1) 与 hash % n 取模的效果是一样的,也就是说,通过 hash&(n-1) 就可以将任意一个hash值定位到数组的某个index里去
因为不想用取模,取模的性能相对较低,所有采用了位运算,这是hashmap 的一个提升性能的优化点,这也是 hashmap 底层原理里面的重要部分
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
i = (n - 1) & hash
,i就是最后寻址算法获取到的那个hash值对应的数组的index,tab[i]直接定位到数组的那个位置,对于hashmap,刚开始肯定是空的,就直接创建了一个 Node 出来,代表了一个 key-value 对,放在数组的那个位置就可以了
hash冲突时,链表处理
假设说,某两个key的 hash 值是一样的,两个key不同,hash值一样,这个概率其实是很低的,除非你自己乱写了 hashCode()方法,你自己人为的制造了两个不同的key,但是hash值一样。
两个key的hash值不一样,但是通过寻址算法定位到了数组的同一个key上去,此时就会出现典型的hash冲突,默认情况下,会向单链表来处理
if ((p = tab[i = (n - 1) & hash]) == null)
这个分支,它的意思是说tab[i],i就是hash值定位到的数组index,tab[i]如果为空,也就是hash定位到的这个位置是空的,之前没有任何人在这里,此时直接放进去一个Node在数组的这个位置即可
如果进入了 else 分支,就说明通过hash定位到数组的位置,是已经有了Node了。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果满足了上述条件,说明是相同的key,覆盖旧的value
// map.put(1,"张三")
// map.put(1,"李四")
if (e != null) { // existing mapping for key
// 张三就是 oldValue
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// value是新的值,是李四
// e.value = value,也就是将数组那个位置的Node的value设置为了新的李四这个值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
// 上述那块代码,其实说白了,就是相同的key在进行value的覆盖
如果上面那个 if 不成立,说明人家的key是不一样的,hash值不一样或者是key不一样
// 这个分支是说,如果这个位置已经是一颗红黑树的话,会怎么来处理
else if (p instanceof TreeNode)
进入到else 这个分支,才是说,key不一样,出现了hash冲突,然后此时还不是红黑树的数据结构,还是链表的数据结构,在这里会通过链表来处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
上述那串代码,就是说如果当前链表的长度(binCount),大于等于了 TREEIFY_THRESHOLD-1 的话,如果链表的长度大于等于8的话,那么此时就需要将这个链表转换为一个红黑树的数据结构了
jdk1.8引入红黑树
如果说出现了大量的hash冲突之后,假设给某个位置挂一个链表特别的长,就很恶心了,如果链表太长的话,会导致有一些 get() 操作的时间复杂度就是 O(n),正常来说,table[i]数组索引直接定位的方式的话,O(1)。但是如果链表,大量的 key 冲突,会导致 get() 操作的性能急剧下降,导致很多问题。
所以说 jdk1.8 以后,人家优化了这块东西,会判断,如果链表的长度达到了8的时候,那么就会将链表转换为红黑树,如果用红黑树的话,get()操作,即使对一个很大的红黑树进行查找,那么时间复杂度会变成 O(logN),性能会比链表的 O(N)得到很大的提升
当你遍历到第8个节点,此时binCount是7,同时你挂上第9个节点,然后会发现binCount>=7,达到了临界值,也就是说,当你的链表节点的数量超过了8的时候,此时就会将链表转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
结论:先是挂链表,当长度超过了8,就将链表转换成红黑树
rehash算法
jdk1.8以后,为了提升 rehash 这个过程的性能,不是说简单的用key 的 hash 值对新数组.length 取模,由于取模的性能较低,jdk1.8以后 hash 寻址这块,统一都是用的位操作
n - 1 0000 0000 0000 0000 0000 0000 0000 1111
hash1 1111 1111 1111 1111 0000 1111 0000 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)
n - 1 0000 0000 0000 0000 0000 0000 0000 1111
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)
此时,上面两个 hash 值会出现 hash 碰撞的问题,使用链表或者是红黑树来解决
如果数组长度扩容到 32 之后,重新对每个 hash 值进行寻址操作,也就是用每个 hash 值跟新数组的 length-1 进行与操作
n-1 0000 0000 0000 0000 0000 0000 0001 1111
hash1 1111 1111 1111 1111 0000 1111 0000 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)
n-1 0000 0000 0000 0000 0000 0000 0001 1111
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果 0000 0000 0000 0000 0000 0000 0001 0101 = 21(index = 21的位置)
hash2 的位置由原来的 5 变成了 21
也就是说,jdk1.8,扩容一定是2的倍数,从16到32到64到128
就可以保证说,每次扩容之后,你的每个hash 值要么是停留在原来的那个index的地方,要么是变成了原来的index(5)+oldCap(16)=21
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
# 如果要对 hashmap 进行扩容的话
# newThr = oldThr << 1;就是乘以2,新数组是老数组的2倍
# 如果 e.next 是 null 的话,这个位置的元素既不是链表也不是红黑树
# 那么此时就是用 e.hash & (newCap - 1),进行与运算,直接定位到新数组的某个位置,然后直接就放在新数组里了
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
# 如果这个位置是一个红黑树的话
# 此时会调用 split() 方法,人家肯定会去里面遍历这颗红黑树,
# 然后将里面每个节点都进行重新 hash 寻址,找到新数组的某个位置
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
# 进入这个分支的话,证明是链表
# 这块的原理,就说说会判断一下,如果是一个链表里的元素的话
# 那么要么是直接放在新数组的原来的那个index
# 要么是原来的index + 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;
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南