HashMap 源码详细解析 (JDK1.8)
概要HashMap 最早出现在 JDK 1.2 中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。 HashMap 底层是基于散列算法实现,散列算法分为散列再探测和拉链式。HashMap 则使用了拉链式的散列算法,并在 JDK 1.8 中引入了红黑树优化过长的链表。数据结构示意图如下: 对于拉链式的散列算法,其数据结构是由数组和链表(或树形结构)组成。在进行增删查等操作时,首先要定位到元素的所在桶的位置,之后再从链表中定位该元素。比如我们要查询上图结构中是否包含元素?
上面就是 HashMap 底层数据结构的原理,HashMap 基本操作就是对拉链式散列算法基本操作的一层包装。不同的地方在于 JDK 1.8 中引入了红黑树,底层数据结构由
源码分析下面开始分析 HashMap 源码实现。 1. Node 节点对象在分析具体代码前,先看 Node 节点对象,这是 HashMap 里面的一个内部类,也是 HashMap 的数据存储对象。具体源码如下: static class Node<K,V> implements Map.Entry<K,V> { // hash 值 final int hash; final K key; V value; 指向下一个节点 Node<K,1)"> next; Node(int hash,K key,V value,Node<K,1)"> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } final V getValue() { value; } final String toString() { return key + "=" + value; } 返回:key的hashCode值和value的hashCode值进行异或运算结果 hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } V setValue(V newValue) { V oldValue = value; value = newValue; oldValue; } 判断相等的依据是,要么是同一个 Node 对象,要么是Map.Entry的一个实例,并且键键、值值都相等就返回True boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key,e.getKey()) && Objects.equals(value,e.getValue())) ; } false; } } 该类继承自?Map.Entry<K,V>,每一个?Entry 就是一个键值对。该类还定一个 next 节点,用于指向下一个节点,也是单链的构成基础。 2. HashMap 继承关系下面将直接进入源码分析,首先来看 HashMap 的继承关系: class HashMap<K,1)">extends AbstractMap<K,V> implements Map<K,V>,Cloneable,Serializable { 继承自?AbstractMap,同时也实现了?Map,Serializable 三个接口。也说明了 HashMap 是可序列化,可克隆的的。Map 接口则是定义了一些常用的增删改查的方法,这样只要是实现该接口的都有相同的方法,方便大家记忆和使用。 2.1 Cloneable 接口Java 中一个类要实现 clone 功能 必须实现 Cloneable 接口,否则在调用 clone() 时会报 CloneNotSupportedException 异常,也就是说, Cloneable 接口只是个合法调用 clone() 的标识(marker-interface): // Object protected Object clone() throws CloneNotSupportedException { if (!(this Cloneable)) { throw new CloneNotSupportedException("Class " + getClass().getName() + " doesn't implement Cloneable"); } internalClone(); } Object 类的?internalClone() 方法是一个 native 方法,native 方法的效率一般来说都是远高于 Java 中的非 native 方法。这也解释了为什么要用 Object 中 clone() 方法而不是先 new 一个对象,然后把原始对象中的信息赋到新对象中,虽然这也实现了 clone 功能,但效率较低。 Object 类中的 clone() 方法还是一个 protected 属性的方法。为了让其它类能调用这个 clone() 方法,重载之后要把 clone() 方法的属性设置为public。 3. 变量定义// 这两个是限定值 当节点数大于 8 时会转为红黑树存储 int TREEIFY_THRESHOLD = 8; 当节点数小于6 时会转为单向链表存储 int UNTREEIFY_THRESHOLD = 6红黑树最小长度为 64 int MIN_TREEIFY_CAPACITY = 64HashMap容量初始大小 int DEFAULT_INITIAL_CAPACITY = 1 << 4; aka 16 HashMap容量极限 int MAXIMUM_CAPACITY = 1 << 30负载因子默认大小 float DEFAULT_LOAD_FACTOR = 0.75fNode是 Map.Entry接口的实现类 在此存储数据的 Node 数组容量是2次幂 每一个 Node 本质都是一个单向链表 transient Node<K,1)">[] table; HashMap 大小,它代表 HashMap 保存的键值对的多少 transient size; HashMap 被改变的次数 modCount; 下一次HashMap扩容的大小 threshold; 存储负载因子的常量 float loadFactor; 上面定义了当中会用到的一些变量,熟悉了就好了。 3.1?transient在这里细心的小伙伴会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。 考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢? HashMap 并没有使用默认的序列化机制,而是通过实现? 也有的人可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?但序列化 table 存在着两个问题:
以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的? 3.2 loadFactor 负载因子loadFactor 指的是负载因子 HashMap 能够承受住自身负载(大小或容量)的因子,loadFactor 的默认值为 0.75 认情况下,数组大小为 16,那么当 HashMap 中元素个数超过 16*0.75=12 的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能 负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费 4. 构造函数HashMap 中主要有四个构造函数,具体如下:
1 默认的构造函数 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; all other fields defaulted } 2 传入一个Map集合,将Map集合中元素Map.Entry全部添加进HashMap实例中 public HashMap(Map<? extends K,? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m,); } 3 指定容量大小 public HashMap( initialCapacity) { (initialCapacity,DEFAULT_LOAD_FACTOR); } 4 指定容量大小和负载因子大小 int initialCapacity,float loadFactor) { 指定的容量大小不可以小于0,否则将抛出IllegalArgumentException异常 if (initialCapacity < 0) new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); 判定指定的容量大小是否大于HashMap的容量极限 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; 指定的负载因子不可以小于0或为Null,若判定成立则抛出IllegalArgumentException异常 if (loadFactor <= 0 || Float.isNaN(loadFactor)) new IllegalArgumentException("Illegal load factor: " + loadFactor); loadFactor; 设置“HashMap阈值”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。 this.threshold = tableSizeFor(initialCapacity); } 可以根据具体场景和需求,使用不同的构造函数。一般对于第1个构造函数,大家用的比较多。不过从构造函数可以看出来的一点是,负载因子?loadFactor 是一个非常重要的参数,默认值是?DEFAULT_LOAD_FACTOR = 0.75f 。当负载因子确定后,会根据负载因子的值给 HashMap 计算一个阈值?threshold ;一旦超过阈值就会调用 resize () 方法扩容。 5. tableSizeFor 计算阈值先看看阈值的计算方法,需要指出的一点是:HashMap 要求容量必须是 2 的幂 。阈值具体计算方式如下: int tableSizeFor( 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; }
下面分析这个算法:?首先,要注意的是这个操作是无符号右移后,再或上原来的值。 为什么要对 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 的情况。? 第一次右移 n |= n >>> 1; 由于 n 不等于 0,则 n 的二进制表示中总会有一bit为 1,这时考虑最高位的 1。通过无符号右移 1 位,则将最高位的 1 右移了 1 位,再做或操作,使得 n 的二进制表示中与最高位的 1 紧邻的右边一位也为 1,如 000011xxxxxx。? 第二次右移 n |= n >>> 2; 注意,这个 n 已经经过了 n |= n >>> 1;?操作。假设此时 n 为 000011xxxxxx ,则 n 无符号右移两位,会将最高位两个连续的 1 右移两位,然后再与原来的 n 做或操作,这样 n 的二进制表示的高位中会有 4 个连续的 1。如 00001111xxxxxx 。? 第三次右移 n |= n >>> 4; 这次把已经有的高位中的连续的 4 个 1,右移 4 位,再做或操作,这样 n 的二进制表示的高位中会有8个连续的 1。如 00001111 1111xxxxxx 。? 以此类推? 注意,容量最大也就是 32bit 的正数,因此最后 n |= n >>> 16;?,最多也就 32 个 1,但是这时已经大于了 MAXIMUM_CAPACITY?,所以取值到 MAXIMUM_CAPACITY?。? 注意,得到的这个 capacity 赋值给了 threshold,因此 threshold 就是所说的容量。当 HashMap 的 size 到达 threshold 这个阈值时会扩容。? 但是,请注意,在构造方法中,并没有对 table 这个成员变量进行初始化,table 的初始化被推迟到了 put 方法中,在 put 方法中会对 threshold 重新计算。 上述这段计算逻辑引自 :?HashMap方法hash()、tableSizeFor() 6. put 添加元素6.1 putMapEntries 添加 map 对象该方法是在构造函数中直接传入一个 map 对象,下面看具体实现代码: void putMapEntries(Map<? extends V> m,1)"> evict) { int s = m.size(); 当 m 中有元素时,则需将map中元素放入本HashMap实例。 if (s > 0) { 判断table是否已经初始化,如果未初始化,则先初始化一些变量。(table初始化是在put时) if (table == null) { pre-size 根据待插入的map 的 size 计算要创建的 HashMap 的容量。 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? ()ft : MAXIMUM_CAPACITY); 把要创建的 HashMap 的容量存在 threshold 中 if (t > threshold) threshold = tableSizeFor(t); } 如果table初始化过,因为别的函数也会调用它,所以有可能HashMap已经被初始化过了。 判断待插入的 map 的 size,若 size 大于 threshold,则先进行 resize(),进行扩容 else if (s > threshold) resize(); 然后就开始遍历 带插入的 map ,将每一个 <Key,Value> 插入到本HashMap实例。 for (Map.Entry<? e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); put(K,V)也是调用 putVal 函数进行元素的插入 putVal(hash(key),key,value,1)">,evict); } } } 主要是在判断一些初始化工作是否已经做了,包括容量,table,确保都可以使用后,再将数据添加到 Map 中。在这里用到了遍历,这里也简单说下。可以发现这里 table 为 null 也没有开始赋值,只是计算了阈值。会在 putVal 中初始化。 6.2 遍历和查找查找一样,遍历操作也是大家使用频率比较高的一个操作。对于遍历 HashMap,我们一般都会用下面的方式: for(Object key : map.keySet()) { do something } (HashMap.Entry entry : map.entrySet()) { } Set keys = map.keySet(); Iterator ite = keys.iterator(); while (ite.hasNext()) { Object key = ite.next(); do something } 要么是通过获得 keyset 来遍历,或者就是拿到?entrySet,最后也可以使用迭代器。具体遍历流程可以参看下图: 遍历上图的最终结果是? 6.3 put 添加元素下面将分析如何添加一个新的元素: V put(K key,V value) { return putVal(hash(key),1)">false,1)">); } HashMap.put的具体实现 final V putVal( onlyIfAbsent,1)"> evict) { Node<K,V>[] tab; Node<K,V> p; n,i; 判定table不为空并且table长度不可为0,否则将从resize函数中获取 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; 这样写法有点绕,其实这里就是通过索引获取table数组中的一个元素看是否为Nul if ((p = tab[i = (n - 1) & hash]) == null若判断成立,则New一个Node出来赋给table中指定索引下的这个元素 tab[i] = newNode(hash,1)">); else { 若判断不成立 Node<K,1)"> e; K k; 对这个元素进行Hash和key值匹配,相等则取出该节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; if (p instanceof TreeNode) 如果数组中德这个元素P是TreeNode类型 判定成功则在红黑树中查找符合的条件的节点并返回此节点 e = ((TreeNode<K,V>)p).putTreeVal(else { 若以上条件均判断失败,则执行以下代码 向Node单向链表中添加数据 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == ) { p.next = newNode(hash,1)">); 若节点数大于等于8 if (binCount >= TREEIFY_THRESHOLD - 1) -1 for 1st 转换为红黑树 treeifyBin(tab,hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != key.equals(k)))) ; p = e; p记录下一个节点 } } if (e != existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == ) e.value = value; afterNodeAccess(e); oldValue; } } ++modCount; if (++size > threshold) 判断是否需要扩容 resize(); afterNodeInsertion(evict); ; } 通过以上的源码,我们可以看出。在添加新节点时,通过 hash 确定其位置。
查找过程是首先得确定它的索引: index = (n - 1) & hash
first = tab[(n - 1) & hash]
这里通过? 上面的计算并不复杂,这里就不多说了。这里的 hash 值是 key.hashCode() 得到的。但是在 HashMap 这里,通过位运算重新计算了 hash 值的值。为什么要重新计算? hash(Object key) { h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 主要是因为 n (HashMap 的容量) 值比较小,hash 只参与了低位运算,高位运算没有用上。这就增大了 hash 值的碰撞概率。而通过这种位运算的计算方式,使得高位运算参与其中,减小了 hash 的碰撞概率,使 hash 值尽可能散开。如何理解呢?把前面举的例子??hash = 185,n = 16,按照 HashMap 的计算方法咱们再来走一遍。 图中的 hash 是由键的 hashCode 产生。计算余数时,由于 n 比较小,hash 只有低 4 位参与了计算,高位的计算可以认为是无效的。这样导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高 4 位数据与低 4 位数据进行异或运算,即?
7. resize() 扩容在 Java 中,数组的长度是固定的,这意味着数组只能存储固定量的数据。但在开发的过程中,很多时候我们无法知道该建多大的数组合适。建小了不够用,建大了用不完,造成浪费。如果我们能实现一种变长的数组,并按需分配空间就好了。好在,我们不用自己实现变长数组,Java 集合框架已经实现了变长的数据结构。比如 ArrayList 和 HashMap。对于这类基于数组的变长数据结构,扩容是一个非常重要的操作。 首先? 接下来上源码:
final Node<K,1)">[] resize() { 保存当前table Node<K,V>[] oldTab = table; 保存当前table的容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; 保存当前阈值 int oldThr = threshold; 初始化新的table容量和阈值 int newCap,newThr = 0/* 1. resize()函数在size > threshold时被调用。oldCap大于 0 代表原来的 table 表非空, oldCap 为原表的大小,oldThr(threshold) 为 oldCap × load_factor */ if (oldCap > 0 若旧table容量已超过最大容量,更新阈值为Integer.MAX_VALUE(最大整形值),这样以后就不会自动扩容了。 if (oldCap >= MAXIMUM_CAPACITY) { 使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 oldCap 距离。这句话有些拗口,简单来说的话,也可以这么理解:
如果? a>0,且是偶数,那么表达式变为:= a/2*m+b+n; 再做一个变换就是: 如果? a=0,那么位置也不变。 下面采用图例再来解释一遍。n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。 ?
![]() 元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit (红色),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成 原索引+oldCap,可以看看下图为 16 扩充为 32 的 resize 示意图 :?
![]() 扩容后,需要进行键值对节点重新映射的过程。在 JDK 1.8 中,重新映射节点需要考虑节点类型。对于树形节点,需先拆分红黑树再映射。对于链表类型节点,则需先对链表进行分组,然后再映射。需要的注意的是,分组后,组内节点相对位置保持不变。关于红黑树拆分的逻辑将会放在下一小节说明,先来看看链表是怎样进行分组映射的。 什么时候扩容:通过 HashMap 源码可以看到是在 put 操作时,即向容器中添加元素时,判断当前容器中元素的个数是否达到阈值(当前数组长度乘以加载因子的值)的时候,就要自动扩容了。此外,HashMap 准备树形化但又发现数组太短,也会发生扩容。 扩容(resize):其实就是重新计算容量;而这个扩容是计算出所需容器的大小之后重新定义一个新的容器,将原来容器中的元素放入其中。 8. 链表树化、红黑树链化与拆分下面这部分内容摘自??HashMap 源码详细分析(JDK1.8)?因为,觉得他这部分写得很好,所以就直接摘过来了。 JDK 1.8 对 HashMap 实现进行了改进。最大的改进莫过于在引入了红黑树处理频繁的碰撞,代码复杂度也随之上升。比如,以前只需实现一套针对链表操作的方法即可。而引入红黑树后,需要另外实现红黑树相关的操作。红黑树是一种自平衡的二叉查找树,本身就比较复杂。本篇文章中并不打算对红黑树展开介绍,本文仅会介绍链表树化需要注意的地方。至于红黑树详细的介绍,如果大家有兴趣,可以参考他的另一篇文章 -?红黑树详细分析。 在展开说明之前,先把树化的相关代码贴出来,如下: ; /** * 当桶数组容量小于该值时,优先进行扩容,而不是树化 */ class TreeNode<K,1)">extends LinkedHashMap.Entry<K,1)"> { TreeNode<K,V> parent; red-black tree links TreeNode<K,1)"> left; TreeNode<K,1)"> right; TreeNode<K,V> prev; needed to unlink next upon deletion red; TreeNode( next) { super(hash,val,next); } } * 将普通节点链表转换成树形节点链表 void treeifyBin(Node<K,V>[] tab,1)"> hash) { int n,index; Node<K,1)"> e; 桶数组容量小于 MIN_TREEIFY_CAPACITY,优先进行扩容而不是树化 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); if ((e = tab[index = (n - 1) & hash]) != ) { hd 为头节点(head),tl 为尾节点(tail) TreeNode<K,V> hd = { 将普通节点替换成树形节点 TreeNode<K,V> p = replacementTreeNode(e,1)">); if (tl == ) hd = { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); 将普通链表转成由树形节点链表 if ((tab[index] = hd) != 将树形链表转换成红黑树 hd.treeify(tab); } } TreeNode<K,V> replacementTreeNode(Node<K,V> p,1)"> next) { new TreeNode<>(p.hash,p.key,p.value,next); } 在扩容过程中,树化要满足两个条件:
第一个条件比较好理解,这里就不说了。这里来说说加入第二个条件的原因,个人觉得原因如下: 当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。 回到上面的源码中,我们继续看一下 treeifyBin 方法。该方法主要的作用是将普通链表转成为由 TreeNode 型节点组成的链表,并在最后调用 treeify 是将该链表转为红黑树。TreeNode 继承自 Node 类,所以 TreeNode 仍然包含 next 引用,原链表的节点顺序最终通过 next 引用被保存下来。我们假设树化前,链表结构如下: HashMap 在设计之初,并没有考虑到以后会引入红黑树进行优化。所以并没有像 TreeMap 那样,要求键类实现 comparable 接口或提供相应的比较器。但由于树化过程需要比较两个键对象的大小,在键类没有实现 comparable 接口的情况下,怎么比较键与键之间的大小了就成了一个棘手的问题。为了解决这个问题,HashMap 是做了三步处理,确保可以比较出两个键的大小,如下:
tie break 是网球术语,可以理解为加时赛的意思,起这个名字还是挺有意思的。 通过上面三次比较,最终就可以比较出孰大孰小。比较出大小后就可以构造红黑树了,最终构造出的红黑树如下: 橙色的箭头表示 TreeNode 的 next 引用。由于空间有限,prev 引用未画出。可以看出,链表转成红黑树后,原链表的顺序仍然会被引用仍被保留了(红黑树的根节点会被移动到链表的第一位),我们仍然可以按遍历链表的方式去遍历上面的红黑树。这样的结构为后面红黑树的切分以及红黑树转成链表做好了铺垫,我们继续往下分析。
|