JDK 源码剖析 —— HashMap(追根究底系列)
前言
HashMap 也算是面试常客了。
HashMap 几乎是我们在 Java 开发中最常用的类之一,它基于 Hash 表实现了一个 Map 结构,使得我们可以根据 Key 对 Value 进行快速查找,时间复杂度接近 。HashMap 允许 null 键和 null 值,其中 null 键的 hash 值记为 0。除此以外,HashMap 是线程不安全的一个类,当多个线程同时读写一个 HashMap 时,可能会出现问题。
但是,你真的了解 HashMap 吗?
本文,我会深入 HashMap 的源码,对其常用方法和相关属性进行解读,进一步了解 HashMap 的机制。
本文基于 Java 8(1.8)。
类签名与成员变量
源码中 HashMap 类签名如下:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
HashMap 继承了 AbstractMap 类,并且实现了 Map、Cloneable 和 Serializable 接口。AbstractMap 类主要就是对 Map 接口的一个初步的实现。Cloneable 接口表示 HashMap 实现了 Object 类中的 clone() 方法,而 Serializable 接口则说明这个类是可序列化的,当然众所周知,Serializable 接口仅仅是一个标志接口,没有规定任何需要实现的方法。
HashMap 有几个默认属性:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 static final int MAXIMUM_CAPACITY = 1 << 30; static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int TREEIFY_THRESHOLD = 8; static final int UNTREEIFY_THRESHOLD = 6; static final int MIN_TREEIFY_CAPACITY = 64;
这里不对这些属性进行解读,而是在方法中使用到这些属性时再做说明。
内部存储结构
在 HashMap 中有两个静态内部类,分别是 Node<K, V>
和 TreeNode<K, V>
,其中,Node<K, V>
继承了 Map.Entry<K, V>
,而 TreeNode<K, V>
继承了 LinkedHashMap.Entry<K, V>
,LinkedHashMap.Entry<K, V>
则继承了 Node<K, V>
类,从而 TreeNode<K, V>
间接也继承了 Node<K, V>
类。Node 类代表一个普通的键值对节点,而 TreeNode 代表一个二叉树节点。它们之间的变换将在下面讲解。
HashMap 内部有一个成员变量:transient Node<K,V>[] table
,是一个 Node 数组,每一个 Node 就是一个 hash 桶。HashMap 中的所有数据实际上都存储在这个数组中。(PS:由于 TreeNode 也是 Node 的子类,所以 TreeNode 也是可以存入 Table 的)
有一个问题就是,table 数组明明是用于保存 HashMap 所有的数据的,为什么被声明为 transient
的(声明为 transient 类型的变量在对象序列化时不会参与)?
ANS:HashMap 在确定一个 key 被存储在 table 的哪个元素中时,是通过 Object.hashCode()
方法获取到对象的哈希值,并将哈希值与桶个数(就是 table 数组的长度)取模来确定的。Object.hashCode()
方法是一个 native 方法,其实现依赖于 JVM 虚拟机的实现。所以在不同平台,同一个对象的哈希值可能是不同的,这就导致了其保存在 table 中的位置可能是不同的,直接将一个 HashMap 传输过去可能会出错。
所以现有的 HashMap 的序列化做法,是将其中的所有 key 都直接保存,在反序列化时再重新生成一个 HashMap,并将 key 逐个插入。
另一个不太重要的原因是,table 数组中的很多成员可能根本就没有被使用,对没有被用到的空间进行序列化会导致结果较大且没有意义,所以序列化时不会保存 table 数组,而是只保存 key。
构造方法
HashMap有三个构造方法:
public HashMap(); public HashMap(int initialCapacity); public HashMap(int initialCapacity, float loadFactor);
第一个和第二个方法都需要调用第三个方法,不同的仅仅是使用默认值而已。第三个方法需要两个参数,initialCapacity
和 loadFactor
,分别是上面提到的 Node 数组 table 的初始化容量,和一个值 loadFactor,表示负载系数。这两个值的默认值分别是 DEFAULT_INITIAL_CAPACITY
和 DEFAULT_LOAD_FACTOR
。也就是说,在无参构造一个 HashMap 时,Hash 桶的初始大小(capacity)为 16,且负载系数(loadFactor)是 0.75。那么负载系数是什么呢?
负载系数是 Map 扩容中的一个重要参数,当 Map 中的元素数量逐渐增多,使得 size / capacity 大于等于负载因子时(注意是 size,是容量,即 key 的个数,而不是占用 Hash 桶的个数,多个 key 可能占用一个桶),就会触发 HashMap 的一次扩容操作。那么,这个值的默认值为什么是 0.75?
在HashMap的注释中,Java的开发者解释了这个问题。
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.
翻译过来就是
作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的 HashMap 类的操作,包括 get 和 put)。设置初始大小时,应该考虑预计的 entry 数及其负载系数,并且尽量减少 rehash 操作的次数。如果初始容量大于最大条目数除以负载因子,rehash 操作将不会发生。
只说了大概是一个统计学原理,是一个折衷,但是依旧没有具体的原因……后来我在 StackOverflow 找到了一个大概的数学解释。如下:
解释是,一个 bucket 空和非空的概率为 0.5,通过牛顿二项式等数学计算,得到这个 loadfactor 的值为 ,约等于 0.693。回答者认为,任何小于 0.75 大于 的值都能提供更好的性能,0.75 可能就是随便写的一个值。
好的,回到主题,我们来看看构造方法~
public HashMap(int initialCapacity, float loadFactor) { ... this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
注意一下这里的 threshold 表示下一次扩容操作时 table 要达到的容量,由于该构造方法并没有对 table 进行初始化,那么下一次扩容操作就需要扩容到 threshold 大小。而在其他情况下,threshold 参数表示当前 map 中元素的上限,即 map 的容量与负载系数的乘积,在元素个数超出 threshold 时就会发生扩容。
除了对参数做了一些校验以外,就仅仅是把数值存了起来,并没有初始化什么东西。tableSizeFor()
方法如下,作用是对于一个初始容量,给出大于等于这个容量的最小的 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; }
这个步骤可以分成两部分,解释和图来源于知乎用户@终端研发部。从二进制的角度看一下这个流程:
5 -> 8、9 -> 16、19 -> 32、37 -> 64 都经历了两个阶段:
Step 1,5->7
Step 2,7->8Step 1,9->15
Step 2,15->16Step 1,19->31
Step 2,31->32
Step 1 就是将最左侧的 1 右边所有的数都填充成 1,Step 2 就是加 1。
那么对应到代码中,Step 1 就是:
n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16;
Step 2 就是:
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
Step 2 很简单,就是做一下与极限值的判断,然后再将数值加一而已。
那么 Step 1 的代码怎么理解呢?其实就是对一个二进制数依次右移,然后与原值取或。本质就是将第一个不为 0 的位的后面所有位都设置为 1。
那么再加一个 1,就是大于原数的第一个 2 的幂了!
这个办法确实很好,但是对于一个特殊情况确是行不通的,就是这个数原本就是 2 的幂,这种情况下这个数会变成自身的两倍。而方法的第一行先把这个数减去 1,就完美地避免了这个情况。
put()方法
put()
方法如下:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
主要功能被放在 putVal()
方法中实现。
注意这里传入 putVal()
的是 key 的 hash 值,hash()
方法的实现如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这里就体现了为什么 HashMap 的 key 可以是 null,当 key 为 null 时,其 Hash 值被当作 0 处理。那么问题来了,为什么不直接使用 key 的 hashCode,而是要将 hashCode 右移 16 位再和自己异或?这就是扰动算法的原理了。
hashCode()
方法,是 Object 类自带的 Hash 函数,返回一个 int 的 Hash 值,理论上,这个值应当均匀得分布在 int 的范围内,即 -2147483648 到 2147483647 之间,接近 40 亿的长度,这显然是很难产生碰撞的。
但是问题在于,HashMap 中的 Hash 桶并没有 40 亿大小,事实上,在最初默认初始化的时候,才 16 个桶。所以需要将这个哈希值映射到这 16 个桶上,一个比较简单均匀的办法,就是取模,对容量取模。例如,以初始长度 16 为例,二进制数为 00000000 00000000 00001111,如果我们让 10100101 11000100 00100101 这个数对 16 取模的话,其实仅仅是取这个数的低四位,而高位全部清零。这种情况下,就会导致很严重的碰撞。
这时就需要扰动函数了,它将一个 32 位数的高 16 位和低 16 位做一个异或操作,就变相地将高位的部分信息保存在了低位,增加了低位的随机性,减少的碰撞的几率。
值得一提的是,在 jdk 1.7 及以前,hash() 函数是这个样子的:
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
这里会对 key 进行四次扰动,可能考虑到过多次的扰动意义其实不大,所以 Jdk 1.8 中将扰动函数修改为一次扰动。
putVal()
方法将进行实际的 put 操作。
putVal()
开头首先判断当前 HashMap 的 table(存储空间)是否已经初始化或者为空,因为在构造函数中,并没有对 table 进行任何操作,如果是空的话需要进行初始的扩容:
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
扩容部分后续再说。
接着获取了 table 上对应位置的 Hash 桶,并判断是否为 null,如果是 null,说明没有发生Hash 碰撞,直接新建一个节点放进去即可,否则要进行碰撞处理:
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 碰撞处理部分 ... }
注意这个地方取出 Hash 桶的下标的方法,桶的下标为:(n - 1) & hash
,其中 n 是桶的个数。上面说过,理论上将大的 Hash 值映射到较小的数组上,使用的取模运算。
这个公式其实就是一种快速的取模运算,大致原理可以看上面那张图的计算下标部分。这种方法有一个局限性,就是 的二进制表示必须全是 1,也就是说,n 必须是 2 的幂次方。这就是 table 的容量必须是 2 的幂次方的原因。
接着我们看一下碰撞处理的部分:
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { ... }
分为三种情况讨论,第一种就是当前桶的头节点的 hash 和现有的相等,且 key 也相同的情况下,将其暂时保存在 e 中,后面需要进行替换,说明这次 push 是更新一个已存在的 key 的value。
否则,如果当前桶的头节点是一个树节点(TreeNode),那么就调用 putTreeVal()
方法向二叉树中插入这个节点。
否则,就是一个普通的链表插入操作了:
for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; }
遍历链表至链表尾,将节点插在尾部,同时检查了一下链表的长度,如果长度大于了 TREEIFY_THRESHOLD
的长度,就需要将这条链表转化为一棵红黑树,调用的是 treeifyBin()
方法。注意这里提一嘴 treeifyBin()
方法,调用了 treeifyBin()
方法并不是绝对会转化为红黑树,在该方法开头有一个判断:
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else ...
这里要求 table 的 length,即桶的数量大于等于 MIN_TREEIFY_CAPACITY
的值(64),才可以进行下一步,否则就仅仅会进行一次扩容,而不会将链表转化为红黑树。主要是为了防止在 HashMap 建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。
如果在遍历过程中发现了相同的 key,说明这次 put 就是一个更新操作,将在下面进行更新。
if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; }
如果 put 是一个更新操作,就需要使用新值覆盖,并将旧的值返回,这里调用的 afterNodeAccess()
可以说是一个回调函数,在 HashMap 中并没有实现,在 LinkedHashMap 中有实现。
++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null;
最终 size 自加一后判断与 threshold 的大小关系,如果大于 threshold,表明需要进行扩容,调用 resize()
方法扩容。
get()方法
get()
方法根据 key 找到对应的 value 并返回,如果找不到的话,返回 null。签名如下:
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
也是一个包装方法,实际的 get 操作在 getNode()
方法中实现,如果 getNode()
返回 null, get 也就返回 null,否则就返回这个 Node 的 value。
getNode()
方法用于根据给定的 hash 和 key 在 table 中寻找 Node。
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
它的实现很简单,大致流程是,首先根据 hash 值找到对应的 hash 桶,如果 hash 桶的头节点的 key 就等于想要的 key,那就直接返回头节点。否则,判断头节点的类型,如果是树节点,就使用 getTreeNode()
方法在红黑树中进行查找,否则就使用普通的链表查找,找到的话返回该节点,否则返回 null。
resize()方法
resize()
方法用于对 table 进行初始化,或者将 table 的容量扩容为原来的两倍。
if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab;
初始,对原本 table 的大小和 threshold 进行了一系列判断,如是否是最新初始化或者原大小是否已经超过上限之类的。最终确定要扩容到的大小 newCap 和新的阈值 newThr。如果没有超出上限的话,newCap,即新桶的大小,是 oldCap 左移一位,即原桶数量的两倍,即 HashMap 扩容时会将桶的个数扩充为原来的两倍。threshold 也直接变为原来的两倍。
接着按照 newCap 的大小初始化一个 Node 数组 newTab,并直接将 table 的引用指向了newTab,那么旧的桶数组则由 oldTab 持有。判断 oldTab 是否为 null,如果是 null,则说明这是 HashMap 构造完成后的第一次 resize()
,无需迁移节点,直接返回 newTab 即可。
迁移过程如下:
for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; 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 { next = e.next; if ((e.hash & oldCap) == 0) { 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); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } }
迁移过程,就是对节点重新计算 Hash 并安排的过程。主要就是对桶的遍历,并且根据桶的头节点的类型来进行不同的动作,如果头节点是一个 TreeNode,那么就需要调用 split()
方法对树进行拆分(第 8 行),否则就对链表进行拆分。对树的操作暂且不说,主要来看对链表的拆分操作,从第 10 行开始。
这里先说结论,在原 Map 中位于同一个桶的链表节点,在扩容后重新 Hash 后,必然位于两个桶之中(也有可能还在一个桶),且这两个桶位置相差 oldCap。较小的那个桶位于原位置。
我们来证明一下:
例如原 table 大小长度为16,那么扩容后的大小为 32,有两个节点的 Hash 分别为 5 和 21(简化情况),那么根据公式 我们来计算这两个节点在新 table 和旧 table 中的位置:
0000 0000 0000 0000 0000 0000 0000 1111 (16-1) & 0000 0000 0000 0000 0000 0000 0000 0101 (5) = 0000 0000 0000 0000 0000 0000 0000 0101 0000 0000 0000 0000 0000 0000 0001 1111 (32-1) & 0000 0000 0000 0000 0000 0000 0000 0101 (5) = 0000 0000 0000 0000 0000 0000 0000 0101 0000 0000 0000 0000 0000 0000 0000 1111 (16-1) & 0000 0000 0000 0000 0000 0000 0001 0101 (21) = 0000 0000 0000 0000 0000 0000 0000 0101 0000 0000 0000 0000 0000 0000 0001 1111 (32-1) & 0000 0000 0000 0000 0000 0000 0001 0101 (21) = 0000 0000 0000 0000 0000 0000 0001 0101
Hash 为 5 和 21 的 key 显然在原 table 中位于一个桶,在计算扩容后的桶的位置时,我们可以注意到,n-1 的二进制数多了一个 1,在这个例子中,就是在第 5 位上多了一个 1,那么由于是与操作,计算后的结果是否改变就仅仅取决于该节点的 Hash 的第 5 位上是否为 1,如果是 0 的话,那么桶的位置不变,否则,计算出的桶的位置就比原桶的位置大 ,正好是原桶的大小。且旧元素迁移到新元素时重新计算 Hash 只有这两种情况,即在同一个旧桶中的元素,迁移到新的 table 时,只有可能保持不变,或者到一个新的桶中,这个桶的 index 为原桶 index+原 table 大小。
这样就简化了迁移过程。
再回到代码:
Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { 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); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
初始化的两个链表 loHead 和 hiHead 分别代表保持原位置的节点组成的链表和迁移到新位置的节点组成的链表。接着遍历原链表上的所有节点,将节点分配到两条链表上。第 6 行的 if 中条件,(e.hash & oldCap)
即可判断出那个关键的二进制位上是否为 0,为 0 的节点放入原位链表 loHead,否则放入新链表 hiHead。最后将这两条链表放到新 table 的对应位置即可。
remove()方法
remove()
方法用于在 HashMap 中删除对应 Key 的 Entry,方法签名如下:
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }
可以看到核心的逻辑是调用 removeNode()
方法,该方法返回被删除的 Entry 的 value。
removeNode()
方法的很多逻辑和 put()
方法很接近,就不具体展开说明了。
红黑树退化成链表
在代码中有一个常量为 UNTREEIFY_THRESHOLD
:
static final int UNTREEIFY_THRESHOLD = 6;
这个常量仅仅会在 split()
方法中使用,用于分裂红黑树并重新计算 Hash,split()
也只会在 resize()
中调用,即只有在扩容时,对红黑树进行分裂,如果树的大小不足6,就会退化为链表。
if (loHead != null) { if (lc <= UNTREEIFY_THRESHOLD) tab[index] = loHead.untreeify(map); else { tab[index] = loHead; if (hiHead != null) // (else is already treeified) loHead.treeify(tab); } } if (hiHead != null) { if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); } }
注意到 TREEIFY_THRESHOLD
和 UNTREEIFY_THRESHOLD
的值并不相等,这主要是因为防止某个桶中的节点个数在这个固定值附近震荡,就会导致频繁的红黑树和链表的转化。
但是,在代码中,红黑树退化为链表的方法 untreeify()
并不仅仅在这一处被调用,也就是说,退化并不仅有这一个条件。
在 removeTreeNode()
方法中,也调用了该方法, removeTreeNode()
是在 remove()
删除节点时使用的。在删除红黑树节点时,有如下判断:
if (root == null || (movable && (root.right == null || (rl = root.left) == null || rl.left == null))) { tab[index] = first.untreeify(map); // too small return; }
这里是对红黑树的结构进行判断,如果满足一定的条件,就会退化为链表,退化条件如下:
- 根的左子树为空
- 根的右子树为空
- 根的左孙子为空
这些情况都是一些比较极端的情况,这些情况下,红黑树已经极度不平衡,查询的效率趋近于链表。
注意这时还没有对节点进行删除,也就是说,这是在删除节点之前进行退化的。
这里我们来看一下什么样的结构会导致退化,图源https://my.oschina.net/u/580483/blog/4294852
最小临界情况:
这是满足不退化的最小情况,如果删除任何一个节点,就会导致下一次remove时进行退化。红黑树节点个数为3。
最大临界情况:
这时,如果删除了0005节点,树不会发生旋转,但是下次remove时因为满足第三个条件而导致退化,红黑树节点个数为10。
#春招##面经##Java##Java工程师#