YY's Studio.

【Java常用容器】HashMap源码分析

2018/07/23

阅读提醒:将本文结合源码一起使用味道更佳哦!~

前言

上一章写到ArrayList的源码分析,而本篇将会对同样是最常用的容器之一的HashMap来进行分析。之前面试求职者,经常会问到HashMap的底层数据结构的部分。很多人只回答出是“哈希表”。因此特意引用网上哈希表的定义:

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。

由定义可以肯定的是HashMap是哈希表,而哈希表到底是怎么实现的呢?本篇将会带领你一步一步拆解

哈希碰撞

在了解HashMap源码之前,我们还需要了解一个概念,就是哈希碰撞。

对于这个概念通常有几个疑问

  • 什么是哈希碰撞(what)
  • 如何解决哈希碰撞(how)
  • 哈希碰撞是怎么出现的(why)

1.什么是哈希碰撞:

哈希值是作为一种数据”指纹“而存在,广泛的运用在查找与存储方面。一般情况大家都希望它是具有唯一性的。而事实上,所有产生hash值的hash算法都不能避免哈希值完全不相同。当多个数据的hash值相同的时候,就称为哈希碰撞。

打个比方,你(key)出生了,被爸妈取名(hash算法)为刘伟,后来发现全国有好多个叫刘伟的人。于是你哭了,我要怎么证明我是我?

2.如何解决哈希碰撞:

常见的解决哈希碰撞的思路有:

  • 开放地址法:通过使用与地址、表长相关的算法再计算值;
  • 再哈希法:通过一定的规则多次hash;
  • 建立一个公共溢出区:通过一个表来记录冲突的hash数据
  • 拉链法:将hash冲突的值存在同一个链表中;

(想要了解更多,可点击传送门)

上面的4个方法主要解决的其实就是:在全国有那么个叫刘伟的情况下,还能找到你。

而以上的方法中被HashMap使用的就是拉链法(我还是不太理解哪里像拉链了),这点下文会介绍到。

3.哈希碰撞是怎么出现的(why)

如1所说,所有的hash算法不存在hash一次就能保证生成的结果一定是唯一的。而一个好的hash算法就是将出现hash碰撞的概率降到最低。

类定义

1
2
public class HashMap<K, V> extends AbstractMap<K, V>
implements Map<K, V>, Cloneable, Serializable
  • AbstractMap:实现了Map接口,实现了Map的get,container等方法,不过很多都被HashMap重写

  • Cloneable:标记接口,声明HashMap重写clone方法。这里HashMap实现的是浅拷贝,即复制内部元素

  • Serializable:标记接口,可被默认的序列化机制序列化与反序列化

主要内容

内容大纲

数据结构

HashMap的数据结构在 JDK1.8比之于JDK1.7有一个重大的升级:增加了红黑树结构。

  • 在 JDK1.7 中对于发生hash碰撞的值,会通过链表的方式插入:
    内容大纲
  • 在JDK1.8中为了解决当链表过长而导致查询速度变慢的问题,在原有的结构上引用了红黑树。
    内容大纲

这里我们先了解一下红黑树优点:在插入、删除、查询的时候时间复杂度是O(log n),在应付数据量较大的时候有着良好的性能。

回到JDK1.7,没有红黑树的情况下对链表的查询、删除、插入将永远是O(n)。当n值较大的时候,所耗费的时间也会随之增大,也正是为了性能的优化,JDK1.8中引入了红黑树。

关于红黑树的内容,这里先不多加赘述。附带一个学习地址

Hash算法

HashMap的哈希值也经历了1.7到1.8的升级过程。

  • JDK1.8的哈希算法:一次位运算,一次异或;

    1
    2
    3
    4
    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • JDK1.7的哈希算法:4次位运算,5次异或;

    1
    2
    3
    4
    5
    6
    static final int hash(Object key) {
    int h;
    h ^= k.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4)
    }

以上的代码运用到了很多的逻辑运算,忘记的同学可以复习一下。传送门

两个版本间的hash算法的变化,出于两个目的:

  1. 减少计算次数,提高效率;
  2. 降低hash碰撞的概率;

这里得出hash值根据以下公式生成索引

1
int index = hash(key) & (length -1)

问题1:为什么求index的值要是要用这种算法?

因为这样的与运算可以可以达到类似取余(取模)的效果,将结果均匀分布在0 ~ length-1上。

以下我们来验证一下,

1
2
3
hash(key)     = 1011 1011 1101 0010
length:2^4-1 = 0000 0000 0000 1111
result = 0000 0000 0000 0010

因为length的高于8的位都是0,所以进行与运算之后都会置为0。因此范围一定落在0 ~ length-1之间。

问题2:为什么length的值要是2倍数?

如果使用奇数作为长度,则会使得HashMap只会在上分布不均,只在偶数数个排列数据。

拿上面的栗子来看

1
2
3
hash(key)     = 1011 1011 1101 0010
length:9-1 = 0000 0000 0000 1000
result = 0000 0000 0000 0010

这样的情况会导致,结果元素只分布在偶数索引上。

问题3:HashMap.put(null,”test”)成立么?

可以,空的key的hash值为空。所以计算出的索引一直会在数组的第一个

结构

对于一个容器来说,数据结构尤其重要。

以下代码节选出一些HashMap中比较重要的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认大小
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //扩容因子

transient Node<K,V>[] table; //数组
transient int size; //元素的个数
transient int modCount; //操作数
int threshold; //阈值

static class Node<K,V> implements Map.Entry<K,V> { //节点类
final int hash; //节点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;
}
}

//JDK1.8新增:
static final int TREEIFY_THRESHOLD = 8; //链表转树最小值
static final int UNTREEIFY_THRESHOLD = 6; //树转链表最小值
static final class TreeNode<K,V> extends LinkedHashMap.LinkedHashMapEntry<K,V> //树化的节点类

以上比较重要的就是扩容因子与阈值,它们间的计算公式如下

1
int threshold = table.length * DEFAULT_LOAD_FACTOR

其次就是Node类,HashMap中将所有传进来的key和value 都通过Node来包装一层。除了key和value这些基础数据之外。当hash碰撞后,将会由Node类中的next成员变量作为持有下一个节点的引用形成链表(单向链表)。而1.8中TreeNode是在数化之后变换的节点类型,感兴趣的可以自己深入

问题4:阈值与扩容因子的作用?

阈值的作用是判断当前是否需要扩容的,当size超过阈值就需要扩容。因此扩容因子直接决定了阈值的大小。而从更深层次的角度上看,扩容因子影响了HashMap的空间利用率。

当扩容因子较小的时候,size很容易就超过阈值,引发扩容。这个时候table的空间还相对充分,元素分布较为分散。因此空间利用率较低。正是因为这样的特性,扩容因子较小的时候也不容易产生哈希碰撞。

反之,扩容因子越大,元素分布越紧密,空间利用率越高,而容易发生哈希碰撞。

某天公司同事小杰宝问了我一个问题——”为什么扩容因子是0.75f“,我一时语塞。网上查完资料后才知道。在理想情况下,经过大量数据概率验证后扩容因子为0.75f的时候,链表长度几乎不超过8。

而HashMap扩容的部分,我放在后面插入的部分讲解。

数据操作

以下以源码均以JDK1.8的为例,其实两者的代码差异不多。只是JDK1.8增加了其中对树的操作

查询操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//1.根据 (n - 1) & hash 方式获取数组上的索引和其上的元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//2. 判断第一个元素的key和value是否就是所需的节点,如果是就直接返回节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//3. 判断第一个节点是否是树节点,如果是树,就调用树的查询方式
if (first instanceof TreeNode)
//3.1 返回树化节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//4. 当前的节点是链表节点,通过for循环查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

以上代码主要做的就是以下步骤:

  1. 通过key的hash找到索引;
  2. 在如果不在数组中,则在树或者链表中遍历查找;
  3. 返回结果;

为了更好的了解这个流程,可以看以下的流程图增加一下印象

问题5:hashMap查询的时间复杂度?

在HashMap的优点之一是查询速度快,在哈希不碰撞的情况下 时间复杂度是O(1),而在hash碰撞的时候有两种情况,树化情况下,时间复杂度O(logn),链表化的情况下,时间复杂度是O(n)。

插入操作

和看ArrayList不同,hashMap我们只需要看一个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K, V>p; int n, i;
//1. 判断table是否为空,为空的话初始化(默认16)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2. 是否有hash冲突,如果没有则直接创建新节点,发现冲突,获取第一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//3. 第一个节点的key是否与要传入的key相同,如果相同,则后面会直接个更新其value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//4. 如果为树节点,则进入树节点插入方法(里面也是要判断是否key相同)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//5. 遍历链表
for (int binCount = 0; ; ++binCount) {
//5.1 找到最后最后的节点插入
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//5.2 找到key相同的值,则后面会直接个更新其value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//6 前面获取到key相同的节点更新值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//6.1 空实现
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//7 判断是否需要扩容
if (++size > threshold)
resize();
//8 空实现
afterNodeInsertion(evict);
return null;
}

以上代码都是偏重一些细节的流程,我们可以简单的概括一下

  1. 在数组,树节点,链表结构中判断当前的key是否存在
  2. 如果存在更新数据,不存在在对应的数据结构中新建节点

如果还没有消化好,可以看看下面的流程图:
upload successful

问题5:hashMap能不能支持key的重复?

从上面的方法中,我们可以看到。无论是链表、树还是hash不冲突的情况都会判断是否已经存在。

扩容

以上还没有讲到的就是扩容,这里在JDK1.7和JDK1.8有一些变化

在JDK1.7中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void resize(int newCapacity) {  

Entry[] oldTable = table;

int oldCapacity = oldTable.length;
//1. 判断是否已经扩容到最大
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//2. 创建新数组
Entry[] newTable = new Entry[newCapacity];
//3. 转移元素
transfer(newTable);

table = newTable;

//更新阈值
threshold = (int)(newCapacity * loadFactor);
}

void transfer (Entry[] newTable) {
Entry[] src = table;

int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
//去掉原数组的元素引用,使其通过GC被回收
src[j] = null;
do {
Entry<K,V> next = e.next;
//计算新的数组下的hash值。在分布(由上可知数组长度变了,hash值也会变)
int i = indexFor(e.hash, newCapacity);
//重新排布在链表上的元素,如果发现hash仍是重复的采用倒插法
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}

以上的流程很简单,做了两件事:

  1. 创建扩容数组,更新阈值;
  2. 迁移数据,这里链表的迁移需要重新hash,或者使用倒插;

现在我们再看看JDK1.8的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//1. 判断是否为空
if (oldCap > 0) {
//1.1判断是否已经为容量
if (oldCap >= MAXIMUM_CAPACITY) {
//1.2 设置不会再触发扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
//1.2 对现有数组扩容 *2,并将阈值也 *2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0) // 自定义
newCap = oldThr;
else { // 默认红的初始化
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 = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//遍历所有数组成员
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
// 这里做的操作是将一条链表拆分成两条
// loHead、loTail 对应一条链表,hiHead、hiTail 对应另一条链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//判断新的元素的索引是否在oldCap中,不理解的同学可以画画位运算
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);
//loHead保存在原来的位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//hiTail的索引换成 index + oldCap
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

而在JDK1.8中,出现了两个变化:

  • 增加树结构;
  • 将链表的迁移做了优化,不再重新创建元素或者是使用倒插法,而是直接修改链表间的引用;

在我看来,对于JDK1.7和JDK1.8的差异我们不需要记住。因为本质上做的事情都是一样的。

  1. 创建扩容后的数组,更新阈值;
  2. 数据迁移,根据不同的数据结构进行差异的迁移;

删除

我们可以看看JDK1.8的删除部分的代码,会发现删除的流程其实和插入的流程及其类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//找到hash值对应的索引上的第一个元素;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//如果为第一个则直接返回结果;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//如果为树结构,则去查找对应树节点;
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//循环遍历链表,获取对应的节点;
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}

if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果是树结构,就删除树结构里的节点
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) //如果是在数组上,就将下一个节点(有可能为空)赋值到数组上
tab[index] = node.next;
else
p.next = node.next; //如果是在链表上,就将上一个节点的指针指向下一个节点
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}

看完以上代码,里面只有删除两步:

  1. 查找节点(这一步和查询是几乎一样的);
  2. 删除节点,和查询一样,分为数组,树,链表做处理;

序列化与fast-fail

这两部分内容其实与ArrayList中的实现与思路是完全一样的,不再多做赘述,不了解的同学可以回头看看,传送门

总结

HashMap应该算是容器中源码难度较大的一种。其中有一些重要的点需要反复巩固的

  1. 数据结构;
  2. 如何生成索引;
  3. 扩容因子、阈值等扩容情况;

看完HashMap让我对JDK的开发人员莫名的敬佩,能通过一些数学知识与数据结构持续将底层性能优化,也正是我要前进的方向。

CATALOG
  1. 1. 前言
  2. 2. 哈希碰撞
  3. 3. 类定义
  4. 4. 主要内容
    1. 4.1. 数据结构
    2. 4.2. Hash算法
      1. 4.2.1. 问题1:为什么求index的值要是要用这种算法?
      2. 4.2.2. 问题2:为什么length的值要是2倍数?
      3. 4.2.3. 问题3:HashMap.put(null,”test”)成立么?
    3. 4.3. 结构
      1. 4.3.1. 问题4:阈值与扩容因子的作用?
    4. 4.4. 数据操作
      1. 4.4.1. 查询操作
      2. 4.4.2. 问题5:hashMap查询的时间复杂度?
      3. 4.4.3. 插入操作
      4. 4.4.4. 问题5:hashMap能不能支持key的重复?
      5. 4.4.5. 扩容
    5. 4.5. 删除
      1. 4.5.1. 序列化与fast-fail
  5. 5. 总结