HashMap集合源码详解

HashMap集合源码详解

目标

  • 掌握HashMap底层数据存储结构,及其变换【链表转红黑树】原理
  • 掌握HashMap的初始化容量大小,及加载因子0.75原理
  • 理解HashMap链表转红黑树边界值设计思想
  • 掌握HashMap扩容机制,及初始化容量最佳实践

一、HashMap集合简介

1.1什么是HashMap?

HashMap是Map接口的实现类。HashMap是基于哈希表数据结构实现的。主要特点:双列集合,每个元素含有key和value。注意:hashMap不是线程安全的。JUC包中的替代集合处理。

特点:

  1. 无序性:存入和取出元素的顺序不一致

  2. 唯一性:key是唯一的

  3. 可存null:键和值都可以为null,但是键是唯一的,键只有一个null

  4. 数据结构:哈希表结构,控制的是key而非value值。

    • 哈希表的实现方案有很多:
    • JDK1.8之前:数组 + 链表
    • JDK1.8之后: 数组 + 链表 + 红黑树【当单个链表长度超过8,并且数组的长度大于64才会出现红黑树结构】
    • 目的:高效率存取数据

    扩展:什么是红黑树?是一种自平衡的二叉查找树。红黑树是1972年由 Rudoif Bayer发明的,当时称之为平衡二叉B树。

1.2HashMap类的继承关系

HashMap继承关系如下图所示:

说明:三个接口,一个抽象父类。

  • Cloneable空接口,表示可以克隆。创建并返回HashMap对象的一个副本。
  • Serializable序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
  • AbstractMap父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。

补充:通过上述继承关系我们发现一个很奇怪的现象,就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。

二、HashMap原理分析

2.1哈希表简介

什么是哈希表

哈希表(Hash table,也叫散列表),是根据建码值直接进行访问的数据结构,也就是说,它通过把键码值映射在数组的一个位置来进行访问。能够大大加快访问元素的速度。这种映射关系,是由一种函数来完成。哈希函数,散列函数。存放记录的位置,是一个数组,散列表。

哈希表的本质,其实是一个数组。这个数组存储值是一个哈希函数算出的值。

目的:为了提升存取数据的速度。

// 哈希表数据结构的案例
public static void main(String[] args) {
    //目标:掌握哈希表的数据结构
    HashMap<String, Integer> map = new HashMap<>();
    map.put("hello", 55);
    map.put("world", 56);
    map.put("java", 53);
    map.put("world", 52);
    map.put("通话", 51);
    map.put("重地", 77);
    System.out.println("map = " + map);// map = {通话=51, 重地=77, world=52, java=53, hello=55}
}

结论:哈希表的本质是一个数组,每个元素的索引位存储的链表。

结论2:

  • 初始化哈希表内的数组的大小是16
  • 索引位置相同,hash值和equals均不同,则创建新节点连接下去
  • 连接列表的长度超过8,并且数组长度大于64,则链表转为红黑树

2.2HashMap存储数据过程详解

1、与数据存储过程相关的属性&概念

1.加载因子:默认值0.75,决定了HashMap的数组的扩容。

final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认值

2.扩容临界值,当前HashMap的集合数组中元素超出了一定范围,进行扩容条件。

int threshold;//(capacity数组容量 * 加载因子)

3.当前集合中数组的容量:capacity,初始化的容量是16

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

4.扩容:当前集合中元素存入达到阈值【扩容临界值】,则进行扩容。HashMap扩容特点每次扩2倍。

5、集合元素个数size:表示当前HashMap中存储的键值对的个数。注意,不等于集合的数组的长度。

2、存储过程图解

