HashMap作为日常开发中最常使用的一个数据结构,其在面试过程中也是必问的一个知识点,所以我们今天就来一窥HashMap的源码。
先来总览一下HashMap的继承关系吧
HashMap继承自AbstractMap类,实现了Map、Serializable和cloneable接口。
下面以一个常见场景引入今天的分析。
public class HashMapTest {
public static void main(String[] args) {
Map map = new HashMap<>();//1
map.put(1, "1");//2
System.out.println(map.get(1));//3
}
}
构造方法
先从构造方法1开始看起吧,HashMap提供了三种构造方法,其中涉及到的变量分别如下所示:
//默认加载因子 实验所得 时间和空间的折中
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//无参构造
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//容量入参的构造方法
public HashMap(int initialCapacity) {
//底层调用的是双参数构造方法
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// HashMap容量上限 2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
public HashMap(int initialCapacity, float loadFactor) {
//入参校验
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果传入的initialCapacity大于MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
//则将initialCapacity设为MAXIMUM_CAPACITY
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子参数校验
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//计算阈值 此时数组table还没有初始化 会在put方法中重新进行赋值
this.threshold = tableSizeFor(initialCapacity);
}
这里重点讲一下tableSizeFor方法,这个方法将返回大于等于给定的initialCapacity的最小的2的n次幂,什么意思?打个比方,如果initialCapacity为10的话,大于等于10,且是2的n次幂的有很多,16,32,64...等等,但只有16是与10的差值最小的那个,所以tableSizeFor最后返回的就是16。下面看一下这个方法是如何实现的。
//
static final int tableSizeFor(int cap) {
//先减去1
int n = cap - 1;//1
// 先无符号右移 再 | 运算
n |= n >>> 1;//2
n |= n >>> 2;//3
n |= n >>> 4;//4
n |= n >>> 8;//5
n |= n >>> 16;//6
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//7
}
就以10为例,运行完第1行后,n=9,然后下面开始进行右移以及位运算,如下所示:
n=10
n-1=9
0000 1001
>>> 1 0000 0100
| 0000 1101
-----------------------
0000 1101
>>> 2 0000 0011
| 0000 1111
-----------------------
0000 1111
>>> 4 0000 0000
| 0000 1111
-----------------------
0000 1111
>>> 8 0000 0000
| 0000 1111
-----------------------
0000 1111
>>> 16 0000 0000
| 0000 1111
最后所得的n为15,且满足由于 n>0 && n
接下来,再来思考几个问题。
①为什么这里要进行1、2、4、8、16这样的运算呢?
对于局部来说,其实就是为了把高位移到低位(对于4位来说,前两位是高位,后两位是低位)这样之后再进行"|"操作,那么就可以将局部得到全1。
②为什么这里只是到16就结束了呢?
因为我们这里针对的数值都是int类型,在Java当中int类型占到4个字节,也就是32位。为什么不进行32位右移呢,这是因为32位右移之后就变成全0了,"|"操作就没有什么意义,也不会影响结果,只是多余的操作。
put方法
接下来就是常用的put方法,其底层实际调用的是putVal方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash方法用来计算key的hashcode
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 右移16位
}
put方法
//存放数据的数组
transient Node[] table;
// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//数组tab
Node[] tab;
// n 表示数组的长度,i 为数组索引下标,
int n, i;
// p 为 i 下标位置的 Node 值
Node p;
//1.1若数组为空的话
if ((tab = table) == null || (n = tab.length) == 0)
//1.2 使用resize方法进行初始化
n = (tab = resize()).length;
//如果当前索引位置tab[i]是空的
// (n-1)&hash 为了使key分散的更均匀
if ((p = tab[i = (n - 1) & hash]) == null)
//直接生成新的节点在当前索引位置上
tab[i] = newNode(hash, key, value, null);
else {
// 否则的话 则说明此处产生了hash冲突
// e 当前节点的临时变量
Node e;
// key的临时变量
K k;
// 如果 key 的 hash 和值都相等 即相同的key val可能相同,可能不同 此时直接覆盖即可
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//直接把当前下标位置的 Node 值赋值给临时变量
e = p;
// 如果是红黑树,使用红黑树的方式新增
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
// 如果是个链表,把新节点放到链表的尾端
else {
//自旋
for (int binCount = 0; ; ++binCount) {
// e = p.next 表示从头开始,遍历链表
// p.next == null 表示p后面没有节点,即是链表的尾节点
if ((e = p.next) == null) {
// 把新节点放到链表的尾部
p.next = newNode(hash, key, value, null);
// 当链表的长度大于等于 8 时,链表转红黑树
// static final int TREEIFY_THRESHOLD = 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=p.next 用于在遍历过程中 p一直向后移动
p = e;
}
}
// 此时已经插入成功
if (e != null) {
//记录一下旧值
V oldValue = e.value;
//当 onlyIfAbsent 为 false 时,才会覆盖值 此值默认为false
if (!onlyIfAbsent || oldValue == null)
//进行赋值操作
e.value = value;
// hashMap中这个方法无用
afterNodeAccess(e);
//返回旧值
return oldValue;
}
}
// 记录 HashMap 的数据结构发生了变化 增删改都可以算作 数据结构变化
++modCount;
// 根据size大小判断是否要开始扩容
if (++size > threshold)
// 扩容
resize();
// hashMap中这个方法无用
afterNodeInsertion(evict);
return null;
}
总结一下,hashMap的put过程:
- 保存数据的数组是否为空,若为空则直接初始化;
- 如果数组下标所在位置为空,则直接进行赋值操作;
- 如果此时数组下班不为空,即产生了hash冲突,则使用链地址法进行解决;
- 如果此时链表中存在相同的key,则直接进行覆盖;
- 如果不同,此时如果是链表的话,则直接插入到链表尾部;
- 如果是红黑树的话,则直接插入到红黑树中;
- 插入成功后,根据onlyIfAbsent来判断是否直接覆盖旧值
- 返回旧值
流程图如下所示:
reSize方法
resize方法一般有两个场景会触发,一个是调用put方法时,若是此时hashMap尚未初始化,则会调用resize方法进行初始化;第二个就是当目前hashmap中元素个数大于阈值threshold时,调用resize方法进行扩容。
final Node[] resize() {
Node[] oldTab = table;
//判断此时hashmap是否已经初始化了
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.根据oldCap是否大于0来判断是初始化还是扩容
//旧容量大于0 说明是扩容
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//MAXIMUM_CAPACITY=2^30
//如果此时hashmap中元素个数已经超过最大容量 直接退出
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 不是的话 新容量为旧容量的2倍 且小于最大容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新阈值也是之前旧阈值的两倍
newThr = oldThr << 1; // double threshold
}
//2.若是初始化 在使用了带有capacity构造函数时,threshold就是此时hashmap的容量大小
else if (oldThr > 0) // initial capacity was placed in threshold
//新容量就等于旧阈值
newCap = oldThr;
//2.若是初始化,但使用了无参构造,则容量和阈值都使用默认的参数
else { // zero initial threshold signifies using defaults
//新容量等于默认容量
newCap = DEFAULT_INITIAL_CAPACITY;
//新阈值就等于默认负载因子与默认容量的乘积
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//用户自定义了map的初始化操作
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新threshold字段等于新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//实例化新的数组 容量为newCap
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//开始将数据由数组迁移到新数组
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
//去除旧数组对桶的引用
oldTab[j] = null;
//不是链表或者树 只是单节点
if (e.next == null)
//计算在新数组当中的索引位置,然后赋值
newTab[e.hash & (newCap - 1)] = e;
//如果是树的话 调用split方法进行处理
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
//如果没超过8个 是链表
//两条链表 高位和低位 分别用来存储同一个链表上的数据
// 后面根据分配分别插入到新数组中不同的位置
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
//下一个节点
next = e.next;
//当前元素的hash值 & 旧数组容量==0 使用低位链表来进行记录
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//如果 hash值 & 旧数组容量==1 使用高位链表来进行记录
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//开始将这两条链表移动到新数组中
// lowHead 与旧数组的index保持一致 然后放到新数组中的index位置上
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//highHead 在旧数组index的基础上+旧数组的容量,然后放到新数组的
//index+oldCap位置处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新的hashMap
return newTab;
}
总结一下resize的流程:
-
判断当前数组是否已经初始化了,如果没有则进行初始化
- 如果使用的无参构造:
- 则数组容量=16;
- 阈值=容量*阈值=12;
- 如果使用带有capacity的构造方法:
- 则数组容量就是此时的阈值大小;
- 如果使用的无参构造:
-
如果已经初始化过了,则是扩容操作
- 旧数组容量是否已经达到最大容量,2^30:
- 是,新阈值直接设为Integer.MAX_VALUE,直接退出;
- 否,新数组容量为旧数组容量的2倍,新阈值也是旧阈值的两倍;
- 旧数组容量是否已经达到最大容量,2^30:
-
创建新数组,开始遍历旧数组中的元素移动到新数组中:
-
当前元素是单节点元素,则直接计算在新数组中的index位置,然后移动到新数组中;
-
当前元素是红黑树类型,则调用 split方法进行处理;
-
当前元素是一个链表,则开始遍历这个链表,使用两条新链表来存储元素:
-
链表上单个节点 e.hash & oldCap ==0 则移动到lowHead这个链表上
-
链表上单个节点 e.hash & oldCap==1 则移动到highHead这个链表上;
-
lowHead这个链表,直接使用旧数组中的索引index,放入到新数组中;
-
highHead这个链表,在新数组中的索引为 index+oldCap;
-
-
与jdk7中 resize方法的区别
- jdk7中的resize方法只有扩容这一个功能;jdk8中的resize方法兼具初始化(懒加载,执行put的时候才去初始化数组)和扩容两个功能;
- jdk7中resize时,会重新计算每个元素在新数组中的位置;jdk8中的resize方法,在移动链表时,利用链表上元素的性质, e.hash & oldCap 这个值来判断这个元素是在新数组中保持与旧数组相同的索引 index,还是 index+oldCap;
- jdk7中,resize方法在移动链表上的元素时,会改变链表元素的相对顺序,如 a—>b 就会变成 b—> a,又因为使用的头插法,所以导致在多线程环境下进行扩容时可能导致链表成环,这样在调用get方法时陷入死循环;jdk8中采用尾插法,同时插入到新链表的时候不会改变链表中元素的相对位置,因此解决了死循环问题;
jdk8中resize方法中的链表移动示意图:
为什么使用 e.hash & oldCap
计算在新数组中的索引位置使用的 e.hash & (newCap-1),由于newCap是oldCap的两倍,这就会导致参与 & 运算时, newCap-1将会比 oldCap-1多一位参加运算。如果这个需要新判断的位置上为0,那么index不变,否则变为需要迁移到(oldIndex + oldCap)这个位置上去。
get方法
public V get(Object key) {
Node e;
//调用getNode方法
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
//判断索引位置处是否有值 否则直接return null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// hash值和key值都相等 则表明命中 直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//判断是链表还是红黑树
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//是红黑树的话,调用getTreeNode方法查找
return ((TreeNode)first).getTreeNode(hash, key);
do {
//链表的话,遍历链表查找匹配
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get方法的流程如下:
- 根据元素的hash值和hash方法定位到数组中索引所在位置;
- 判断该索引位置是否存在值:
- 不存在的话,直接返回null;
- 比较hash值和key,若是相等,则表明命中,返回该索引位置所在的元素;
- 若不相等,判断此处是否存在红黑树或者链表结构:
- 若是红黑树,则调用getTreeNode方法进行查找;
- 若是链表,则遍历链表进行查找;
jdk新增的getOrDefault方法
jdk8中新增一个getOrDefault方法,该方法在查找时,若是查找不到则返回传入的默认值。源码如下:
public V getOrDefault(Object key, V defaultValue) {
Node e;
//可以看出与 get方法的不同 若是查找不到 默认返回defaultValue 其余与get方法一致
return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
}
remove方法
public boolean remove(Object key, Object value) {
return removeNode(hash(key), key, value, true, true) != null;
}
final Node removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node[] tab; Node p; int n, index;
//先定位
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node node = null, e; K k; V v;
//直接找到
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)
node = ((TreeNode)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//node不为null 说明命中了要删除的节点
// 如果不需要对比value 或者是需要对比value 但value也相等 则开始进行删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果是红黑树的话 调用removeTreeNode进行删除
if (node instanceof TreeNode)
((TreeNode)node).removeTreeNode(this, tab, movable);
//如果是首节点的话 直接指向下一个节点 一个的话可以看做是只有一个节点的链表 这样也可以置
//为null
else if (node == p)
tab[index] = node.next;
else
//否则的话,进行链表的移动
p.next = node.next;
//更新modCount
++modCount;
//更新size
--size;
afterNodeRemoval(node);
//返回删除的节点
return node;
}
}
return null;
}
remove方法流程梳理如下:
- 利用元素的hash和hash方法进行定位
- 若是当前索引位置处为null,直接返回null;
- 比较hash和key,若相等,则表明找到了待删除的节点
- 若不是,则判断该位置是红黑树还是链表:
- 若是红黑树,则调用getTreeNode方法进行查找;
- 若是链表,则遍历链表进行查找;
- 若是找到了待删除节点,则开始进行删除:
- 若该索引位置处为红黑树,则调用removeTreeNode方法进行删除;
- 若是链表,则执行链表相关的操作进行删除;
- 最后更新 modCount和 size等属性的值;
常见面试题
为什么数组长度都是2的倍数?
- 当数组都是2的倍数时,2^n-1的二进制表示中所有位置都是1,这样与一个全部都是1的二进制数进行 & 操作时,速度会大大提升;
- 计算元素的索引位置时,一般采用的是 % 操作,但是如果数组长度都是2的倍数的话,hash & (length-1) 等价于 hash % length,但是 & 操作的效率更高,因为 % 在操作系统会进行转换, & 操作不用;
- 数组长度为2的倍数时,不同key计算出相同的index的概率较小,减少hash碰撞;
为了减少hash碰撞,hashMap做了哪些操作?
- hash方法中,hashCode ^ hashCode >>>16,这样所得的hash值可以将hashCode的高位和低位都利用上,降低不同key通过hash方法获得相同hash值的概率,减少hash冲突;
- 计算索引位置时,hash & (length-1),由于 length始终是2的倍数,length-1后的二进制表示中各位都是1,一个数与各个位都是1的数进行 & 操作,进一步降低hash冲突;
参考
https://juejin.im/post/6844904048185851911#heading-8
https://juejin.im/post/6847902223884779533#heading-10
https://juejin.im/post/6844904134080987144#heading-33