JDK1.7与JDK1.8中HashMap的扩容

简介: JDK1.7与JDK1.8中HashMap的扩容

基础知识


16bc783400bd4913948f8da19781f096.png

注意


  • Java中散列表用链表数组实现,每个链表被称为桶,想要查找表中对象的位置,就要先计算它的散列码,然后与桶的总数取余数,所得到的结果就是保存这个元素桶的索引。
  • hash:翻译为”散列表“,就是把任意长度的输入,通过散列算法,变成固定长度输出,该输出结果是散列值。这种转换是一种压缩映射,散列表的空间通常小于输入的空间,不同的输入可能会散列成相同的输出,所以不能从散列表来唯一的确定输入值。
  • hash冲突:就是根据key即经过一个函数f(key)得到的结果的作为地址去存放当前的key value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经有人先来了。就是说这个地方要挤一挤啦。这就是所谓的hash冲突啦。
  • 散列冲突:Java8中,桶满时会丛链表变为平衡二叉树(红黑树)。如果选择的散列函数不当,会产生很多的冲突,或者如果有恶意的代码试图在散列表中填充多个有相同散列码的值。转变成红黑树就能够提高性能。
  • 加载因子(装填因子):决定何时对散列表进行在散列。例如默认为0.75,则表中超过75%的位置已经填入元素,这个表就会用双倍的桶数(bucket)自动的进行在散列。
  • 拉链法:将链表与数组结合。也就是说创建一个链表数组,数组中每一格就是一个数组。若遇到哈希冲突,则将冲突的值加到链表中就可以了。
  • 桶的个数就是数组的长度,默认为16。


为什么加载因子等于0.75?


作为一般规则,默认负载系数(0.75)提供了时间和空间成本之间的良好权衡。较高的值会减少空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括get和put)。在设置映射的初始容量时,应该考虑映射中预期的条目数量及其负载因子,以减少重新散列操作的数量。如果初始容量大于最大条目数除以负载因子,则不会发生重新散列操作。


HashMap的底层原理


HashMap是存储的是Key-Value键值,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,数组初始化长度为16,数组中的每一个元素初始值都是null,这个数组就是我们常说的桶或坑位。


JDK1.8之前:数组+链表


HashMap是线程不安全的,不安全的具体原因就是在高并发场景下,扩容可能产生死锁(Jdk1.7存在)以及get操作可能带来的数据丢失。


线程不安全的主要原因:


多线程扩容(resize方法)导致形成的链表环( transfer方法将所有表项从当前表转移到newTable使链表成环造成线程不安全问题)!


看源码先看一下重要的几个成员变量:


  1. DEFAULT_INITIAL_CAPACITY = 1 << 4; Hash表默认初始容量
  2. MAXIMUM_CAPACITY = 1 << 30; 最大Hash表容量
  3. DEFAULT_LOAD_FACTOR = 0.75f;默认加载因子
  4. TREEIFY_THRESHOLD = 8;链表转红黑树阈值
  5. UNTREEIFY_THRESHOLD = 6;红黑树转链表阈值
  6. MIN_TREEIFY_CAPACITY = 64;链表转红黑树时hash表最小容量阈值,达不到优先扩容。

put方法原理:


当调用HashMap.put(“qijian”,0),插入一个key为"qijian"的元素。Entry(key-value)插入的位置(index,table下标)由hash函数来决定。index = Hash(“qijian”)


当发生hash冲突时,当我们插入一个Entry的时候发现数组的对应下标中已经存在元素(链表的头节点)。此时新来的Entry节点插入链表,使用头插法插入。next一次指向下一个元素。使用头插法的原因:设计者认为后插入的元素被get的几率更大。


get方法原理:


首先对key做一次hash映射,得到index ( index = Hash(“qijian”) ) , 此时使用的hash需要与put时的hash函数是一样的。这时候只需要顺着对应链表的头节点,一个一个向下来查找,查找到key为qijian的Entry就是我们要查找的Entry。


扩容方法:


//将此映射的内容重新散列到一个容量更大的新数组中。当此映射中的键数达到其阈值时,将自动调用此方法。如果当前容量为MAXIMUM_CAPACITY,此方法不会调整map的大小,但将threshold设置为Integer.MAX_VALUE。这有防止未来调用的效果
//参数:newCapacity—新容量,必须是2的幂;必须大于当前容量,除非当前容量是MAXIMUM_CAPACITY(在这种情况下值无关)。
void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }


transfer方法源码:


void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        //当部位空时
        while(null != e) {
            //记录当前hash表的e.next
            Entry<K,V> next = e.next;
            if (rehash) {
                //rehash计算出数组的位置(hash表中桶的位置)
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            //Returns index for hash code e.hash
            int i = indexFor(e.hash, newCapacity);
            //将e.nehfxt指向new hash表中的第一个元素
            e.next = newTable[i];
            //将e放入到new hash表的头部
            newTable[i] = e;
            //转移e到下一个节点, 继续循环下去
            e = next;
        }
    }
}


单线程扩容


假设:hash算法就是简单的key与length(数组长度)求余。hash表数组长度为2,如果不扩容, 那么元素key为3,5,7按照计算(key%table.length)的话都应该碰撞到table[1]上。

扩容:hash表数组长度会扩容为4重新hash,key=3 会落到table[3]上(3%4=3), 当前e.next为key(7), 继续while循环重新hash,key=7 会落到table[3]上(7%4=3), 产生碰撞,这里采用的是头插入法,所以key=7的Entry会排在key=3前面(这里可以具体看while语句中代码)当前e.next为key(5), 继续while循环重新hash,key=5 会落到table[1]上(5%4=3), 当前e.next为null, 跳出while循环,resize结束。


cfedc540dacb44b59b59ecc3a5c71309.png


多线程的扩容


假设有两个线程正在进行扩容,且同时进入了transfer()方法。


while(null != e) {
    Entry<K,V> next = e.next;//当第一个线程执行到此时被挂起
    if (rehash) {
        e.hash = null == e.key ? 0 : hash(e.key);
    }
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
}


下面我们通过走读源码分析一下为什么多线程下HashMap是线程不安全的。


HashMap线程不安全主要体现在扩容多线程的扩容。这里详细分析一下。其主要分为两步:


1,扩容:创建一个新的Entry空数组,长度是原来数组的2倍。


2,Rehash:遍历数组,把所有的Entry重新Hash到新的数组。Hash公式:index = hashCode(key) & (Length - 1);


假设HashMap的负载因子为0.75f,且HashMap已经到了Resize()的临界点,数组长度为4,这时有两个线程对HashMap进行put操作:


1837eaff81314cfbb42418abd75b73a6.png


此时,发现空间利用率达到了0.75,此时HashMap将进行扩容。


线程B执行到Entry<K,V> next = e.next;时被挂起,此时对于线程B来说:e = Entry3 ,next = Entry2。这时候线程A也在进行着Rehash,完成后如下:


cabff6cbd2ca480aabb01326cfca9133.png


接下来,线程B恢复继续没有完成的操作,newTable[i] = e; e = next;


dd5807030f48469c9af1c477ae22d2df.png

紧接着,进行下一轮的while循环,又执行到Entry<K,V> next = e.next 这时e = Entry2; next = Entry3 ,如下:


e533d72d36cb45c29e9cf0c12d8170b8.png


接下来继续执行,利用头插法把Entry2插入到线程B数组的头节点,代码如下:


e.next = newTable[i];
newTable[i] = e;
e = next;


5a78f256385a4efc84311aaf387cd5ea.png


此时,e = Entry3;next = Entry3.


接下来,第三次循环开始,再次执行到Entry<K,V> next = e.next; ,此时:


e = Entry3 ; next = Entry3.next = null;


继续执行, e.next = newTable[i]; 此时指向如下:


newTable[i] = Entry2; e = Entry3;


Entry2.next = Entry3 ; Entry3.next = Entry2;


很明显这里已经成环。此时。当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形链表,所以程序将会进入死循环!


JDK1.8: 数组+链表/红黑树