3、存储过程源码分析

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // HashMap中的数组
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //1.判断数组是否为空,如果为空,则进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        //则进行扩容【添加第一个元素的时候,会进行容量】
        n = (tab = resize()).length;
    //2.如果数组不为空,求出key的对应数组容量所在索引位置。求解索引位位置,采用取余的方式哈希值%16
    //(数组长度 - 1) 位运算 hash 范围也在数组长度以内。前提条件 : 数组的长度只能是2的n次幂
    if ((p = tab[i = (n - 1) & hash]) == null)//如果数组对应key指的索引位置,没有元素
        //直接新增一个节点
        tab[i] = newNode(hash, key, value, null);
    else {
        //3.如果不为空
        Node<K,V> e; K k;
        //判断,第一个节点对象的哈希和key是否相同,如果相同,则覆盖
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //4.判断当前的数组索引对应的链表,是否是红黑树
        else if (p instanceof TreeNode)
            //5.如果是红黑树,则将元素加入红黑树节点
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //6.循环当前数组索引位的所有的链表元素,判断,元素是否哈希值和equals方法结果相同,则覆盖
            //如果不相同,忽略
            for (int binCount = 0; ; ++binCount) {
                //全部不相同,则将key和value创建一个新的节点,连接到链表上
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //判断,当前链表的长度是否大于8,如果大于则链表转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //链表转红黑树的方法,被使用的概率极低!
                        treeifyBin(tab, hash);
                    break;
                }
                //第一个节点对象的哈希和key是否相同,如果相同,则覆盖
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //如果相同,则覆盖
        if (e != null) { // existing mapping for key、
            //覆盖,新元素的值,替换老元素的值
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //记录修改元素
    ++modCount;
    //判断当前容量是否触发扩容机制
    //当前数组的长度,大于阈值12
    if (++size > threshold)
        resize();//扩容
    afterNodeInsertion(evict);
    return null;
}

2.3HashMap底层数据结构详解

1、什么是数据结构?

数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或存储效率。数据结构往往同高效的检索算法和索引技术有关。

数据结构:就是存储数据的一种方式。ArrayList、LinkedList

2、HashMap的数据结构-哈希表

  • 在JDK1.8之前,HashMap的数组 + 链表组成
  • 在JDK1.8之后,HashMap的数组 + 链表 + 红黑树组成。

数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

什么是哈希冲突?两个对象调用其自身的hashCode方法,产生相同的哈希值,计算的数组索引位置相同,哈希值也相同产生哈希碰撞。

JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

JDK1.8引入红黑树很大程度优化了HashMap的性能,那么对于我们来讲保证HashSet集合元素的唯一,其实就是根据对象的HashCode和equals方法来决定的。如果我们往集合中存放自定义的对象,那么保证其唯一,就必须复写hashCode和equals方法建立属于当前对象的比较方式。

当位于一个链表中的元素较多,即hash值相等但内容不相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阈值)超过8时且当前数组的长度>时,将链表转换为红黑树,这样大大减少了查找时间。JDK1.8在哈希表中引入红黑树的原因只是为了查找效率更高。

简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加红黑树部分)实现的。如下图所示。

3、数据结构的源码

HashMap底层数据结构的源码

transient Node<K,V>[] table;//存储HashMap的数组,链表就是node对象

缓存数组的对象

transient Set<Map.Entry<K,V>> entrySet;

当前HashMap集合存了多少个元素的

transient int size;

红黑树

TreeNode<K,V>

链表

Node<K,V>

三、HashMap源码分析

3.1HashMap的默认初始化容量

初始化容量是多少?16

//默认16, 1<<4 == 1*2的4次方 ==> 16
//初始化的容量,满足2的n次幂
//HashMap的容量,必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

初始化容量必须是2的n次幂,为什么?【大厂面试常见题目之一】

向HashMap中添加元素时,要根据key的hash值去确定其在数组中的具体位置。HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同。

怎么让元素均匀分配呢?

这里用到的算法时hash&(length-1)。hash值与数组长度减一的位运算。算法本质作用是类似于取模,hash%length。但是计算机中直接求余效率远不如位运算。这里的位运算的前提:length是2的n次幂!

为什么这样能均匀分布减少碰撞呢?

举例:位运算规则说明:按&位运算,相同的二进制数位上,都是1的时候,结果为1.否则为零。

例如:数组长度8时,均匀分布在数组中,哈希碰撞的几率比较小;
哈希值->314924944

1、方式一:取模运算,在计算机中,取模运算的效率比较低,相对于位运算。
314924944 % 16 = 0
314924945 % 16 = 1
314924946 % 16 = 2
314924947 % 16 = 3
314924948 % 16 = 4
314924949 % 16 = 5
....

2、方式二:位运算,前提length是2的n次幂!
位运算:
	0001 0010 1100 0101 0101 1111 1001 0000
&   0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------
	0000 0000 0000 0000 0000 0000 0000 0000 ==> 0
314924944 & 16 = 0
314924945 & 16 = 1
314924946 & 16 = 2
314924947 & 16 = 3
....

结论是:数组索引存储的数据结构均匀分布了,减少哈希碰撞的几率

反例:

例如:数组长度10时,没有均匀分布,碰撞几率比较大:

程序员计算器求解:
314924944 & (10 - 1) = 0
314924945 & (10 - 1) = 1
314924946 & (10 - 1) = 0
314924947 & (10 - 1) = 1
....交替为0
结论是:数据全部分布在第一个和第二个索引位置上,大大增加了哈希碰撞的几率。效率低下。

手动设置初始化容量

HashMap构造方法还可以指定集合的初始化容量大小:

HashMap(int initialCapacity) 构造一个带指定初始化容量和默认加载因子(0.75)的空HashMap。

注意:当然如果不考虑效率问题,求余即可。就不需要长度必须是2的n次幂了。如果采用位运算,必须是2的n次幂!

那么来了,如果有哪个蠢蛋,瞎搞。HashMap也自带纠错能力。具备防蠢货能力。如果创建HashMap对象时,输入的数组长度不是2的n次幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。

源码:

//初始化一个指定容量的HashMap集合
public HashMap(int initialCapacity) {
    //加入默认的加载因子
    this(initialCapacity, DEFAULT_LOAD_FACTOR);//0.75
}

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    //当前HashMap的容量不是无限大,最大的是
    //static final int MAXIMUM_CAPACITY = 1 << 30; 2的30次方
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    //防蠢货功能
    this.threshold = tableSizeFor(initialCapacity);
}
//自动调整初始化的容量,让其符合2的n次幂条件
//如果传入的值是10,会自动校正为最近的一个2的n次幂,16
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;
}

加入给的初始化容量为10,最终容量会变为最近的16!

举例:加入给的初始化容量为10,最终容量会变为最近的16!

小结:

1.根据key的hash确定存储位置时,数组长度是2的n次幂,可以保证数据的均匀插入。如果不是,会浪费数组的空间,降低集合性能!

2.一般情况下,我们通过求余%来均匀分散数据。只不过其性能不如位运算【&】。

3.length的值为2的n次幂,hash&(length - 1) 作用完全等同于hash % length。

4.HashMap中初始化容量为2次幂原因是为了数组数据均匀分布。尽可能减少哈希冲突,提升集合性能。

5.即便可以手动设置HashMap的初始化容量,但是最终还是会被重设为2的n次幂。

3.2HashMap的加载因子0.75和最大容量

1.加载因子相关属性

哈希表的加载因子(重点)默认0.75

final float loadFactor;//加载因子
//默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;//加载因子决定了何时会扩容

集合最大容量:100个亿【10 7374 1824】

static final int MAXIMUM_CAPACITY = 1 << 30;

扩容的临界值:计算方式为(容量 乘以 加载因子)

int threshold;//扩容临界值
//默认容量,默认的加载因子,扩容临界值16 * 0.75 = 12

2.为什么加载因子设置为0.75,初始化临界值是12?

加载因子可以手动设置!

loadFactor太大导致查找效率低,小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。

  • 加载因子越大趋近于1,数组中的存放的数据(Node)也就越多,也就越稠密,也就是会让链表的长度增加。

  • 加载因子越小趋近于0,数组中的存放的数据(Node)也就越少,也就越稀疏。也就是会让链表的长度不会太长。

如果希望链表尽可能少些,性能更好。就要提前扩容,但导致的问题是数组空间浪费,有些桶没有存储数据!典型的鱼和熊掌不可兼得!

举例:

例如:加载因子是0.4。 那么16*0.4--->6  如果数组中满6个空间就扩容会造成数组利用率太低了。每次扩容变一倍。
	 加载因子是0.9。 那么16*0.9--->14  那么这样就会导致链表有点多了,导致查找元素效率低。

