HashMap的源码分析

HashMap集合(高级)

一、HashMap简介

HashMap是通过键值对(K-V)的方式存储数据。是基于哈希表的实现的Map接口,是Java中常见的集合之一。HashMap的实现不是同步的,这意味着HashMap的线程不安全。

在JDK1.8之前HashMap由 数组+双向链表组成的。数组是HashMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode()方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”),在jdk1.8之后解决了哈希冲突,当链表的长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时索引位置上的所有数据改为使用红黑树存储

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fC4Z2EKT-1600262321053)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598145015582.png)]

注意:当链表的长度大于阈值,但是当前数组的长度小于64时,此时并不会使用红黑树存储,而是进行数组扩容

1.1特点:

  1. 存取无序的。
  2. 键和值位置都是null,但是键位置只能是一个null。
  3. 键位置是唯一的,底层的数据结构控制键的。
  4. jdk1.8之前数据结构是:数组 + 链表 ;jdk1.8之后数据结构是:链表 + 数组 + 红黑树。
  5. 阈值 > 8 并且数组长度大于 64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询。

二、HashMap集合底层的数据结构

2.1、数据结构概念

​ 数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。

在jdk1.8之前HashMap采用的是 数组+链表 数据结构组成的。

在jdk1.8之后HashMap采用的是 数组+ 链表+红黑数 组成的。

2.2、HashMap底层的数据结构存储数据的过程

存储过程如下所示:

使用的代码:

public class HashMapDemo1 {
    public static void main(String[] args)
    {
        Map<String, Integer> map = new HashMap<>();
        map.put("string",13);
        map.put("str",15);
        System.out.println(map);
    }
}

通过put方法进行存储数据。

  1. 当创建HashMap对象时,在jdk1.8之前,构造方法中创建一个长度是16的Entry[] table 数组用来存储键值对的数据。在jdk1.8之后,不是在HashMap的构造方法底层创建数组,是在第一次调用put()方法时创建Node[] table数组用来存储键值对的数据

  2. 用String类中重写hashCode()方法计算出值,然后加上数组的长度采用某种算法计算出向node数组中存储数据的空间索引值。

  3. 假设向哈希表中存储数据 (“string”,13),假设计算出的hashCode方法结合数组计算出的索引值在哈希表中已经存在,此时底层会比较两者之间的hash值是否一致,如果不一致,就会在此空间上划分出一个节点来存储键值对数据(“string”,13)。这种方法就被称之为"拉链法"。

  4. 假设向哈希表中存储数据(“string”,17),那么根据调用hashCode()方法结合数组长度计算出索引值都是相等的,此时比较后存储的数据"string"和已经存储在数据的hash值是否相等,如果hash值相等,此时发生哈希碰撞
    那么底层会调用"string"所属类String中的equals()方法比较两个内容是否相等:

    1. 相等:则将后添加数据的value覆盖之前的value。
    2. 不相等:那么继续向下和其他的数据的key进行比较,如果都不相等,则划分出一个节点存储数据。
    Map<String, Integer> map = new HashMap<>();
    map.put("string",13);
    map.put("string",15);
    System.out.println(map);
    System.out.println("string".hashCode());
    
    结果:
    {string=15}
    -891985903
    

三、HashMap的继承关系

HashMap继承关系如下图所示:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o1hNbDIb-1600262321062)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598162121370.png)]

说明:

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

四.HashMap集合类的成员

4.1、成员变量

1、序列化版本号

//默认的版本号
private static final long serialVersionUID = 362498820763181265L;

2、集合默认的初始化容量(必须是二的n次幂)

//默认的初始化容量是 16 -- 1 << 4相当于1 * 2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

问题:为什么必须是2的n次幂?如果输入的值不是2的幂比如是10会怎么样?

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

//构造一个带指定初始容量和默认加载因子(0.75)的空HashMap
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

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

这个算法实际就是取模,hash % length。计算机中做取模运算的效率不如位运算。所以源码中做了优化,使用hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂。

为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。

举例:

说明:按位于运算:相同的二进制数位上,都是1的时候,结果为1,否则为零

例如长度为8的时候,3 & (8-1) = 3  2 & (8 -1) = 2,不同位置,不碰撞;
例如长度length为8时候,8是2的3次幂。二进制是:1000
如下所示:
hash&(length - 1)这种算法是如何减少哈希碰撞的?可以让数组空间分布均匀
hash:3  数组长度 8
3 & 7

3: 00000011
7: 00000111
------------进行按位于操作
    00000011   --->  3  索引


 当hash:2 数组长度:8
 2 & (8-1)

 2: 00000010
 7: 00000111
 -----------进行按位于操作
    00000010  ---> 2  索引

如果数组长度不是 2 的n次幂:

 如果数组长度不是 2 的n次幂? 计算出的索引特别容易出现hash碰撞,导致其余数组空间很大程度上并没有存储数据,链表或者红黑树过长,效率过低
 hash:3  数组长度: 9
 3 & (9-1)

 3: 00000011
 8: 00001000
 ----------- 进行按位于操作
    00000000 --->  0  索引

hash:2 数组长度: 9
 3 & (9-1)

 2: 00000010
 8: 00001000
 ----------- 进行按位于操作
    00000000 --->  0  索引

注意:当然如果不考虑效率直接求余即可(就不需要求长度必须是2的n次幂)

