小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
HashMap是以key,value这种键值对形式存储数据,在jdk1.8开始底层是由数组,链表,红黑树而形成的,在链表长度达到8的时候,并且数据总数达到64,就会将链表转为红黑树。
在学习HashMap源码之前,先抛出几个问题:
-
为什么底层中数组的长度总是保持2的次方
-
为什么要对key的哈希值进行扰动
-
为什么在1.8之前链表是头插法,而从1.8开始变成尾插法
这些问题现在看起来可能还一头雾水,不用怕,待我将hashMap源码分析一波,这些问题迎刃而解,吊打面试官。
在HashMap源码中大量运用了一些进制运算,一些运算符可能大家不提前了解会对下面源码的阅读带来困扰,这里先简单的列举一下。
01
<< ,>>,>>> 运算符
<<:这个是向左移,不分正负数,低位补0,例如000011,
<< 2 就是向左移两位,那么就变成001100,转为10进制就是变成了原来数值的2的2次方倍
>>: 这个是向右移,如果该位为正数,则高位补零,若为负数,则补1.
>>> :这个是无符号右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0。
02
与,或,异或
与:符号表示为&,两者相对应,如果存在0,那就是0,如果全部为1.那就是1.
例如两个二进制进行与操作:0011和0101,根据上面的规则,结果就是0001.
或:符号是| ,如果存在1,那就是1,如果全为0,那就是0
异或:符号是^,如果相等就为0,不相等就是1
这些进制运算符,如果不熟悉,或者是对进制转换不熟悉,可以再去复习一波,这样看源码才更容易理解。
01
HashMap整体继承结构
整体结构
HashMap是继承的AbstractMap,然后实现的Map接口,整体继承体系也不算复杂。
04
字段属性
/**
*1向左移位4个,00000001变成00010000,
* 也就是2的4次方为16,使用移位是因为移位是计算机基础运算,
* 效率比加减乘除快。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量为2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
*加载因子大小,为扩容所使用
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 当链表转为红黑树的阀值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
*红黑树转为链表的阀值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
*当整个hashMap元素超过64时,才有可能转为红黑树
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 存储元素的数组
*/
transient Node<K,V>[] table;
/**
* 将数据转换成set的另一种存储形式,这个变量主要用于迭代功能
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 元素数量
*/
transient int size;
transient int modCount;
/**
* 临界值,也就是元素达到这个数量时会进行扩容
*
* @serial
*/
int threshold;
/**
* 加载因子,这个是个变量
*
* @serial
*/
final float loadFactor;
这里强调下,描述容量的CAPACITY指的是数组的长度而不是集合的元素数量,size才表示的是现有的元素数量
06
构造函数解析
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;
//设置阀值
//tableSizeFor的作用就是生成比传入参数要大的并且是2的次方的数
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
//指定初始容量
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
//指定加载因子为默认值
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
构造函数中设置阈值调用了tableSizeFor方法,这个方法的作用就是保证你传进来的容量如果不是2的次方,都会被调整为2的次方,例如你传入9,那对于9来说,离它最近并且比它大的
2的次方就是16.这里给大家看下这个方法的源码。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
看第一步,可能就突然被整懵了,为啥要进行减一操作呢,先别急,下面会讲解到,我们看到它的计算方式就是n与n的无符号右移之后的结果进行异或运算,我们举个例子来看看
例如5,它的二进制就是00000101:
n |= n >>> 1;
0000 0101
0000 0010
0000 0111
n |= n >>> 2;
0000 0111
0000 0001
0000 0111
n |= n >>> 4;
0000 0111
0000 0000
0000 0111
.....
可以看到,其最后运算后的结果都是00000111,再加1其结果就是00001000,,就是5的最小2的整数幂8.
其实这个算法思路就是将数字的最高非0位后面全部置为1
n= ; 1000 0000 0000 0000 0000 0000 0000 0000
n |= n >>> 1; 1100 0000 0000 0000 0000 0000 0000 0000 将最高位拷贝到下1位
n |= n >>> 2; 1111 0000 0000 0000 0000 0000 0000 0000 将上述2位拷贝到紧接着的2位
n |= n >>> 4; 1111 1111 0000 0000 0000 0000 0000 0000 将上述4位拷贝到紧接着的4位
n |= n >>> 8; 1111 1111 1111 1111 0000 0000 0000 0000 将上述8位拷贝到紧接着的8位
n |= n >>> 16; 1111 1111 1111 1111 1111 1111 1111 1111 将上述16位拷贝到紧接着的16位
由上面可以看出其通过这五次的计算,最后的结果刚好可以填满32位的空间,也就是一个int类型的空间,这就是为什么必须是int类型,且最多只无符号右移16位!
那我们现在就来说一下为什么要减1:
以 n = 8为例
0000 1000
最后的结果为:
0000 1111
对其加一得到的是16,显然没有把自身包含进去
若减一
n = 7
0000 0111
最后的结果为:
0000 0111
对其加一得到的是8
可以看到,当数字本身就是2的次方时,如果不进行减1操作,那么经过运算后就会出现得到的不是它本身,而是比它大的最接近的2的次方,没有把自身包含进去,这是不行的,仅仅一个减1操作就可以规避这个Bug,设计的确实是很巧妙。
06
put方法
put方法是HashMap的方法,先通过一张流程图大致了解下
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal方法主要的核心参数就是通过扰动函数计算出来的hash值,以及,key,value,这个扰动函数hash()的作用就是增加hash的散列性,,随机性,减少hash碰撞
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
通过让key的hash值进行无符号右移16位之后再与本身进行异或的操作,让其hash值的高16位也参与运算
接着来看putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab:引用当前hashMap的散列表
// p:当前散列表的元素
// n:数组的长度
// 表示数组的下标位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//延迟初始化,懒加载
n = (tab = resize()).length;
//第一种情况:如果当前下标位置的桶位刚好是空的,那就直接创建node将k,v存入其中即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// e:n不为null的时候,找到了一个与当前要插入元素key一致的元素
// k:表示临时的key
Node<K,V> e; K k;
//表示当前桶位的头元素与要插入的元素key相同,后续会进行值替换的操作
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) {
//迭代到链表最后一个元素,也没有找到要插入元素的key相同的情况
//加入到链表末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//达到树化条件,进行树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//树化操作
treeifyBin(tab, hash);
break;
}
//说明找到相同key的元素需要进行替换操作
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;
}
根据上面那个流程图再结合源码,就很好理解了。
-
第一步就是判断table数组是否为空,为空就通过resize方法进行初始化,可以看到其初始化是懒加载的思想,并不是hashMap初始化的时候对数组进行初始化,而是在第一次调用put方法的时候再进行初始化,这种做法的目的就是避免资源浪费。
-
tab[i = (n - 1) & hash],根据hash值计算数组下标位置,如果当前下标位置没有元素,那就直接插入,有的话就先比较当前桶位头元素的key是否与要插入的key相等,相等就替换
-
在不满足前两个条件的情况下,就要判断当前桶位是链表还是红黑树,是红黑树就用红黑树的方式进行插入
-
如果是链表,那就进行for循环一一遍历,比较是否有相等的key,如果遍历到最后一个结点都没有相等的key,就将值插入到链表的尾节点,插入完成还要看是否达到树化的条件,就是链表长度是否达到8,达到就调用树化的方法进行树化操作。
-
最后判断元素是否达到要扩容的阀值,达到就进行扩容操作。
06
resize方法
final Node<K,V>[] resize() {
//引用扩容前的哈希表
Node<K,V>[] oldTab = table;
//扩容前的table长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 扩容前的阈值
int oldThr = threshold;
//newCap:扩容之后的数组大小
//扩容之后的阈值
int newCap, newThr = 0;
// 如果 扩容前数组长度大于0
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新的数组长度等于扩容前的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的阈值的等于扩容前的两倍
newThr = oldThr << 1; // double threshold
}
//扩容前的阈值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//扩容之前,table不为null
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//说明当前桶中有数据,具体是单个数据,链表,红黑树,还未知
if ((e = oldTab[j]) != null) {
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
//低位链表:存放的是在扩容之后数组下标位置与扩容前数组下标位置一致
Node<K,V> loHead = null, loTail = null;
//高位链表:存放的是扩容之后数组下标位置为当前数组下标位置+扩容之前数组长度
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//hash 0 1111
//hash 1 1111
//oldCap 10000
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;
}
扩容的方法有点长,但其实从总体来看就做了三件事
-
第一件事就是计算新桶数组的容量Cap以及新的阀值threshold.扩容就是扩容到原来的2倍
-
第二件事就是根据新的数组容量创建新的数组
-
第三件事就是重点了,将老数组的数据转移到新数组
重点说下这个转移数组数据的操作,转移的操作也是分三种情况:
如果当前桶位就一个数据,那就直接计算下在新数组的下标位置,然后插入即可:newTab[e.hash & (newCap - 1)] = e;
如果不是一个元素,那就要判断这个桶位的数据是链表还是红黑树,如果是红黑树,就通过红黑树的方式进行数据转移,红黑树的方式这里先不进行解析,后期会单独讲解红黑树。如果是链表,转移的操作就很神奇,也狠巧妙,让我们来看一看其操作方式:
先说下结论,链表中的结点要么是保持数组下标位置不变进行转移,要么就重新计算数组下标位置,这个计算方式也很简单,就是结点在原来数组的下标位置+原来数组大小,例如
你再数组下标位置是15,数组长度是16,那么在新数组的位置就是16,那么如何来区分哪些结点使用这两种的哪种方式进行转移呢?
在源码中,可以看到,它创建了两个Node,一个是低位链表,一个是高位链表
怎么计算结点属于高位链表,还是低位链表,通过
e.hash & oldCap) ==0,如果结点的hash值与容量与操作后结果为0,就是低位链表,不是0就是高位链表。在同一桶中不代表其hash值是一样的,所以可以根据这点进行区分;
我们都知道,容量是2的次方,因此它的二进制就是1后面全是0,那么在与hash值进行与操作,其实就是看第一位是否为0,如果是0在与操作后结果就是0,这样就将结点分到低位链表,不是就分到高位链表,这里在往链表插入时使用的是尾插法,在1.7的时候扩容使用的是头插法,分好类之后,那么低位链表就按照数组下标位置不变插入到新数组,高位链表数组下标位置就是
oldCap+oldindex,这样就完成了数据转移的操作,扩容就圆满完成了。
06
get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
//当前hashMap散列表
Node<K,V>[] tab;
//first 桶位中的头元素
//e :临时元素
Node<K,V> first, e;
//数组长度
int n;
K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//第一种情况:定位出来的桶位元素即为咱们要get的数据
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)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//第三种情况:桶位是链表
do {
//进行do while循环,一一遍历找到要查找的元素
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get方法就是看getNode方法
-
第一种情况就是定位出来的桶元素的头结点就是要的数据,那就直接获取返回
-
如果桶位不止一个元素,可能就是链表,或者是红黑树
-
如果是红黑树就按照红黑树的获取方式进行获取
-
如果是链表,就遍历结点,找到要的元素
06
remove方法
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// 引用当前hashMap的散列表
Node<K,V>[] tab;
//当前Node的头节点
Node<K,V> p;
// n:表示散列表的数组长度
// index:表示数组下标位置
int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// node: 要查找的元素
// e:当前node的下一个元素
Node<K,V> 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) {
// 第二种情况 p是红黑树
if (p instanceof TreeNode)
// 红黑树查找操作
node = ((TreeNode<K,V>)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不为空,说明按照key查找到需要删除的数据了
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);
// 第二种:桶位头元素即为查找结果,则将该元素的下一个元素放到桶位中
else if (node == p)
tab[index] = node.next;
else
// 因为Node为查找结果,且为p的下一个元素,
// 因此只需要将p的下一个元素指向查找结果node的下一个元素即可
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
remove方法可以分为两个步骤,第一个步骤就是找到要删除的数据,第二个步骤就是进行删除
-
通过hash值找到所在桶的位置,然后进行查找,查找也分三个步骤,先看是不是当前桶位头结点元素,如果不是就看这个桶位是链表还是红黑树,然后不同的方式进行查找
-
查找到后就进行移除操作,具体删除步骤在代码中我已经列的很详细。
06
问题解答
我在文章开头列的几个问题,在文末我进行一一解答,前两个问题在源码分析中已经阐述,来看最后一个问题
为什么链表在jdk1.8之前采用的是头插法,1.8开始采用尾插法
在java8之前,数据插入链表是用的头插法,但是这就可能出现环形链表,死循环的情况
例如在扩容的时候,在扩容前,链表指向是A->B->C
这时候A的下一个指针是指向B的,那么在resize的时候,因为是头插法,新的元素会被放在链表的头部位置
就有可能出现下面这种情况
这就形成了环形链表
从jdk1.8开始,就改为尾插法,这样在扩容的时候会保持元素原本的顺序,就不会出现链表成环的情况。