所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。

threshold计算公式:capacity(数组长度默认16) * loadFactor(加载因子默认0.75)。

这个值是当前已占用数组长度的最大值。当size>=threshold的时候,那么就要考虑对数组的resize(扩容)。扩容后的 新HashMap容量是之前容量的两倍。

不要改动加载因子,非常不建议!

3.修改加载因子

同时在HashMap的构造器中可以定制loadFactor。但最好别 改~

构造方法:
HashMap(int initialCapacity, float loadFactor) 构造一个带指定初始容量和加载因子的空 HashMap。

小结:

HashMap集合中,默认的加载因子是0.75! 不建议修改

HashMap集合最大容量,10个亿。2的 30次方。

3.3 HashMap的红黑树转换边界值详解

1、边界转换相关属性

1.边界转换值1:当链表的长度超过边界值8,就会启动转换红黑树【JDK8】。前提:数组长度不能低于64

//当桶中节点数,超过边界值,就会转为红黑树
static final int TREEIFY_THRESHOLD = 8;

2.转换边界值2:当Map里面的数量超过这个值时,表中的 桶才能进行树形化,这个值不能小于4 * TREEIFY_THRESHOLD(8)

static final int MIN_TREEIFY_CAPACITY = 64;//就是边界值 * 4 ==> 8*4 ===> 32

2.桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。

4.降级转换边界值:当链表的 值小于6则会从红黑树转会链表。

//桶中元素的下边界,如果一个链表是红黑树,但是元素比较少,还会将红黑树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;

2、为什么Map桶中节点个数超过8才转为红黑树?

HashMap的红黑树数据结构几乎不会被用到,本质上还是一个链表+数组!!!

8阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是Bucket(桶))从链表转成树的阈值,但是并没有说明为什么是8;

在HashMap官方注释说明:

//翻译后内容
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换会普通的桶。在使用分布良好的用户hashcode时,很少使用红黑树。
情况下,在随即哈希码下,桶中节点的频率服从泊松分布,默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大,忽略方差,列表大小k的预期出现次数(exp(-0.5)*pow(0.5, k)/factorial(k))。
第一个值是:
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
more: less than 1 in ten million

红黑树节点对象占用空间是普通链表节点的两倍,所以只有当桶中包含足够多的节点时才会转成红黑树。当桶中节点数变少时,又会转成普通链表。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成链表。

这样就解释了为什么不是开始就将其转换为红黑树节点,而是数量数才转。

说白了就是空间和时间的权衡!

官方还说了:当hashCode哈希函数值离散性很好的情况下。红黑树被用到的概率非常小!概率为0.00000006。

理想的情况下,优秀的hash算法,会让所有桶的节点的分布频率会遵循泊松分布。我们可以看到,一个桶中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。因为数据被均匀分布在每个桶中,所以几乎不会有桶中的链表长度会达到阈值!

所以之所以选择8,不是随便决定的,而是根据概率统计决定的。由此可见,发展将近30年的Java每一项改动和优化都是非常严谨和科学的。也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数字。

但是哈希函数【hashCode】是有用户控制,用户选择的hash函数,离散性可能会很差。JDK又不能阻止用户实现这种不好的hash算法。因此,就可能导致不均匀的数据分布。所以超过8了,就采用红黑树,来提升效率。

也是防蠢货设计!

扩展:Poisson分布(泊松分布),是一种统计与概率学里常见到的离散[概率分布]。泊松分布的概率函数为:

泊松分布的参数λ是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数。

小结:

  • 当链表的长度超过边界值8,就会启动转换红黑树【JDK1.8】。数组长度不能低于64。
  • 开发的时候,使用HashMap的链表,很难转为红黑树操作。

3.5HashMap的treeifyBin()方法详解-链表转红黑树

1、转换相关属性

1.转换边界值1:当链表超过转换边界值8,就会转红黑树(1.8新增),前提条件:数组长度大于64.

//当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;

2.转换边界值2:当Map里面的数量超过这个值时,表中的桶才能进行树形化,这个值不能小于4*TREEIFY_THRESHOLD(8)

//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;

3.桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。

4.降级转换边界值:当链表的值小于6则会从红黑树转回链表。

//当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;

2、转换treeifyBin()方法源码分析

节点添加完成之后判断此时节点个数是否大于TREEIFY_THRESHOLD临界值8.如果大于则将链表转换为红黑树,转换红黑树的方法treeifyBin,整个代码如下:

//判断条件:桶中节点数量大于等于  边界阈值-1。【计算机数数都是从0开始】
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);//执行链表转红黑树的方法

treeifyBin方法如下所示:

//将所有的链表的节点,转换为红黑树节点
//如果哈希表的数组太小【64】了,就不会去转换!
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //1.如果当前数据为空,或者数组的长度小于红黑树的最小阈值。就会去扩容,而非进行转为红黑树
    //MIN_TREEIFY_CAPACITY == 64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();//扩容
    //2.转换为红黑树。目的,就是通过空间,换取时间!
    //当前哈希在表桶节点不为空
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //进行转换:链表转为红黑树
        //红黑树头部节点hd
        //红黑树尾部节点tl
        TreeNode<K,V> hd = null, tl = null;
        //遍历所有的链表的节点,将链表节点转为红黑树结构的节点	
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);//原始节点替换为红黑树节点
            if (tl == null)//如果尾部节点为空,将头部节点给尾部节点
                hd = p;
            else {//如果不是
                p.prev = tl;//将尾部节点交给prev节点
                tl.next = p;//当前节点p,交给尾部节点的下一个节点
            }
            tl = p;
        } while ((e = e.next) != null);
        //判断当前索引,头部节点如果不为空
        if ((tab[index] = hd) != null)
            hd.treeify(tab);//将桶中的第一个节点,转为红黑树的顶部节点
    }
}

小结:

1.HashMap集合中,链表节点红黑树节点的临界值是8,前提是集合中数组的最大容量是64以上。否则会对数组进行扩容!

3.6HashMap的扩容机制

扩容的由来:

  • 在不断向Map集合中添加数据的时候,数组并不是一直保持初始容量16.扩容阈值 加载因子*容量 12。
  • 数组 + 链表【查询效率变的很慢】
  • 红黑树占用空间大【占用空间大】

1、扩容相关属性

1.扩容计数器:用来记录HashMap的修改次数、

//每次扩容和更改map结构的计数器
transient int modCount;

2.转换边界值1:当链表超过转换边界值8,就会转红黑树(1.8新增),前提条件:数组长度大于64。

//当前(bucket)上的节点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;

3.转换边界值2:当Map里面的数量超过这个值时,表中的桶才能进行树形化,这个值不能小于4*TREEIFY_THRESHOLD(8)

//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;

4.桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。

5.哈希表的加载因子(重点)默认值是0.75,决定了扩容的条件

// 加载因子
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

6.集合最大容量:10,7374,824【10亿】

//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

7.扩容的临界值:计算方式(容量 乘以 加载因子)

//临界值 当实际大小超过临界值时,会进行扩容
int threshold;

2、扩容机制

了解HashMap的扩容机制,你需要搞懂这两个问题:

1.什么时候需要扩容?

2.HashMap的扩容做了哪些事情?

问题1:什么时候需要扩容?

主要在两种情况下进行扩容:

1.当HashMap集合中,实际存储元素个数超过临界值(threshold)时,会进行扩容。默认初始化临界值是12。

2.当HashMap中,单个桶的链表长度达到了8,并且数组长度还没到达64。会进行扩容。

问题2:HashMap的扩容做了哪些事?

将原数组中桶的节点,均匀分散在了新的数组的桶中。

HashMap扩容时分散使用的rehash方式非常巧妙。并没有进行hash函数调用。

由于每次扩容都是翻倍,与原来计算的(n-1)&hash的结果相比,只是多了一个bit位。所以节点要么就在原来的位置,要么就被分配到原位置+旧容量这个位置。

结论:rehash,原始桶中的元素,扩容之后,要么在当前索引位上,要么在原索引位+数组长度!

怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:

