文章目录
前言
十万个为什么
- 数组长度为什么要是2^n
- jdk8后为什么要调整转为红黑树,不转为二叉搜索树?什么时候转为红黑树
- 为什么是大于8
- 为什么是数组长度大于64的时候
- 链表插入的时候是选择头插还是尾插呢?
- HashMap的扩容时机是什么时候
这些问题你都知道么? 如果你不知道那么只须花你10分钟的时间就够了,认真的看完这篇文章, 这些问题,你也能对答如流.
一、HashMap的起源
我们在学一个东西的时候常常上来就是怎么用, 而忘了关注底层. 而有时候关注了底层时又一头扎进去源码里,无法自拔, 我们常常忘了思考这个东西的起源是什么?为什么要有这个东西?
1.1 为什么要有HashMap?
当我们需要存储一个key-value的数据时,我们会这样写(不同类型的key和value肯定要用泛型啊)
class Node<K,V> {
K key;
V value;
}
然后我们会提供get,set方法,面向接口编程啊, 搞一个接口
class Node<K,V> implements Map.Entry<K,V>{
K key;
V value;
}
1.1.1 总结
我们用Node表示了一个键值对的数据,然后搞了一个接口来抽象一些方法, 实现对键值对数据的查询,新增
1.2 怎么存储Node呢?
我们知道存储数据无外乎数组和链表,
- 数组: 长度固定, 查询快O(1),增删慢,增删时需要把index后面的数据后移,扩容等
- 链表: 长度不固定, 增删快,查询慢O(n)
在实际应用场景中我们会发现查找居多, 所以我们选择数组来存储,即:
Node<K,V>[] table;
1.2.1 怎么实现快速查找呢?
我们的需求是:通过key快速查找value,那么数组怎么查最快呢?当然是通过下标查,于是我们很自然的想到,把Node节点放在key对应的hashCode数组位置不就行了么?这样是实现了快速查找,那么有什么问题么?
- 这样我们的数组长度要很大很大,这样大的连续内存显然是不可能的
于是,我们的需求就应运而生,就是我们需要用一个较小的数组存储很多的数据,怎么搞?
- 我们需要一个key->index的映射,这要怎么搞?
- 我们可以拿key的hashCode对数组长度取余啊,这样就把key分布在固定长度的数组中了啊,
那么这样的算法有什么问题么? - 问题就是同一个index上会有多个数据,这便是hash冲突,那么解决hash冲突有哪些办法呢?(拓展阅读: 解决哈希冲突的常用方法 )
- 我们采用链地址法,即当出现hash冲突的时候,我们以链表的形式存放这些数据,此时数组的每一个位置我们叫桶. 讲到这看似完美的解决了问题,但这样的实现有哪些问题呢?
-
- 取模是一个耗时的操作,怎么优化
-
- 这样的hash算法肯定会造成一个桶上有很多元素,即hash算法不够好,即jdk自带的key.hashCode()不够好,不够散列,至于说为什么不够散列呢?后面会说
- 3 . 链表的长度太长,造成查找效率变低
1.2.2 问题的解决
第一个问题看似很好解决,但其实有可以深究的地方.我们知道<算法导论>中提到,对2的n次幂取余时最不好的操作,因为这样只有低n位有作用, 高位没用了. 最好选择素数, 这样会更加散列, 分布更加均匀.
那为什么jdk还要选择2的n次幂呢?因为我们希望插入时快一点啊, 而对2的n次幂做运算时,只需要运算低n位,这显然比素数要快啊, 但是这样hash冲突会变多, 怎么办呢,这就是后面hash函数要讲的了
当一个数是 2^n 时, 任意整数对2^n取模等效于:
h % 2^n = h & (2^n -1) ,于是乎我们知道,当我们把数组长度设置为2^n时,取模效率最高, 这就是数组长度为什么要是2^n
第三个问题也好解决:
链表的查找复杂度是O(n),而二叉查找树的查找时间复杂度是O(logn),比链表快,于是我们便用红黑树代替链表
等等,有两个问题奥:
- 为什么用红黑树?
这其实就是二叉树种类的区别和用途了,红黑树是一种非完全平衡的二叉搜索树,比较适合做查找. 至于其他树为啥不适合可以自行百度各种二叉树的区别 - 链表长度多大时转为红黑树呢?
这个问题jdk源码中的注释给了我们答案,当链表长度超过8时转为红黑树,为什么? - 首先在链表长度很小时,链表和红黑树的查找效率没有差很多,而且TreeNode所占的内存是比Node大的,所以一开始我们没必要用红黑树
- 链表转红黑树是消耗性能的,我们要尽量减少这种情况的发生,即这个值不能选的太小,即大多数情况,要选择少数情况; 但是也不能太大, 要合理, 于是乎,在jdk的源码注释中写着, 研究表明链表长度大于8的情况是很少的, 这个8是比较合适的,相当于是jdk帮我们做了选择
其他两个问题都与hash算法有关, 下面详细说说
1.2.2 Hash算法
什么叫hash算法?
首先我们来看维基百科对于hash function的定义:
- 散列函数(英语:Hash function)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。
我们知道在Object类中有一个native 的hashCode()
public native int hashCode();
我们可以看出把key映射为int,然后对index取余.此时数组长度为2^n.
OK,至此我们确定了是原始的hashCode对2的n次幂取余,那么这样有什么问题呢?
- 我们会发现,这样的取余操作只与2^n的低n位有关系,高位没有关系(详细的可以看这里),这样一来,则出现hash冲突的概率变大了,即jdk自带的hashCode()并不是那么好了,那么怎么解决呢?
- 现在我们的需求是要让高位也参与到hash算法中,于是乎
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这便是hashMap中的hash算法,即让hashCode想右移16位然后与hashCode做异或,这样的结果低位便同时有了高位和低位的特征,即比以前更加散列,这便解决了问题二
但是到这还是有问题啊, 即使我们算法写的再好,当我向hashMap中存放大量数据时,还是会有很大的概率hash冲突,难道我在put的时候发现链表上超过8了就转红黑树么? 不是的, 而是链表长度大于8且数组长度大于64的时候才会转为红黑树
大于8我们已经解释了,OK,那为什么数组长度要大于64呢? 我是这样理解的(其实是源码告诉我的)
当数组长度不到64时,此时链表长度又达到了8. 此时问题是查找效率低,我们要解决这个问题, 有两种方案:
- 变为红黑树,提高查找效率
- 有没有一种办法让链表长度变小呢? 哎,您还问对了,有啊, 就是把数组长度变大啊, 这样hash冲突就会变少啊(为什么呢?后面告诉你),但是数组长度一直变大也不太好吧, 于是jdk给我们规定了一个值64,
- 当数组长度不到64时, 链表长度又达到了8,便会扩容,
- 当数组长度达到了64, 链表长度达到了8时, 此时再扩容为128占用内存空间太大了, 就不扩容了, 变为红黑树吧
下面贴出源码,这里只贴出部分源码, put的源码分析后续会在后续分析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
else {
//死循环遍历
for (int binCount = 0; ; ++binCount) {
//注意,此时给e赋值为桶中第一个节点的下一个节点
//如果找到了尾结点,就new一个node放在后面
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表的长度达到了8个, 就将链表转换成红黑数以提升查找性能
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果链表上有一个node的key和要put的key一样就break了
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
}
下面是treeifyBin的源码,即红黑树的添加
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//这里就是小于64的时候,先扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
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);
}
}
OK,那什么是扩容机制呢?
二、扩容机制
扩容就是当HashMap中的空间不够了,放不下了我就增加容量了啊,那有的人就问了,怎么会放不下呢, 我红黑树一直搞下去啊, 对此,我只想说, 幼稚!!!查起来太慢了啊
那么HashMap是什么时候扩容的呢?是当达到了数组长度的3/4就扩容么, 还是达到capacity的3/4呢?
下面我们先介绍几个概念
1. 几个概念
先看下HashMap的结构图:
感谢这位老哥的图
约定:
约定数组结构的每一个格格称为桶
约定桶后面存放的每一个数据称为bin, bin这个术语来自于JDK 1.8的HashMap注释
- size: 表示HashMap中存放KV的数量(为链表和树中的KV的总和)。
- capacity: 桶容量(数组长度). HashMap中桶的数量。默认值为16。扩容后是之前的2倍,且容量都是2的幂。
- loadFactor: 装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。
- threshold: 扩容阈值,当size大于threshold时,就会触发扩容.threshold=capacity*loadFactor
2. put(K key, V value)
扩容函数其实就是resize(),但什么时候会调用这个方法,其实在put的源码中很清楚,所以要讲清楚扩容机制就要搞懂put源码,下面我们一起来看源码
2.1 resize()
我们先来看resize(),即是怎么扩容的
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;
}
//如果扩容后没有超过int范围且老数组长度大于等于16,则数组长度扩容为之前的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//这里是如果在构造函数时指定了initialCapacity的话,此时oldThr 不为0,则以改值作为新数组的长度
//例如:Map<String, String> map = new HashMap<>(5);
//该构造方法执行后,threshold既是大于指定值的最小的2的n次幂
else if (oldThr > 0)
newCap = oldThr;//8
else {
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//16*0.75=12
}
//这种情况也是构造方法指定了initialCapacity,会走到这里,计算扩容阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;//8*0.75=6
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//设置扩容阈值
threshold = newThr;
//经过上面的代码我们知道了,如果构造函数时没有指定initialCapacity,则新数组长度为16
//如果指定了initialCapacity,则新数组长度为大于initialCapacity最小的2的n次幂
//初始化数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//如果是构造好map后的第一次put, oldTab 为null,这段代码不走的,直接返回了
//如果是达到扩容阈值了,走的resize(),会走到这里
//这里的核心思想是: 把原map中的元素重新放入新的数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 这里注意, table中存放的只是Node的引用, 这里将oldTab[j]=null只是清除旧表的引用,
//但是真正的node节点还在, 只是现在由e指向它
oldTab[j] = null;
//如果e没有后继节点了就把e放在新数组中,注意这里是和newCap - 1做与操作
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果当前节点e是红黑树节点, 这里就是树的一些操作了,感兴趣的自行阅读吧
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//走到这就是链表的情况了,且链表长度小于8
else {
//这里我们先理解为有两个链表,lo,hi,然后搞了4个指针,分别指向头尾节点
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//do{}while{}的讲解我们单独列出,看下面
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;
}
我们先看下do{}while{}的大致逻辑
do {
next = e.next;
...
} while ((e = next) != null);
这就是循环遍历这个链表,然后我们再来看do块中的if{}else{}
//插入lo链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//插入hi链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
看到这里可能你有点迷糊,别着急, 继续往下看,你会明白的,我保证
最后一段代码
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
这一段的意思是:
如果lo链表非空, 我们就把整个lo链表放到新table的j位置上
如果hi链表非空, 我们就把整个hi链表放到新table的j+oldCap位置上
综上所述: 我们知道,把原来的链表拆分为两个链表hi和lo,然后分别放在新数组的j和j+oldCap的位置上.拆分的标准就是(e.hash & oldCap) == 0, 那么这个表达式到底是怎么得出来的呢?
大家有没有发现, hi和lo,是不是感觉有点熟悉, 就是hight和low的前两个字母啊, 那么也就是说,
用 (e.hash & oldCap) == 0 这个来判断以前的链表元素中是应该放在低位还是高位, OK,坚持,我们快接近答案了, 只要我们搞懂(e.hash & oldCap) == 0是怎么来的,就豁然开朗了,下面我们专门讲解(e.hash & oldCap) == 0
2.1.1 (e.hash & oldCap) == 0
首先我们要明确三点:
- oldCap一定是2的整数次幂, 这里假设是2^m
- newCap是oldCap的两倍, 则会是2^(m+1)
- hash对数组大小取模(n - 1) & hash 其实就是取hash的低m位
例如:
我们假设 oldCap = 16, 即 2^4,
16 - 1 = 15, 二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111
可见除了低4位, 其他位置都是0(简洁起见,高位的0后面就不写了), 则 (16-1) & hash 自然就是取hash值的低4位,我们假设它为 abcd.
以此类推, 当我们将oldCap扩大两倍后, 新的index的位置就变成了 (32-1) & hash, 其实新的index就是取 hash值的低5位. 那么对于同一个Node, 低5位的值无外乎下面两种情况:0abcd 和 1abcd
那么怎么确定这个第4位(我们把最低为称作第0位)的值呢?只要:
hash & 0000 0000 0000 0000 0000 0000 0001 0000
即hash & oldCap
如果hash & oldCap ==0,说明第4位是0,则该节点在新表的下标位置与旧表一致
如果 (e.hash & oldCap) == 1, 说明第4位是1, 那么新节点了位置就是1abcd, 而1abcd = 0abcd + 10000 = 0abcd + oldCap
故虽然数组大小扩大了一倍,但是同一个key在新旧table中对应的index却存在一定联系: 要么一致,要么相差一个 oldCap。
故得出结论:
如果 (e.hash & oldCap) == 0 则该节点在新表的下标位置与旧表一致都为 j
如果 (e.hash & oldCap) == 1 则该节点在新表的下标位置 j + oldCap
真的是妙蛙种子的妙啊,真的牛*
下面我们再以图示的方式看下链表的重放
在这里感谢这位大佬的图和讲解,我的讲解大多搬自他这里
下面我们继续看put(),这个方法很重要
2.2 put(K key, V value)
先上源码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//HashMap的三个构造函数中, 都不会初始Table, 因此第一次put值时,
// table一定是空的, 需要初始化,table的初始化是延迟到put操作中的,resize()已讲过
// 扩容时机1: 第一次put时
//总结:这一步就是初始化map,获取初始化后的数组大小
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果计算出来的下标对应的桶没有值,则new一个node放进去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//走到这就是目标位置桶中已经有东西了
else {
Node<K,V> e; K k;
//注意:p在前一个if块中已经赋值了,p指向数组中index位置的元素
//判断当前要存储的key和桶中存在的key是否相等,可以看出,key相等的条件是
//1. key的hashCode相等
//2. key的 == 为true或者equals为true
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) {
//如果找到了尾结点,就new一个node放在后面
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表的长度达到了8个, 就将链表转换成红黑数以提升查找性能
//下面单独将treeifyBin()
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果链表上有一个node的key和要put的key一样就break了
//也就是说当put的时候,如果发现了要put的key和节点的key一样,就不往下遍历了
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 到这里说明要么待存储的key存在, e保存已经存在的值
// 要么待存储的key不存在, 则已经新建了Node将key值插入, e的值为Null
// 如果待存储的key值已经存在,替换旧值,并返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果onlyIfAbsent为false
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//这个函数只在LinkedHashMap中用到, 这里是空函数
return oldValue;
}
}
//记录map被修改的次数
++modCount;
// 因为又插入了新值, 所以我们得把数组大小加1, 并判断是否需要重新扩容
//扩容时机2: 如果等于阈值,还没扩容,下次put的时候就扩容了
if (++size > threshold)
resize();
//这个函数只在LinkedHashMap中用到, 这里是空函数
afterNodeInsertion(evict);
return null;
}
2.2.1 treeifyBin(Node<K,V>[] tab, int hash)
这是在链表长度达到8个的前提下
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//扩容时机3: 如果数组长度小于64,则不是说转为红黑树, 而是扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
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);
}
}
3 扩容时机
经过上面的讨论,下面的几种情况下会发生扩容
- 第一次put时
- 如果达到阈值,则下次put的时候就扩容了
- 如果链表长度达到8了,但数组长度小于64时,则扩容来减小链表长度,而不是转为红黑树,注意:在扩容完成之后,如果某个index是树,同时现在该树的节点个数又小于等于6个了,则会将该树转为链表。
四、补充
4.1 头插和尾插
写到这,我们就剩一个问题没有解决了,就是在解决hash冲突时, jdk8为什么要把头插改为尾插呢?
- 头插法: 在链表的头部插入元素,并将next指针指向之前的头部
- 尾插法: 新插入的元素在链表的尾部
直接说结论: 因为头插法在多线程环境下扩容, 会出现链表的死循环的情况.而采用尾插法则不会有这个问题
4.2 负载因子0.75
负载因子的含义是:当map中的key和容量(数组长度)的比值达到负载因子时,触发扩容操作.
- 如果当负载因子设置的很大时,那么空间利用率会提高, 但随之而来的是hash冲突的概率会变大,
- 如果负载因子设置的很小, 那么hash冲突的概率会变小,但是会有频繁的扩容操作,而且空间利用率变低,
- 所以,负载因子要定一个空间利用率和hash冲突的折中
那么这个值应该是多少呢? 这个在hashMap的源码注释中告诉了我们:
Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)).
这个意思是说当负载因子设置为0.75时,根据泊松分布, 每个桶的数据量(即链表长度)很少会达到8,这样查找的效率会比较高
总结
这些就是我对HashMap的理解,后续有新的理解后会更新在这篇中,读者有什么疑问亦可写在评论中,我们共同进步!!!