由于最近一直在准备面试(比较菜,准备的晚所以现在还在准备= =)系统的搞一下HashMap,毕竟这个东西还是经常被问到的,甚至会被怼到源码上,所以这两天花了点时间把HashMap的源码扒下来看了一下;
只捡了一些基础的地方(扒的不是很深),粘了一部分源码(大段代码预警),结合代码搞了一点自己的理解在里面,不一定100%精准,大神勿喷,欢迎指正,交流。
全手工码字,未经允许请勿转载。
一、HashMap简介
HashMap 是一个基于哈希表的 Map 接口的实现,以 键值对(Key-Value)的形式存在。
HashMap 可以看作一个 节点数组( Node
),数组中的每一个位置可以看做是一个个的桶(bucket),桶中存放的是 哈希值相同的键值对 形成的 链表或者红黑树(JDK 1.8);
哈希值相同的键值对最初会以链表的形式被存在桶中,当 链表的长度达到一个阈值(TREEIFY_THRESHOLD = 8)的时候,链表会被转换为红黑树;
HashMap中比较重要的点大概有:
- HashMap的内部实现
- 容量(capacity)和负载因子(load factor)
- 树化
- 线程安全问题
二、HashMap的内部实现
1、HashMap支持 null 的键值
HashMap 支持键值为空(null);而另一种HashTable,(本身使用了synchronized关键字进行同步,线程安全),则不支持null的键值
HashMap本身支持 null的键的原因,是因为它在构造其 hash值的时候,如果发现键(key)是null,会默认将其设置为0
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
2、Node 数组
HashMap 的内部,是以一个 节点数组 Node
的形式存在的,这个 Node 实际上是 HashMap的一个 静态内部类;HashMap中的键值对,实际上都会封装成这个Node对象存在;
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final 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()))
return true;
}
return false;
}
}
3、HashMap的构造方法
HashMap有四个构造方法,一个一个看
// HashMap最终被调用的构造函数
// 至于为什么是最终调用的,看下面的几个构造函数就知道了
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);
}
从这个构造函数中,我们会发现,在构造函数中并没有去创建任何的东西,只是对阈值和负载因子之类的一些数据进行了初始化
// 可以手动指定初始容量的HashMap构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
注意,HashMap的容量一定为2的倍数,如果参数中的初始容量不是,则会被tableSizeFor方法调整成大于这个参数,且最接近的2的幂(见第一个构造函数的第13行)
// tableSizeFor方法
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;
}
// 无参构造方法
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认的负载因子,大小为0.75
}
无参构造方法会使用到默认的容量大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
其默认的大小为16
// 将现有的Map映射到新的 HashMap中
public HashMap(Map extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
putMapEntries()
方法用于将 Map 插入到新的HashMap中,简而言之就是调用了HashMap的putVal()
方法;
其中第二个参数,如果是false,表示这是一次初始化,如果是True,则和afterNodeInsertion()
方法有关(这个方法是用于HashMap的子类 LinkedHashMap中供其继承的,用来回调移除最早放入Map中的元素)
4、HashMap的put和get方法
4.1 get方法
HashMap的get方法比较简单,就是通过一个Key,通过匿名内部类Node的getNode()方法来查找这个Node是否存在,存在就返回它的Value值
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
4.2 put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
找到这个 put()
方法的定义可以发现,它的put方法实际上调用了另一个 putVal()
方法
其中,调用putVal()
方法的第一个参数,它其实并不完全是 Key 的 hashCode,而是 Key 的 hashCode 和 它右移16位取 异或 得到的值;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里进行右移的操作,实际上是为了减少hash冲突的几率;int类型是4个字节的,右移16位异或可以同时保留 高于16位 和 低于16位 的特征;
通常,hashCode的差异主要存在于高位,因此需要保留高位的特征,这样才能减少哈希碰撞的几率;
使用 异或 的原因是,异或操作能够保留 高16位 和 低16位 的全部特征(高低位相同则为0,不同则为1);
如果使用 & 与 运算,则结果向全0靠拢,而使用 | 或 运算,则结果向全1靠拢,不能同时反映高位和低位的特征
言归正传,来看一下putVal()
方法的代码,这个东西太长了,很多地方就直接以注释的形式写了理解;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
// table 是 transient Node[] table; HashMap这个类的一个成员
if ((tab = table) == null || (n = tab.length) == 0)
// table为空,表示初始创建
n = (tab = resize()).length;
//如果索引为i的桶为空,表示当前桶中没有任何元素(链表为空),则创建第一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
// 如果桶的第一个元素恰好和插入的元素的hash和key都相同,即插入的元素恰好是桶的第一个元素,无需执行其他操作,直接到后面覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果p节点是TreeNode,表示已经发生过红黑树的转换,那么调用插入 树节点的方法
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
// 否则是常规插入
else {
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;
}
// 如果在遍历的中途发现有 key 和 hash重复的情况,记录这个节点,后续对value进行覆盖操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
// existing mapping for key,存在相同key,则覆盖原有的value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // 记录HashMap修改的次数
// 判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
总结一下这个putVal()
方法的一些特点:
-
如果没有创建过HashMap,或者只是创建的HashMap什么都没有,则会调用扩容的
resize()
方法,这个方法既可以创建,也可以扩容if ((tab = table) == null || (n = tab.length) == 0) // table为空,表示初始创建 n = (tab = resize()).length; if (++size > threshold) resize();
-
HashMap的桶(Node数组),数组的 i 的决定方式,不是简单的使用 hashCode
i = (n - 1) & hash
此处的 hash,在上面提到过,是通过
hash()
这个方法,将 key 的 hashCode 和 它右移16位 进行异或运算得到的一个值(进行异或是为了减少哈希碰撞)至于为什么要和 容量 - 1 进行 与& 运算,是因为这个
hash
的值很有可能大于它的容量n,因此执行&运算,将忽略超过容量的若干二进制位,保证最终计算得到的索引不会越界;为什么是
i = (n - 1) & hash
?事实上这个公式是HashMap必须保证容量为2的幂的原因之一,我们暂且不讨论为什么n是2的幂,考虑一下,当 n - 1 是奇数 或者 是偶数的时候会有什么情况:
- 如果 n - 1 是个偶数,则会出现这样一种情况,计算得到的 i 一定为偶数(由于使用的位运算,而偶数的二进制末位一定为0,这个0 & 任何数 的结果都是0,因此结果必然不会出现奇数);这样的坏处很明显,索引为奇数的桶中将不会有任何元素,因此会产生空间的浪费和大量的哈希碰撞
- 而如果 n - 1是奇数,那么计算得到的 i 既可能是奇数,也可能是偶数
关于这个n为什么是2的幂,后面会再说到
4.3 resize()方法
resize()
方法用于HashMap的扩容(同时也具有创建HashMap的能力,在这里也提一下);同样的,这个代码很长,也以注释的形式写一下理解;
final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 进行扩容,将容量左移1位,相当于x2
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
// 最开始创建,oldCap为0的时候执行
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将 threshold(触发扩容的阈值) 设置为2倍
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 创建一个新的 大小为原先的2被的 Node 数组
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) { // e为原先每一个桶中的链表头
// 把原先的桶置空
oldTab[j] = null;
// 如果原先的桶中只有一个元素,则直接将这个链表头放到新的位置上
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果获取到的e是树节点,就按照红黑树的方式处理
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
// 链表的拆分
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node 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;
}
}
}
}
}
return newTab;
}
在 resize()
方法中,需要特别说明一下这个链表拆分方法(第一次读源码的时候属实没搞懂它这个链表拆分是什么意思…)
else { // preserve order
// 链表的拆分
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
// 检索 原先的节点是否换了新的桶
// 如果(e.hash & oldCap) == 0) ,表示扩容之后不会进入新桶,这样的节点,形成一条链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 如果 (e.hash & oldCap) != 0),表明经过扩容之后,再计算索引会落入一个新的桶,这样的节点,形成另一条链表
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;
}
}
当发生扩容的时候,假设容量为8的HashMap中有两个节点,这个两个节点的 hash 分别为 01010 和 00010,那么根据 桶索引的计算 i = (n - 1) & hash
:
0111 & 01010 = 0010 = 2,即,在扩容之前,这个节点会落到 i = 2 这个桶中
0111 & 00010 = 0010 = 2,显然,这个节点在扩容之前也会落在 i = 2 这个桶中
此时发生了扩容,此时 n = 16, oldCap = 8,我们再来看代码中(e.hash & oldCap) == 0
这一行的执行结果:
1000 & 01010 = 1000 = 8
1000 & 00010 = 0000 = 0
这个现象表明了,第一个节点之所以最终落到了 i = 2 这个桶中,是因为一开始的容量不够,将其高位第二位的1给忽略了,再来看一下,此时发生扩容以后的索引的计算
0 1111 & 0 1010 = 0 1010 = 10
0 1111 & 0 0010 = 0 0010 = 2
可以发现,第一个节点确实会落到其他的桶中,这就说明 :
-
(e.hash & oldCap) == 0
这个是否等于 0 的判断,足以说明节点是否会落在原来的桶中 - 并且,由于一次扩容就是二进制左移1位,在这个情况下,至多会影响像第一个节点这样,第4位是1的节点,换言之,如果一个节点可能落到其他桶中,那么这个节点一定是第4位是1,它能落到的新桶的索引,必然是 旧索引+8
- 如果是从 8 扩容到 16,则 落到新桶的节点的新索引,一定是 旧索引+16,以此类推
这样一来,再看上面的代码就好理解了,扩容之后,它要么落到原来的桶中,要么落到一个新桶中,只要使用这样的判断将 原来的链表拆分成两个链表,然后将这两个链表的头放到对应的桶中,当我们遍历完所有的桶的时候,扩容操作也就完成了。
三、HashMap的 容量 和 负载因子
容量 和 负载因子 是 HashMap中重要的部分,它们共同决定了可用的桶的数量,如果空桶太多,则会浪费空间,如果桶的利用率过高则会严重影响操作性能;
1、负载因子的选择
负载因子主要用于HashMap的扩容操作,扩容阈值 = 容量 * 负载因子;当HashMap的节点数组大小超过扩容阈值的时候,HashMap就会发生扩容;
显然,由此看来,负载因子的选择是比较重要的,频繁的扩容 或是 不进行扩容都会影响性能;
关于这个桶的利用率过高(负载因子设置得比较接近于1)导致影响操作性能这个问题,我看了一些博客,说法大致上来说就是,如果负载因子设置的太大,会使得查找效率降低,比较含糊。
后面看到了一篇,大概的意思是,如果设置负载因子为1,就会增大哈希碰撞的概率,一旦发生了很严重的哈希碰撞,那么它的底层将会形成结构复杂的红黑树,红黑树虽然查询效率会高,但是如果节点过多,结果过于复杂,这个复杂的查找操作成本 会高于 我们将HashMap扩容,从而将产生哈希碰撞的节点分散到新的桶中的操作消耗;
另一篇博客用了比较数学的方法,通过泊松分布来分析,得到的结论是,大概在0.7左右的时候,时间和空间的消耗将比较平衡,是一种比较均衡的选择;
而如果将负载因子设置的过小(比如0.5),那么哈希碰撞的概率会被降低,但是会造成比较严重的空间浪费(才用了一半就要扩容,并且还是翻倍)
因此,对于负载因子:
- 如果没有特殊的需求,不要轻易的对负载因子进行修改,JDK默认的负载因子具有很好的通用性,适合大量的场景;
- 如果需要调整,尽量不要超过0.75,这样会显著增加冲突,降低性能
- 如果负载因子需要调小,则需要实现设定好初始容量,避免频繁进行扩容
2、HashMap的容量问题
HashMap经典问题之——为什么HashMap的容量一定是2的幂?
上面提到了,HashMap的节点进入桶的索引的计算方式:i = (n - 1) & hash
那么为什么是 n - 1? n又为什么一定是2的幂呢?
首先,在我们计算这个索引 i 的时候,我们实际上可以这样做:i = hash % n
,但是这样的方法效率并不高;而相对的,&运算属于位运算,至少它比 % 取模要快;并且我们发现
(n - 1) & hash = hash % n
并且它使用效率更高的位运算,这就是使用 n - 1 的原因;
其次为什么 n 必须是2的幂,这个是由于,2的幂的二进制的形式,必然是 1 后面有若干个全0,而我们需要的 n - 1 就恰好是 0 后面有若干个全1;
这种形式的好处就在于,最终计算得到的索引值是均匀的,可以减少哈希碰撞
在前面提到了一个 hash的计算,是这样的
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(h = key.hashCode()) ^ (h >>> 16)
之所以要将 hashCode右移16位取异或运算,原因在上面讲过了,由于hashCode的差异通常在高位,因此这个操作可以将高低16位的特征都保留下来,放在了它的低位上,从而减少哈希冲突;
而在计算索引的时候,n - 1 使得计算出的索引只取决于 hash它的x位低位值(x是几取决于容量需要用几个二进制位来表示)
简而言之,我们保证它的容量是2的幂,就是为了要这若干个1,有了这若干个1,才可以让索引计算的结果是均匀的,最终才能避免哈希碰撞,举一个例子:
hash | (n-1)& hash | 结果 |
---|---|---|
0 | 1111 & 0 | 0 |
1 | 1111 & 1 | 1 |
2 | 1111 & 10 | 2 |
3 | 1111 & 11 | 3 |
4 | 1111 & 100 | 4 |
5 | 1111 & 101 | 5 |
…… | …… | …… |
16 | 1111 & 10000 | 0 |
17 | 1111 & 10001 | 1 |
18 | 1111 & 10010 | 2 |
我的理解是,当hash是均匀的时候,最终计算得到的索引也恰好是均匀的,如果(n - 1)不全是1,那么即使 hash是均匀的,得到的结果也是不均匀的;
四、HashMap的树化
HashMap的树化有一点需要特别注意,虽然进行树化的阈值被设置为了8,但是还有另外一个条件:
- 如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
- 如果容量大于 MIN_TREEIFY_CAPACITY,这个时候,达到树化阈值才会进行树的改造
MIN_TREEIFY_CAPACITY 的值为 64,因此不是当链表长度大于8就一定会发生树化,数组容量必须先达到64,否则只是会进行扩容,而非树化!
在数组容量条件已经满足的情况下,继续讨论链表长度的问题:
当链表的长度过长之后,存取性能都会急剧下降,并且,如果有人通过构造会产生哈希碰撞的恶意代码对系统进行攻击,链表会导致CPU的大量占用,这会造成哈希碰撞拒绝服务攻击,因此在链表过长的时候需要对其进行树化,将链表改造成红黑树;
1、为什么节点数达到8的时候需要进行树化
这一点在HashMap的源码中有一段注释,将的是这个问题
/*
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
全英文不好看,强行翻译一下:
由于树形节点的大小大约是常规节点的两倍,所以我们只有当容器包含足够多的节点时才使用它们(见TREEIFY_THRESHOLD)。当它们变得太小(由于移除或调整大小)它们被转换回普通的bin。在使用分布良好的用户哈希码,树bin是很少使用。理想情况下,在随机哈希码下,bin中的节点频率遵循泊松分布
参数为默认大小调整的平均值约为0.5
阈值为0.75,虽然有较大的方差,因为调整粒度。忽略方差,期望列表大小k的出现次数为(exp(-0.5) pow(0.5, k) /阶乘(k))。第一个值是:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
大概意思是说,在随机的hash码的情况下,出现哈希碰撞从而产生节点数超过8的链表的可能性非常的低,比8更多的情况几乎小于千万分之1;
并且,红黑树的平均查找长度为 log(n),当 n = 8 的时候,使用红黑树的平均查找长度为 3;而链表的平均查找长度为 n / 2,当 n = 8 的时候,链表的平均查找长度为 4,因此在 n = 8 的时候,使用红黑树的效率更高,这个时候转换成红黑树需要的性能消耗将显得没有那么重要。
有关红黑树转换回链表:
在HashMap的源码中,我们可以找到
static final int UNTREEIFY_THRESHOLD = 6;
如果我们由于删除操作,将一个桶中的红黑树节点删除到了6个的时候,就会把红黑树改造成链表。
至于为什么不是7,可以把7理解成一个缓冲量,如果在7的时候就进行树的链表改造,那么万一刚刚改造成链表,就又插入了一个元素,就会造成刚改回来的链表又执行转换为红黑树的操作,有可能在 链表 < -- > 红黑树 之间进行反复横跳,会造成性能的浪费;