当链表长度大于阈值(默认为8)并且容量大于64时,将链表转化为红黑树,以减少搜索时间(空间换时间)。同时采用尾插法来解决扩容操作下的循环死链问题。 如果阈值大于8,但是容量小于等于64时,优先选择扩容。


红黑树就是为了解决二叉查找树的缺陷,二叉查找树在某些情况下会退化成一个线性结构。


在 JDK 1.8 中对 HashMap 进行了优化,在发生 hash 碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全


引入了新的数据结构,同时对put方法做了修改。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v0k0VXnu-1635672200016)(HashMap.assets/image-20211030234054834.png)]


JDK 1.8 链表为啥改为尾部插入呢?

和 HashMap 的扩容机制有关。数组容量是有限的,数据多次插入的,到达一定的数量就会进行扩容,也就是 resize。进行扩容时需要将扩容前的元素重新 hash 到新的数组中。


单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,旧数组中同一链表中元素可能被放到了新数组的不同位置上,一旦多线程并发调整完成,就可能出现环形链表。


使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

相关文章
|
6月前
|
存储 Java
HashMap扩容机制详解
HashMap扩容机制详解
|
1月前
|
存储
让星星⭐月亮告诉你,HashMap的put方法源码解析及其中两种会触发扩容的场景(足够详尽,有问题欢迎指正~)
`HashMap`的`put`方法通过调用`putVal`实现,主要涉及两个场景下的扩容操作:1. 初始化时,链表数组的初始容量设为16,阈值设为12;2. 当存储的元素个数超过阈值时,链表数组的容量和阈值均翻倍。`putVal`方法处理键值对的插入,包括链表和红黑树的转换,确保高效的数据存取。
56 5
|
1月前
|
算法 索引
让星星⭐月亮告诉你,HashMap的resize()即扩容方法源码解读(已重新完善,如有不足之处,欢迎指正~)
`HashMap`的`resize()`方法主要用于数组扩容,包括初始化或加倍数组容量。该方法首先计算新的数组容量和扩容阈值,然后创建新数组。接着,旧数组中的数据根据`(e.hash & oldCap)`是否等于0被重新分配到新数组中,分为低位区和高位区两个链表,确保数据迁移时的正确性和高效性。
65 3
|
1月前
|
算法 索引
HashMap扩容时的rehash方法中(e.hash & oldCap) == 0算法推导
HashMap在扩容时,会创建一个新数组,并将旧数组中的数据迁移过去。通过(e.hash & oldCap)是否等于0,数据被巧妙地分为两类:一类保持原有索引位置,另一类索引位置增加旧数组长度。此过程确保了数据均匀分布,提高了查询效率。
38 2
|
1月前
|
Java
Java基础之 JDK8 HashMap 源码分析(中间写出与JDK7的区别)
这篇文章详细分析了Java中HashMap的源码,包括JDK8与JDK7的区别、构造函数、put和get方法的实现,以及位运算法的应用,并讨论了JDK8中的优化,如链表转红黑树的阈值和扩容机制。
27 1
|
6月前
|
存储 安全 算法
【JAVA】HashMap扩容性能影响及优化策略
【JAVA】HashMap扩容性能影响及优化策略
|
3月前
|
存储 Java
【Java集合类面试七】、 JDK7和JDK8中的HashMap有什么区别?
JDK7中的HashMap使用数组加链表解决冲突,而JDK8增加了红黑树结构以优化链表过长时的性能,提高查找效率。
|
3月前
|
前端开发 Java
2022最新版JDK1.8的安装教程、包含jdk1.8的提取码(亲测可用)
这篇文章提供了详细的JDK 1.8的安装步骤,包括下载、安装向导、环境变量配置,以及如何检查安装是否成功的方法。
2022最新版JDK1.8的安装教程、包含jdk1.8的提取码(亲测可用)
|
5月前
|
存储 安全 Java
深入解析Java HashMap的高性能扩容机制与树化优化
深入解析Java HashMap的高性能扩容机制与树化优化
149 1
|
5月前
|
索引
HashMap扩容为什么每次都是之前的2倍
HashMap扩容为什么每次都是之前的2倍
88 0