HashMap 底层原理探究
前言:
hashMap 是一个 key——value 映射的集合,它的数据结构由 hash 表来实现,可以通过 key 达到快速获取 value 的效果。
本文将基于 JDK8 来分析 HashMap 底层实现原理。
1. Hash 表的实现
hash 表是由数组 + 链表实现了,至于数组和链表的优缺点,这里不做概述。
当需要通过 hash 表查询某一 key 值的 value。比如:对 key8 进行 hash 计算,得出数组的下标是 1,再基于链表遍历形式比较 key8,获取 key8 的 value8 即可。如果当链表太长,我们也可以将其转换为红黑树。
2. HashMap 的实现
2.1 数据结构
HashMap 就是基于 Hash 表实现的,它的每一个元素是个 Node<K,V> 对象,里面有 key、value、hash 等成员变量。Node 类如下所示:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key 计算后的 hash 值
final K key;
V value;
Node<K,V> next; // 下一个节点
Node(int hash, K key, V value, Node<K,V> 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;
}
}
Node 实现了 Map.Entry 接口,它必须实现 getkey()、getValue()、hashCode()等方法。
HashMap 使用数组 table 来存放 Node 元素,如果出现 node 的 hash 值一样,key 值不一样,则会以链表的形式往下添加。
transient Node<K,V>[] table;
2.2 存取机制
2.2.1 初始化
执行如下代码,创建 HashMap 对象
HashMap<String, Integer> map = new HashMap<>();
调用 HashMap 无参构造函数
public HashMap() {
// 扩容因子 float DEFAULT_LOAD_FACTOR = 0.75f;
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
这里可以看到,简单创建一个 HashMap 是不会设置对 table 数组进行初始化的,只是简单设置下扩容因子,后续 put 元素会调用 resize() 方法进行扩容。
当然我们也可以手动设置 table 数组大小。
HashMap<String, Integer> map = new HashMap<>(7)
// 可以调用有参构造手动设置 table 数组大小
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
initialCapacity 值会经过 tableSizeFor() 转换得到真正的 table 数组初始化长度。
/**
* 根据容量参数,返回一个2的n次幂的table长度
*/
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 的 n 次方值,作为 table 数组长度。这里可以看出,即便我们设置初始化长度最后也会转换为一个 大于等于 cap 的 2 的 n 次方值作为 tableSize。这里抛出一个问题,tableSize 为什么要返回一个 2 的 n 次方作为数组长度?这个问题,在本文后面会进行详细解答。
tableSizeFor 方法解析
该方法设计的非常巧妙,但是又很晦涩难懂,看不懂的同学可以直接跳过。
- 首先我们必须知道该方法会返回一个大于等于 cap 的 2 的 n 次方数。那在 32 位bit下 2 的 n 次方数有哪些?
2^0:0000 0000 0000 0000 0000 0000 0000 0001 2^0 -1:0000 0000 0000 0000 0000 0000 0000 0000
2^1:0000 0000 0000 0000 0000 0000 0000 0010 2^1 -1:0000 0000 0000 0000 0000 0000 0000 0001
2^2:0000 0000 0000 0000 0000 0000 0000 0100 2^2 -1:0000 0000 0000 0000 0000 0000 0000 0011
2^3:0000 0000 0000 0000 0000 0000 0000 1000 2^3 -1:0000 0000 0000 0000 0000 0000 0000 0111
… …
2^6:0000 0000 0000 0000 0000 0000 0100 0000 2^6 -1:0000 0000 0000 0000 0000 0000 0011 1111
… …
2^31:0100 0000 0000 0000 0000 0000 0000 0000 2^31 -1:0011 1111 1111 1111 1111 1111 1111 1111
这个也就是 MAXIMUM_CAPACITY 的值
2^32:1000 0000 0000 0000 0000 0000 0000 0000 2^32 -1:0111 1111 1111 1111 1111 1111 1111 1111
这个已经超过 int 的最大取值范围了
从上面看,2 的 n 次方数的二进制形式是不是很有规律。现在要你实现一个算法,就是在 int 取值范围中的任何二进制数要转换为一个大于等于且最接近的 2 的 n 次方数,你会怎么实现?
举例子:
数值74: 0000 0000 0000 0000 0000 0000 0100 1010
经过算法要转化为
数值2^7:0000 0000 0000 0000 0000 0000 1000 0000
这个看起来很简单,直接最高位1往前进一位,最高位后面都置于0就可以。但通过高效的按位运算和位移运算做起来很难。tableSizeFor() 方法是经过这样转化的。
将要转化为 2^n 改为 要转化为 2^n - 1次方。因为 2^n - 1 有效位都是1,更好使用按位运算。
数值74 - 1: 0000 0000 0000 0000 0000 0000 0100 1001
经过算法要转化为
数值2^7 - 1:0000 0000 0000 0000 0000 0000 0111 1111
最终将得出的 2^7 - 1 + 1 就是 2^7。这个看起来也简单,只要将最高位 1 以后的数都置为 1 即可。
再来看 tableSizeFor() 方法是怎么做的,首先执行:
int n = cap - 1;
也就是 n 要转化最终结果要是 2^N - 1,最后返回的时候 + 1即可。接下来执行位移运算 >>> 和 位运算 | :
|
运算如下:
1 0 0
| | |
1 1 0
........
= 1 1 0
这样里为了好看,稍微改了下代码,本质不变。
n = n | (n >>> 1);
n = n | (n >>> 2);
n = n | (n >>> 4);
n = n | (n >>> 8);
n = n | (n >>> 16);
如果 n 是 0,最后转化的完 n 也是 0,这种情况不考虑。我们考虑 n != 0 的情况。如果 n != 0 ,最高位肯定是 1。
- 步骤1,先将最高位 1 往右无符号位移 1 个位置,我不需要判断最高位在哪,我只需要最高位往右无符号位移 1 个位置,然后 n | (n >>> 1),就可以让 n 的最高位和次高位都是 1,然后赋值给 n。这时的n,最高位及最高位往后 1 位都是 1。
- 步骤2,执行 (n >>> 2),将 n 最高位和次高位无符号往后移 2 个位置位,然后 n | (n >>> 2),因为最高位和次高位都是1,往后移 2 位,也就是 (n >>> 2) 得到二进制,最高位往后数 3、4位也是 1,再执行 n | (n >>> 2) ,然后赋值给 n,这时的 n 最高位及最高位往后数 3 位都是 1。
- 执行完步骤3,得到的 n,最高位及最高位往后数 7 位都是 1。
- 整个都执行完,得到 n,最高位及最高位往后数 31 位都是 1。
也就是个算法执行完,它可以保证 n 的二进制最高位后面所有位都转化为 1。
比如:数值74 - 1: 0000 0000 0000 0000 0000 0000 0100 1001 ,经过转化后,会得到
数值2^7 - 1:0000 0000 0000 0000 0000 0000 0111 1111
最后再执行两个三元表达式:
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
如果 n < 0 返回 2^0,如果 n >= MAXIMUM_CAPACITY(2^31),则返回 2^31,其他则返回 n + 1。也就是输入数值 74 最后会返回 2^7 - 1 + 1。
2.2.2 put 元素
首先会调用这个方法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
再 put 元素之前,先对 key 进行 hash 计算,得到一个 hash值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
重点看 hash = (h = key.hashCode() ) ^ ( h >>> 16 )
代码。
为什么要重写 hashCode()?
如果 k 是个对象,则调用对象本身的 hashCod() 方法,如果没重写,则调用 Object类的 hashCode 方法;如果是包装类,都有重写 hashCod() 方法。比如 String key1 = “a” 和 String key2 = “a”,这两个 key 调用 String.hashCode() 方法后 得到的 hash 值是一样的,这也符合我们的意愿,相同的字符串 hash值是一样的。但如果是 Object 类,如以下代码:
Object key1 = new Object();
Object key2 = new Object();
System.out.println(key1.hashCode());
System.out.println(key2.hashCode());
执行完得到的 hash值是不一样的,因为它们本身就是两个不同对象。但如果我们把自定义类作为 key 时候,就得好好考虑下,该不该重写 hashCode() 方法了。
比如自定义 Key 类:
public class Key {
private String st1;
public Key(String st1) {
this.st1 = st1;
}
}
执行如下代码,你会发现,这虽然是两个不同的 Key对象,但是有相同的成员变量 st1,应该要有相同的 Hash值,所以你必须重写 hashCode() 方法。
Key key1 = new Key("a");
Key key2 = new Key("a");
System.out.println(key1.hashCode());
System.out.println(key2.hashCode());
重写 hashCode() 方法,再执行如上代码,就得到相同的 hash值了
public class Key {
private String st1;
public Key(String st1) {
this.st1 = st1;
}
@Override
public int hashCode() {
return st1.hashCode();
}
}
再看hash = (h = key.hashCode() ) ^ ( h >>> 16 )
为什么要执行 h ^ (h >>> 16) ?
在计算出 h 值后,h = key.hashCode()。会执行 h ^ (h >>> 16)
,这个步骤是为了干嘛?
将 h无符号右移 16 为相当于将高区 16位移动到了低区的 16位,再与原 h 做异或运算,可以将高低位二进制特征混合起来,也就是低 16位 集成了高 16位和低 16位本身的特征。为什么要这么做呢?
在计算元素应该存在 table 数组哪个下标的时候,是通过 (table.length - 1) & hash
来计算出下标。
&
运算如下:
1 0 0
& & &
1 1 0
........
= 1 0 0
我们可以知道,当 length 很大的时候,超过 16 位,hash 的高位也会参与计算,这没问题;但如果 length 很小的时候,只有低 16位,高位全是 0,那么 hash 只有低位会参与计算,高位的特征就失效了。hash值有多少位参与运算完全是跟 length 有关,当 length 太小,2个不同的 hash 值只要 length 有效位是一样的就会得出同样的下标。
举例子:当 length = 8 的时候,8 - 1,二进制:0000 0000 0000 0000 0000 0000 0000 0111
现有 2个不经过 h ^ (h >>> 16)
运算的 hash值:
hash1:0000 0000 0000 0010 0000 0000 0000 0101
hash2:0000 0000 0000 0000 0000 0000 0000 0101
它们在计算下标后,都是 5。
如果它们都执行 h ^ (h >>> 16)
后,再去计算下标,得出一个是 5,一个是 7。
所以该代码目的,在 length 比较小的时候,hash 值低位也集成高位的特征,使得计算出的下标更加分散,减少重复,也就是减少 hash碰撞。
为什么 length 长度要是 2的幂次方
旨在高效计算和减少hash碰撞,怎么理解?
- 假如 table.length 可以是任意长度,比如 5,5 - 1的二进制是:
0000 0000 0000 0010 0000 0000 0000 0100,那它在 & hash
的时候你会发现,计算的下标全是4,其他下标根本计算不出来。
- 这里你肯定会想,我用 hash % (table.length - 1),也能很好的计算出下标,但是它没有上面表达式高效。我们在调用 get(key) 的时候也会经过计算下标,所以效率问题还是需要考虑进去。
putVal 方法详解
计算出了 key 的 hash值后,我们继续往下分析源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 如果 tab = null 或者 tab.length = 0,则进行扩容
n = (tab = resize()).length;
// 计算出索引下标位置
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果tab数组索引下标上没有 node,则创建一个 node,存入 hash、key、value等值
tab[i] = newNode(hash, key, value, null);
else {
/**
如果tab数组下标上有 node,则说明发生了 hash碰撞,
这里会比较 tab数组下标的 node与新增的 node是否判定为同一个进行覆盖,不是同一个,进行链表添加进去。
*/
Node<K, V> e;
K k;
/**
根据 hash值是否一样 && (key对象是否是同一个 || 调用equals方法后返回true) 来判断是覆盖还是进行链表添加
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 需要进行覆盖,进入这里,后续会替换value的值
e = p;
else if (p instanceof TreeNode)
// 不需要进行覆盖,且 node 是红黑树节点进入这里
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
else {
// 不需要进行覆盖,node 是链表节点进入这里
for (int binCount = 0; ; ++binCount) {
// 这里如果没发生 node需要覆盖,则一直遍历下去,直到新node添加到尾节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 这里判断 链表长度是否大于等于 8
if (binCount >= TREEIFY_THRESHOLD - 1)
// 链表转换为红黑树
treeifyBin(tab, hash);
break;
}
// node 节点遍历时,需要判断是否有节点 和新增节点是同一个进行覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
// e != null 说明需要覆盖,修改value的值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
/**
判断是否需要扩容
threshold = tab.length * 扩容因子(默认是0.75)
size 是map中所有node的个数
*/
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
当 hash 计算出的下标上有元素 p 时,我们需要根据如下校验来判断是否进行覆盖还是新增。
(p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
- 首先计算出新元素在数组中索引下表的位置
- 如果该索引下表位置没有 node,则生成新的 node添加进去,如果有 node,则会进行判断
- 如果两个 key 的 hash值不一样,只是它们计算出的索引下标是一样,认为它们是不同的 key
- 如果 hash值一样,则会可能发生 hash碰撞,需要继续判断
- 通过 == 直接判断 key 是不是同一对象
- 不是同一个对象,则调用 key 对象自定义重写的 equals() 方法来判断是否是同一个 key。
是同一个key,则进行覆盖,遍历结束,都不是同一key,则基于链表 or 红黑树添加下去。
- 如果 node 元素个数 > table.length * 扩容因子(0.75),则会进行扩容。
该方法逻辑图如下所示:
2.2.3 resize 方法详解
resize 方法主要是用来对 table 数组扩容的,为什么要扩容?因为如果填充比很大,说明利用的空间很多,如果一直不进行扩容的话,链表就会越来越长,这样查找的效率很低。加载因子(默认0.75),当达到这个比例,则会进行扩容。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 旧表的长度不为0,进入这里
// 判断旧表长度是否大于允许最大长度
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
// 新表长度newCap = 旧表长度oldCap * 2
} 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 {
// 旧表长度是0,无参构造刚创建的HashMap,并没有初始化table数组,进入这里
// newCap = 16。初始化长度为16
// newThr = 0.75 * 16 = 12。当前table扩容阈值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 如果新表扩容阈值 = 0,计算新表扩容阈值 = 新表长度 * 0.75
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 构造新表,初始化新表。
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 旧表不为null,需要将旧表数据复制到新表
for (int j = 0; j < oldCap; ++j) {
// 遍历旧表
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// oldTab[j] != null
oldTab[j] = null;
if (e.next == null)
// 如果oldTab[j] 只有一个 node,直接添加到新表
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果oldTab[j]上 不止一个 node,并且是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;
/**
判断node节点的索引下标是否需要修改
*/
if ((e.hash & oldCap) == 0) {
// 索引下标不需要改变的 node组成链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
// 索引下标需要改变的 node组成链表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
// 索引不变的 node链表,将头节点添加进新表 原位置j
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// 索引需要改变的 nide链表,将头节点添加进新表
// 原位置j + oldCap 组成新的索引下标
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新表
return newTab;
}
(e.hash & oldCap) 算法
(e.hash & oldCap)
这个代码很巧妙,我们知道 oldCap 是 2的n次方。这里假设 oldCap = 16,newCap = 16 * 2。
oldCap : 0000 0000 0000 0000 0000 0000 0001 0000
oldCap - 1 : 0000 0000 0000 0000 0000 0000 0000 1111
newCap : 0000 0000 0000 0000 0000 0000 0010 0000
newCap - 1 : 0000 0000 0000 0000 0000 0000 0001 1111
可以看出,newCap 比 oldCap 高一位,newCap - 1 比 oldCap - 1 也高一位。如果(e.hash & 16)
= 0,说明 e.hash 在 newCap - 1 最高位肯定是0,所以 e.hash & oldCap - 1 == e.hash & newCap - 1,索引下标位置不需要变:
举例子:(e.hash & 16)
= 0,e.hash 二进制如下:
XXXX XXXX XXXX XXXX XXXX XXX XXX0 XXXX。X 可以是 0 or 1;
旧表索引下标
XXXX XXXX XXXX XXXX XXXX XXXx XXX0 XXXX
&
0000 0000 0000 0000 0000 0000 0000 1111 oldCap - 1 : 15
=
0000 0000 0000 0000 0000 0000 0000 XXXX
新表索引下标计算
XXXX XXXX XXXX XXXX XXXX XXXx XXX0 XXXX
&
0000 0000 0000 0000 0000 0000 0001 1111 newCap - 1 : 31
=
0000 0000 0000 0000 0000 0000 0000 XXXX
得出的索引下标是一样的。
这里还有一个问题,索引下标需要修改的 node 它在新表中的位置可以直接通过 原位置 + oldCap 确定,也就是,e.hash & (oldCap - 1) +oldCap == e.hash & (oldCap <<<1 - 1)?
其实是一样的,这里不做过多推导。
总结扩容步骤:
- 判断 table 是否需要新初始化,初始化长度默认是16,如果不是,新表长度 = 旧表长度 * 2
- 遍历旧表所有索引下标上的 node
- 如果 node 不为空,且没有 next节点,则直接进行添加到新表
- 如果 node 不为空,node 是红黑树节点。。。。。
- 如果 node 不为空,是链表的头节点,这里会判断分成2个链表,一个是需要改变索引下表的链表,另外一个是不需要改变索引下表的链表,最后都添加到新表
- 返回新表
2.2.4 get 元素
通过get方法,我们可以基于 key 拿到 value
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
key 进行hash计算
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) {
// 通过 (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;
}
我们存是以 key.hash & (length - 1) 计算出索引下标位置的,我们查寻找出目标元素的索引下标位置也要基于这个方式计算。同时在判断 node 是不是我们要找的那个,判断逻辑和存也是一样的,都是基于((k = first.key) == key || (key != null && key.equals(k))))。