HashMap底层原理的深入了解(JDK1.7和JDK1.8对比)

文章详细探讨了HashMap在JDK1.8中的实现细节,包括为何链表转红黑树的阈值设为8,如何利用泊松分布理论,以及为何选择0.75作为负载因子。还讨论了头插法和尾插法的区别,以及HashMap的扩容机制,强调了在性能和空间效率之间的平衡设计。

如果不了解HashMap基础的同学可以先看一下下面这篇文章:

HashMap原理了解前准备_Strine的博客-CSDN博客

本章节主要深入讲解HashMap在JDK1.8版本的一些问题

为什么链表转换为红黑树的阈值是8而不是其他?

首先我们先了解一下泊松分布:

在日常生活中,大量的事件都是有固定发生频率的,例如:

某地区平均每小时出生xx个孩子;

某包子铺平均每天销售xx个包子;

某公司客服平均每10分钟接xx个电话;

这些事件的特点就是:我们可以预估这些事件的总数,但是没法知道具体的发生时间,例如我们知道客服平均每十分钟接1个电话,但是下一个十分钟会接到多少个电话?有可能3个,也有可能一个都接不到,这个是我们没办法知道的;

因此泊松分布就是描述某段时间内事件具体的发生概率;

好,我们现在再来看为什么链表转换为红黑树的阈值8不是9,10呢,这个和HashCode的碰撞次数和泊松分布有关,主要是为了寻找一种时间和空间的平衡,在负载因子为0.75(默认)的情况下,单个Hash槽内的元素个数为8的概率特别低,因此将7作为一个分水岭,等于7的时候不做转换,大于等于8的时候才转换为红黑树,并且当红黑树的节点小于等于6个以后又会恢复为链表形态;

下图就是在源码中链表中存储的元素个数的概率:

     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006

那么为什么不直接使用红黑树呢?

我们在看下面的这个源码的时候可以发现,它在长度达到8的时候转换为红黑树,调用数的插入方法,长度在降为6的时候又转换回去,这体现了时间和空间平衡的思想;

最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大问题,但是当链表越来越长,时间复杂度O(n)的问题也就体现出来,查询效率变低,因此需要使用红黑树来保证查询的效率,在上表中我们看到,链表达到阈值8的概率非常非常小,因此如果hashCode分布良好,很少会出现链表很长的情况,因此通常情况下,并不会发生从链表转换为红黑树的情况;

并且红黑树需要进行左右旋操作, 而链表不需要,TreeNodes占用空间是普通Nodes的两倍,因此直接使用的话在数据量少的时候空间利用率是没有链表高的。

源码:

如果超过了阈值8则调用转化为红黑树存储:

           if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }

为什么Hash运算使用的是高16位异或运算?

 static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

是为了让Hash值的散列度更高减少Hash冲突的发生,由于HashMap的哈希值是一个32位的整数,而底层数组的长度一般是2的幂次方,因此只有低16位的Hash值对数组长度取模之后才能确定元素在数组中的位置;

但是如果只使用低16位进行hash运算,这样就会造成key的散列度不是很高,那么不同的元素可能会产生相同的hash值,进而发生hash冲突,而将高16位与低16位进行异或运算之后,可以将高低位的信息都加入到hash计算中,减少hash冲突的发生,进而提高HashMap的性能和效率;

为什么负载因子的默认值为0.75而不是其他?

HashMap的负载因子是因为它的容量和元素个数的比值,当元素个数超过容量乘以负载因子的时候就会触发扩容操作,默认负载因子为0.75是通过实验得出的最优值,它既能保证HashMap的性能又能避免过多的空间浪费;

在HashMap中,负载因子为0.75,也就是元素个数达到容量的百分之75的时候就会触发扩容操作

如果负载因子设置的太小:就会导致频繁扩容,如果设置的太大又会导致大量的Hash冲突,使得性能变得很低;

假如负载因子为1:也就是说只有当HashMap的数组装满之后才会进行扩容,虽然这样空间利用率提升了,但是会导致大量的Hash冲突,底层的红黑树变得异常复杂,查询效率变得非常低,时间效率也就降低;

假如负载因子为0.5或者更少:虽然Hash冲突降低了,查询效率提高了也就是说时间效率提高了,但是这样就会导致频繁扩容,空间利用率大大降低;

JDK1.7和1.8版本的链表插入有什么区别,为什么?

在JDK1.7中,HashMap的链表存储使用的是头插法,也就是新节点插入到链表的头部,这是因为在jdk1.7中,当出现hash冲突的时候,会使用链表来解决冲突,新的元素会插入到链表的头部,这样就可以保证最近插入的元素能够被快速访问到;

头插法存在的问题:

JDK1.7进行扩容的时候,每个元素的rehash之后,都会插入到新数组对应索引的链表头,这就导致了原链表的顺序A->B->C,在扩容之后rehash之后可能变成C->B->A,元素的顺序发生了改变;

因此在并发的场景下,扩容的时候就可能会出现循环链表的问题,而JDK1.8之后从头插法改为了尾插法,元素顺序就不会发生改变,避免出现了循环链表的问题;

尾插法:

当发生hash冲突的时候,追加到链表的尾部,扩容的时候元素顺序不会发生改变,并且因为加入了红黑树的原因,链表长度在达到阈值8会转换为红黑树存储,因此我们使用尾插法会计算一个bitCount值,在添加的时候会判断该值是否达到了阈值8,如果达到了就会转换为红黑树,这是头插法没办法做到的,如果使用头插法在每次插入之后还需要进行遍历计算长度,性能特别低;

{
    tab[i] = newNode(hash, key, v, first);  //生成新的节点
    if (binCount >= TREEIFY_THRESHOLD - 1)   //判断加入该节点之后是否达到阈值8
        treeifyBin(tab, hash);  // 如果达到了的话就转换为红黑树存储
}

HashMap的扩容机制

初始化:

 if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

该对象第一次被new出来执行put方法的时候

首先会根据new的不同构造器(是否指定容量,是否指定初始化容量)决定以不同的方式计算当前map的新容量/阈值

得到新的容量和阈值之后,创建一个新的数组table

判断旧的table是否存在,如果存在就扩容,不存在就直接返回新的数组并结束;

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;
        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];
    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;
}

扩容

已经初始化完了之后的扩容操作

目的:减少hash碰撞

要做的事情:将旧数组的元素重新迁移到新的数组中去

遍历旧数组的容量,得到当前位置的节点对象

1.判断是否还有下一个节点

        如果没有下一个节点,直接重新计算在新数组中的索引位置(hash&newCap-1),并将该元素存入新的数组

2.判断当前节点是否是树节点,如果是树节点

        对当前树进行拆分

                拆分后判断新的节点数是否<6,如果小于就会恢复为链表;

                否则为数的根节点计算新的索引位置,并存入新数组;

                新的索引位置=》存放树的根节点

                        低位:原来的位置

                        高位:原来的索引+旧数组的长度

3.如果不是单个节点也不是树节点,当做链表来处理

        将链表拆分为低/高位链表

                拿当前节点的hash与旧数组的长度做与运算,最终得到的结果只会是0或1;

                如果为0:该节点会存入低位链表;

                如果为1:该节点会存入高位链表;

        计算新的索引=》存放链表的头节点

                低位:原来的位置

                高位:原来的索引+旧数组的长度

简单的来说

扩容操作主要包括以下几个步骤:

当 HashMap 中元素个数达到了负载因子(默认为0.75)与当前容量的乘积时,就会触发扩容操作。

  1. 将原有的 Entry 数组复制到一个新数组中,新数组的长度是原来的两倍。
  2. 遍历新数组中的每个位置,如果该位置上有元素,则计算其在新数组中的索引位置,并将其插入到该位置上。
    • 如果该位置上只有一个元素,则直接插入。
    • 如果该位置上多于一个元素,则判断这些元素是否需要转化成红黑树。如果是,则将它们转化为一棵红黑树;如果不是,则继续使用链表存储。
  3. 扩容完成后,将原有的 Entry 数组置为 null,等待垃圾回收。

需要注意的是,由于扩容操作比较耗费时间和内存资源,程序在运行过程中会尽量避免频繁扩容。一般来说,初始时HashMap会创建一个小的底层数组,然后随着元素数量的增加,逐渐扩容到预设的最大值。此外,扩容操作也可能会导致并发问题,因此在多线程环境下需要使用线程安全的HashMap实现或者通过同步机制来保证扩容过程的正确性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Strine

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值