Java容器源码分析之HashMap与HashSet

HashMap与HashSet介绍

HashMap是Java为我们提供的一个存放键值对的容器,通过键的hash值定位存储位置,具有很快的访问速度。但是其遍历顺序是不确定的,这里所说的不确定是指其遍历的顺序与放入顺序不一致、多次遍历输出顺序不一致(可能会放进数据导致reHash,改变原有顺序)。HashMap允许有一个null的键,对于值没做要求。HashMap也是个非线程安全的容器,在并发环境下可以使用ynchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

HashSet是Java为我们提供的一个集合类容器,他不允许容器内元素重复,底层采用HashMap实现(适配器模式)。

以下是HashMap的UML图与HashSet的Uml图:

HashMap UML图

HashSet UML图

HashMap源码分析

HashMap数据结构

HashMap采用了一种数组+链表+红黑树(JDK1.8新增)的数据结构;

HashMap数据结构

HashMap实际存放数据的是一个叫Entry的Node节点,它的定义如下:

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

存入HashMap里的数据均用Entry封装起来,通过键值定位到数组的位置,当该位置为空时,放入Entry元素,当该位置不为空时通过equals比较该位置与放入元素的键值,当键值相等时直接替换,当键值不相等时,采用拉链法解决hash冲突。

拉链法解决hash冲突:当hash冲突发生时,通过在该位置建立链表,将所有hash值相同但是equals不相等的元素通过链表链起来解决hash冲突;

当HashMap中拉链的长度超过8时,HashMap将链表数据结构转换为红黑树,此时的查找时间复杂度降低为O(log n)。

HashMap方法解析

get方法

HashMap get方法源码如下:

1
2
3
4
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

首先先拿到key的hash值,然后通过hash值定位到数组中该hash位置的node,然后返回node的value;

计算key的hash值方法hash():

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

计算一个key的hash值得具体算法是:当key为null时,hash值为0,不为null时,首先得到该key的hashCode,然后异或上key的hashCode值无符号右移16位。

通过hash值得到数组指定位置node元素方法getNode():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

首先,通过(n - 1) & hash得到元素在数组中的位置然后取出元素,其中n是数组的总长度;当这个元素与要取的key相等(==与equals)时,该元素就是要取得元素;当不相等时,即判断为hash冲突了;当hash冲突时,HashMap通过查找链表(红黑树)的方式查找该位置的拉链(红黑树),直到找到一个元素与key equals相等时返回或者没找到返回null,此时的查找就变为对链表或红黑树的遍历。

put方法

HashMap put方法源码如下:

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;
//判断table数组是否已经初始化
if ((tab = table) == null || (n = tab.length) == 0)
//没有初始化,开始初始化table数组
n = (tab = resize()).length;
//通过(n - 1) & hash获取该元素在table中该放入的位置
if ((p = tab[i = (n - 1) & hash]) == null)
//若该位置为空,则直接放入元素
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//该位置不为空时,开始判断是否是hash冲突
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//不是hash冲突,开始判断该位置的数据结构是否是红黑树
else if (p instanceof TreeNode)
//当是红黑树时,使用红黑树插入方式插入元素
e = ((TreeNode<K,V>)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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//当遍历到链表的某一个位置时,该位置元素与要插入元素equals相等时即判断两个元素相等,不用插入,跳出循环
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
//当此时HashMap长度大于长度 * 扩容因子时扩容
resize();
afterNodeInsertion(evict);
return null;
}

HashMap在put一个元素的时候会去调用其putVal方法,在putVal方法中,首先会去检测用来存储元素的数组是否已经初始化了,因为HashMap与ArrayList一样采用了延迟为数组分配空间的策略,我们可以来看下它的构造方法:

1
2
3
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

默认的构造方法中,就设置了一个扩容因子。

若数组已经初始化了,HashMap会通过(n - 1) & hash计算出该元素在数组中的位置,当该位置没有元素时,直接放入,当该位置已经有元素了,HashMap开始判断是否与该位置的元素发生了Hash冲突。

当发生了Hash冲突时,HashMap会判断该位置是通过红黑树还是链表,然后通过红黑树或者链表添加数据的方式添加新元素。

最后HashMap会检查此时数组的长度,当长度超过数组容量*扩容因子时会开始扩容的过程。

HashMap扩容函数resize方法:

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
87
88
89
90
91
92
93
94
//resize兼容了扩容和HashMap初始化的过程
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时即说明HashMap已经初始化了,开始扩容
if (oldCap >= MAXIMUM_CAPACITY) {
//当长度超过1<<30长度时,直接将新长度设置为Integer.MAX_VALUE,为1<<30的两倍
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//当(oldCap << 1) < MAXIMUM_CAPACITY且oldCap大于默认长度时,直接扩容为原有长度的两倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
//当数组没有初始化时,若oldThr>0,则说明调用的是有参数的构造函数
//此时新长度就是带参数构造函数计算出来的临界长度
newCap = oldThr;
else {
//数组未初始化,且调用的是没有参数构造函数
//数组长度为默认值1<<4
//计算临界长度
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
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//通过e.hash & oldCap判断元素是否需要移动
if ((e.hash & oldCap) == 0) {
if (loTail == null)
//确定首元素
loHead = e;
else
//循环遍历将元素迁移至loHead为首的链表中
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 != null不为空说明元素不用移动,直接放在数组原位置
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
//hiTail != null说明元素需要移动,将链表放在j + oldCap新位置
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//完成扩容
return newTab;
}

这里需要讲一下的,扩容时通过e.hash & oldCap判断元素是否需要移动,具体示例如下:

  • 示例1:

    • 通过e.hash & oldCap判断元素是否需要移动:
      e.hash = 10 0000 1010
      oldCap = 16 0001 0000
      & = 0 0000 0000 //不需要移动
    • 通过(e.hash & (oldCap-1))计算得到的下标位置:
      e.hash = 10 0000 1010
      oldCap - 1 = 15 0000 1111
      & = 10 0000 1010 //位置没变
  • 示例2:

    • 通过e.hash & oldCap判断元素是否需要移动:
      e.hash = 17 0001 0001
      oldCap = 16 0001 0000
      & = 1 0001 0000 //元素需要移动,新的下标位置是原下标位置 + 原数组长度
    • 通过(e.hash & (oldCap-1))计算得到的下标位置:
      e.hash = 17 0001 0001
      newCap - 1 = 31 0001 1111
      & = 17 0001 0001 //位置为17

HashSet源码分析

HashSet是通过HashMap实现的,首先我们来看下Hashset的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public HashSet() {
map = new HashMap<>();
}

public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}

public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}

public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}

HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

通过HashSet的构造函数我们就能略知一二,HashSet有个map的成员变量保存了一个HashMap的实例。

HashSet的add方法:

1
2
3
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

HashSet的add方法很简单,就一行代码;成员变量PRESENT是new出来的一个static final的Object对象。

HashSet通过将值作为HashMap的key,一个static final的Object对象作为value用来实现了一个不可重复的set容器

思考

  • Q:为什么HashMap与HashSet容量总是2的n次方?
    A:HashMap通过h & (table.length -1)来得到该对象的保存位,当长度总是2的n次方时,h & (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。

  • Q:为什么HashMap要对长度取模?
    A:把hash值对数组长度取模运算,可以使元素相对均匀分布在数组中。

本文首发于我在万达摆地摊's blog,转载请注明来源