HashMap很细的分析--令人发指

前言

在说HashMap之前,我们先说一说hash冲突。当数据通过hash算法求得hash值的时候,是不可避免的出现相同的hash值,这也叫做hash冲突。通常我们会采用4种方式去应对hash冲突的情况。

  1. 开放地址法: 当出现冲突的时候,用这个hash值+增量序列然后对散列表长度取模得到地址,这个增量序列通常有三种方式获取。第一种就是线性探测 ,当冲突之后,往后一个一个找,直到找到空位。第二种是二次探测 ,也就是反复横跳着找,比如1,-1,2,-2,3,-3。第三种就是伪随机法 取个伪随机数。
  2. 链地址法: HashMap采用的就是这种方式,当冲突的时候,所有hash冲突的数据挂在一条链路上,处理冲突很简单,插入也快,但是查找效率不高。
  3. 公共溢出区: 就是字面意思,所有冲突的单独放一起。
  4. 再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其实都是需要保持树化的。
}

所以我们推测几种情况。

  1. 低位区链表长度小于等于6,高位区小于等于6------低位区从红黑树转为链表,高位区从红黑树转为链表
  2. 低位区链表长度小于等于6,高位区大于6 --------低位区从红黑树转为链表,高位区维持树状
  3. 低位区链表长度大于6,高位区小于等于6 --------低位区维持树状,高位区从红黑树转为链表
  4. 低位区链表长度大于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常见循环方式列举

  1. entrySet直接拿到key,value,推荐使用
java 复制代码
       Set<Map.Entry<Integer, String>> entrySet = map.entrySet();
       for (Map.Entry<Integer, String> entry : entrySet) {
           entry.getValue();
       }
  1. keySet方式循环
java 复制代码
        Set<Integer> keySet = map.keySet(); 
        for (Integer kk : keySet) { map.get(kk); }
  1. 迭代器方式
java 复制代码
           Iterator<Integer> it = map.keySet().iterator();
        while (it.hasNext()) {
            Integer ii = (Integer) it.next();
            map.get(ii);
        }

我将在下一篇文章中来写ConcurrentHashMap,编写不易,求点赞,求轻喷~

相关推荐
m0_571957582 小时前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
魔道不误砍柴功4 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2344 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨4 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
Chrikk5 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*5 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue5 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man5 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟5 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity6 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq