1 https://segmentfault.com/a/1190000012926722#articleHeader7
2 https://www.zhihu.com/question/20733617
3 https://tech.meituan.com/java-hashmap.html
4 https://www.zhihu.com/question/57526436/answer/153262129
5 https://segmentfault.com/q/1010000000630486
本篇文章我们来聊聊大家日常开发中常用的一个集合类 - HashMap。HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。
在本篇文章中,我将会对 HashMap 中常用方法、重要属性及相关方法进行分析。需要说明的是,HashMap 源码中可分析的点很多,本文很难一一覆盖,请见谅。
上一节说到 HashMap 底层是基于散列算法实现,散列算法分为散列再探测和拉链式。HashMap 则使用了拉链式的散列算法,并在 JDK 1.8 中引入了红黑树优化过长的链表。数据结构示意图如下:
本篇文章所分析的源码版本为 JDK 1.8。与 JDK 1.7 相比,JDK 1.8 对 HashMap 进行了一些优化。比如引入红黑树解决过长链表效率低的问题。重写 resize 方法,移除了 alternative hashing 相关方法,避免重新计算键的 hash 等。不过本篇文章并不打算对这些优化进行分析,本文仅会分析 HashMap 常用的方法及一些重要属性和相关方法。如果大家对红黑树感兴趣,可以阅读我的另一篇文章.
http://www.coolblog.xyz/2018/01/11/%E7%BA%A2%E9%BB%91%E6%A0%91%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/
HashMap 的构造方法不多,只有四个。HashMap 构造方法做的事情比较简单,一般都是初始化一些重要变量,比如 loadFactor 和 threshold。而底层的数据结构则是延迟到插入键值对时再进行初始化。HashMap 相关构造方法如下:
/** 构造方法 1 */
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/** 构造方法 2 */
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/** 构造方法 3 */
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
/** 构造方法 4 */
public HashMap(Map extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
上面4个构造方法中,大家平时用的最多的应该是第一个了。第一个构造方法很简单,仅将 loadFactor 变量设为默认值。构造方法2调用了构造方法3,而构造方法3仍然只是设置了一些变量。构造方法4则是将另一个 Map 中的映射拷贝一份到自己的存储结构中来,这个方法不是很常用。
上面就是对构造方法简单的介绍,构造方法本身并没什么太多东西,所以就不说了。接下来说说构造方法所初始化的几个的变量。
我们在一般情况下,都会使用无参构造方法创建 HashMap。但当我们对时间和空间复杂度有要求的时候,使用默认值有时可能达不到我们的要求,这个时候我们就需要手动调参。在 HashMap 构造方法中,可供我们调整的参数有两个,一个是初始容量 initialCapacity,另一个负载因子 loadFactor。通过这两个设定这两个参数,可以进一步影响阈值大小。但初始阈值 threshold 仅由 initialCapacity 经过移位操作计算得出。他们的作用分别如下:
相关代码如下:
/** The default initial capacity - MUST be a power of two. */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/** The load factor used when none specified in constructor. */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
final float loadFactor;
/** The next size value at which to resize (capacity * load factor). */
int threshold;
如果大家去看源码,会发现 HashMap 中没有定义 initialCapacity 这个变量。这个也并不难理解,从参数名上可看出,这个变量表示一个初始容量,只是构造方法中用一次,没必要定义一个变量保存。但如果大家仔细看上面 HashMap 的构造方法,会发现存储键值对的数据结构并不是在构造方法里初始化的。这就有个疑问了,既然叫初始容量,但最终并没有用与初始化数据结构,那传这个参数还有什么用呢?这个问题我先不解释,给大家留个悬念,后面会说明。
默认情况下,HashMap 初始容量是16,负载因子为 0.75。这里并没有默认阈值,原因是阈值可由容量乘上负载因子计算而来(注释中有说明),即threshold = capacity * loadFactor。但当你仔细看构造方法3时,会发现阈值并不是由上面公式计算而来,而是通过一个方法算出来的。这是不是可以说明 threshold 变量的注释有误呢?还是仅这里进行了特殊处理,其他地方遵循计算公式呢?关于这个疑问,这里也先不说明,后面在分析扩容方法时,再来解释这个问题。接下来,我们来看看初始化 threshold 的方法长什么样的的,源码如下:
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
上面的代码长的有点不太好看,反正我第一次看的时候不明白它想干啥。不过后来在纸上画画,知道了它的用途。总结起来就一句话:找到大于或等于 cap 的最小2的幂。至于为啥要这样,后面再解释。我们先来看看 tableSizeFor 方法的图解:
上面是 tableSizeFor 方法的计算过程图,这里cap = 536,870,913 = 229 + 1
,多次计算后,算出n + 1 = 1,073,741,824 = 230
。通过图解应该可以比较容易理解这个方法的用途,这里就不多说了。
说完了初始阈值的计算过程,再来说说负载因子(loadFactor)。对于 HashMap 来说,负载因子是一个很重要的参数,该参数反应了 HashMap 桶数组的使用情况(假设键值对节点均匀分布在桶数组中)。通过调节负载因子,可使 HashMap 时间和空间复杂度上有不同的表现。当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键的键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间。至于负载因子怎么调节,这个看使用场景了。一般情况下,我们用默认值就可以了。
HashMap 的查找操作比较简单,查找步骤与原理篇介绍一致,即先定位键值对所在的桶的位置,然后再对链表或红黑树进行查找。通过这两步即可完成查找,该操作相关代码如下:
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
// 1. 定位键值对所在桶的位置
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) {
// 2. 如果 first 是 TreeNode 类型,则调用黑红树查找方法
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
// 2. 对链表进行查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
查找的核心逻辑是封装在 getNode 方法中的,getNode 方法源码我已经写了一些注释,应该不难看懂。我们先来看看查找过程的第一步 - 确定桶位置,其实现代码如下:
// index = (n - 1) & hash
first = tab[(n - 1) & hash]
这里通过(n - 1)& hash
即可算出桶的在桶数组中的位置,可能有的朋友不太明白这里为什么这么做,这里简单解释一下。HashMap 中桶数组的大小 length 总是2的幂,此时,(n - 1) & hash
等价于对 length 取余。但取余的计算效率没有位运算高,所以(n - 1) & hash
也是一个小的优化。举个例子说明一下吧,假设 hash = 185,n = 16。计算过程示意图如下:
上面的计算并不复杂,这里就不多说了。
在上面源码中,除了查找相关逻辑,还有一个计算 hash 的方法。这个方法源码如下:
/**
* 计算键的 hash 值
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
看这个方法的逻辑好像是通过位运算重新计算 hash,那么这里为什么要这样做呢?为什么不直接用键的 hashCode 方法产生的 hash 呢?大家先可以思考一下,我把答案写在下面。
这样做有两个好处,我来简单解释一下。我们再看一下上面求余的计算图,图中的 hash 是由键的 hashCode 产生。计算余数时,由于 n 比较小,hash 只有低4位参与了计算,高位的计算可以认为是无效的。这样导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高4位数据与低4位数据进行异或运算,即 hash ^ (hash >>> 4)。通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中。此时的计算过程如下:
在 Java 中,hashCode 方法产生的 hash 是 int 类型,32 位宽。前16位为高位,后16位为低位,所以要左移16位。
上面所说的是重新计算 hash 的一个好处,除此之外,重新计算 hash 的另一个好处是可以增加 hash 的复杂度。当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性。这也就是为什么 HashMap 不直接使用键对象原始 hash 的原因了。
和查找查找一样,遍历操作也是大家使用频率比较高的一个操作。对于 遍历 HashMap,我们一般都会用下面的方式:
for(Object key : map.keySet()) {
// do something
}
或
for(HashMap.Entry entry : map.entrySet()) {
// do something
}
从上面代码片段中可以看出,大家一般都是对 HashMap 的 key 集合或 Entry 集合进行遍历。上面代码片段中用 foreach 遍历 keySet 方法产生的集合,在编译时会转换成用迭代器遍历,等价于:
Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
Object key = ite.next();
// do something
}
大家在遍历 HashMap 的过程中会发现,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的。但这个顺序和插入的顺序一般都是不一致的。产生上述行为的原因是怎样的呢?大家想一下原因。我先把遍历相关的代码贴出来,如下:
public Set keySet() {
Set ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
/**
* 键集合
*/
final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
// 省略部分代码
}
/**
* 键迭代器
*/
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
abstract class HashIterator {
Node next; // next entry to return
Node current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
Node[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
// 寻找第一个包含链表节点引用的桶
do {} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Node nextNode() {
Node[] t;
Node e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
// 寻找下一个包含链表节点引用的桶
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
//省略部分代码
}
如上面的源码,遍历所有的键时,首先要获取键集合KeySet对象,然后再通过 KeySet 的迭代器KeyIterator进行遍历。KeyIterator 类继承自HashIterator类,核心逻辑也封装在 HashIterator 类中。HashIterator 的逻辑并不复杂,在初始化时,HashIterator 先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历。举个例子,假设我们遍历下图的结构:
HashIterator 在初始化时,会先遍历桶数组,找到包含链表节点引用的桶,对应图中就是3号桶。随后由 nextNode 方法遍历该桶所指向的链表。遍历完3号桶后,nextNode 方法继续寻找下一个不为空的桶,对应图中的7号桶。之后流程和上面类似,直至遍历完最后一个桶。以上就是 HashIterator 的核心逻辑的流程,对应下图:
遍历上图的最终结果是 19 -> 3 -> 35 -> 7 -> 11 -> 43 -> 59
,为了验证正确性,简单写点测试代码跑一下看看。测试代码如下:
/**
* 应在 JDK 1.8 下测试,其他环境下不保证结果和上面一致
*/
public class HashMapTest {
@Test
public void testTraversal() {
HashMap map = new HashMap(16);
map.put(7, "");
map.put(11, "");
map.put(43, "");
map.put(59, "");
map.put(19, "");
map.put(3, "");
map.put(35, "");
System.out.println("遍历结果:");
for (Integer key : map.keySet()) {
System.out.print(key + " -> ");
}
}
}
在本小节的最后,抛两个问题给大家。在 JDK 1.8 版本中,为了避免过长的链表对 HashMap 性能的影响,特地引入了红黑树优化性能。但在上面的源码中并没有发现红黑树遍历的相关逻辑,这是为什么呢?对于被转换成红黑树的链表该如何遍历呢?大家可以先想想,然后可以去源码或本文后续章节中找答案。