"娜扎"的哈希值740274
公式:(n-1) & hash
740274 & (16 - 1) = 2

//通过哈希值,算出两个字符串在同一个桶中
string key = "天乐";//hashCode值  727623
string key = "德华";//hashCode值  780919
计算hash
727626 & (16 - 1) = 7
780919 & (16 - 1) = 7

扩容之后,巧妙的计算rehash【更高效】
727623 & (32-1) = 7
780919 & (32-1) = 7 + 16 = 23
//结论 :同一个桶内的节点,要在原来位置,要么就是"原位置+旧容量"!

画图说明:

下图为16扩充为32的resize示意图:将原数组中桶内的节点,均匀分散在了新的数组的桶中。

结论:

1.7是两个字符串计算的原始索引,存入同一个桶中。扩容之后,”天乐“在原来位置,”德华“被分配原位置+旧容量位置。

2.因此我们在扩容HashMap时,不需要重新计算hash。只需要看原来的hash值新增bit是1还是0就可以了!

​ 1.是0索引没变

​ 2.是1索引变成”原索引+oldCap(原位置+旧容量)“。

注意:

  • 扩容必定伴随rehash操作,遍历hash表中所有元素。这种操作比较耗时!
  • 在编程中,超大HashMap要尽量避免resize,避免的方法之一就是初始化固定HashMap大小!

3、扩容方法resize()源码解读

扩容情况之一:

//HashMap的扩容方法
final Node<K,V>[] resize() {
    //将一个全局的数组,赋值局部的数组变量oldTable
    Node<K,V>[] oldTab = table;
    //获取原始数组的容量:16
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //获取原始哈希表的扩容边界阈值
    int oldThr = threshold;
    //定义新的数组的容量,定义新数组的扩容边界阈值
    int newCap, newThr = 0;
    //判断,原始容量是否大于0
    if (oldCap > 0) {
        //判断原始的额容量是否大于最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {//10个亿
            threshold = Integer.MAX_VALUE;//扩容的边界阈值,将等于int的最大值
            //直接结束当前的循环,已经到达最大值了
            return oldTab;
        }
        //如果没有达到最大值,进行扩容oldCap << 1(翻倍)。
        //判断翻倍之后小于最大值,并且 原始容量大于最大容量16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //扩容边界阈值也翻倍:12 --> 24
            newThr = oldThr << 1; // double threshold
    }
    //判断边界阈值是否大于0.
    else if (oldThr > 0) // initial capacity was placed in threshold
        //如果大于0,旧的阈值替换为新的阈值
        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);
    }
    //求出的新的边界阈值,给予全局的HashMap的集合
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //创建新的扩容后的数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;//将新的数组赋值给全局的变量
    if (oldTab != null) {//判读新的数组,如果不为null
        //将原始的数组,均匀的分散在新的数组中
        for (int j = 0; j < oldCap; ++j) {//循环遍历操作
            Node<K,V> e;//遍历后的原始数组的节点
            if ((e = oldTab[j]) != null) {//遍历的节点,存在元素
                oldTab[j] = null;//清空原始数组索引的数据,节省空间,提高GC效率
                //判断当前节点下,是否还有元素
                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...while循环
                    do {
                        next = e.next;
                        //rehash操作,
                        //当前节点的key的哈希值,位运算新的,得出权限的位置
                        if ((e.hash & oldCap) == 0) {//要么是0,要么是1
                            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);
                    //当前节点的key的哈希值,位运算新的,得出权限的位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

3.7HashMap容量初始化最佳策略

hashMap的扩容是一个比较耗费性能操作:创建新的数组,又是赋值、rehash操作...

1、HashMap的初始化问题描述

《阿里巴巴Java开发手册》中建议初始化HashMap的容量。

为什么要建议初始化HashMap容量?

  • 防止自动扩容,影响效率!

当然阿里的建议是有理论支撑的。我们上面介绍过HashMap的扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor*capacity。

所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会有可能发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。

设置初始化容量,数值不同性能也不一样!

当已知HashMap中,将存放的键值对个数时,容量设置成多少合适呢?

是直接设置键值个数吗?并不是,我接下来看

2、HashMap中容量初始化多少合适?

假如现在HashMap集合要存入,16个元素。如果你初始化16个。集合总容量是16,扩容阈值是12.最终HashMap集合在存入到12个元素时,会进行一次扩容操作。这样会导致性能损耗。

有没有更好的办法?

《阿里巴巴Java开发手册》有以下建议:初始化的容量就是扩容的阈值!

如果我们通过initialCapacity/0.75F + 1.0F 计算:16/0.75 + 1 = 22。

22经过Jdk处理之后【2的n次幂】,集合的初始化容量会被设置成32。

集合总容量是32,扩容阈值是24。最终HashMap集合在存入到16个元素时,完全不会进行扩容。榨取最后一滴性能!

有利必有弊,这样的做法会增加数组的无效容量,牺牲一小部分内存。出于对性能的极致追求,这部分牺牲是值得的!

四、HashMap面试题精讲

1、HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?

底层采用的是key的hashCode方法 加 异或(^) 加 无符号右移(>>>)操作计算出hash值。

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

而哈希表中,计算数组索引的方法是位运算,如下代码。保证所有的hash最终落在数组最大索引范围之内。

i = (n - 1) & hash

还可以采用:取余法、平方取中法,伪随机数法。

取余数方式较为常见10%8,但是与位运算相比,效率较低。

2、当两个对象的hashCode相等时会怎么样?

会产生哈希碰撞
如果若key值相同,则替换旧的value。不然连接到链表后面,链表长度超过阈值8,数组长度大于64自动转换为红黑树存储。

3、何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?

只要两个元素的key计算的哈希码值相同就会发生哈希碰撞。
jdk8前使用链表解决哈希碰撞。
jdk8及以后使用链表+红黑树解决哈希碰撞。
哈希基本可以确定是唯一性:但是很有可能会出现哈希碰撞!

4、hashCode和equals方法有何重要性?

非常重要!

1.HashMap使用key对象的#hashcode()和#eques(Object obj)方法去决定键值对的存储位置。

当从HashMap中获取值时,也会被用到

  • 如果这两个方法没有被正确地实现,两个不同key也会产生相同的#hashcode()和#equals(Object obj)输出。这就会导致元素存储位置的混乱,降低HashMap性能。

2.#hashcode()和#equals(Object obj)保证了集合元素的唯一性

所有不允许存储重复数据的集合类,都使用这两个方法,所以正确实现它们非常重要。

  • 如果01.equals(o2),那么o1.hashCode() == 02.hashCode 总是为true的。
  • 如果o1.hashCode() == o2.hashCode(),并不意味o1.equals(o2)会为true。

5、HashMap默认容量是多少?

  • 默认容量都是16,加载因子是0.75。
  • 就是当HashMap填充了75%就会扩容,最小扩容阈值(16 * 0.75 = 12)
  • 扩容一般会扩为原内存2倍。

6、HashMap的长度为什么是2的n次幂?

为了能让HashMap存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀分散在数组中。这个问题的关键在于存储数组索引位的算法【hash & (length - 1)】。相当于取余的操作!

这个算法可以如何设计呢?

1.程序员们一般会想到%取余,但是效率太低。

2.在计算机运算中,位运算高于取余操作。而位运算能够做到与取余相同效果的前提是,数组长度是2的n次幂。

这就解释了HashMap的长度为什么是2的幂次幂。

7、加载因子值的大小对HashMap有什么影响?

加载因子的大小决定了HashMap的数据密度

  • 加载因子越大,数据密度越大,发生碰撞概率越高,数组桶中链表长度越长,查询或者插入时的比较次数增多。从而导致性能下降。

  • 加载因子越小,数据密度越小,发生碰撞概率越小,数组桶中链表越短,查询和插入时比较的次数也就越小。性能会更高。

    加载因子越小,越容易触发扩容,会影响性能

    加载因子越小,存储数据量越小,会浪费内存空间

鱼与熊掌不可兼得!

按照其他语言的参考及研究经验,会考虑将加载因子设置为0.7到0.75【最佳】!

posted @   InfinityOOE  阅读(90)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示