[JDK集合源码系列] -- JDK1.8HashMap源码解析

请添加图片描述请添加图片描述
因为热爱所以坚持,因为热爱所以等待。熬过漫长无戏可演的日子,终于换来了人生的春天,共勉!!!

1.HashMap概述

  • HashMap继承体系

在这里插入图片描述
从继承体系可以看出:

  1. HashMap 实现了Cloneable接口,可以被克隆
  2. HashMap 实现了Serializable接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
  3. HashMap 继承了AbstractMap,父类提供了 Map 实现接口,具有Map的所有功能,以最大限度地减少实现此接口所需的工作。
  • HashMap基本属性与常量

/*
 * 序列化版本号
 */
private static final long serialVersionUID = 362498820763181265L;

/**
 * HashMap的初始化容量(必须是 2 的 n 次幂)默认的初始容量为16
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
 * 最大的容量为2的30次方
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的装载因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 树化阈值,当一个桶中的元素个数大于等于8时进行树化
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 树降级为链表的阈值,当一个桶中的元素个数小于等于6时把树转化为链表
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 当桶的个数达到64的时候才进行树化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

/**
 * Node数组,又叫作桶(bucket)
 */
transient Node<K,V>[] table;

/**
 * 作为entrySet()的缓存
 */
transient Set<Map.Entry<K,V>> entrySet;

/**
 * 元素的数量
 */
transient int size;

/**
 * 修改次数,用于在迭代的时候执行快速失败策略
 */
transient int modCount;

/**
 * 当桶的使用数量达到多少时进行扩容,threshold = capacity * loadFactor
 */
int threshold;

/**
 * 装载因子
 */
final float loadFactor;

(1)容量:容量为数组的长度,亦即桶的个数,默认为16最大为2的30次方,当容量达到64时才可以树化。

(2)装载因子:装载因子用来计算容量达到多少时才进行扩容,默认装载因子为0.75

(3)树化:树化,当容量达到64且链表的长度大于8时进行树化,当链表的长度小于6时可能反树化

面试问题:

  1. 为什么集合的初始化容量(DEFAULT_INITIAL_CAPACITY)必须是 2 的 n 次幂?
// 默认的初始容量是16	1 << 4 相当于 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

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

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

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

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

例如,数组长度为 8 的时候,3 & (8 - 1) = 3,2 & (8 - 1) = 2,桶的位置是(数组索引)3和2,不同位置上,不碰撞。

再来看一个数组长度(桶位数)不是2的n次幂的情况:
在这里插入图片描述

从上图可以看出,当数组长度为9(非2 的n次幂)的时候,不同的哈希值hash, hash & (length - 1) 所得到的数组下标相等(很容易出现哈希碰撞)。

小结一下HashMap数组容量使用2的n次幂的原因:
在这里插入图片描述

  1. 如果创建HashMap对象时,输入的数组长度length是10,而不是2的n次幂会怎么样呢

HashMap<String, Integer> hashMap = new HashMap(10);

HashMap双参构造函数会通过tableSizeFor(initialCapacity)方法,得到一个最接近length且大于length的2的n次幂数(比如最接近10且大于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;
}

说明:

当在实例化 HashMap 实例时,如果给定了 initialCapacity,由于 HashMap 的 capacity 必须是 2 的幂,因此这个方法tableSizeFor(initialCapacity);用于找到大于等于 initialCapacity 的最小的 2 的幂

分析:

1.int n = cap - 1;为什么要减去1呢?
防止 cap 已经是 2 的幂。如果 cap 已经是 2 的幂,又没有这个减 1 操作,则执行完后面的几条无符号操作之后,返回的 capacity 将是这个 cap 的 2 倍(后面还会再举个例子讲这个)。

2.最后为什么有个 n + 1 的操作呢?

如果 n 这时为 0 了(经过了cap - 1后),则经过后面的几次无符号右移依然是 0,返回0是肯定不行的,所以最后返回n+1最终得到的 capacity 是1。

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

完整例子:
在这里插入图片描述

所以由结果可得,当执行完tableSizeFor(initialCapacity);方法后,得到的新capacity是最接近initialCapacity且大于initialCapacity的2的n次幂的数

  • 存储结构:

    • HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null,此外,HashMap 中的映射不是有序的。

    • jdk1.8 之前 HashMap 由 数组 + 链表 组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的 hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用)而存在的(“拉链法”解决冲突)。jdk1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大于 64 时,此时此索引位置上的所有数据改为使用红黑树存储

2.HashMap扩容机制

  • HashMap 默认初始桶位数16,如果某个桶中的链表长度大于8,则先进行判断:

  • 如果桶位数小于64,则先进行扩容(2倍),扩容之后重新计算哈希值,这样桶中的链表长度就变短了。【定位桶的方式:通过数组下标 i 定位,添加元素时,目标桶位置 i 的计算公式,i = hash & (cap - 1),cap为容量

  • 如果桶位数大于等于64,且某个桶中的链表长度大于8,则对链表进行树化(红黑树,即自平衡的二叉树)

  • 如果红黑树的节点数在小于等于6时,红黑树可能会重新变会链表
    在这里插入图片描述
     我们来分析分析上面这个逻辑,进入这个untreeify() 的要求是,root == null, root.right null, root.leftnull, root.left.left==null四种情况,我们以7个节点的红黑树来分析,A为root节点。
    在这里插入图片描述

1.最多节点情况:当我们删除节点D时,满足root.left.left==null这个条件,此时节点数为6,这棵树要进行非树化;而如果选择删除G节点,这时节点数也为6,但是不用退树化,最大节点数为6。