小结:

  1. 有上面可以看出,当根据key的hash确定其在数组的位置时,如果是2的n次幂,可以保证数据的均匀插入,如果不是2的n次幂,可能数组的一些位置永远不会插入数据,浪费数组的空间,增大了hash碰撞的几率

  2. 另一方面,一般我们可能会想到通过 % 取余来确定位置,只不过性能不如按位于运算。而且当数组长度是2的n次幂时:hash&(length-1) == hash % length。

  3. 因此,HashMap容量是为2次幂的原因。就是为了数据能够均匀的分布在数组中,减少hash冲突,毕竟hash冲突越大,代表数组中的一个链的长度越大,这样的话会降低hashmap的性能。

  4. 如果创建HashMap对象时,输入的数组长度是10,不是2的n次幂。HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是那个数最近的数字

    //创建HashMap集合对象。指定数组的长度为10的话,不是2的n次幂
    HashMap hashMap = new HashMap(10);
    
    public HashMap(int initialCapacity) {  //initialCapacity ==> 10
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
     public HashMap(int initialCapacity, float loadFactor) {
            if (initialCapacity < 0)
                // 初始化容量为负数的异常
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
         //当判断 默认加载因子 小于 0的话或者 判断加载因子是否是Float类型的数字
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
            this.loadFactor = loadFactor;
            this.threshold = tableSizeFor(initialCapacity);
        }
    
    
       /**
         * Returns a power of two size for the given target capacity.
         *返回 2的幂次大小容量
         */
        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;
        }
    

    说明:

    由此可以看到,当在实例化HashMap实例时,如果给定了initialCapacity(假设是10),由于HashMap的capacity必须是2的幂次方,因此这个方法用于找到大于等于initialCapacity(假设是10)的最小2次幂(initialCapacity如果就是2的幂,则返回的还是这个数)。

    下面分析这个算法:

    • 首先,为什么要对cap做减1操作。int n = cap - 1:这是为了防止,cap已经是2的幂。如果cap已经是2的幂,又没有执行这个减1操作,则执行完后面的几条无符号后移操作之后,返回的capacity将是这个cap的2倍
    • 如果n这是为0了(经过了cap - 1之后),则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作)。注意:这里只讨论n不为0的情况。
    • 注意:|(按位或运算):运算规则:当相同的二进制位数上都是0的时候,才为0。否则就是1

    第一次右移:

    //首先通过new HashMap<>()创建HashMap的对象
    Map<String,String> map = new HashMap<>(10);
    //在通过调用数组大小的方法
    this.threshold = tableSizeFor(initialCapacity);
    //执行第一次右移 n = 10 - 1 == 9 
    int n = cap - 1;
    //先无符号右移1位,在执行按位或运算
    n |= n >>> 1;
     00000000 00000000 00000000 00001001 // 9
     00000000 00000000 00000000 00000100 // 9 右移1位
     | // 按位或运算
     00000000 00000000 00000000 00001101 // 13 按位或运算
    
    
    

由于n不等于0,则n的二进制表示中总会有一bit为1,这时考虑最高位的1。通过无符号右移1位,则将最高位的1右移1位,再做或操作。使得n的二进制表示中与最高位的1紧邻的右边也为1:

00000000 00000000 00000000 00001101

第二次右移:

n |= n >>> 2;//n通过第一次右移变为了:
 00000000 00000000 00000000 00001101 //13
|
 00000000 00000000 00000000 00000011 //13右移变为了3
 --------------------------------------------------------
 00000000 00000000 00000000 00001111 //按位或运算的结果15

注意:这个n已经经过了 n |= n >>> 1;操作。假设此时的n的二进制为 00000000 00000000 00000000 00001101,则n无符号右移两位,会将最高位两个连续的1右移两位,然后原来的n做或操作。这样n的二进制表示高位中会有4个连续的1。

# 此时的n通过按或运算变成了15
00000000 00000000 00000000 00001111 ---> 15 

第三次右移:

//n通过第一、二次右移之后变为了15
n |= n >>> 4;
  00000000 00000000 00000000 00001111 // 15
|
  00000000 00000000 00000000 00000000   // 15的二进制右移4位 变为 0
       --------------------------------------------------------
  00000000 00000000 00000000 00001111     //根据按位或运算之后,结果还是15 

这次把已经有的高位中的连续的4个1,右移4位,在做操作,这样n的二进制表示中高位中正常会有8个连续的1。如 00001111 1111XXXX。

以此类推

注意:容量最大也就是32字节(32bit)的正数,因此最后 n |= n>>>16;最多也就是32个1(但是这已经是负数了,在执行tableSizeFor方法之前,对initialCapacity做了判断,如果大于 MAXIMUN_CAPACITY(2^30),则取MAXIMUN_CAPACITY。如果等于MAXIMUN_CAPACITY(2^30),会执行移位操作。所以这里面的移位操作之后,最大30个1,不会大于等于MAXIMUN_CAPACITY。30个1,加1之后的2^30)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2LY3Pp9t-1600262321063)(C:\Users\20296\Desktop\Java笔记\image\java\HashMap\未命名文件.jpg)]

注意:得到的这个capacity却被赋值给了threshold;

总结:如果初始容量不是2的n次幂的话,HashMap就会通过tabaleSizeFor()方法,进行一系列的右移操作后,变成比初始值指定的容量还要大的2的n次幂容量。

3、默认的加载因子,默认值为 0.75

 /**
     * 如果在创建HashMap对象时,没有指定加载因子的话,将会来使用默认的加载因子.
     *加载因子决定着数组扩容,因为HashMap的初始化容量为16,但是不会将16个空间都存满了值之后再进行扩容,而是通过 初始容量 16 * 默认的加载因子 0.75 给定一个限制值,当数组中的元素大于 ==  12时,再进行扩容。
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

4、集合最大容量

 /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     *集合最大容量的上限是:2 ^ 30
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

5、当链表的值超过8则会转红黑树(jdk1.8之后新添加的一种数据结构)

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

问题:为什么Map桶中节点个数超过8才转为红黑树?

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

在HashMap中有一段注释说明:

/**
    *Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
*/

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1PFTGeHu-1600262321065)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598326095817.png)]

    /* *
     * 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
     */ 

TreeNodes占用的空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TressNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且查看源码的时候发现,链表的长度达到8时就会转成红黑树,当长度降到6时就转成普通bin

这样就解释了为什么不是一开始就将其转换为TreeNodes,而实需要一定节点数才转为TreeNodes,说白了就是权衡,空间和时间的权衡

这段内容还说到:当hashCode方法离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为 0.00000006,几乎是不可能的事件。所以,之所以选择8,不是随便决定的,而实根据概率统计决定的。由此可见,发展将近30年的Java每一项改动和优化都是非常的严谨的。

也就是说:选择8是因为泊松分布,超过8的时候,概率已经非常小了,所以选择8这个数字来作为阈值。

泊松分布的简介

泊松分布(Poisson distribution),是一种统计与概率学里常见到的离散机率分布。

特点:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SKvkK6vL-1600262321066)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598339088785.png)]

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

以下是在一些资料上面翻看的解释:

红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为 n/2,当长度为8时,平均查找长度为 8/2 = 4,这才有转换成树的必要;链表长度如果小于等于6,6/2 = 3,而log(6) = 2.6,虽然速度也很快,但是转化为树结构和生成树的时间并不会太短。

6、当链表的值小于6则会从红黑树转回链表

