您的位置:首页 > 编程语言 > Java开发

Java - 源码走读:HashMap(JDK1.7)

2020-08-25 21:06 134 查看

源码走读:HashMap(JDK1.7)

  • 四、扩容
  • 五、重哈希
  • 六、查找元素
  • 七、modCount
  • 〇、HashMap 功能概述

    HashMap 作为一种双列集合,从使用者的角度来看,它最主要的两个功能就是添加查找

    • 添加键值对:将键值对(key-value)以某种形式封装,并设计一套规则来存储
    • 根据键找值:根据输入的 key,在表中找到 key 所在的位置,并返回对应的 value。如果找不到,返回 null
    • key 不可以重复,且可以是 null,value 不做要求
    • 若新添加的 key 在集合中已经存在,则不会添加新元素,而是将新的 value 替换掉原来的 value

    可以说,HashMap 中的其它方法都是围绕这两个功能做进一步开发。从开发者的角度看,设计这样一种双列集合形式的数据结构,在满足这两个基础功能的前提下,更进一步地是要思考如何能够最大限度地提高添加和查找的效率本文以 JDK1.7 为基础,从添加和查找两个功能点出发,并结合上述提出的问题,由浅入深地探索 HashMap 背后的实现原理。前方长文预警

    大多数人或许都知道或者了解:在 JDK1.7 中,HashMap 的数据组织形式是“数组+链表”。

    这个结构如何组织 key-value 对?从上面的两个主要功能可以发现:在 HashMap 中,元素的添加和查询不需要指定具体的索引号。那么对于一个给定的 key,它应该存放在数组的哪个索引位置呢?

    一、从 ArrayList 谈起

    ArrayList 是一个基于数组实现的集合对象。它的添加元素方法 add() 有两种重载形式:(JDK1.8/ArrayList.java/457行~486行)

    • 一种是需要指定索引的添加元素方法(478~486):这种情况下,根据 ArrayList 在堆中的地址以及索引号计算地址偏移,找到对应索引号的地址。然后将新的element把原来的替换掉即可。

    • 一种是无需指定索引的添加元素方法(463~467):ArrayList 集合维护一个成员变量 size 用以记录当前集合内保存的元素数量。ArrayList 初始化时,size=0。因此,无索引的元素添加方式就是在 ArrayList 集合的最后一个索引位置添加元素,然后 size++,在实现元素递增的语义描述的基础上也实现了待插入元素地址的预设。

    基于对 ArrayList 元素添加方式的思考,结合“数组+链表”的组织形式,一种 HashMap 添加元素过程的猜想如下图

    数组是有固定长度的,添加元素时,从左到右遍历数组,依次填入元素。待数组满时,从第一个节点开始向下延伸链表。当第一层链表填满时再向下添加第二层链表,如此往复……这种方式很好理解,实现起来也不难,优势和缺点都非常明显。

    • 优点:插入 put(key, value) 满足O(1)时间复杂度
    • 缺点:查询慢 get(key),O(NL),N:数组长度,L:链表平均长度

    HashMap 的查找是根据 key 找 value,按照上述插入元素的方式,在不清楚 key 的位置的前提下,查找一个 key 最坏需要将数组和数组下挂的链表全部遍历一次,性能很差。

    HashMap 的添加元素过程应该是这样一种机制:这个机制能够充分利用 key 来标定元素在数组中的地址。显然,散列函数是一个不错的选择。HashMap,顾名思义,这个数据结构的确使用到了哈希值,那么哈希值在 HashMap 中的哪个环节得到了应用?又是如何应用的呢?

    观察字符串对象的散列函数(JDK1.8/String.java/1465~1476)

    我们把这个过程看作一个黑箱,方法的调用者是字符串对象本身,这个方法会返回一个 int 类型变量,它就是一个哈希值(散列值)。我们不纠结这个方法内部的计算流程,可以将哈希值视为一个随机数,HashMap 的添加和查询动作就是基于哈希值大做文章。

    尝试计算不同对象的散列值:
    可以发现,散列值的特点是取值非常不固定,且横跨整个 int 类型取值范围。而数组是一种线性表,它的元素存储必须保证是在一段连续且固定长度的存储空间内,显然,直接使用散列值作为数组索引值并不合适。

    一种可行的思路是:将散列值对数组最大长度N取余数(%)。这样就可以实现将任意的散列值都转换成 0~N-1 内的值了,以后对于任意给定的 key,不论添加还是查询,只要先计算 key 的哈希值,然后将哈希值对N取模就能找到key的位置了,插入和查找的时间复杂度都是 O(1)!

    实际上,HashMap 并不是直接使用 key 的哈希值,也不是通过对哈希值取余来计算在数组中的索引位置,这里先了解这样做的基本逻辑,最终的目的是为了将任意的散列值都可以转换成合法的数组索引,后面会详述 HashMap 中执行这两部操作的具体流程。

    二、添加元素

    1. Entry 接口

    Entry 是 Map 接口中的一个成员内部接口,是 Java 中表征键值映射关系的标准。HashMap 中有一个名为 Node 的静态成员内部类实现了这个接口来封装 key-value 对。(JDK1.8/HashMap.java/275~317)


    数组中真正保存的不是 Node 对象本身,而是 Node 对象在堆空间中的引用地址

    2. 哈希冲突

    将 key 的哈希值关于数组长度的余数作为数组索引是一种不错的思路,它保证了添加和查询的效率,但这时又面临一个新的问题:数组的长度是有限的,如果不停向集合中插入元素,那么很有可能会出现,两个或多个不同的 key 计算哈希值对 N 取余后的结果是一样的,这称为哈希冲突

    为了克服这个问题,有两种解决思路:

    1. 将哈希冲突的元素串成一个链表来保存。将数组的索引值保存为链表头部元素的引用地址,这个地址指向一个链表头对象,链表对象中保存的所有产生哈希冲突的元素。这样,在查找时,找到索引值后,再遍历一遍链表,时间复杂度也就是 O(L)。本章节主要对链表的操作展开讨论
    2. 对 HashMap 的数组进行扩容,扩容缓解哈希冲突的原理和流程在后续章节有专门讨论

    使用链表处理哈希冲突问题,一个绕不开的话题是 如何向链表中插入元素?
    首先声明一个前提:HashMap 中的链表采用的是单向链表,刻保证链表表头对象的引用成员/局部变量不能丢失。参考LinkedList,链表的插入可分为头插法和尾插法,不论是头插法还是尾插法,在不确定是否存在重复 key 的情况下,都要对链表进行一次遍历。在遍历过程中:

    • 若没有发现重复 key,则头插法直接在表头插入并修改对应数组索引的引用地址。而尾插法就顺势直接将表尾元素的next 指针指向一个新的 Node 对象即可。并返回 null
    • 若发现重复 key,不论头插法还是尾插法,直接将重复 key 对应的 value 替换,并返回旧 value 即可。

    事实上,HashMap 源码中采用的是头插法(JDK1.7/HashMap.java/895~899)

    第897行:将冲突索引 bucketIndex 中保存的时新 Entry 对象的引用,而新表头的 next 引用是之前的表头 Entry 对象。

    4. 初识构造方法

    JDK1.7 的 HashMap 有四种构造方法,除了第四种(479~491)是基于另一种 Map 接口的实现类对象构造以外,前三种(438~477)均是原生初始化方法,初始构造过程都需要指定一个负载因子或者数组初始容量

    • 默认初始容量/数组初始长度:initialCapacity,16
    • 最大容量限制:MAXIMUM_CAPACITY,230
    • 默认负载因子:loadFactor,0.75F(注意这是一个 float 型变量,使用相关重载构造方法时要注意不能传入 double 型变量)
    • 默认扩容门限:threshold,12(16 × 0.75)

    5. 数组容量的初始化

    从构造方法中可以发现,数组初始容量支持手动指定。但实际上,真正的数组容量并不是直接采用人为指定的容量。而是2次幂整数中大于人为指定容量的最小值

    例如:外部传入初始容量为30,有25=32>30,而24=16<30,则数组真正得容量取32。

    这是一种高效地利用哈希值计算插入索引的辅助机制,我们暂时先不纠结为什么要这样设计。先来通过源码和一些简单的示例来分析数组初始容量的修正过程。至于背后的原因,后面会做进一步的解释。

    10进制下2的次幂数有这样一个特点:在2进制下的表示形式有且仅有一位是“1”,其它均为“0”


    在JDK1.7中,修正初始容量的方法是 roundUpToPowerOf2() 方法

    可以发现,内部主要实现修正容量值的方法是 Integer.highestOneBit()。转到该方法


    其中共同的部分均包括了对输入值的连续右移或运算操作,这样做的目的是什么呢?

    举个例子:假设我们实例化一个 HashMap 对象,初始长度指定为 17。而调用 Integer.highestOneBit(17) = 16,虽然是一个2次幂数,但它是一个小于人为指定容量的最大2次幂数。下面来分析 highestOneBit() 方法中的执行流程,见下图:


    可以看到,经过一系列移位和或运算,低位全部变为”1“,将最后的结果再做一次右移1位,然后将刚才的结果与之相减,便得到一个仅最高位是"1",其它低位全部为”0“的结果,这时转换成十进制必然是一个2次幂数

    还有,为什么要先后移位1,2,4,8,16位呢?因为输入容量是一个 int 型变量,有4字节,32位,1+2+4+8+16 = 32-1。只有遍历了全部的二进制位,才能保证找到符合条件的2次幂数。进而实现获得合适的数组长度。

    理解了上述过程,后面就能理解如何获取2次幂整数中大于人为指定容量得最小值了,在 JDK1.7 的源码中首先将目标值-1,再左移,左移相当于×2,这样再计算highestOneBit就能够得到正确的数组容量了。


    至此,HashMap 设置数组容量的过程就总结完了。

    6. 利用哈希值确定数组索引

    在第二章中我们讨论了元素添加策略,并在上一节着重阐述了哈希数组长度的初始化过程。本节将深入讨论利用 key 确定索引位置的过程,以及为什么数组的长度必须是一个2次幂数。

    我们期望的索引值应该是这样的:

    1. 索引值必须是合法的:索引结果必须是 0~N-1 范围内的整数
    2. 哈希运算的结果应该是均匀的:不同索引下哈希冲突的元素数量尽可能相近,即尽量使每个索引下挂载的链表长度尽可能一致,保证集合的查询效率足够均衡。

    观察 JDK1.7 中的 HashMap put() 方法:
    487~491 行依次是扩容分支和对 key=null 时的处理,后面是添元素的流程。首先是调用 hash() 方法计算了一个哈希值


    哈希种子:JDK1.7 中的 HashMap 内有一个成员变量 hashSeed,称为哈希种子,默认为0。若满足358行的条件,那么首先,哈希种子会与 key 的哈希值计算一次异或。注意异或的特性:0^X = X,所以,如果哈希种子为0,那么362行的计算结果就是 key 的哈希值

    为什么要引入哈希种子?后面为什么要进行这么多次的位移和异或运算?

    我们暂时不纠结 hash() 方法中的细节,暂时知道它最后会返回一个int类型的哈希值即可。先回到 put() 方法中,第493行又调用了 indexFor() 方法计算了最终的数组应该保存的索引号:


    从376行中可以发现:最后索引的计算并不是通过取余数实现的,而是处理后的哈希值与数组长度-1的位与运算结果。为什么要这样实现?好处是什么?

    这里就体现出数组长度必须为2的幂次方数的必要性了。如前文所述:2的幂次方数的二进制形式有且仅有一位是“1”,且“1”是它的最高位,它表示数组的长度。那么对应的十进制数-1换算成二进制就是最高位变成“0”,且更高位也全部是“0”,而下面的低位全部变为“1”。


    上图中以16为例,16-1=15的二进制低4为全为“1”,而高28位全部为0,那么15与其它任意数进行二进制位与运算时,不论低4位是什么结果,高28位必然是“0”,那么这就能绝对保证得到的结果最大只能是15,即低4位全部是“1”;最小是0,即低4位全部是”0“。这里就同时解释了为什么数组的长度必须是2的幂次方数,以及哈希值要与数组长度-1做与运算的原因

    对比取余法,这样做的好处是:只进行一次位与运算,对比取余运算,计算效率非常高。(取余涉及到除法和减法,这两种运算在底层都是由大量的加法、移位、取反实现,在计算机中都是十分耗时的运算过程)

    但这样还是存在另一个问题:在这个操作中,高28位相当于被”屏蔽“掉了,只有低4位真正决定了最后的数组索引。这显然是不合适的,而且高位信息被白白浪费了,而且有限的信息很可能加重部分索引产生哈希冲突的概率,从而限制了HashMap的查询性能,如何克服这个问题?

    这时,回到哈希值计算和处理方法hash()中,观察第367和第386行:
    这里进行了大量的无符号右移和异或运算的作用就体现出来了。右移运算使得高位信息被强行拉到低位,而异或运算就是将高位信息与低位信息进行融合交互。两个运算联合实现了将整个32位int数中的信息”压缩“到有限的低4位中,这样就能在一定程度上避免了高28位被”屏蔽“的问题。

    以上,如何索引的确定过程就总结完毕。

    7. 对于 key==null 的处理

    put() 方法中,第491行中调用了一个 putForNullKey() 方法用于专门处理空键情况。Java中,null指向一段固定的地址空间,且无法计算哈希值。从第513行和第522行中可以看出:JDK1.7 对于 key==null 时的 Node 对象处理方式是默认将其放在数组的第0索引位。

    四、扩容

    1. 单线程下的扩容

    从前面的章节中可以发现:扩容是伴随着添加元素进行的(put() 方法中,第488行调用的 inflateTable() 方法),当满足扩容条件时就先执行扩容操作,然后再添加元素。

    在 inflateTable() 方法中,具体实现扩容操作的是 resize() 方法:新数组直接扩容两倍。然后计算新元素的哈希值,并根据新数组的长度计算新的索引号,最后添加 Node 对象。


    575~578行:如果数组长度已经达到系统支持的最大长度则不再扩容。

    主要的元素迁移工作都在transfer()方法中
    显然,遍历“数组+链表”结构需要一个双重循环:外层使用 for 循环遍历旧数组(长度固定),内层使用 while 循环遍历每个数组索引指向的 Node 链表(判断 next 指针是否为 null)。

    在内层循环体中,用 e 遍历数组(table)中的链表头引用。e 指向的是当前链表元素,循环开始时指向的就是链表头;开辟一个 next 指针指向 e 的下一个元素用于确保在移动 e 指针指向的元素时,后面的元素不会丢失。后面有一个重哈希分支(JDK1.8中被删除),暂时先不考虑这个过程,向后看。

    597行:元素e根据自身的哈希值和新数组的长度计算在新数组中的索引。
    598~599行:执行的是元素迁移。将元素 e 中的 next 指针指向新表中对应的索引,注意:新表是一个 Node 数组,最终继承自 Object 类,且初始化在堆中,所以数组中元素的默认初始值是 null。相当于 e.next 指向 null,然后数组对应索引的指针再指向元素 e,这便实现了链表的头插法。
    600行:循环体最后,将指针 e 指向 next 指针指向的元素,开始迁移下一个元素。往复循环

    思考1:关于第597行代码,在不考虑重哈希的情况下,元素在旧数组中的索引和在新数组中的索引之间有什么联系?


    这里还是利用了数组长度必须为2次幂数的特性,原数组翻倍扩容,数组长度值的二进制形式相当于左移1位,那么进入 indexFor() 方法后,2次幂数-1的结果就是之前的高位“1”变为“0”,后面的所有低位都变成“1”,做与运算的效果就相当于:扩容后的“屏蔽”位数少了1位。以16向32扩容为例,原来是低4位不会被屏蔽,扩容至32后变成低5位不会被屏蔽。

    这样一来,对于扩容前后,低4位的位与运算的结果是不变的,对于新增的第5位:

    • 如果第5位的结果是“0”,那么该元素在扩容后的索引号和扩容前相同。
    • 如果第5位的结果是“1”,那么该元素在扩容后的索引号相当于扩容前的索引号+旧数组的长度。

    也就是说:扩容的目的不仅仅是将数组的内容增加,也是从一定程度上压缩了每个索引下挂靠链表的长度,旧数组上的链表在新数组中是有概率地被分成两条更短的链表的。

    总之,扩容即扩大了 HashMap 的容量,也提高了集合的查找效率,是一个空间换时间的策略。

    思考2:假设某个索引下的链表元素在迁移过程中没有出现索引改变的情况,那么迁移后的新链表与迁移前的旧链表之间有什么关系?

    从 transfer() 方法中的598行~600行代码中可以发现:元素迁移是从头至尾逐个迁移,且迁移到新数组时采用的也是头插法,那么新链表其实就是旧链表的倒序。

    2. 多线程场景下的扩容

    思考3:HashMap 中的这种元素迁移方式在多线程场景下可能会产生什么问题?

    考虑这样一种情况:现在有两条线程 A,B。两条线程并发访问同一个 HashMap 并执行添加元素操作


    在某一时刻,HashMap 已经达到临界扩容条件:假设线程 B 抢到 CPU 执行权,而B线程执行到了 transfer() 方法中的第593 行时进入到阻塞态。此时线程 A 抢夺到 CPU 执行权,并完成了扩容操作,参考上面的链表迁移,当线程 A 执行完扩容操作时,堆中的 HashMap 可能是这样:


    此时线程 A 的指针 e1 和 next1 已经都指向 null,线程 B 重新拿到执行权。注意,由于堆中的变量保存都是引用值,也就是说,当线程 A 完成扩容动作以后,线程 B 的两个指针(e2和next2)应该是这样:


    线程 A 的扩容动作使链表倒序,到了线程 B 抢到执行权时,e2 指针指向的对象反而成了 next2 指针的下一个元素了。此时线程 B 继续执行扩容动作,当准备迁移最后一个元素时,问题出现了:

    592行:next2 指针指向 e2.next,此时 e2.next 指向的是 null,也就是 next2 指向 null。跳过重哈希和索引计算(假设索引不改变)


    598行:这时,e2.next指向 newTable[i],这时问题来了,由于链表已经倒序了,指向newTable[i]的后果导致链表组成了一个环!
    599行:e2 又指向了 next2,也就是 null,此时线程B的扩容动作执行完毕。

    这时元素 Node C 由于丢失了引用不久就会被GC回收。至此,后续代码中一旦涉及到对这个索引的读和写都必然进入死循环!这说明:HashMap不是线程安全的

    五、重哈希

    resize()方法中的第581行代码中的transfer()方法具体执行了迁移方法。 transfer()方法的形参列表是这样:
    transfer()方法的内层循环体中有这样一个条件分支:

    由这些信息可知,我们知道了initHashSeedAsNeeded()方法的输入值是新数组的容量,这个方法会返回一个boolean值,用于指示是否要做重哈希运算。

    那么问题来了,什么时候需要做重哈希?

    先看一下initHashSeedAsNeeded()方法中都写了些什么吧:


    该方法最后将局部变量 switching 返回,而switching = currentAltHashing ^ useAltHashing;

    • currentAltHashing 用于判断 哈希种子hasdSeed是否为0。
    • useAltHashing 由JVM配置和当前容量同时决定,sun.misc.VM.isBooted()返回虚拟机是否已经启动?通常返回true,那么主要的判定依据就是第二个条件:capacity >= Holder.ALTERNATIVE_HASHING_THRESHOD

    Holder是HashMap中的一个成员内部类,这个类中有一个静态块


    静态成员 ALTERNATIVE_HASHING_THRESHOD 最后由 threshold 决定,而threshold主要由字符串对象 altThreshold 的取值指定。而设置 altThreshold 的方式则需要配置JVM虚拟机的环境变量“jdk.map.althashing.threshold”,

    这个方法是唯一能够修改成员变量 hashSeed 的方法,当需要做 switching 为真时,也就是确定需要做重哈希的时候,便更新 hashSeed,如果指定 useAltHashing 为 true,则随机生成一个 hashSeed,否则为仍为初始值0。修改 hashSeed 的目的是为了增加哈希值的散列度,使 Node 元素更加均匀地分布在数组中。

    六、查找元素

    比较简单,计算 key 得哈希值找到数组索引,然后遍历对应得链表,直至找到对目标 key,返回对应的 value,否则返回 null

    七、modCount

    modCount 记录的是 HashMap 对象的修改次数,记录修改次数的目的:避免动态删除过程中可能出现的问题。

    观察以下反编译代码:
    增强 for 循环的本质是实例化一个迭代器并使用 while 循环迭代。HashMap 和 Iterator 中会同时维护一个 modCount 成员和一个 expectedModeCount 成员。在添加两个元素后,modCount 和 expectedModeCount 均为2。

    在 while 循环体中,由于遍历的是迭代器,而删除操作是 HashMap 执行的,remove() 方法中只会更新 modCount,而不更新 expectedModeCount,这就会导致 HashMap 中的 modCount 和 expectedModeCount 不同步,从而报出异常


    解决的办法是:通过迭代器对象删除元素


    迭代器在删除元素的时候会调用 HashMap 中的 remove() 方法,在删除元素后,它会将 modCount 与expectedModCount同步(944行),从而避免 ConcurrentModificationException 异常。

    参考资料:https://www.bilibili.com/video/BV1ye411s7z8

    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: