参考:https://www.cnblogs.com/yeya/p/13247070.html
HashMap底层数据结构:数组+单链表+红黑树
HashMap底层数据结构设计的非常巧妙,充分利用了数组随机查询快链表增删快的特点,大大提高了效率,而在JDK1.8引入红黑树的目的是为了解决当发生Hash冲突时元素查找效率随着链表长度的增加而降低的问题,这就是所谓的树化,要想利用红黑树的查找优势就需要付出代价,这个代价就是红黑树为了维持平衡需要的左旋右旋以及变色操作,这些操作同样耗时,而链表为了查找元素遍历整个链表的操作也耗时,当链表中的元素个数少时,插入删除成本高,不宜用红黑树,应该用链表;当链表中的元素个数多时,查询成本高,不宜用链表,应该用红黑树,所以我们需要结合自身需求平衡好两种操作的耗时以此来提高效率,这个平衡点就是我们人为需要设置的。平衡点一:当链表长度大于等于8且底层数组长度大于等于64时才能进行树化操作;平衡点二:当红黑树中的节点数小于等于6时进行红黑树转为链表的退化操作
核心的成员变量
以下所带默认的字样的意思是如果在初始化的时候没有指定该变量的值,那么就按官方给定的值来
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认的数组初始化容量为16static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认的加载因子transient Set<Map.Entry<K,V>> entrySet;
//KV键值对的视图static final int MIN_TREEIFY_CAPACITY = 64;
// 链表最小树化的数组容量,也就是说 数组容量最小为64 这个条件是链表能否进行树化的一个必要条件之一transient Node<K,V>[] table;
// 这个就是底层的数组 元素类型为Nodeint threshold;
//这个就是决定底层数组是否进行扩容的阈值(capacity * load factor)当前容量 * 加载因子static final int TREEIFY_THRESHOLD = 8;
//链表的长度至少为8,它也是链表能否进行树化的一个必要条件之一static final int UNTREEIFY_THRESHOLD = 6;
//从红黑树退化成链表的必要条件,也就是说当一个红黑树的节点数量小于或等于6时就可以退化成链表了
初始化
无参初始化
带参初始化
下面的tableSizeFor(int cap)方法是为了得到一个与输入参数接近的2次幂整数,比如输入9,得到就是16,hashMap用的是懒初始化的方式,也就是说容量初始化是第一次添加元素进行的,查看resize方法可以知道,这时的数组容量就是上面tableSizeFor方法的返回值,这就有个问题了:为什么要保证数组初始化容量是2的次幂?
答:索引的计算方式是(n - 1) & hash
,也就是数组长度减一与上hash值,那么2次幂整数减一之后二进制数低位都是1高位都是0,和hash进行与之后就能保证最后的索引值一定在[0,n-1]之内,所以tableSizeFor方法的作用就是为了保证计算出来的索引不越界
添加元素流程 put
下面的hash函数是为了让高16位也参与下标索引的运算,目的是降低hash冲突的概率,见如下分析:
(n - 1) & hash
这个就是节点对应的下标值,如果两个节点值下标相同也就发生了哈希碰撞了,我们现在讨论数组长度非常小且没有使用hash()方法的情况(即简单的用hashCode()的结果值作为hash,也就是hash=key.hashCode()),如果数组长度非常小,也就是n很小,那么也就是说n在二进制中的高位大部分为0,或者说都是0,这时如果与hash进行&操作的话,hash的高16位就没有参与运算了,最后的索引值其实只用到了hash的低16位,这样做计算出来的索引值显然是没有用hash的全位计算出的索引值丰富的,根据以上分析可知,这个hash()方法的目的是在数组长度很小的情况下,尽量使索引值更加散列(散布均匀),也就降低了hash冲突的概率
/**
* @param hash 键key的hash值
* @param key 要存储的键
* @param value 要存储的值
* @param onlyIfAbsent 这个值决定了是否替换已经存在的值 false是替换(默认) true不替换
* @param evict 是否在创建模式,默认为true表示不在创建模式
* @return previous value 如果插入的键在容器中存在就返回旧的值 否则返回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;
//如果table数组为空就进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
//这里的扩容代替了初始化
n = (tab = resize()).length;
//前面通过Key计算的Hash值并不就是存入数组中的实际位置 实际位置为(n - 1) & hash
//如果该位置上没有节点 那么就直接插入
if ((p = tab[i = (n - 1) & hash]) == null) //p被赋值为数组中的元素 也就是一个链表的首节点
tab[i] = newNode(hash, key, value, null); //从最后一个参数为null就可以看出 这是尾插法
else {//之后的操作就是将当前位置节点的key与要插入的节点的key进行比较
// 如果该位置上有节点
Node<K,V> e;//从后面可以看出 这个e节点保存的是已经存在的key 的键值对
K k; //保存了当前位置节点的键
// 标注A:插入的节点的key与首节点的key相同 就将e赋值 此时e不为null 而且e的key与插入的key相同
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果当前节点是树节点的话 也就是说这时的链表已经转为了红黑树 那么就按红黑树的方式插入节点
else if (p instanceof TreeNode)
//标注B:此时的e不为null 而且e的key与插入的key相同
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//要插入的节点既不存在也不是红黑树类型的 那么就是简单的链表插入了
for (int binCount = 0; ; ++binCount) {//这个循环是为了找到链表中的最后一个节点
if ((e = p.next) == null) { //如果当前位置节点p是链表的最后一个节点 就直接插入到p的后面即可
p.next = newNode(hash, key, value, null);
//如果当前链表的长度超过了8(能否进行树化条件之一) 这里将阈值-1 是因为binCount是从0开始的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//将链表进行树化 从treeifyBin这个方法中可以知道树化的的条件之一是底层数组的长度大于MIN_TREEIFY_CAPACITY=64
treeifyBin(tab, hash);
break; //这时节点已经插入完毕 退出循环即可 标注C:注意这时的e是null
}
//如果插入的值已经存在就直接退出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; //标注D:注意这时的e非null 而且e的key与插入的key相同
p = e;//节点后移 判断下一个节点 p.next.next
}
}
//注意查看上述注释中的标注ABCD 就可以知道当插入的键值对中的键已经在容器中存在时 那么e就是容器中键与插入键相同的键值对
//如果e 不为null 表示在该容器已经存在了要插入的键 而键不能重复 所以值就只能被覆盖
//这里就是在HashMap前后插入相同的键值对时 后插入的键值对的值会覆盖掉前面键值对值 并返回旧的值的缘由
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; //返回null表示插入的键值对在当前容器中不存在
}
经过上述源码分析,HashMap添加元素的大概步骤:
- 检查底层数组是否为空,如果为空就需要初始化
- 根据输入的key结合当前数组的长度计算出该键值对在数组中的位置
(n - 1) & hash
- 判断该位置上有没有元素,如果没有元素就直接插入键值对,此时程序跳到第8 步,否则下一步
- 判断该位置的key与传入的key是否相同 如果相同则进行7 步,否则下一步
- 判断该节点是否是红黑树节点类型,如果是则用红黑树的方式插入数据并进行第7步,否则下一步
- 遍历链表寻找最后一个节点并在此节点后面插入键值对,遍历的过程中还需要判断此时链表是否可以进行树化
- 判断在该容器是否存在相同的key,相同则覆盖value值并结束方法返回旧值,否则下一步
- 操作数加一并判断是否进行扩容,最后返回null
获取元素流程 get
/**
* @param hash 键的hash值
* @param key 键key
* @return 返回值value
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab;
Node<K,V> first, e; //first保存了当前hash值所对应的键值对 也就是首节点
int n; //底层数组的长度
K k;
//如果 当前数组存在且当前hash值所表示的数组下标存在
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
//如果首节点的key等于要查询的key 那么这个首节点就是要查询的节点 返回即可
if (first.hash == hash && ((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 {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//没有找到就返回null
return null;
}
大致思路:在底层数组不为空且有数据的情况下根据传入hash值确定位置,判断该位置是否为空,为空立即返回null,方法结束,否则判断其key是否与传入的key相等,相等就返回此节点,否则判断该节点是数节点还是链表节点,如果是树节点的话就按照红黑树的查找方式查找,否则就遍历链表查找,并返回结果
扩容或者初始化resize
扩容的流程:
整个扩容分为两个部分:1、确定新容量和新阈值;2、将旧数组中的所有元素包括链表复制到新数组中
第一部分:
主要根据旧数组容量来判断:
- 正常扩容的情况(不是第一次添加元素的情况):如果旧容量大于0且不超过最大值的情况下,就将旧容量扩容2倍大小(即新容量=旧容量*2),且在旧容量大于等于16的情况下将旧阈值扩容2倍(即新阈值=旧阈值*2)
- 用有参构造初始化第一次添加元素的情况:如果旧容量等于0并且旧阈值大于0,就将旧阈值赋值给新容量(即新容量=旧阈值),
- 用无参构造初始化第一次添加元素的情况:如果旧容量等于0并且旧阈值也等于0,也就是用无参构造初始化HashMap的情况下,这时的旧容量值被赋值为默认数组长度16(即新容量=16),旧阈值被赋值为默认数组长度16*默认加载因子0.75为12(即新阈值=12)
上述三个判断结束后,一定能够确定新容量的值,但是可能确定不了新阈值的值,也就是新阈值可能还是0,这个时候对新阈值的处理是,新阈值=新容量*加载因子
第二部分:
在经过第一部分后,新容量和新阈值也就确定了,后续的操作就是数组元素复制了,遍历旧数组将其中的内容赋值到新数组中,
在遍历赋值的过程中,有如下判断:
- 如果当前位置没有链表,也就是没有产生哈希冲突,就直接将该元素赋值给新数组中对应的位置即可,这个新位置是
e.hash & (newCap - 1)
,e是旧数组中的Node<K,V>节点 - 如果当前位置的节点为树节点,就执行split方法通过对红黑树进行分割将旧数组转移到新数组,注意该方法中的
untreeify
方法是红黑树退化的时机 - 如果该位置上有链表 则将链表分为高位链表为低位链表,低位链表还是存放在原来的位置 ,高位链表存放的位置是在原数组位置的基础上加上旧容量的位置
final Node<K,V>[] resize() {
//将原始数组赋值到临时数组oldTab
Node<K,V>[] oldTab = table;
//旧数组的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//决定是否进行扩容的阈值 这个阈值在初始化容量时已经确定了 this.threshold = tableSizeFor(initialCapacity);
int oldThr = threshold;
int newCap, newThr = 0;
//如果旧容量大于0
if (oldCap > 0) {
//如果旧容量 超过了最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
//阈值就是Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
//返回旧数组 因为数组的长度已经到达最大值了 不能再扩容了
return oldTab;
}
//新容量=旧容量<<1 即新容量是旧容量的2倍
//这里还判断了旧容量的值是否大于等于默认容量16 是因为有可能初始化HashMap的时候传入的容量小于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
//新阈值等于旧阈值的两倍
newThr = oldThr << 1; // double threshold
}
//在旧容量等于0时 如果旧阈值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
//新容量容量设置为旧阈值
newCap = oldThr;
//在旧容量等于0时 如果旧阈值也等于0
//也就是用无参构造初始化HashMap的情况下 所以可以知道 在用无参构造初始化时 并不是立即创建长度为16的数组,而是要等到在加入第一个元素时执行这个resize方法才会创建
else {
//新容量为默认容量
newCap = DEFAULT_INITIAL_CAPACITY;
//新阈值为默认容量*默认加载因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新阈值等于0 那么新阈值就被重新赋值为新容量*加载因子
//如果新容量大于最大容量 或者新阈值超过了最大容量 那么新阈值就被重新赋值为Integer.MAX_VALUE
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];//这里就初始化了数组 这就是为什么resize方法同时具备了初始化和扩容两份功能的原因
//新旧数组更替
table = newTab; //这时候的table还是空的 没有数据 只有容量大小
//下面就是将旧数组中的数据复制到新数组中
if (oldTab != null) {
//遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果当前位置不为空
if ((e = oldTab[j]) != null) {
//将当前位置赋值为null 目的是为了方便垃圾回收器及时回收 节约内存空间
oldTab[j] = null;
//如果当前位置没有链表 没有发生Hash冲突
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;
}
扩容的时机
时机一:在put方法中判断当前容器中的元素数量是否大于阈值,如果大于阈值就会进行扩容
时机二:在put方法中,发生了哈希碰撞,往链表中添加元素的时候,当链表的长度大于等于8的时候,会进入treeifyBin方法,该方法中会进一步判断树化条件之一 (数组长度是否大于等于64),如果数组长度小于64,则会进行扩容,在此处会有扩容的原因是:如果元素数组长度小于这个值,没有必要去进行结构转换,当一个数组位置上集中了多个键值对,那是因为这些key的hash值和数组长度取模之后结果相同。(并不是因为这些key的hash值相同) 因为hash值相同的概率不高,所以可以通过扩容的方式,来使得最终这些key的hash值在和新的数组长度取模之后,拆分到多个数组位置上
链表转红黑树的时机
在添加数据的时候判断链表的长度是否超过了8,如果是则调用treeifyBin方法,该方法里面会进一步判断底层数组的长度是否超过了64,当这两个条件都满足时就遍历链表将每一个元素通过replacementTreeNode方法转为树节点,这就是树化操作了,树化的具体场所在treeifyBin方法中
为什么会在putVal方法中进行链表转红黑树判断呢?
因为在添加元素的时候可能会产生哈希冲突,也就可能会导致链表长度增加,当链表长度大于8的时候就需要用红黑树来解决查询效率低的问题了
红黑树转链表的时机
在添加数据的时候有时候会需要扩容resize方法,该方法中会判断要复制到新数组的节点的类型是否为树类型,如果是则会执行split方法进行节点分割拆分,在split方法中判断当前节点数是否小于等于6,如果是则就执行untreeify方法进行退化(红黑树转链表),其中的操作与链表转为红黑树类似,遍历红黑树将每个节点通过replacementNode方法转为链表节点,这个untreeify方法就是退化的具体执行场所
为什么会在resize()方法中进行红黑树转链表的判断呢?
因为扩容的目的是为了降低哈希冲突,随着扩容的进行,原数组上因为哈希冲突导致的链表长度会慢慢减小,如果长度小于等于了6的话,就需要转链表了
两种状态变化的时机都是从put方法开始,也就是都在增加元素时才会触发状态的改变,只是具体的执行方法不一样
为什么阈值默认为8?
根据泊松分布,在负载因子0.75(HashMap默认)的情况下,单个hash槽内元素个数为8的概率小于百万分之一,8个元素的hash值相同这种情况是非常少的,也就是链表转为红黑树的概率就很低,因为链表转红黑树是耗费性能的,说到底虽然链表转红黑树是为了提高查询效率,在节点数多时是一个不错的解决方案,但是因为红黑树本身的复杂结构(相对于链表来说),转化的过程和维护平衡就很耗费时间,所以为了尽量避免链表转红黑树的发生就这样设计了
为什么加载因子为0.75?
HashMap中的threshold是HashMap所能容纳键值对的最大值。计算公式为length*LoadFactory。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数也越大,loadFactory越趋近于1,那么数组中存放的数据(entry也就越来越多),数据也就越密集,也就会有更多的链表长度处于更长的数值,我们的查询效率就会越低,当我们添加数据,产生hash冲突的概率也会更高。loadFactory越小,越趋近于0,数组中个存放的数据(entry)也就越少,表现得更加稀疏
0.75是对空间和时间效率的一种平衡选择,如果负载因子小一些比如是0.4,那么初始长度16*0.4=6,数组占满6个空间就进行扩容,很多空间可能元素很少甚至没有元素,会造成大量的空间被浪费,如果负载因子大一些比如是0.9,这样会导致扩容之前查找元素的效率非常低,loadfactory设置为0.75是经过多重计算检验得到的可靠值,可以最大程度的减少rehash的次数,避免过多的性能消耗
为什么底层数组长度要大于64是树化的条件之一
和上面的阈值为8的设计原因一样,都是为了尽可能的避免红黑树的产生,能够通过扩容桶量就不愿意放在树上
为什么当红黑树的节点小于等于6时退化
主要是一个过渡,避免链表和红黑树之间频繁的转换。如果阈值是7的话,删除一个元素红黑树就必须退化为链表,增加一个元素就必须树化,来回不断的转换结构无疑会降低性能,所以阈值才不设置的那么临界。
为什么要转成红黑树而不是其他的树
首先发生结构转变的原因是链表的元素太多导致其查询效率降低(效率降低的原因:需要从头到尾遍历元素),这时要转为的结构需要解决上述问题,那么可以定位在树这个结构上,其中在查询方面占优势的有两种树结构:红黑树和二叉查找树,二叉查找树在特殊情况下会变成一条单链表(特殊情况:后来的元素都比第一个元素大或者小),所以它不适用,而红黑树虽然为了维护现有的平衡需要左旋或者右旋,但是相比遍历一整条链表,这种性能上的消耗还是在承受范围内的,所以最后的选择是红黑树
Put方法的返回值
调用put方法时,如果已经存在一个相同的key, 则返回的是前一个key对应的value,同时该key的新value覆盖旧value,如果是新的一个key,则返回的是null;
HashMap与HashTable的区别
首先,Hashmap和hashtable他们的父类不一样,一个是abstractmap,一个是dictionary,因为在hashtable中所有的方法都加了内置锁synchronized,所以说在hashtable中所有的方法都是同步方法,因此HashTable是线程安全的,但hashmap是线程不安全的,在hashtable中,我们计算哈希值的方法是不一样的,hashtable计算哈希值比较简单,它直接调用hashCode()方法,但hashmap中就是通过取哈希值并通过高位向右移并与自身进行异或运算得到的一个哈希值。此外,他们在扩容时扩容的量不一样,hashmap扩容是为原来两倍,但hashtable扩容为原来的2n+1;hashtable在源码中是在构造方法里进行数组的初始化,它的初始化容量为11,在hashmap里面,他是第一次调用put方法的时候会初始化数组,也就是懒加载,且它的初始化容量是16,而且要求hashmap的容量必须是2的次幂以保证它的计算下标效率,在hashmap里面计算下标是通过容量-1 与 上我们的哈希值 ((n - 1) & hash
),但在hashtable中是直接将hashCode与数组长度进行取余计算下标值((hash & 0x7FFFFFFF) % tab.length
),HashMap和Hashtable 对于Hash冲突的解决方案都是链地址法,链表是单向链表。HashTable中key和value都不能为null,否则抛出空指针异常,HashMap是支持null键和null值的
HashMap1.7与1.8的区别
- 底层数据结构不一样,JDK1.7的HashMap的底层结构是数组+链表,JDK1.8的HashMap的底层结构是数组+单链表+红黑树,引入红黑树的目的是为了解决在Hash碰撞比较激烈的情况下,链表长度过长导致查询效率降低的问题
- 节点结构不一样:JDK1.7中节点是Entry对象,该对象中的hash值是可变的,因为涉及到rehash方法,JDK1.8中节点是Node对象(Entry与Node都实现了Map.Entry<K,V>接口,所以这里不是区别),区别在于Node对象中的hash是被final修饰的,是常量不可变。还有一个区别在于JDK1.8中因为引入了红黑树,所以它也引入了新节点TreeNode
- Hash算法不一样:JDK1.8中计算出来的结果只可能是一个,所以hash值设置为final修饰。1.7会先判断这Object是否是String,如果是,因为String存在字符串常量池的问题,可能会导致这个键的hash值唯一,所以为了尽量避免Hash碰撞,就不采用String复写的hashcode方法。
- 扩容时,链表节点插入方式不一样:JDK1.7中的 HashMap 在扩容时和添加时都是使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题
- 扩容和插入的相对顺序不一样:在JDK1.7的时候是先扩容后插入的,这样就会导致无论这一次插入是否发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容,但是在1.8的时候是先插入再扩容的,这样做的好处就是减少了这一次无效的扩容,原因就是如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容,但是在1.7的时候就会急造成扩容