HashMap采用key-value存储结构,每个key对应唯一的value,查询和修改的速度都很快,能达到O(1)的平均时间复杂度。它是非线程安全的,且不保证元素存储的顺序。HashMap是最常见的Map类型结构,那么它有哪些特殊之处呢,下面就来一步一步彻底揭开它的神秘面纱。(基于1.8版本)
一.存储结构
jdk1.8以前,HashMap采用的存储结构是一个Entry类型数组+单链表,其结构如下:

jdk1.8之后,HashMap采用的存储结构是一个Node类型数组+单链表或红黑树。其存储结构如下:

当链表上的节点数大于等于8时,链表会转换成红黑树形式,当节点数小于等于6时,红黑树结构会转换回链表结构。
为什么要这样设计呢?
当数据量很大的时候,因为链表的查询复杂度为O(n),所以它会转换成红黑树的结构,红黑树查删改的复杂度都为O(logn),但是,当数据很少的时候,修改红黑树上的数据会通过左旋右旋变色等操作来保持树的平衡,其带来的性能开销比用链表存储大。(那为何不直接用二叉查找树来直接存储呢?原因当然是因为二叉查找树时不平衡的,在特殊情况下会退化为单链表,查找速度缓慢)
table数组
transient Node<K,V>[] table;
链表节点Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//存储hash值
final K key;
V value;
Node<K,V> next;
}
二.几个概念
2.1 几个变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//默认初始化容量16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量2的30次方
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认装载因子
static final int TREEIFY_THRESHOLD = 8;//链表长度大于等于8时转换成红黑树
static final int UNTREEIFY_THRESHOLD = 6;//红黑树节点小于等于6时转换成链表
transient int size;//元素的数量
int threshold;//扩容阈值
final float loadFactor;//装载因子
//还有我们所说的capacity即是数组的长度
//计算公式:threshold=capacity*loadFacotor
//当size>=threshold会进行扩容
2.2桶下标计算
桶下标计算相当于取模运算,即桶下标=hash%capacity
但是我们是用位移运算去实现的。因为capacity都是2的n次幂
capacity-1转换成二进制数即是n个1
所以hash%capacity=hash&(capacity-1)
因此我们又可以把capacity-1称为capacityMask
举个例子:
3%4=3;
3&3=11&11=3;
37%16=5;
37&15=10101&1111=101=5;
三.源码分析
3.1 构造函数
1. HashMap()
//全部使用默认值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
2.HashMap(int initialCapacity)
//传入一个初始化capcity
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);//调用第三个构造函数
}
3.public HashMap(int initialCapacity, float loadFactor)
//传入一个初始化capcity和装载因子,建议无特殊需求不要改装载因子
public HashMap(int initialCapacity, float loadFactor) {
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);
}
//这个函数有点复杂,调用了Integer类的numberOfLeadingZeros(cap-1),具体没去
//研究,主要作用是初始化返回一个大于等于cap的最小的2的n次幂的数
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
我们可以看出上述构造函数,虽然有参构造传进去了capacity,并没有创建一个table数组,而是直接初始化了threshold。
3.2 put()操作
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);//先通过key计算hash值,然后调用putVal函数。
}
我们看到put操作首先通过key计算了hash值,然后调用putVal函数,那么我们先来看看hash值是怎么计算的。
static final int hash(Object key) {
int h;//用来存储key的hashcode
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//h为key的hashcode,hash值的求法为h异或h右移16位
}
由源码可知,通过key计算hash值通过用扰动函数,让hash值更为散列以此来减少碰撞。
举个例子:
任何一个Object类型的hashCode方法得到的Hash值是一个int型,Java中int型是
4*8=32位的,但是HashMap的capacity最大也就是31位(static final int MAXIMUM_CAPACITY = 1 << 30;),
也就是说capacityMask最大也就是1<<31-1等于30个1,而且,大多数情况下,hashMap的capacity也不会用到那么多位,正常初始化时capacity大小也就是16,也就是10000,
它的capacityMask就是1111。在计算桶下标时,只有 hashcode的低4位是有用的,无
论高位是什么,它计算出的桶下标都是一样的。例如:
// Hash碰撞示例:假设刚初始化capacity为16
H1: 00000000 00000000 00000000 00000101 & 1111 = 0101
H2: 00000000 11111111 00000000 00000101 & 1111 = 0101
为了减少这种碰撞,我们可以通过扰动函数,混合hashcode的高位和低位,让hash值不
仅仅受低位影响。
00000000 00000000 00000000 00000101 // H1
00000000 00000000 00000000 00000000 // H1 >>> 16
00000000 00000000 00000000 00000101 // hash = H1 ^ (H1 >>> 16) = 5
00000000 11111111 00000000 00000101 // H2
00000000 00000000 00000000 11111111 // H2 >>> 16
00000000 00000000 00000000 11111010 // hash = H2 ^ (H2 >>> 16) = 250
// 最终没有Hash碰撞
index1 = (n - 1) & H1 = (16 - 1) & 5 = 5
index2 = (n - 1) & H2 = (16 - 1) & 250 = 10
看完了hash值计算的过程,我们继续进入到putVal里面一探究竟
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//由3.1构造函数可知,构造时没有对table进行初始化,所以,第一次put操作必定会
//调用resize(),resize操作后面详讲
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//下面if语句先计算桶下标,如果该下标没有内容,则直接新建一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//否则进行以下处理
Node<K,V> e; K k;
//快速插入:如果该桶链上第一个节点的hash值和要put进去key的hash值相同并且key
//也相同,则先用变量e把该节点暂时保存,以便后续修改这个节点的值。
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) {//binCout用来记录链表中节点的个数
//如果遍历完链表依旧没有找到key相同的节点,则需要在链表尾端插入一个新节点。
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果插入后链表长度大于等于8,转换成红黑树结构
treeifyBin(tab, hash);
break;
}
//如果遍历途中找到了key相同的节点,则用e把该节点占时保存(上面执行了)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e不为空,即是在链表找到了key相同的节点,只需要改变值即可
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//没有找到的话,上面循环里面往链表里面插入了数据,需要判断此时size是否大于阈
//值。如果大于阈值需要进行扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
到此为止HashMap的put操作就此结束,总结一下其大概过程
a.通过key计算hash值(需要用到扰动函数)
b.通过hash值计算桶下标
c.判断桶是否为空,如果为空先resize()
d.0判断桶第一个节点的key是否和插入的key相同,如果相同,用e把该节点展示保存,以便后续修改值
d.1如果不相同,则需要遍历链表
d.1.0如果遍历过程中找到了key相同的节点,用e暂时保存以便后续修改
d.1.1如果遍历过程中没有找到,则需要在链表尾端插入新节点(jdk1.8之前是头插),插入完后,还需判断链表长度是否大于等于8,如果是的话需要转换成树结构
e.判断e是否为空,不为空的话,说明链表中存在key相同的节点,只需要修改值就好了,如果为空,说明没有key相同的节点,而且循环中已经插入了新节点,所以此时需要考虑此时size是否会超过阈值,超过需要resize().
3.3 resize()操作
HashMap的put过程是不是很简单,上面出现了两次resize()操作,下面我们再去resize()函数看看有没有什么别样的风景。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//由3.1构造函数可知,构造时没有对table进行初始化,所以,第一次put操作必定会
//调用resize()
int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧容量
int oldThr = threshold;//旧阈值
int newCap, newThr = 0;
if (oldCap > 0) {//如果旧容量大于0
//如果旧容量大于等于最大值,则不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果旧容量的两倍小于最大容量并且旧容量大于默认初始容量(16),则容量扩大为
//两倍,扩容阈值也扩大为两倍
//如果没有capacity没有达到16,则这里只扩大capacity,后面再计算threshold,
//设计的着实巧妙
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果旧的阈值大于0,显然是通过构造函数传入capacity时初始化了threshold,这
//个时候初始化是capacity=threshold=大于等于传入cap的最小的2的n次幂的一个数
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//这里相当于无参构造函数第一次put操作,全部使用默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//新的扩容阈值为0,则需要计算即可(为什么会出现这种情况?使用有参构造时,初始
//化了阈值,resize里面通过阈值初始化capacity的时候,没有更新newWhr,为了就
//是在这里调整阈值,HashMap设计者果然是个大师,设计的如此巧妙。
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//上面主要更新了新的capacity和threshold,下面主要时更新table里面的内容了
@SuppressWarnings({"rawtypes","unchecked"})
//新建一个大小为newCap的Node数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//把table设为新的table
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//遍历旧table的所有桶,
if ((e = oldTab[j]) != null) {
//清空旧桶,便于GC回收
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
//如果这个链不止一个节点,但是又不是树结构,就会将一条链分化成两条链
//一个叫低位链表,一个叫高位链表
//e.hash & oldCap) == 0则放在低位链,否则放在高位链
//举个例子,假如原来的capacity是4,扩容之后变成8
//hash1为3&4=11&100=0放在3号桶
//hash2为7&4=111&100=1则放在7号桶,而没有扩容时它是放在3号桶的
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//如果e.hash & oldCap) == 0则放在低位链
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()的大概过程
resize()总结
resize主要分两个过程
1.初始化新的capacity和threshold,有三种不同情况
1.1table里面有数据时的扩容,即size大于0。分三种可能:
oldCap >= MAXIMUM_CAPACITY不扩容//第一种不扩容
(newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//第二种,capacity和threshold都扩大两倍,第三种不满足该条件只扩容capacity,后面再计算threshold
1.2带参数构造函数第一次put操作会来到这里,根据threshold进行扩容
1.3无参构造函数第一次扩容会来到这里,使用默认值。
2.修改table里面的数据
分红黑树形式和链表形式,本文主要讲的时链表形式, 一个链会分化成两个链,节点处于哪个链通过e.hash & oldCap) == 0是否为真来判断,为真则在低链,为假则在高链。
码了近7000字,剩下的方法有时间再敲,如果觉得这篇文章对你很有帮助,那就麻烦慷慨的点个赞吧😁
HashMap深入解析
本文详细剖析HashMap的内部结构,包括其存储机制、扩容策略及put操作流程。解释了HashMap如何通过链表和红黑树实现高效的数据存储与检索,以及如何避免哈希冲突。
1万+

被折叠的 条评论
为什么被折叠?



