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 到新的数组中。


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


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

相关文章
|
2天前
|
Java
【JDK 源码分析】HashMap 操作方法
【1月更文挑战第27天】【JDK 源码分析】HashMap Put 元素 初始化
|
7月前
|
设计模式 算法 Java
面试官:JDK1.8 HashMap扩容rehash算法是如何优化的?
本文跟大家聊一聊一个常见的面试题,那就是JDK1.8 HashMap扩容rehash算法是如何优化的?
|
2天前
|
安全 Java
【JDK 源码分析】HashMap 线程安全问题分析
【1月更文挑战第27天】【JDK 源码分析】HashMap 线程安全问题分析
|
2天前
|
存储 Java
【JDK 源码分析】HashMap 底层结构
【1月更文挑战第27天】【JDK 源码分析】HashMap 底层结构
|
2天前
|
存储 安全 Java
Hashtable和HashMap:差异,数据结构概述,以及JDK的影响
Hashtable和HashMap:差异,数据结构概述,以及JDK的影响
26 0
|
10月前
|
存储 算法 安全
高频面试题-JDK源码篇(HashMap)
我觉得HashMap是一个高频面试题,甚至被问烂了,如果你还不懂HashMap原理,你很幸运,看了这边文章之后你将不存在这个问题!这里整理了一下HahsMap可能会被问到的知识点,从源码的角度去做了一些分析,当然你可以试着自己先回答一下: HashMap底层用到了那些数据结构? 为什么要用到链表结构? 为什么要用到红黑树? HashMap如何扩容的? HashMap是如何Put一个元素的? HashMap是如何Get一个元素的? 什么是Hash冲突 还有哪些解决Hash冲突的方式?
52 0
|
10月前
|
安全 算法 Java
JDK 7 HashMap 并发死链
JDK 7 HashMap 并发死链
|
11月前
|
存储 安全 算法
HashMap为什么在多线程操作下不安全(jdk1.7和jdk1.8原因不同)
HashMap为什么在多线程操作下不安全(jdk1.7和jdk1.8原因不同)
73 0
|
11月前
|
安全 Java 索引
JDK常用的数据类型【1】 ——HashMap(分享篇)
JDK常用的数据类型【1】 ——HashMap(分享篇)
77 0
|
12月前
|
Java
【JavaP6大纲】Java基础篇:为什么jdk8以后HashMap会使用红黑树优化?
【JavaP6大纲】Java基础篇:为什么jdk8以后HashMap会使用红黑树优化?