/**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     *当桶上的结点数小于这个值时树会转成链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;

7、最小的树容量

/**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     *当Map里面的数量超过这个值时,表中的桶才能转化成红黑树,否则桶内的元素过多时会进行扩容,而不是树形化为了避免进行扩容、树形化选择的冲突,这个值不能小于  4 * TREEIFY_CAPACITY(8)
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

8、table用来初始化(必须是2的n次幂)

//不被序列化的,存储元素的数组
transient Node<K,V>[] table;

table在JDK1.8中我们了解到**HashMap是有数组+链表+红黑树来组成的结构,其中table就是HashMap中的数组**,JDK1.8之前数组类型是Entry<K,V>类型。在JDK1.8之后是Node<K,V>类型。这是换了个名字,都实现了一样的接口:Map.entry<K,V>。负载存储键值对数据的。

9、用来存储缓存的

//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;

10、HashMap中存放元素的个数(重点)

//存放元素的个数,注意这个不等于数组的长度
transient int size;

size为HashMap中 K-V的实时数量,不是数组table的长度。

11、用来记录HashMap的修改次数

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

12、用来调整大小下一个容量的值计算方式为(初始容量*负载因子)

//临界值,当实际大小(容量*加载因子)超过临界值时,会进行扩容
int threshold;

13、哈希表的加载因子(重点)

//加载因子
final float loadFactor;

说明:

  1. loadFactor加载因子,是用来衡量HashMap满的程度,表示Hash Map的疏密程度,影响hash操作到同一个数组位置的概率,计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity是桶的数量,也就是table的长度length。

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

  3. 当HashMap里面的容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太拥挤了,需要扩容,而扩容这个过程涉及到rehash、复制数据等操作,非常消耗性能。所以开发中应该尽量减少扩容次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免

  4. 同时在HashMap的构造中可以定制loadFactor

    构造方法:
    HashMap(int initialCapacity, float loadFactor)构造一个带指定初始容量和加载因子的空 HashMap。
    
    
  5. 为什么加载因子loadFactor设置为0.75,初始化临界值为12呢?

    1. loadFactot越趋近于1,那么数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组存放的数据(entry)也就越少,也就越稀疏。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ami10Qrm-1600262321067)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598409132808.png)]

​ 如果希望链表尽可能少些,要提前扩容,有的数组空间有可能一直没有存储数据。加载因子尽可能小一些。

例如:

加载因子是0.4时,那么通过初始容量16 * 0.4 ---->6 如果数组中满6个空间就扩容会造成数组利用率太低了。

加载因子是0.9时,那么 16 * 0.9 ----> 14 那么这样就会导致链表有点多了。导致查找元素效率低。

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

threshold(边界值)计算公式:capacity(数组长度默认为 16) * 默认的加载因子(0.75)。这个值是当前已占用数组长度的最大值。当**Size >=threshold**的时候,那么就要考虑对数组的resize(扩容),也就是说,这个的意思就是衡量数组是否需要扩增的一个标准扩容后的HashMap 容量是之前容量的两倍

4.2、构造方法

HashMap中重要的构造方法,他们分别如下:

1、构造一个空参的HashMap,默认初始容量(16)和加载因子(0.75)

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的加载因子0.75 复制个loadFactor,并没有创建数组。  
    }

在JDK1.8之前是直接在无参构造方法中创建数组的。但是在JDK1.8之后是在调用put方法后创建数组的。

2、构造一个指定初始值容量和默认加载因子(0.75)HashMap

/**
* int initialCapacity 指定的初始容量
*/
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

3、构造一个指定初始值容量和负载因子的HashMap。

/*
	指定“容量大小”和“加载因子”的构造方法,
	initialCapacity :指定的容量
	loadFactor:指定的加载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
    //判断初始容量是否小于 0的
        if (initialCapacity < 0)
            //如果小于0,则抛出非法的参数异常
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
    //判断初始容量是否大于集合的最大容量 MAXUMUN_CAPACITY -》 2的30次幂
        if (initialCapacity > MAXIMUM_CAPACITY)
            //如果大于最大容量,就将最大容量赋值个初始容量
            initialCapacity = MAXIMUM_CAPACITY;
    //判断 加载因子是否小于0 或者  加载因子不是一个数字的话
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            //如果满足上面的条件,抛出非法参数的异常
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFac tor);
    //将指定的加载因子赋值个HashMap成员变量的加载因子loadFactor。
        this.loadFactor = loadFactor;
    /*
    	tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量最大的最小的2的n次幂。
    	但是,在tableSizeFor()方法内部将计算的结果返回给这里了,并且直接赋值给threshold 边界值,有些人觉得这里是一个bug,应该这样书写:
    	this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
    	但是,在JDK1.8之后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到put()方法中,在put方法中会对threshold重新计算,put方法的具体实现我们下面会进行讲解
    */
        this.threshold = tableSizeFor(initialCapacity);
    }

//调用tableSizeFor方法
 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;
    }


说明:

对于this.threshold = tableSizeFor(initialCapacity);疑问解答

tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量最大的最小的2的n次幂。
    	但是,在tableSizeFor()方法内部将计算的结果返回给这里了,并且直接赋值给threshold 边界值,有些人觉得这里是一个bug,应该这样书写:
    	this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
    	但是,在JDK1.8之后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到put()方法中,在put方法中会对threshold重新计算,put方法的具体实现我们下面会进行讲解

4、包含另一个“Map”的构造函数

