前言
在说HashMap之前,我们先说一说hash冲突。当数据通过hash算法求得hash值的时候,是不可避免的出现相同的hash值,这也叫做hash冲突。通常我们会采用4种方式去应对hash冲突的情况。
- 开放地址法: 当出现冲突的时候,用这个hash值+增量序列然后对散列表长度取模得到地址,这个增量序列通常有三种方式获取。第一种就是线性探测 ,当冲突之后,往后一个一个找,直到找到空位。第二种是二次探测 ,也就是反复横跳着找,比如1,-1,2,-2,3,-3。第三种就是伪随机法 取个伪随机数。
- 链地址法: HashMap采用的就是这种方式,当冲突的时候,所有hash冲突的数据挂在一条链路上,处理冲突很简单,插入也快,但是查找效率不高。
- 公共溢出区: 就是字面意思,所有冲突的单独放一起。
- 再hash法: 也是字面意思,就是不断的hash,直到没有冲突。
HashMap的简介
HashMap是基于映射(键值对)处理的数据数据结构。是程序员使用的频率最高的数据结构之一,在jdk1.8之后引入红黑树,优化底层数据结构。
传统的数据的数据结构是在内存中申请一段连续的内存空间,查询的时间复杂度为O(1),而插入和删除是O(n),这是因为伴随着数据的移动。
在Java7中数组+链表的形式,一旦链表过长那么会很影响put和get的效率,所以在Java8之后引入了红黑树+链表来代替链表。
为什么不一直使用链表?
我们是很清楚链表的弊端的,就是查询的效率很低,是O(n),当数据量很大的时候是不能接受这样的查询效率的。在JDK1.8之前它的结构大概是这样的
所以会一直花费心思在扰动函数上,让hash的更加分散。
为什么选择红黑树?
我们通过比较几种树的特性来说为什么选择了红黑树。
二叉查找树
我们最常见的就是二叉查找树,它的特点就是左节点永远都比它的父节点小,右节点一定比它的父节点大,比如下图
但是它有个很大的问题,就是容易瘸腿,比如下面
当瘸腿的时候,你会发现这和链表没啥区别了。然后就出现了下面这个树。
平衡二叉树
这个树的特性就是每一个节点的左子树和右子树的高度差至多等于1,如果大于1就需要进行左旋或者右旋,这样就保证了它的查询的时间复杂度始终保持在O(LogN)。如下图
虽然查询满足了我们的需求,但是它的问题是在插入和删除的时候维护树上面复杂很多,需要频繁的调整树结构来保证查询效率,所以就引申出了红黑树
红黑树
相对于平衡二叉树,它的特点如下
- 根节点是黑色的。
- 所有的叶子节点都是黑色的,并且不存数据。
- 任何相邻的节点不能同时为红色,红色节点是被黑色节点隔开的。
- 每个节点到达它可达的叶子节点的路径,都经过相同数目的黑色节点。
主要优点就是:它不追求绝对的平衡,插入最多两次旋转,删除最多三次旋转。
具体的实现我会单独的放到我数据结构专栏,太复杂了。。。
HashMap什么时机会从链表转为红黑树?什么时候再转回来呢?
在上面我说了,JDK1.8之后是链表+红黑树来替代的链表,组合的意思就是代表着这两种数据结构是需要切换的,这个时机就是链表长度到达8的时候进行切换,当然这个只是条件之一,还有一个条件是想转换为红黑树,table中,也就是hash表的横向容量必须达到64,否则就会优先resize
hash表。等讲完resize的方法的时候再总结一下HashMap中的存储数据的结构。所以就引出一些疑问。
为什么不在hash冲突的时候直接就使用红黑树呢?
因为红黑树的空间是链表的两倍,虽然能提升查询效率,但是插入的速度慢了很多,还涉及到平衡树的时候的各种旋转变色,所以链表其实是起到一个缓冲的作用。
为什么要到达8的时候转为红黑树呢?
原文注释上说,当hashCode遵循泊松分布的时候,因为hash冲突造成桶的的链表长度等于8的概率只有0.00000006。官方认为这个概率足够低,当需要红黑树介入的时候,你可以想象一下你的数据会是什么样的。
什么时候从红黑树转为链表呢?
当红黑树节点小于6的时候就会转回来,为什么是6呢,首先不能是8,因为如果是8的话,你会面临着频繁的链表与红黑树的切换,就让我想起了那句台词,我进来了,我出来了,我又进来了,我又出来了。。。。而选择6不选择7是因为,官方经过大量的验证,发现,当红黑树节点小于6的时候,它的优势就不那么明显了,不足以抵消维护红黑树的开销,之前听过一个大哥这么说的,经过工业严谨的实验证明,6在绝大多数的情况下表现良好。
所以我们可以看出,作者在链表转为红黑树这里考虑的非常多,就是要让两种结构达到一个均衡点,过于草率的进行结构转换的话,其实非常的影响效率。
从源码入手解析HashMap工作原理
接下来我会从源码开始一步一步引申出对HashMap的一些疑问。 我们先看一下HashMap中比较重要的一些属性,我的注释会解释相关含义
HashMap中的重要属性介绍,以及一些疑问
java
//默认的初始容量,1向左移动4位10000=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子,0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当桶上的节点数大于等于这个也就是8的时候会转为红黑树的条件之一,上面有提到
static final int TREEIFY_THRESHOLD = 8;
//当红黑树节点小于等于这个值的时候会从红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;
//这个是链表转红黑树的第二个条件,必须table的容量达到这个数才可以,否则会优先resize hash表
static final int MIN_TREEIFY_CAPACITY = 64;
//存放元素的数组,k就是元素的hash值,v是这个hash值对应的桶,而且长度总是2的幂次倍
transient Node<K,V>[] table;
//存放具体元素的集
transient Set<Map.Entry<K,V>> entrySet;
//存放元素的个数
transient int size;
//每次更改Map结构的计数器,单独调用putVal的时候也会增加,不是代表的扩容的次数
transient int modCount;
//阈值,当实际大小超过这个的时候会进行扩容table,等于容量*负载因子
int threshold;
//负载因子
final float loadFactor;
从上面的解释中,我们大概清楚了HashMap的主要属性,而后面所有的Map操作都和这些属性相关。先解释一些疑问。
为什么容量要设置成2的幂次倍,怎么能保证呢?
在说这个问题之前,我们先看一下HashMap中是如何计算hash的,毕竟这个用作确定位置。
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
看三元表达式的后半部分就可以了。(h = key.hashCode()) ^ (h >>> 16)
。用key的hashCode去异或hashcode向右移16位。接着提出几点疑问
为什么要选择异或运算,而不是与运算和或运算?
与运算的方式是同为1则为1,否则为0。或运算是有1则为1,否则为0。异或是不同为1,相同为0。这样一对比其实就能发现与运算的结构更向0靠拢,或运算的结果更向1靠拢,而异或运算更加平均。公平起见,选异或。
为什么要让hashcode异或hashcode右移16位?
首先我们要知道的是hashcode是int类型,所以占4个字节32位,hash方法可以认为是对key本身的hashcode进行二次加工,这其实和HashMap计算key的位置的方式有关系的,确定位置在putVal
的方法中,为了不搞乱,我单独把它拿出来了,如下,看一下它计算位置的方式i = (n - 1) & hash
,n就是hash表的长度,用长度-1然后去&hash,看着挺难懂的,其实它就等于hash%n。当然这个前提是n得是2的幂次方。因为基于&的方式去计算,要快于十进制%的计算方式很多。所以这也解释了为什么容量要设置成2的幂次倍,就是采用取模的方式取址,还要提升计算速度!
再回来说为什么要右移16位。举个例子:你的hashcode是1111110001然后&1111,等于=0001,然后下一个hashcode和上一个只是发生了高位的变化,比如1100110001然后&1111,结果其实还是0001,那就会导致计算出来的地址都一样,类似这种情况那大家不都在一个桶里了吗,当长度较低的时候,只有hashcode的低16位参与运算了,所以没有选择用hash直接计算地址,向右移动16位相当于让高位下来了,然后再去异或hashcode,相当于高低位结合了,也就提升了hash值的散列程度。
为什么负载因子要设置成0.75?为什么不设置成1呢?
设置成1的话就代表着,只有table装满了的时候才会去扩容,这时候所有键的hash都需要重新计算,这比较损耗性能,同时,这也会导致冲突的hash比较多。所以既不能频繁的扩容,也不能太晚的扩容,所以0.75是一个很均衡的值,而且0.75也就是四分之三,用table的长度去乘这个负载因子得出来的数就是扩容的阈值,而0.75刚好使得这个阈值是一个整数,因为上面我们提到的,容量是2的幂次倍。
那么说完为什么容量要设置成2的幂次倍了,我们再说一下它是怎么保证的
其实就是和构造方法相关了,在这里我就直接写了,对后面看构造函数的源码也是有帮助的,它的构造方法中有个非常关键的方法就是tableSizeFor
,这个方法就是重新计算容量的,即便你重新传了初始容量,也要计算得出真实容量,我们看一下源码的实现
java
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;
}
整个过程就是为了找到传入容量最近的大于它的一个2次幂的值,下面是我根据上面的算法写的几个例子
从例子中就可以很清楚的看出来为什么要传入的容量-1去计算。
接下来,我接着走源码,看看构造方法是怎么样的逻辑
HashMap的构造方法的工作流程解析
它的构造方法有四个,我这里只解析其中一个,如下
java
//参数位Map的构造函数,看过这个的解析,那么,其他三个一看就明白了
public HashMap(Map<? extends K, ? extends V> m) {
//先将负载因子赋值为默认的0.75
this.loadFactor = DEFAULT_LOAD_FACTOR;
//调用这个方法创建HashMap
putMapEntries(m, false);
}
putMapEntries解析
继续看putMapEntries
方法
java
//这个方法不是只有构造方法调用,比如putAll也会调用
//所以里面就包含了很多判断阈值,或者是否为空的情况
//evict这个参数,在putVal时候,LinkedHashMap会用,这里不说它
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//记载一下传入map的元素个数
int s = m.size();
//如果传入的map有数据,就需要把这些数据都弄到新Map中
if (s > 0) {
//看看hash表有没有初始化,如果没有初始化走下面这个if
if (table == null) { // pre-size
//这个就是计算容量的,我们如果自己设置容量可以用这个计算方法
//长度除负载因子在+1就是新Map的容量
float ft = ((float)s / loadFactor) + 1.0F;
//如果新容量小于最大容量,就用,否则就用最大容量
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//但是这个容量不能直接用,要先判断是否到达了扩容的阈值
//当然了调用构造方法的话阈值就是默认的0
if (t > threshold)
//然后把容量传进去,计算一个阈值
threshold = tableSizeFor(t);
}
//这个或者其实就包含了table不等于空的情况,因为table不为空的时候
//阈值一定是计算出来了,如果数据的个数大于了阈值,那么就扩容
else if (s > threshold)
//扩容方法
resize();
//循环放元素
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
//放元素的方法
putVal(hash(key), key, value, false, evict);
}
}
}
tableSizeFor
方法上面说过了,接下来说resize
,和putVal
方法。
resize,扩容方法解析,看注释
java
//扩容方法,这个方法也不仅仅构造方法的时候使用,所以里面包含了很多判断
//我们先总结一下上一个方法已经得到了什么,这个方法在上面的方法中,只有参数的map不为空的情况下才会调用
//如果你传入的map为空,这个方法会通过你第一次调用Put方法的时候计算出来,后面说put方法的时候说
//这里我们假设传入的map不为空,那么就会计算出来的阈值是大于0的。
//而且传入参数的size大于阈值,所以调用resize扩容
final Node<K,V>[] resize() {
//记录一下当前table,称之为老table
Node<K,V>[] oldTab = table;
//如果老table为null 那么老table的容量就是0,否则就是老table的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//记录一下当前阈值,称之为老阈值,然后基于方法的注解,当前解析情况的阈值是大于0的
//(如果上一个方法的参数是个空map,那么这个阈值就是0了
//得等到第一次put元素的时候,阈值才会发生变化)
int oldThr = threshold;
//初始化新容量和新阈值都是0
int newCap, newThr = 0;
//如果老容量大于0,当然这个情况不是构造方法出现的,而是针对已初始化的map进行扩容。
if (oldCap > 0) {
//如果老容量大于最大容量了,那么阈值就直接给一个最大值,同时也不需要扩容了,直接返回就行了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果老容量向左移动一位,也就是扩大一倍之后小于最大容量,并且老容量大于等于默认初始容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新容量和新阈值同比增长为原来的2倍(阈值= 容量*0.75)所以乘法就是同比
newThr = oldThr << 1; // double threshold
}
//如果老容量=0 并且,老的阈值大于0的情况下,这也就符合我们上面的方法注释说的,传入非空的Map
else if (oldThr > 0) // initial capacity was placed in threshold
//新容量取老的阈值,而这个值是什么呢,上面方法计算出来的,也就是2的幂次倍
newCap = oldThr;
else {
//如果老容量=0并且,老阈值也等于0,那么就好办了,全都给默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//这个时候发现个问题,新容量已经计算出来了,但是新阈值在上面的else if里面没有赋值
//如果新阈值还是=0的话
if (newThr == 0) {
//那么就用常规手段,新容量*负载因子计算出来
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//同时修改阈值
threshold = newThr;
//-------------------------------------------------------
//到这里扩容的第一个阶段已经完成,新容量和新阈值都计算出来了
//-------------------------------------------------------
//直接根据新容量创建一个新的table出来
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//然后将新table赋值给table,那么到这里要返回的HashMap的table,和阈值都有了
table = newTab;
//如果老的table不为空,那么就需要将元素从老的table转移到新的table的过程
//否则就直接返回新table就行了
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果老的数组位置的元素不为null,那么先用e记录一下
if ((e = oldTab[j]) != null) {
//然后赋值为null,便于GC
oldTab[j] = null;
//如果节点没有后续元素,那么直接放到数组中
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果节点是二叉树结构
else if (e instanceof TreeNode)
//这个方法就会进行切割,主要功能就是切割成两个链表,也就是红黑树的
//低位区和高位区,然后用低位区的长度去判定,如果小于等于6将会去树化
//也就是红黑数转为数组+链表,否则就保持树结构,继续放值。后面仔细分析
((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循环的作用就是将原来的链表,拆成两个,一个链表存储hash结果不变的数据
//另一个存储hash发生变化的数据
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;
}
这里我们详细解释两个问题
扩容方法中怎么计算的扩容之后的索引地址
我们看代码中我留下的注释,需要解释这样一行代码(e.hash & oldCap) == 0
,用这个来判定索引是否发生了变化。我们举个例子来说明。当前容量=8,hash(a) = 100;那么我a放进去的index = 100 &(8-1) = 4。好了,现在我们扩容了,当前容量=16 index = 100 &(16-1) = 4。而此时。100 &8 =0。所以就说明了一种情况,当元素的hash&原容量=0的时候,当它扩容之后,计算出来的index都是相等的!
扩容方法中如果索引发生变化,为什么新索引地址 =原地址+原数组长度呢?
我们用土办法想,原本计算就是等价于hash%长度,那长度扩大二倍,对于取模运算来说新地址就是要加上原来的长度。举个例子,hash值=95,取模8 = 7,然后扩容,长度=16,95%16 = 15 ->7+8。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);这个方法做了什么
直接看我对源代码的解释
java
//这个切割方法主要作用就是决定着你扩容之后是保持树结构,还是去树话,退成数组+链表
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
//定义低位区头尾和高位区头尾,和长度计数器,低位区代表索引不变的链表
//高位区代表索引改变的数据链表,下面是它的数据加载过程
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
//循环这棵红黑树,但是一直往下走但是是以单链表的形式走的,毕竟树是left和right形式遍历
//而红黑树是从链表转换来的,所以保留了原来的next,和prev的属性
//这样走能更加直接的检查单链表长度
for (TreeNode<K,V> e = b, next; e != null; e = next) {
//把e的next给next去遍历
next = (TreeNode<K,V>)e.next;
//e的next赋值null,方便gc
e.next = null;
//这个我们见过,就是来判定扩容之后索引是否发生改变的,=0代表没有变化
if ((e.hash & bit) == 0) {
//如果低位区尾=null 那e就作为低位区的头节点
if ((e.prev = loTail) == null)
loHead = e;
//否则就一直挂载尾部
else
loTail.next = e;
loTail = e;
//然后低位区计数器+1
++lc;
}
//否则就是要移动走的,新索引=原索引+原数组长度,挂载高位区的链表上,逻辑和上面类似
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
//如果低位区不等于空
if (loHead != null) {
//同时低位区的链表长度小于等于6,那么就去树化,变成链表放在数组的这个索引位置上
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
//否则就维持树化,并且重新构建红黑树
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
//高位区类似低位区的逻辑
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
//在这里总结一下,它用两个链表存储了两种情况,一种是扩容之后索引不变
//一种是变化的,那么不管哪个链表长度大于6其实都是需要保持树化的。
}
所以我们推测几种情况。
- 低位区链表长度小于等于6,高位区小于等于6------低位区从红黑树转为链表,高位区从红黑树转为链表
- 低位区链表长度小于等于6,高位区大于6 --------低位区从红黑树转为链表,高位区维持树状
- 低位区链表长度大于6,高位区小于等于6 --------低位区维持树状,高位区从红黑树转为链表
- 低位区链表长度大于6,高位区链表长度大于6------低位区维持树状,高位区维持树状。
所以这里很清楚的就知道了HashMap中链表和红黑树可能同时存在的,8进化红黑树,6退化红黑树,这是经过很严格的科学计算的。同时用链表和红黑树来保证性能,nb!
数组+链表转换为红黑树的时候,table还是存在的,桶的概念也存在,当存储结构是红黑树的时候,会将所有元素构建成一颗红黑树,而元素在不在map中还是根据桶来判定的,如果计算的索引值在table中不为空,说明可能存在的,所以table是非常重要的,而不是不要table了直接去树中查找。而且table中每个格会存储这个桶对应的节点,这个节点可能是红黑树的任意节点,也可能是链表,所以在红黑树的查找方法中需要判定是不是头节点,如果不是的话就需要循环到头节点然后查找。
HashMap get方法解析
直接上代码
java
//这个没啥说的
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
java
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//当table不为空并且table长度大于0 同时元素的hash值存在于table表中的时候去查找
//否则直接返回空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果第一个节点的hash就命中了,并且key相等,就直接返回,否则就继续查找
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 {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
HashMap putVal方法解析
java
//参数说明,前三个不说了,onlyIfAbsent为false代表如果数据存在
//改变数据的value,evict代表table是否在创建模式
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为空,那么就初始化table,然后用n记录当前table容量
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果计算出来的索引地址的数据为null,就将插入的值直接放在当前位置
//作为这个桶的头节点了
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//下面就是Hash冲突的逻辑了
Node<K,V> e; K k;
//下面的逻辑就是找合适的节点
//如果hash相等同时key相等,那要处理的节点就找到了,直接给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果是红黑树结构,调用红黑树的put节点方法,后面细说
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);
//这个就是判断链表长度的,为什么TREEIFY_THRESHOLD - 1也就是8-1=7
//是因为binCount从0开始的,如果链表长度大于等于8
//那么就转换为红黑树,然后跳出循环
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;
//这个代码很精炼,在上一个判断e = p.next,e就是p的下一个节点
//然后p=e。其实就是p= p.next
//既在循环中找e,还能让链表循环下去
p = e;
}
}
//如果e不等于null,那就是根据onlyIfAbsent决定是否覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//回调方法,HashMap是空实现
afterNodeAccess(e);
return oldValue;
}
}
//结构变化+1
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在看一下树化的方法
java
//树化方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果table为空或者长度小于64 那么就优先扩容,不树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//树化逻辑 e去记载插入数据位置的节点,也就是头节点
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 = tl.next,直到e为null,循环结束
tl = p;
} while ((e = e.next) != null);
//如果头节点不为空,那么就调用真正的树化方法,属于红黑树的范围了
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
HashMap remove方法解析
java
//参数解释,前三个不说了;matchValue为true的时候代表只有value相等的时候删除
//movable如果为false当删除的时候不移动其他节点
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//依旧是同样味道的判断,确定删除的key存在map中
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//如果hash刚好等于table中存储的节点的hash,那就直接给node
//记住这个,这个是要删除的key正好是头节点的情况
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//如果p有子节点就往下走,否则就是空了
else if ((e = p.next) != null) {
//红黑树的情况,去树中查找节点
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是node的父节点
p = e;
} while ((e = e.next) != null);
}
}
//如果node不为空加上matchValue的一些校验的情况下
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);
//如果node == p说明就是头节点的情况,直接把node的next给到索引位置就醒了
else if (node == p)
tab[index] = node.next;
//不等于,那就不是头节点,那么p将会是node的父节点
//然后node的next给到p的next代替node,就删除node了
else
p.next = node.next;
//计数器和数量计算
++modCount;
--size;
//HashMap的空实现
afterNodeRemoval(node);
return node;
}
}
return null;
}
至此HashMap中的我们常见的和一些比较重要的方法就全部解析完了。这里没有解析到红黑树之后进行的过程,到那个地方就不是HashMap的范畴了,而是数据结构的知识层面了。上面也说了很多很多对于它算法的一些讲解和我自己的理解,也手推了很多过程能帮助更好的理解。而从上面的方法分析过程来看,很多的地方都是很相似的,比如判断数据是否存在,然后对链表的操作,以及什么时候扩容,怎么扩容,什么时候从链表转树,什么时候转回来,怎么计算hash,为什么计算,怎么计算数据在hash表中的索引,扩容之后索引怎么计算等等等等。写的也比较多,大家慢慢看,带着问题去看,去多思考多理解,会有很大的帮助。下面我再说一些扩展层面的知识吧。
扩展知识---HashMap与HashTable
我直接上一个表来看一下二者之间的差别
集合 | HashMap | HashTable |
---|---|---|
线程安全 | 否 | 是,基于方法锁 |
是否允许空值 | k,v都允许 | k,v都不允许 |
默认初始容量 | 16 | 11 |
默认负载因子 | 0.75 | 0.75 |
扩容机制 | 原来的2倍 | 原来的2倍+1 |
是否支持fail-fast*(快速失效,比如循环时删除元素,直接抛异常) | 支持 | 不支持 |
HashTable其实是比较老的一种键值对的集合,计算hash用的取模的方式,不允许存放空值,查询性能也很低,我们大多数情况下都不会使用它,而是使用HashMap,虽然HashMap线程不安全,会出现数据一致性问题,扩容问题等,但是多线程的情况下我们也会使用ConcurrentHashMap
而不是HashTable
。
扩展知识---HashMap常见循环方式列举
- entrySet直接拿到key,value,推荐使用
java
Set<Map.Entry<Integer, String>> entrySet = map.entrySet();
for (Map.Entry<Integer, String> entry : entrySet) {
entry.getValue();
}
- keySet方式循环
java
Set<Integer> keySet = map.keySet();
for (Integer kk : keySet) { map.get(kk); }
- 迭代器方式
java
Iterator<Integer> it = map.keySet().iterator();
while (it.hasNext()) {
Integer ii = (Integer) it.next();
map.get(ii);
}
我将在下一篇文章中来写ConcurrentHashMap
,编写不易,求点赞,求轻喷~