HashMap是基于hash表的实现
关于hash表,也可以康康我的另一篇文章嗷~简单理解hash表
底层存储数据使用的是数组,其中数组中的每个元素有个特殊的叫法:Entry(jdk1.7及以前),1.8之后叫Node
HashMap的初始化大小
HashMap map = new HashMap();
就这样我们创建好了一个HashMap,接下来我们看看new之后发生了什么
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- loadFactor : 负载因子
- DEFAULT_LOAD_FACTOR : 默认负载因子,看源码知道是0.75
当你新建一个HashMap的时候,人家就是简单的去初始化一个负载因子,不过我们这里想知道的是底层数组默认是多少呢,没有得到我们的答案,我们继续看源码
想一下之前ArrayList的初始化大小,是不是在add的时候才创建默认数组,这里会不会也一样,那我们看看HashMap的添加元素的方法,这里是put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
注意这里有两个方法:
- putVal()
- hash()
这里需要再明确下,这是我们往HashMap中添加第一个元素的时候,也就是第一次调用这个put方法,可以猜想,现在数据已经过来了,底层是不是要做存储操作,那肯定要弄个数组出来。
我们来康康这个hash方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
哈希表的数据存储有个很明显的特点,就是根据你的key使用哈希算法计算得出一个下标值,而这里的hash就是根据key得到一个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;
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;
}
代码又多又晕,源码果然是折磨人,我们逐步的来瞅瞅
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
Q:这个table是什么呢?
A:HashMap底层数组——Node数组
transient Node<K,V>[] table;
这就是HashMap底层的那个数组,之前说了jdk1.8中数组中的每个元素叫做Node(也就是开头提到的),所以这就是个Node数组。
那么上面那段代码啥意思呢
其实就是我们第一次往HashMap中添加数据的时候,这个Node数组肯定是null,还没创建呢,所以这里会去执行resize这个方法。
resize方法
resize方法的主要作用就是初始化和增加表的大小,说白了就是第一次给你初始化一个Node数组,其他需要扩容的时候给你扩容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 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
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY; /关注代码1
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]; ///关注代码2
table = newTab;
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;
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;
}
这个代码看着是不是也很绝望,哎
同样,我们关注一些重点代码
newCap = DEFAULT_INITIAL_CAPACITY;
有这么一个赋值操作,DEFAULT_INITIAL_CAPACITY字面意思理解就是初始化容量啊,是多少呢?
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
这里是个移位运算,就是16,现在已经确定具体的默认容量是16了,那具体在哪创建默认的Node数组呢?继续往下看源码,有这么一句
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
到这里我们发现,第一次使用HashMap添加数据的时候底层会创建一个长度为16的默认Node数组,那么为什么是16呢?
想搞明白为啥是16不是其他的,那首先要知道为啥HashMap的容量要是2的整数次幂?
我们先来看看这个16是怎么来的
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
这里使用了位运算,为啥不直接16呢?这里主要是位运算的性能好,为啥位运算性能就好,那是因为位运算人家直接操作内存,不需要进行进制转换,要知道计算机可是以二进制的形式做数据存储啊。
对于HashMap而言,存放的是键值对,所以做数据添加操作的时候会根据你传入的key值做hash运算,从而得到一个哈希值,也就是以这个哈希值来确定你的这个value值应该存放在底层Node数组的哪个位置。
那么这里一定会出现的问题就是,不同的key会被计算得出同一个位置,那么这样就冲突啦,位置已经被占了,那么怎么办?
首先冲突了,我们要想办法看看后来的数据应该放在哪里,给它找个新位置,这是常规方法,除此之外,我们也可以聚焦到hash算法这块,尽量减少冲突,让得到的下标值能够均匀分布。
好了,以上巴拉巴拉说一些理念,下面我们看看源码中是怎么计算下标值的:
i = (n - 1) & hash
这是在源码中有这么一段,它就是计算我们上面说的下标值的,这里的n就是数组长度,默认的就是16,这个hash就是这里得到的值:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
继续看它:
i = (n - 1) & hash
这里是做位与运算,接着我们还需要先搞明白一个问题,为什么要进行取模运算和位运算?
要知道,我们最终是根据key通过哈希算法得到下标值,这个是怎么得到的呢?通常做法就是拿到key的hashcode然后与数组的容量做取模运算,为啥要做取模运算呢?
比如这里默认是一个长度为16的Node数组,我们现在要根据传进来的key计算一个下标值出来然后把value放入到正确的位置,想一下,我们用key的hashcode与数组长度做取模运算,得到的下标值是不是一定在数组的长度范围之内,也就是得到的下标值不会出现越界的情况。
明白了这点,我们再来看
i = (n - 1) & hash
这里就是计算下标的,为啥不是取模运算而是位与运算呢?使用位与运算的一方面原因就是它的性能比较好,另外一点就是这里有这么一个等式:
(n - 1) & hash = hash % n
总结起来就是使用位与运算可以实现和取模运算相同的效果,而且位与运算性能更高
为什么要减一作位运算?
理解了这个问题,我们就快接近为什么容量是2的整数次幂的答案了,根据上面说的,这里的n-1是为了实现与取模运算相同的效果,除此之外还有很重要的原因在里面。
在此之前,我们需要看看什么是位与运算
比如拿5和3做位与运算,也就是5 & 3 = 1(操作的是二进制),怎么来的呢?
5转换为二进制:0000 0000 0000 0000 0000 0000 0000 0101
3转换为二进制:0000 0000 0000 0000 0000 0000 0000 0011
1转换为二进制:0000 0000 0000 0000 0000 0000 0000 0001
所以,位与运算的操作就是:第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n位也为1,否则为0
我们继续回到之前的问题,为什么做减一操作以及容量为啥是2的整数次幂,为什么呢
2的整数次幂减一得到的数非常特殊,有啥特殊呢,就是2的整数次幂得到的结果的二进制,如果某位上是1的话,那么2的整数次幂减一的结果的二进制,之前为1的后面全是1
比如说
我们先看2的整数次幂,有2,4,8,16,32等等,16的二进制是:10000,16减1得15,15的二进制是:1111
16转换为二进制:0000 0000 0000 0000 0000 0000 0001 0000
15转换为二进制:0000 0000 0000 0000 0000 0000 0000 1111
然后我们再看计算下标的公式
(n - 1) & hash = n % hash
n是容量,它是2的整数次幂,然后与得到的hash值做位于运算,因为n是2的整数次幂,减一之后的二进制最后几位都是1,再根据位与运算的特性,与hash位与之后,得到的结果是不是可能是0也可能是1,也就是说最终的结果取决于hash的值,如此一来,只要输入的hashcode值本身是均匀分布的,那么hash算法得到的结果就是均匀的。那冲突的几率就减少啦!
而如果容量不是2的整数次幂的话,就没有上述说的那个特性,这样冲突的概率就会增大,所以就解释了为什么容量是2的整数次幂了
那为啥是16呢?理论上是2的整数次幂都行,但是如果是2,4或者8有点小,添加不了多少数据就会扩容,也就是会频繁扩容,这样影响性能,32或者更大的话那不就浪费空间了嘛,所以,16就作为一个非常合适的经验值保留了下来!
哈希冲突的解决方法
在添加数据的时候尽管为实现下标值的均匀分布做了很多努力,但是势必还是会存在冲突的情况,那么该怎么解决冲突呢?
- 开放寻址法
- 拉链法
为什么使用尾插法
Java8之前是头插法,啥意思嘞,就是放在之前Node的前面,为啥要这样,这是之前开发者觉得后面插入的数据会先用到,因为要使用这些Node是要遍历这个链表,在前面的遍历的会更快。但是在Java8及之后都使用尾插法了,就是放到后面。
这里主要是一个链表成环的问题,使用头插法是不是会改变链表的顺序,你后来的就应该在后面嘛,如果扩容的话,由于原本链表顺序有所改变,扩容之后重新hash,可能导致的情况就是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
这样的话在多线程操作下就会出现死循环,而使用尾插法,在相同的前提下就不会出现这样的问题,因为扩容前后链表顺序是不变的,他们之间的引用关系也是不变的。
关于扩容
我们知道第一次使用HashMap是创建一个默认长度为16的底层Node数组,如果满了怎么办,那就需要进行扩容了,也就是之前谈及的resize方法,这个方法主要就是初始化和增加表的大小,关于扩容要知道这两个概念:
- Capacity:HashMap当前长度。
- LoadFactor:负载因子,默认值0.75f。
比如HashMap的容量是100,负载因子是0.75,乘以100就是75,所以当你增加第76个的时候就需要扩容了
扩容的步骤:
首先是创建一个新的数组,容量是原来的2倍,为啥是2倍,想一想为啥容量是2的整数次幂,这里扩容为原来的2倍不正好符合这个规则嘛。
然后会经过重新hash,把原来的数据放到新的数组上,至于为啥要重新hash,你容量变了,相应的hash算法规则也就变了,得到的结果自然不一样了。
参考了两篇文章:
https://blog.youkuaiyun.com/adam_swx/article/details/98945132
https://ithuangqing.blog.youkuaiyun.com/article/details/103847137
愚人节快乐~