//构造一个映射关系与指定Map相同的新HashMap 
public HashMap(Map<? extends K, ? extends V> m) {
    //负载因子loadFactor变为默认的负载因子0.75
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

调用putMapEntries(m,false)方法

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    //获取传递集合长度
        int s = m.size();
    //判断长度 是否 大于 0
        if (s > 0) {
            //如果大于0,在判断数组是否为空
            if (table == null) { // pre-size
                //如果数组为空的话,就将集合长度 除以负载因子 在加上 1
                float ft = ((float)s / loadFactor) + 1.0F;
                //再进行三目判断 ft 是否小于 最大的容量
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                //再判断 t 是否大于边界值
                if (t > threshold)
                    //如果大于边界值再调用tableSizeFor方法将t转换成比t大的最小2的n次幂 
                    threshold = tableSizeFor(t);
            }
            //如果数组不等于null,那么再判断集合长度是否 大于 边界值
            else if (s > threshold)
                //进行扩容操作
                resize();
            //遍历原来的集合,将原来的集合存进新的集合中
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                //取出key和value
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

注意:

float ft = ((float) s / loadFactor) + 1.0F;这一行代码最后为什么需要加1?

s/loadFactor的结果是小数的话,加1.0F与(int)ft相当于是对小数做一个向上取整以尽可能的保证更大容量,更大的容量能够减少resize()方法调用次数。所以 +1.0F是为了获取更大的容量。减少执行扩容所消耗的时间。

4.3、成员方法

4.3.1、增加方法put()

put发放是比较复杂的,实现步骤大致如下:

  1. 先通过hash()方法计算出key映射到哪个桶;
  2. 如果桶上没有碰撞冲突,则直接插入;
  3. 如果出现碰撞冲突了,则需要处理冲突:
    1. 如果该桶使用红黑树处理冲突。则调用红黑树的方法插入数据。
    2. 否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树。
  4. 如果桶中存在重复的键,则为该键替换新值value;
  5. 如果size大于阈值threshold,则进行扩容;

具体的方法如下:

 public V put(K key, V value) {
     //先调用hash方法计算出key的hash值  
        return putVal(hash(key), key, value, false, true);
    }

说明:

  1. HashMap只提供了put用于添加元素,putVal方法只是给put方法调用的一个方法,并没有提供给用户便用。所以重点看putVal方法。

  2. 可以看到putVal方法中的key在这里执行可hash方法

    static final int hash(Object key) {
            int h;
        //判断 key是否为空,如果为空的话就返回0,否则的话通过调用hashCode计算哈希值在将计算出的值无符号右移16位,在进行按位异操作得到最后的hash值。  
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
    
    

    在putVal函数中使用到了上述hash函数计算的hash值:

    final V putVal(int hash,K key, V value, boolean onlyIfAbsent,boolean ecivt){
        //这里的n表示数组的长度,默认n = 16
        if ((p = tab[i = (n - 1) & hash]) == null){
            
        }
    }
    
    

    计算过程如下:

    说明:

    1. key.hashCode();返回散列值也就是hashcode。假设随便生成一个值。
    2. n表示数组初始化的长度是16;
    3. &(按位与运算):运算规则:相同的二进制数位上,都是1的时候,结果为1,否则都为零。
    4. ^(按位异或运算):运算规则:相同的二进制数位上,数字相同都为0,否则都是1。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kIhWYTkB-1600262321067)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598499405843.png)]

    简单来说就是:

    • 高 16 bit不变,低16 bit做了一个异或操作(得到的 hashCode转化为32位二进制,前16位和后16位低 16bit和高16 bit做了一个异或)。

    问题:问什么需要这样的操作?

    如果当n即数组长度很小,假设是16的话,那么 n-1 即为 —》1111,这样的值和hashCode()直接做按位与操作,实际上只使用了hash值得后4位。如果当hash值的高位变化很大,低位变化很小,这样就很容易造成hash碰撞,所以这里把高低位都利用起来,从而解决了这个问题。

    例如:
    key.hashCode()的值 :
    					1111 1111 1111 1111 1111 0000 1110 1010 			
     		&
    n - 116 - 1 = 150000 0000 0000 0000 0000 0000 0000 1111
    -------------------------------------------------------------
    			0000 0000 0000 0000 0000 0000 0000 1010 ----> 计算的索引结果为10
    其实就是将hashCode值做为数组索引,那么如果下个高位hashCode不一致,低位一致的话,就会造成计算的索引还是 10,从而造成了hash冲突,降低性能。
    例如:
    key.hashCode()的值:
    		1111 1001 0000 1111 1111 0000 1110 1010
    		&
    		0000 0000 0000 0000 0000 0000 0000 1111
    		--------------------------------------------
    		0000 0000 0000 0000 0000 0000 0000 1010 ----> 计算的索引结果为10
    
    

    (n - 1)& hash = -> 得到下标( n - 1) n表示数组长度16,n-1就是15;

    取余数的本质就是不断做除法,把剩余的数减去,运算的效率要低于位运算。

    现在看putVal()方法,看看它到底是怎样运作的。

    主要参数:

    • int hash ----> hash方法计算出的key的hash值;
    • K key -----> put方法传过来的 key,原始的key;
    • V value -----> put方法传过来的value,要存放的值;
    • boolean onlyIfAbsent -----> 如果true就代表不更改现有的值;
    • boolean evictl -----> 如果为false表示table为创建状态;

    putVal()方法源代码如下表示:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            //初始化数组
            Node<K,V>[] tab;
            Node<K,V> p;
            int n;
            int i;
            /**
             * transient Node<K,V>[] table;表示存储集合中元素的数组</>
             * (tab = table) == null 表示将table数组中的元素赋值给tab,然后判断是否为空。第一次创建的话肯定是null;
             * (n = tab.length) == 0 表示将数组的长度赋值给 n,然后判断是否等于0,如果等于0
             * 满足条件,进入(tab = resize()).length代码进行数组的扩容,并且将扩容之后的数组长度赋值给n
             */
            //如果数组为空,或者数组的长度等于 0
            if ((tab = table) == null || (n = tab.length) == 0)
                //进行扩容,并且将数组的长度赋值给n
                n = (tab = resize()).length;
            //判断 将数组长度大小减1 与传过来的hash值进行按位与操作,计算出往数组存储的索引,默认的数组长度为n= 16;
            //根据传过来的hash值和数组长度减1进行按位与操作,判断是否为空
            if ((p = tab[i = (n - 1) & hash]) == null)
                //如果等于 null,就根据传进来的hash值,key,value创建一个新的节点
                tab[i] = newNode(hash, key, value, null);
            else {
                //如果计算出的索引值已经存在
                //定义一个k,v的节点,
                Node<K,V> e; K k;
                //判断指定索引的p节点的hash值是否等于传进来的hash值并且判断p节点的key是否等于传进来的key或者传进来的key不等于null并且key等于k
                //判断
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    //如果等于,就将p节点赋值给e
                    e = p;
                //判断 p 是否是 树节点的实例
                else if (p instanceof TreeNode)
                    //如果是的话,调用树实例的putVal方法进行添加
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                else {
                    //如果hash值相等但是,key不相等的话,就进行遍历链表
                    for (int binCount = 0; ; ++bincount) {
                        //p节点的下一个节点赋值给e后再判断是否等于null,p节点最是最后一个节点了,就直接添加节点
                        if ((e = p.next) == null) {
                            //如果p节点的下一个节点为null的话,就创建一个节点,并且使p.next指向新创建的节点
                            p.next = newNode(hash, key, value, null);
                            //判断 节点个数是否大于 8
                            //但是当链表的节点大于7的时候,就将链表转换为红黑树
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                //如果大于8,就将链表转化为红黑树
                                treeifyBin(tab, hash);
    
                            break;
                        }
                        //判断e节点的hash值是否等于传进来的hash值,
                        /*
                        判断 e节点中的hash值是否与hash值相等,如果相等就出现hash冲突
                        将e节点的key赋值给k来和传进来的 key进行比较,如果key相等就说明桶中的链表存放了该key
                         */
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            /*
                            要添加的元素和链表中的存在的元素的key相等了,则跳出fot循环,不用再继续比较了
                            直接执行下面的if语句
                             */
                            break;
                        /**
                         * 说明新添加的元素和当前节点不相等,继续查找下一个节点,
                         * 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历集合链表。
                         */
                        p = e;
                    }
                }
                /*如果e节点 不为空的话
                表示在桶中找到key值、hash值与插入的元素相等的节点,
                也就是说通过上面的操作找到了重复的键,所以这里把该键的旧值覆盖为新值,通过put方法完成了修改
                 */
                if (e != null) { // existing mapping for key
                    //就将e节点中存储的value赋值给oldValue,记录e节点的value
                    V oldValue = e.value;
                    //判断 onlyIfAbsent为false的话或者oldValue等于null
                    if (!onlyIfAbsent || oldValue == null)
                        //就表示可以更改现在的值,就将新的value赋值个e节点中的value,进行value覆盖
                        e.value = value;
                    //访问后调用
                    afterNodeAccess(e);
                    //返回oldValue
                    return oldValue;
                }
            }
            //修改Map结构+1
            ++modCount;
            //判断数组内存储的元素大小是否大于边界值
            if (++size > threshold)
                //如果大于边界值的话,就执行数组的扩容操作
                resize();
            afterNodeInsertion(evict);
            return null;
        }
    
    

    小结:

    1. 当第一次调用该方法时,先判断数组是否为空或者数组的长度是否小于0;
      1. 当数组为空或者长度小于0的时候,这些resize方法进行扩容,在将扩容之后的数组长度赋值给n;这也就是说在JDK1.8之后是在put方法里面创建数组的。
    2. 在根据 (n - 1) & hash公式进行计算出索引值,并且根据索引值取出内容,在判断是否等于null;
      1. 如果等于 null,就表示该节点为空,可以直接插入内容。就会调用 newNode()方法创建一个链表节点。
    3. 如果数组不为空而且计算出的索引值也不等于空,就对进入判断 p节点中的hash值是否等于传进来key计算出的hash值;
      1. 如果两者相等的话,就表示会出现hash冲突,在进行判断 p节点的key是否等于 传进来的key,如果等于就表示桶中的链表存在该key,就会将 p节点的内容赋值给 e节点。
      2. 如果p节点的hash值不等于 传进来的hash值的话,就会进行判断 p节点是不是 树节点得实例;
        1. 如果是树节点得实例,就会调用putVal版的树方法。
      3. 否则 hash值相等,但是key不相等,就会进行遍历链表;
        1. 判断p节点的下一个节点是否等于null,如果等于null,就表示遍历到链表的尾部,可以直接添加节点。
        2. 当添加节点之后,会进行判断 链表的节点数是否 大于 链表转红黑树的边界值,如果大于的话,就会调用treeifyBin方法,这个后面会分析的。
    4. 如果e节点 不为空的话,表示在桶中找到key值、hash值与插入的元素相等的节点,也就是说通过上面的操作找到了重复的键,所以这里把该键的旧值覆盖为新值,通过put方法完成了修改。
    5. 判断数组内桶的数量是否大于边界值,如果大于边界值,就进行扩容。
4.3.2、将链表转换为红黑树结构的方法treeifyBin

if (binCount >= TREEIFY_THRESHOLD - 1)会调用treeifyBin方法,因为binCount表示的是链表的节点数,而 TREEIFY_THRESHOLD则是桶中节点数的边界值,当链表的节点数大于 8 就会执行该方法。

源码分析:

/**
     * 替换指定hash表的索引处桶中的所有链表节点,除非表太小,否则将修改大小
     * 当链表的节点数大于8,就将链表转换为红黑树
     * @param tab Node<K,V> table</>
     * @param hash int hash值
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        //定义数组的长度n,数组的索引值index,定义带K,V键值对的Node节点
        int n, index; Node<K,V> e;
        //当链表的节点数大于8,但是需要判断数组为空或者数组长度小于最小树形化的阈值(64)
        //目的就是因为,当数组很小,那么转换为红黑树的话,然后遍历效率要低一些
        //这时进行扩容,那么c重新计算hash值,链表的长度有可能变短,数据会方法到数组中,这样相对来说效率会高一些。
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            //如果等于null或者n小于最小的树容量就执行数组扩容的方法
            resize();
        //否则如果 通过 (n-1) & hash 公式进行计算,取出tab[index]的值赋值给e,并且判断不等于null
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            /*
            执行到 这里说明数组的长度已经大于最小的树形化阈值,开始将链表转换为红黑树
             */
            //初始化 树节点的 头节点和尾节点
            TreeNode<K,V> hd = null, tl = null;
            //循环
            do {
                //创建一个新的树节点,并且赋值给 p
                //e 表示桶中的元素
                TreeNode<K,V> p = replacementTreeNode(e, null);
                //如果尾节点等于空
                if (tl == null)
                    //就将之前创建好的p 赋值给头节点
                    hd = p;
                //否则的话
                else {
                   /*
                   p.prev = tl ;将上一个节点p赋值给现有的p的前一个节点
                   tl.next = p;将x现有节点p赋值给tl的下一个节点
                    */
                    p.prev = tl;
                    //在使tl.next节点指向p,这样就将新创建的节点添加新树中
                    tl.next = p;
                }
                //将p节点赋值给tl节点,这样桶中又是hd、p、tl
                tl = p;
                //继续取出链表中节点转换为红黑树
                //将 e节点的下一个节点赋值给,并且判断e节点不等于null,继续循环,这样就完成了链表的遍历
            } while ((e = e.next) != null);
            //让桶中第一个元素即数组中的元素指向新建的红黑树节点,以后该桶中的数据结构就是红黑树结构,而不是链表
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }


小结:上述操作一共做了如下几件事:

  1. 根据hash表中元素个数确定是扩容还是树形化。
  2. 如果是树形化遍历桶中的元素,创建相同个数的树形化节点,复制内容,建立起联系。
  3. 然后让桶中的第一个元素指向新创建的数根节点,替换桶的链表内容为树形化内容。
4.3.3、扩容方法 resize()
4.3.3.1、扩容机制

想要了解HashMap的扩容机制你要有这两个问题

  • 什么时候才需要扩容;
  • HashMap的扩容是什么;

1、什么时候才需要扩容:

当HashMap中的元素个数超过数组大小(数组长度) * loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值是(DEFAULT_LOAD_FACTOR)0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过了 12时,就把数组的大小扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已知预知HashMap的元素个数,那么预知元素的个数能够有效的提高HashMap的性能。

补充:

当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决;如果已经达到了64,那么这个链表会转变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize()方法时判断树的节点个数为6时,会将红黑树转换成链表。

2、HashMap的扩容是什么

进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中的所有的元素,是非常耗时的。在编写程序时,要尽量避免扩容。

HashMap在进行扩容时,使用rehash方式非常的巧妙,因为每次扩容都是翻倍,与原来计算的( n - 1 ) & hash的结果相比,只是多了一个bit位, 所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置。

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

原数组的长度:16  n = 16 -  1 ---->15
            0000 0000 0000 0000 0000 0000 0001 0000          16
            0000 0000 0000 0000 0000 0000 0000 1111          15  n -1
假设hashCode()生成值:
(n - 1) & hash
            0000 0000 0000 0000 0000 0000 0000 1111
            & 
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
            0000 0000 0000 0000 0000 0000 0000 0101 --------》通过计算,得出索引值为5

            0000 0000 0000 0000 0000 0000 0000 1111    15   n-1
            &
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101
----------------------------------------------------
            0000 0000 0000 0000 0000 0000 0000 0101 -----》通过计算,得出的索引值还是5

=======================上面是数组还没有进行扩容的,下面是执行了数组扩容的==================================            

现在数组的长度扩容,从16扩容到32
            0000 0000 0000 0000 0000 0000 0010 0000       32
            0000 0000 0000 0000 0000 0000 0001 1111       31  n-1
            &
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
            0000 0000 0000 0000 0000 0000 0000 0101   ----->通过计算,数组进行扩容之后的索引值还是5

            0000 0000 0000 0000 0000 0000 0001 1111       31  n-1
            &
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101
----------------------------------------------------
            0000 0000 0000 0000 0000 0000 0001 0101 -----》通过计算,得出索引值是21

扩容之后的索引位置,可能是原来的索引值,也可能是"旧的索引值 + 旧的容量"

结论:
计算新的索引高位是0那么存储到原来的索引位置,如果高位是1那么存储的索引位置是原来"旧的索引 + 旧的容量"

因此元素在重新计算hash之后,因为n变为2倍,那么n-1的标记范围在高位多1bit,因此新的index就会发生这样的改变:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7r72Nef5-1600262321068)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598613552796.png)]

说明:5是假设计算出来的原来索引。这样就验证了上述所描述的:扩容之后所以节点要么就在原来的位置,要么就被分配到"原位置 + 旧容量"这个位置;

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成“原索引 + oldCap”。可以看看下图为16扩充为32的resize示意图:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tfQCwGOq-1600262321068)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598613819137.png)]

正是因为这样巧妙的rehash方式,既省去了重新计算hash值得时间,而且同时,由于新增的bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每一个桶上的节点数一定小于原来桶上的节点数,保证了rehash后不会出现严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中

4.3.3.2、源码resize()方法的解读

下面是代码的具体实现

final Node<K,V>[] resize() {
        //将table数组赋值给oldTab
        Node<K,V>[] oldTab = table;
        //如果数组的为空的话,容量就为0,否则的话就获取数组的长度。
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //将threshold阈值赋值给oldThr,一开始边界值为0
        int oldThr = threshold;
        //定义一个newCap和newThr;分别是新的容量和新的阈值
        int newCap, newThr = 0;
        //判断 就得容量是否大于0
        if (oldCap > 0) {
            //如果大于0,就继续判断旧的容量是否大于等于最大容量
            if (oldCap >= MAXIMUM_CAPACITY) {
                //如果九的容量大于等于最大容量,那么就将Integer的最大值赋值给边界值
                threshold = Integer.MAX_VALUE;
                //然会数组
                return oldTab;
            }
            //否则如果旧的容量 左移1位等于旧的容量的2倍并且赋值给新的容量 小于最大容量,并且旧的容量大于等于默认的初始容量
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //就将旧的边界值 左移1位赋值给新的边界值
                newThr = oldThr << 1; // double threshold
        }
        //否则如果 旧的边界值 大于 0
        else if (oldThr > 0) // initial capacity was placed in threshold
            //就将旧的边界值 赋值给 新的容量
            newCap = oldThr;
        //否则初始化位0的话
        else {               // zero initial threshold signifies using defaults
            //将默认的初始容量赋值给 新的容量
            newCap = DEFAULT_INITIAL_CAPACITY;
            //将默认的负载因子 * 默认的初始容量 赋值给新的边界值,边界值就等于 12
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //判断 新的边界值是否等于0
        if (newThr == 0) {
            //如果等于0,就将新的容量 * 负载因子并且赋值给ft
            float ft = (float)newCap * loadFactor;
            //判断新的容量小于最大容量 并且 ft小于最大的容量,如果满足的话就将ft强转并赋值给新的边界值,否则就将Integer的最大值赋值给新的边界值
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //再将新的边界值赋值给threshold
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
                //根据新的容量创建一个节点数组并且赋值给新的数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        //将扩容后的数组赋值给table
        table = newTab;
        //判断旧的数组 不等于 null
        if (oldTab != null) {
            //遍历数组集合,将旧的数组内容放进新的数组中
            for (int j = 0; j < oldCap; ++j) {
                //定义一个节点 e
                Node<K,V> e;
                //取出旧的数组中的元素集合,将取出的元素放进node e中存放
                if ((e = oldTab[j]) != null) {
                    //如果不为空的话,就将该下标的存储的内容赋值为null,方便与gc的回收
                    oldTab[j] = null;
                    //判断e节点的下一个节点是否等于null
                    if (e.next == null)
                        //表示当前的 e不是一个链表,而是一个数组
                        //如果等于nll的话,将e节点的内容赋值给通过e节点中的hash值 和 新的容量 -1 通过按位于运算出新数组的下标
                        newTab[e.hash & (newCap - 1)] = e;
                    //否则如果 e节点是 树节点的实例
                    else if (e instanceof TreeNode)
                        //将红黑树拆分开
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //当e.next != null 并且也不是红黑树
                    else { // PRESERVE ORDER
                        /*
                        链表的拆分:
                        将通过 (n - 1) & hash 计算出同一链表中节点的hash值,
                        如果高位为1,索引值(index) = (oldIndex + olaCap);
                        如果高位为0,索引值不变
                        创建两个头尾节点,表示两条链表
                        因为旧链表上的元素放入新数组中,最多变成两条链表
                        一条是下标不变的,用来直接存放旧链表的,可以称之为 A链表
                        一条是下标+数组容量,用来存放高位是1的hash索引 可以称之为 B链表
                         */
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        //用于存放e.next的内容
                        Node<K,V> next;
                        //循环链表
                        do {
                            //先将e.next的内容赋值给next
                            next = e.next;
                            //判断 e节点的hash值 和 旧容量进行按位与运算,并且判断是否等于0
                            //如果等于 0 ,就直接将这个节点放入新的数组中,下标不变
                            if ((e.hash & oldCap) == 0) {
                                //判断这个A链表的尾节点是否等于null
                                if (loTail == null)
                                    //如果等于null,就直接将e节点赋值给A链表的头节点
                                    loHead = e;
                                //否则 A链表的尾节点不等于null
                                else
                                    //将e节点添加到A链表的尾部
                                    loTail.next = e;
                                //将A链表的尾部更新指向新加入的节点
                                loTail = e;
                            }
                            //如果不等于0,则就为1
                            else {
                                //判断 B链表的尾部是否等于null
                                if (hiTail == null)
                                    //如果等于null,使e节点成为B链表的头节点
                                    hiHead = e;
                                //否则
                                else
                                    //将e节点添加到 B链表的尾部
                                    hiTail.next = e;
                                //将链表的尾部更新指向新加入的节点
                                hiTail = e;B
                            }
                            //判断 e 的下一个是否等于null,入等于null,就表示是最后一个节点
                        } while ((e = next) != null);
                        //所有节点遍历完毕,判断下标不变的链表是否有节点在其中
                        if (loTail != null) {
                            //将这条链表的最后一个节点的下一个节点指向为null
                            loTail.next = null;
                            //将该链表的头节点,存放在指定索引值新数组中
                            newTab[j] = loHead;
                        }
                        //B链表与A链表的原理相同,只是在新数组中的索引值发生了改变
                        if (hiTail != null) {
                            hiTail.next = null;
                            //在新的数组的下标为(原数组的索引值+旧容量)
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        //扩容完毕,返回新的数组
        return newTab;
    }

4.3.4、删除方法(remove)

在理解了put方法之后,会发现HashMap的增(改)删查三大方法,都是在内部对链表进行操作,当出现链表节点数大于8就将链表转换成红黑树。但是当树节点个数小于6时,又将其结构从红黑树转换成链表结构。

public V remove(Object key) {
        //定义一个Node<k,V>类型的节点
        Node<K,V> e;
        //还是先通过hash方法计算key的hash值,在调用其内部删除节点方法
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

removerNode方法:

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;
        //判断数组不等于null && 数组的长度必须大于 0 && 根据数组长度减1 和hash值进行与运算的索引值去除的内容不等于null
        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;
            //判断删除的元素和传递的key值是否相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //取出节点的数据
                node = p;
            //判断 p的下一个节点取出来不等于null
            else if ((e = p.next) != null) {
                //就判断p节点是否是树节点的实例
                if (p instanceof TreeNode)

                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                //否则的话,就遍历链表
                else {
                    //循环
                    do {
                        //判断e节点的hash值和传递进来的hash值是否相等,再判断传递的key是否相等
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            //如果相等,就将e节点赋值给node辅助节点
                            node = e;
                            //退出循环
                            break;
                        }
                        //否则的话,就将e再复制给p节点
                        p = e;
                        //判断e节点的下一个节点是否为null
                    } while ((e = e.next) != null);
                }
            }
            //判断node节点是否等于null && matchValue取反就是true,就可以进行删除
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                //判断node节点是否是树节点的实例
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                //否则 如果 p节点是否和node相等
                else if (node == p)
                    //如果相等,就执行链表的删除
                    tab[index] = node.next;
                else
                    p.next = node.next;
                //map的修改次数加1
                ++modCount;
                //元素的实际大小减1
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        //否则就返回根据key键映射不到值,删除失败
        return null;
    }

总结:

  1. 现计算出key的hash值,再通过调用内部的方法,判断其key的hash值是否存在,如果不存在就返回null。
  2. 如果存在就需要判断其桶中链表的节点key是否相等,如果相等还需要判断该节点的数据结构是链表还是红黑树。
  3. 如果桶中的节点数有多个,就需要进行遍历操作。
4.3.5、查找元素方法(get)

查找方法,通过元素的key进行映射从而找到value

代码如下:

 public V get(Object key) {
        Node<K,V> e;
        //还是需要先计算出key的hash值,再通过getNode方法进行查找桶中的value
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

get()方法主要调用的是getNode方法,代码如下:

final Node<K,V> getNode(int hash, Object key) {
        //定义数组,辅助节点,数组长度n,K
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //先判断数组是否为空,&& 判断数组的长度是否为0 && 根据(n - 1) & hash
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //判断辅助节点的hash值是否和传递进来的hash值相等 && 判断key值是否相等
            /*
            判断数组元素是否相等,根据索引值检查第一个元素, 
            */
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                //如果相等,就代表着查找到,返回first
                return first;
            //如果key值不相等,就将辅助节点的下一个节点peek出,判断是否等于null
            if ((e = first.next) != null) {
                //再判断辅助节点是否是树节点的实例
                if (first instanceof TreeNode)
                    //调用get方法数节点版的、
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //如果不是树节点的实例,就开始循环
                do {
                    //判断key的hash值是否像等
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                    //如果不相等,就peek出下一个节点
                } while ((e = e.next) != null);
            }
        }
        //否则返回null
        return null;
    }

总结:

  1. get方法的实现步骤:

    1. 通过hash值获取该key映射到的桶。
    2. 桶上的key就是要查找的key,则直接找到并返回。
    3. 桶上的key不是要找到的key,则查看后续的节点:
      1. 如果后续节点是红黑树节点,通过调用红黑树的方法根据key获取value。
      2. 如果后续节点是链表节点,则通过循环遍历链表根据key获取value。
  2. 上述红黑树节点调用getTreeNode方法通过树形节点的find方法进行查找:

    final TreeNode<K,V> getTreeNode(int h, Object k) {
                return ((parent != null) ? root() : this).find(h, k, null);
            }
               final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
                //遍历红黑树,将当前桶的第一个复制给p节点
                TreeNode<K,V> p = this;
                //循环
                do {
                    int ph, dir; K pk;
                    //分别获取左节点和右节点
                    TreeNode<K,V> pl = p.left, pr = p.right, q;
                    //r如果p节点的hash值 大于 传进来的hash值
                    if ((ph = p.hash) > h)
                        //就将左节点复制个p
                        p = pl;
                    else if (ph < h)
                        p = pr;
                    //否则 如果 p节点的key值等于传进来的key
                    else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                        //返回 p节点
                        return p;
                    else if (pl == null)
                        p = pr;
                    else if (pr == null)
                        p = pl;
                    else if ((kc != null ||
                              (kc = comparableClassFor(k)) != null) &&
                             (dir = compareComparables(kc, k, pk)) != 0)
                        p = (dir < 0) ? pl : pr;
                    else if ((q = pr.find(h, k, kc)) != null)
                        return q;
                    else
                        p = pl;
                } while (p != null);
                return null;
            }
    
    
  3. 查找红黑树,由于之前添加时已经保证这个树是有序的,因此查找时基本就是折半查找,效率高。

  4. 这里和插入时一样,如果对比节点的hash值和要查找的hash值相等,就会判断key是否相等,相等就直接返回。不相等就从子树中递归查找。

  5. 若为树,则在书中通过key.equals(key)查找,O(logn);

  6. 若为链表,则在链表中通过key.equals(key)查找,O(n);

4.3.6、遍历HashMap集合几种方式

1、分别遍历Key和Values

private static void method(Map<String, String> map) {
        //获取所有的Key
        Set<String> strings = map.keySet();
        for (String string : strings) {
            System.out.println("遍历的Key:\t"+string);
        }
        //获取所有的value
        Collection<String> values = map.values();
        for (String value : values) {
            System.out.println("遍历的Values:\t"+value);
        }
    }

2、使用迭代器迭代

private static void method_1(Map<String, String> map) {
        Set<Map.Entry<String, String>> entrySet = map.entrySet();
        for (Iterator<Map.Entry<String, String>> it = entrySet.iterator();it.hasNext();){
            Map.Entry<String, String> next = it.next();
            System.out.println("使用Iterator迭代器获取Key和Values:\t"+next.getKey()+"----"+next.getValue());
        }
    }

3、使用get方式(不建议使用)

private static void method_2(Map<String, String> map) {
        //获取所有的键
        Set<String> keySet = map.keySet();
        for (String s : keySet) {
            String value = map.get(s);
            System.out.println("通过get方法获取到的:\t"+s+"-----"+value);
        }
    }

说明:根据阿里巴巴开发手册,不建议使用这种方法,因为需要迭代两次。keySet获取Iterator一次,还通过get又迭代一次。降低性能。

4、JDK8以后的使用Map接口中的默认方法

 /**
     * 再JDK8之后使用Map接口默认的方法
     * default void forEach(BiConsumer<? super K,?super V> action</>)
     * 对此映射中的每一条目执行给定的操作,直到所有的条目都被处理或操作引发异常。
     * 参数:
     *  BiConsumer 消费接口:
     *  抽象方法 :void accept(T t,U u)给定的参数执行此操作
     *  参数 :
     *  T ---》Key
     *  U ---》Value
     * @param map
     */
    private static void method_3(Map<String, String> map) {
        map.forEach((key,value)->{
            System.out.println("JDK8之后使用Map接口默认的方法\t"+key+"----"+value);
        });
    }

五、如何设计多个非重复的键值对要存储HashMap的初始化?

5.1、HashMap的初始化问题描述

如果我们确切的知道我们有多少键值对需要存储看,那么我们在初始化HashMap的时候就应该指定它的容量,以防止HashMap自动扩容,影响使用效率。

默认的情况下HashMap的容量是16。但是,如果用户通过构造函数指定了一个数字作为初始化的容量,那么HashMap会通过tableSizeFor方法进行计算,得到一个大于该数字的第一个2的幂作为容量。(3 —> 4、 7 —>8 、 13 —> 16 )。这点再上面已经描述了为什么这样做。

在《阿里巴巴Java开发手册》中建议我们设置HashMap的初始化容量。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eRUk7ZYX-1600262321069)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598679143368.png)]

那么,为什么要这么建议呢?

当然,以上建议都是在理论的支撑上进行的。上面介绍过,HashMap的扩容机制,就是当达到扩容条件时,会进行扩容。HashMap的扩容条件就是当数组的元素大小(szie) 大于 边界值(threshold)时就会发生扩容。在HashMap中,threshold = loadFactor *capacoty;

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

但是设置初始容量大小之后,设置的数值不同也会影响性能,那么当我们已知HashMap中需要存放的K,V个数时,容量设置成多少是最好的呢?

5.2、HashMap中容量的初始化

当我们使用HashMap(initialCapacity)来初始化容量的时候,JDK会默认帮我们计算一个相对合理的值当做初始化容量。那么,是不是只需要把已知的HashMap中即将存放的元素个数直接传递给initialCapacity就可以了呢?

关于这个值的设置,在《阿里巴巴Java开发手册》有以下建议:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dQlo5gI0-1600262321070)(C:\Users\20296\AppData\Roaming\Typora\typora-user-images\1598680305766.png)]

