HashMap的底层原理,是几乎面试被面试官狂问的的一个问题,今天带大家“简单”过一下HashMap源码---
目录
首先先了解以下HashMap的成员变量们的含义,不然后续就不知所云了
准备工作
-
首先先了解以下HashMap的成员变量们的含义,不然后续就不知所云了
int threshold;//阈值——与扩容有关
transient int size;//HshpMap中的键值对数量
final float loadFactor;//加载因子
transient int modCount;//记录对HshpMap的操作次数——与迭代器有关
transient Node<K,V>[] table;//Node的四个成员变量有必要多说一下
final int hash;//记录哈希值
final K key;//K值
V value;//V值
Node<K,V> next;//实现单向链表
- 进入今天正题,以下代码为例开始解析
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<>();
map.put("k", "v");
}
进入HashMap
首先,new一个HashMap,进入Hashmap
执行了Hashmap的空参构造,底层都发生了什么:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
final float loadFactor;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
首先,当我们在堆中new一个HashMap的之后,我们会给HashMap的成员变量加载因子赋值,值为0.75f,这个加载因子的作用主要是用于后续底层数组扩容使用,这里不做赘述。
进入源码
进入put,以下为put源码:↓↓↓↓↓↓↓↓↓
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可以看到,进入put方法时,底层又执行了一个putVal方法,这里可以看到,参数中多了一个hash(key)方法,我们点进去看一下,hash(key)方法的返回值到底是什么?↓↓↓↓↓↓↓↓↓
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
关于hashCode
直接说结果:
hash(key)是一个静态方法,方法存在一个初始int变量h,key参数传入后通过位运算后将结果赋值给h,继而返回,这个h就是我们所说的hashCode哈希值。有必要多说的是,在java中,任意对象都可以通过Object.hashCode();方法得到一个哈希值,但在这里可以看到在计算出hashCode值后又对其进行了位运算使计算出的hashCode值更加复杂,这里我们称之为哈希扰动,为什么扰动呢,之后会讲到。这个方法主要的作用是返回该key值的hashCode值。
putVal()方法
了解这个之后我们回到之前,进入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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
对这一大坨代码进行一个拆解。↓↓↓↓↓↓↓↓↓
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
初始化
方法入参最关键的三个值,hash值、key值、value值。
进入方法首先初始化三个变量
Node数组tab
Node对象p
int类型n、i
if判断:显然结果一定为true,初始化时成员变量中的Node数组对象tab赋值给tab,此时初始化(tab=table)==null完全符合,int变量n也被tab.length赋值,那么(n=tab.length)==0也完全符合,结果为true,直接进入n=(tab=resize()).lngth。
可以知道,执行完这一条java语句后,tab的长度便赋值给了int变量n,这里又进入一个方法resize(),这个方法主要是用于初始化数组和扩容,内部比较复杂,这里只看这几行继续进入->
resize()--初始化数组
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
由上往下推算,一直到初始化newCap和newThr,newCap就是初始数组长度16,newThe就是扩容阈值=初始数组长度*加载因子=12。
最后将table返回,得到一个Node类型长度为16的数组。所以在执行HashMap的空参构造时,它的底层会生成一个长度为16的Node对象数组。
回到putVal继续往下走↓
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
解释:如果对象p(先通过n-1和hash值进行与运算算出数组下标i,继而获取到tab[ i ]对象)等于空,那么新建一个Node对象赋值给tab[ i ]。
源码:tab[i = (n - 1) & hash])
这个代码的厉害之处就在于两个地方
1、n(数组长度-1)通过与hash值与运算得到长度始终是0—tab.length-1,永不会数组越界。
2、通过hash值的不同计算出来的数组下标每个值出现的概率是平均的,比如如果多次计算,百分之八十的数组下标都是0,百分之二十的数组下标都是15,那么数组下标1-14这些位置就全部浪费了。
(n - 1) & hash
先说第一条为何永不越界,(n-1)很重要,这里牵扯到位运算,代码说明
tab.length()=16;
n=(16-1);
15 & hash =?;
转为二进制进行与运算,这里随便给一个hash值
15:0000 1111
h :1111 1010
&---------------
( 0000 1010 )= 数组下标 i = 10; 下标范围:0000 0000 - 0000 1111 (0-15)
与运算都为1时值为一,否则都为0,这里可以看到n和hash值进行与运算高四位都是0,说明计算出来的数组下标一定小于等于15,这便保证了数组下标一定不会越界,但是有一个前提,在进行与运算前n值一定要是(tab.length-1),如果不是便有可能导致数组越界,代码演示如果不执行n-1的情况:
tab.length()=16;
n=16;
16 & hash =?;
转为二进制进行与运算,这里随便给一个hash值
16:0001 0000
h :1111 1010
&---------------
( 0001 0000 )= 数组下标 i = 16 越界!; 下标范围:0000 0000 或 0001 0000 (0或16)
2的次方数
可以看到两个现象:
1)如果不n-1,数组将有可能越界
2)如果数组的长度不是2的次方数,假设有参构造传的长度值为17时,也符合上图的16和hash值进行与运算,虽然数组是不会越界了,但与运算的结果值左高三位和低四位都是0,也就导致了数组下标的取值范围只有两个(0或16),这会导致Node数组中间14个位置存储不了对象。
所以如果底层数组不是2的次方数,将会有这两大弊端。
再说一下如何保证我们新添加的元素均匀的分布在Node数组每一个位置呢?
hash扰动与^运算
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
key值刚进入底层时,先执行Object的hashCode方法生成一个hash(h)值,再将h值右移16位,再将原hash值和右移后的hash值进行异或运算生成一个新的hash值。
为什么要这样?这样保证原hash值的高位和低位都参加运算,从而保证结果更加均匀散列,进一步降低hash碰撞的几率,并且使高16位和低16位的信息都被保留了
而这里采用异或运算而不采用与运算或者或运算的原因是异或运算可以更好的保留各部分的特征,与运算进算出来的二进制结果值会向1靠拢,而或运算的二进制结果值会向0靠拢。
HashMap有一个有参构造,可以手动传一个指定的长度数值,假如我们调用HashMap的有参构造,传一个长度10,那么底层会给我们创建一个长度为10的Node数组吗?来看一下
HashMap的初始长度
HashMap<String, String> map = new HashMap<>(10);
↓
↓
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
↓
↓
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);//主要看这一行,调用了一个方法
}
↓
↓
/*
*Returns a power of two size for the given target capacity.
*返回一个大于等于cap的2的次方数
*/
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
紧接着这个值进入resize方法,赋值给oldThr 一直到给Node数组长度成功的赋上这个值 ↓
由上得出,即使我们传入一个10,那么他也会进行一个计算,返回一个大于等于10的2的次方数,也就是16,加入我们传入的是17,那么那会计算出32,以此类推…………
这就保证了不管我们传入的值是什么,进入到resize()方法,底层的Node数组长度必定是一个2的次方数。
添加元素时的场景
数组索引存在元素
-
key值相等
回到putVal,如果不为空,走else,
三种情况判断:
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
1、比较要添加的key的hash值和数组里这个位置的hash值是否相等。
2、比较新添加的key的地址和数组该索引位置的key地址是否相等。
3、判断添加的key是否为空且两个key值是否相等。
如果key值完全满足三个条件,Node<k,v> e = p; 执行下述代码:
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
onlyIfAbsent在下图putVal方法的入参。
onlyIfAbsent值从之前传参可以看到等于false。这里说一下什么时候onlyIfAbsent为true,感兴趣的同学可以看一下HashMap的putIfAbsent方法的源码,调用这个方法的时候传进去的onlyIfAbsent值就为true。
首先获取原value值,经过判断,将新的value值覆盖之前的value值,并且返回原value值
-
key值不相等--TreeNode
如下图所示,触发else if ↓
如果数组该位置存在对象且这个对象的类型是TreeNode类型,那么传入的key值和value值封装为一个TreeNode对象,并且添加到这个红黑树里。
-
key值不相等--Node
如下图所示,触发else if ↓
这里是最核心的部分!这里p就是Node数组该位置存在的对象。
进入循环
首先是一个循环,循环主要是做两件事情,一个是找到数组对象的尾节点,另外一个是遍历这个位置的元素个数。
单向链表产生
进入循环后来到第一个判断
看p的next的值也就是下一个值是否为空,如果为空则代表p就已经是该位置的最后一个元素,这个时候就把我们添加到map集合里的key值和value值封装为一个Node对象,作为p的next值,这样就在这个索引位置形成了一个链表,这里采用尾插法。如下图:
紧接着来到第二个判断:
遍历这个位置的时候会binCount统计这个位置的元素个数,如果binCount>=7时,那么就把这个位置的Node对象全部转换为TreeNode对象,并以红黑树方式存在。
转换红黑树+双向链表
上图👆那么binCount大于等于7时即将转换为树结构时,元素的个数是多少呢?答案是9个,元素个数和binCount的差值为2,推导一下就可以算出来。也就是即将转红黑树时,链表上实际上是9个元素,转红黑树的时候又进入到另外一个方法treeifBin(tab,hash)进入:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//数组长度小于64不可以转红黑树
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {//长度=64进入
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);//此时才转红黑树成功
}
}
链表转红黑树有两个条件,一个是链表长度等于9个,并且数组长度大于64时才能转为红黑树,否则还是链表,当符合转为红黑树条件时,进入do循环,遍历每一个Node对象,对每一个Node对象进行转换TreeNode对象操作,遍历完成后,元素类型转换成功。并且在转换的过程中将这条单向链表转换为双向链表。
这些都完成后,最后执行treeify方法,将这个双向链表转换为红黑树。
回到第三个判断:
这里因为是个循环,索引每次遍历都会把传入的key值和该位置上的每一个对象的key值进行比较,如果相等,则退出循环。
如果顺利走完循环执行完最后一次p=e时,那么对象就成功添加进了map里。添加成功后执行最后一块代码:
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
resize()--数组扩容
元素转移和链表的转移
我们每次调用HashMap方法时都会让数组长度也就是ssize+1,当size大于threshold(阈值之前说了,初始值16*加载因子0.75=12)时,执行resize方法进行数组扩容,进入方法图解解析:
现在仅仅是生成了扩容后新的数组,那么老数组中的元素呢??继续看下面:
分割红黑树split()
刚才的图解把这个简单提了一下,这里再解释一下,红黑树的转移来到split方法
红黑树的转移和链表转移的思路差不多,这里也做图解,解释不清楚的,可以自行百度相关细节:
假设转移前红黑树的元素个数为10个,转移后高位和低位元素个数分别为7个和3个,那么新数组应该长这样:一颗红黑树+一个链表
lc计数器和hc计数器
红黑树的直接转移与重新生成
为什么为这样,这就用到那两个计数器lc和gc了,相关换包括红黑树的直接转移或重新生成👇
至此,此篇关于HashMap的解析基本完成,当然这并不是HashMap底层的所有解析,所以我才起名《不完全解析》,不过了解这么多基本够用,以后会多做源码解析篇章,敬请关注!