2.最少节点情况:当EFG不存在时,在A,B,C,D中删除任意一个节点,都会满足上述四种规则中的一种。则存在最少节点情况,有4个节点,此时不会树化。

  • 以上情况都是会将树转化成链表,此时的节点是 4<= nodes <=6 ,由此可以看出,当节点数在小于6时,是可能转化成链表,但不是绝对情况, 所以使用定义的变量(固定数量6)也不正确。只好通过判断去动态获取节点数。

节点数量原因分析  
  为什么在小于6的时候可能转换成链表,而在大于8的时候转化成红黑树?

主要通过时间查询节点分析,红黑树的平均查询时间为 log(n), 而链表是O(n),平均是O(n)/2。

  • 当节点数为8时,红黑树查询时间3,链表查询时间是4, 可以看出来当红黑树查询效率大于了链表。(两个函数曲线问题,当节点更多是,比红黑树需要的时间更多)
  • 当节点数为6时,为什么转换成链表,我认为主要时因为节点数太少,如果还是用红黑树,为了维持红黑树的特点,则需要翻转,左旋,右旋,等,更消耗性能。

3.为什么优先扩容桶位数(数组长度),而不是直接树化?

原因:

  • 桶位数(数组长度)比较小时,应尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率。因为红黑树需要逬行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。所以结上所述为了提高性能和减少搜索时间,底层阈值大于8并且数组长度大于64时,链表才转换为红黑树
  • 而当阈值大于 8 并且数组长度大于 64 时,虽然增了红黑树作为底层数据结构,结构变得复杂了,但是,长度较长的链表转换为红黑树时,效率也变高了

4.HashMap存储数据的过程详解

  1. 首先,HashMap<String, Integer> hashMap = new HashMap();当创建 HashMap 集合对象的时候,HashMap 的构造方法并没有创建数组,而是在第一次调用 put 方法时创建一个长度是16 的数组(即,16个桶) ,Node[] table (jdk1.8 之前是 Entry[] table)用来存储键值对数据。

  2. 当向哈希表中存储put(“a”, 3) 的数据时,根据"a"字符串调用 String 类中重写之后的 hashCode() 方法计算出哈希值,然后结合数组长度(桶数量)采用某种算法计算出向 Node 数组中存储数据的空间索引值(比如table[i],这里的i就是该Node数组的空间索引)。如果计算出的索引空间没有数据(即,这个桶是空的),则直接将<“a”, 3>存储到数组中

  3. 当向哈希表中存储数据<“b”, 4>时,假设算出的 hashCode() 方法结合数祖长度计算出的索引值也是3,那么此时数组空间不是 null(即,这个桶目前不为空),此时底层会比较 "a"和 “b” 的 hash 值是否一致如果不一致,则在空间上划出一个结点来存储键值对数据对 <“b”, 4>,这种方式称为拉链法

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

    • 相等:将后添加的数据的 value 覆盖之前的 value。

    • 不相等:继续向下和其他的数据的 key 进行比较,如果都不相等,则划出一个结点存储数据,如果结点长度即链表长度大于阈值 8 并且数组长度大于 64 则将链表变为红黑树

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

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

在这里插入图片描述

  1. jdk1.8 中引入红黑树的进一步原因:

    • jdk1.8 以前 HashMap 的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势

    • 针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

  2. put方法的图示
    在这里插入图片描述

  • size 表示 HashMap 中键值对的实时数量(即,所存储元素的数量),注意这个不等于数组的长度
  • threshold(临界值)= capacity(容量)* loadFactor(负载因子)。这个值是当前已占用数组长度的最大值。size 超过这个值就重新 resize(扩容),扩容后的 HashMap 容量是之前容量的2倍

5.HashMap1.7和1.8的区别

1.resize扩容优化

2.引入了红黑树

3.解决了多线程死循环问题,但仍然是非线程安全

差异 JDK1.7 JDK1.8
存储结构 数组+链表 数据+链表+红黑树
初始化方式 单独函数:inflateTable() 直接集成到了resize()中
hash值计算方式 9次扰动 = 4次位运算 + 5次异或运算 2次扰动=1次位运算+一次异或
存放数据的规则 无冲突时,存放在数组上,有冲突时,用拉链法形成一条链表,头结点在数组上 无冲突时,存放在数组上,有冲突时,如果数组长度小于64,先扩容;如果数组长度大于等于64且链表的长度大于8,将该链表转化为红黑树结构
插入数据的方式 头插法 尾插法(在链表/红黑树尾部插入)
扩容后存储位置的计算 遍历全部元素重新hash计算位置 扩容后:1.如果是单个元素,重新hash运算一次;2.旧元素 e.hash & oldCap = 0,新表中与旧表中位置一样;3.旧元素 e.hash & oldCap != 0, 位置为旧表位置+旧表容量

6.HashMap与Hashtable的区别

1.线程是否安全方面

HashMap是非线程安全的,HashTable是线程安全的;HashTable内部的方法基本都经过synchronized修饰。(如果要保证线程安全的话就使用ConcurrentHashMap吧!)

2.效率方面

HashMap要比HashTable(使用synchronized加锁)效率高。另外,HashTable基本被淘汰,请不要在代码中使用它

3.对key为null或者value为null的支持方面

HashMap支持一个key为null,当key为null时,直接hash方法直接返回零值,这样的键只可以有一个,value可以有一个或多个;hashTable中key-value都不能为null,如果为null会抛出空指针异常(NullPointerException)

4.原始容量大小与每次扩充容量大小的不同方面

①创建时如果不指定容量初始值, Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1.;HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。

②创建时如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为2的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说HashMap总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。

5.底层数据结构方面

JDK1.8以后的HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

请添加图片描述请添加图片描述

猜你喜欢

转载自blog.csdn.net/qq_43295483/article/details/119721901