也就是说,如果设置的默认值为7,经过JDK处理之后,会被设置成8,但是,这个HashMap在元素个数达到 8 * 0.75 = 6的时候就会进行一次扩容,这明显不是我们希望见到的。我们应该减少HashMap的扩容次数。

如果通过initialCapacity / 0.75F + 1.0F计算,7/0.75+1 = 10,10经过JDK处理之后,会被设置成16,这就大大减少扩容的次数。

当HashMap内部维护的hash表的容量达到75%时(默认的情况下),会触发rehash,而rehash的过程是比较消耗时间的。所以初始化容量要设置成initialCapacity/0.75F+1.0F的话,可以有效的减少冲突也可以减小误差。

所以,可以认为,当明确指定HashMap的元素个数的时候,把默认的容量设置成initialCapacity/0.75F+1.0F是一个在性能上相对好的选择。但是,同时也会牺牲些内存。

想要在代码中创建一个HashMap的时候,如果已知这个Map中存放的元素个数时,给定HashMap设置初始容量可以在一定程度上提升效率。

但是,JDK并不会直接拿用户传进来的数字当作默认容量。而是会进行一番运算,最终得到一个2的幂。

但是,为了最大程度的避免扩容带来的性能消耗,建议可以把默认容量设置成 initialCapacity/0.75F+1.0F

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值