一、传统 HashMap的缺点
(1)JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。
(2)当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。
(3)针对这种情况,JDK 1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题
二、JDK1.8中HashMap的数据结构
2.1HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的
static
final
class
TreeNode<K,V>
extends
LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
// red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
// needed to unlink next upon deletion
boolean
red;
}
|
2.2HashMap 中关于红黑树的三个关键参数
TREEIFY_THRESHOLD
|
UNTREEIFY_THRESHOLD
|
MIN_TREEIFY_CAPACITY
|
---|---|---|
| static final int UNTREEIFY_THRESHOLD = 6 | |
| | |
2.3HashMap 在 JDK 1.8 中新增的操作:桶的树形化 treeifyBin()
在Java 8 中,如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度。
这个替换的方法叫 treeifyBin() 即树形化。
//将桶内所有的 链表节点 替换成 红黑树节点
1
final
void
treeifyBin(Node<K,V>[] tab,
int
hash) {
2
int
n, index; Node<K,V> e;
3
//如果当前哈希表为空,或者哈希表中元素的个数小于 进行树形化的阈值(默认为 64),就去新建/扩容
4
if
(tab ==
null
|| (n = tab.length) < MIN_TREEIFY_CAPACITY)
5
resize();
6
else
if
((e = tab[index = (n -
1
) & hash]) !=
null
) {
7
//如果哈希表中的元素个数超过了 树形化阈值,进行树形化
8
// e 是哈希表中指定位置桶里的链表节点,从第一个开始
9
TreeNode<K,V> hd =
null
, tl =
null
;
//红黑树的头、尾节点
10
do
{
11
//新建一个树形节点,内容和当前链表节点 e 一致
12
TreeNode<K,V> p = replacementTreeNode(e,
null
);
13
if
(tl ==
null
)
//确定树头节点
14
hd = p;
15
else
{
16
p.prev = tl;
17
tl.next = p;
18
}
19
tl = p;
20
}
while
((e = e.next) !=
null
);
21
//让桶的第一个元素指向新建的红黑树头结点,以后这个桶里的元素就是红黑树而不是链表了
22
if
((tab[index] = hd) !=
null
)
23
hd.treeify(tab);
24
}
25
}
26
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
27
return
new
TreeNode<>(p.hash, p.key, p.value, next);
28
}
|
上述操作做了这些事:
(1)根据哈希表中元素个数确定是扩容还是树形化
(2)如果是树形化遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
(3)然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容
三、分析HashMap的put方法
3.1HashMap的put方法执行过程可以通过下图来理解,自己有兴趣可以去对比源码更清楚地研究学习。
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
1
public
V put(K key, V value) {
2
// 对key的hashCode()做hash
3
return
putVal(hash(key), key, value,
false
,
true
);
4
}
5
6
final
V putVal(
int
hash, K key, V value,
boolean
onlyIfAbsent,
7
boolean
evict) {
8
Node<K,V>[] tab; Node<K,V> p;
int
n, i;
9
// 步骤①:tab为空则创建
10
if
((tab = table) ==
null
|| (n = tab.length) ==
0
)
11
n = (tab = resize()).length;
12
// 步骤②:计算index,并对null做处理
13
if
((p = tab[i = (n -
1
) & hash]) ==
null
)
14
tab[i] = newNode(hash, key, value,
null
);
15
else
{
16
Node<K,V> e; K k;
17
// 步骤③:节点key存在,直接覆盖value
18
if
(p.hash == hash &&
19
((k = p.key) == key || (key !=
null
&& key.equals(k))))
20
e = p;
21
// 步骤④:判断该链为红黑树
22
else
if
(p
instanceof
TreeNode)
23
e = ((TreeNode<K,V>)p).putTreeVal(
this
, tab, hash, key, value);
24
// 步骤⑤:该链为链表
25
else
{
26
for
(
int
binCount =
0
; ; ++binCount) {
27
if
((e = p.next) ==
null
) {
28
p.next = newNode(hash, key,value,
null
);
//链表长度大于8转换为红黑树进行处理
29
if
(binCount >= TREEIFY_THRESHOLD -
1
)
// -1 for 1st
30
treeifyBin(tab, hash);
31
break
;
32
}
// key已经存在直接覆盖value
33
if
(e.hash == hash &&
34
((k = e.key) == key || (key !=
null
&& key.equals(k))))
35
break
;
36
p = e;
37
}
38
}
39
40
if
(e !=
null
) {
// existing mapping for key
41
V oldValue = e.value;
42
if
(!onlyIfAbsent || oldValue ==
null
)
43
e.value = value;
44
afterNodeAccess(e);
45
return
oldValue;
46
}
47
}
48
++modCount;
49
// 步骤⑥:超过最大容量 就扩容
50
if
(++size > threshold)
51
resize();
52
afterNodeInsertion(evict);
53
return
null
;
54
}
|
1
public
V put(K key, V value) {
2
if
(table == EMPTY_TABLE) {
//空表table的话,根据size的阈值填充
3
inflateTable(threshold);
4
}
5
if
(key ==
null
)
6
return
putForNullKey(value);
7
int
hash = hash(key);
//成hash,得到索引Index的映射
8
int
i = indexFor(hash, table.length);
9
for
(Entry<K,V> e = table[i]; e !=
null
; e = e.next) {
//遍历当前索引的冲突链,找是否存在对应的key
10
Object k;
11
if
(e.hash == hash && ((k = e.key) == key || key.equals(k))) {
//如果存在对应的key, 则替换oldValue并返回oldValue
12
V oldValue = e.value;
13
e.value = value;
14
e.recordAccess(
this
);
15
return
oldValue;
16
}
17
}
18
//冲突链中不存在新写入的Entry的key
19
modCount++;
20
addEntry(hash, key, value, i);
21
return
null
;
22
}
|
3.2HashMap 在 JDK 1.8 中新增的操作: 红黑树中查找元素 getTreeNode()
*/
1
final
Node<K,V> getNode(
int
hash, Object key) {
2
Node<K,V>[] tab; Node<K,V> first, e;
int
n; K k;
3
if
((tab = table) !=
null
&& (n = tab.length) >
0
&&
4
(first = tab[(n -
1
) & hash]) !=
null
) {
5
if
(first.hash == hash &&
// always check first node
6
((k = first.key) == key || (key !=
null
&& key.equals(k))))
7
return
first;
8
if
((e = first.next) !=
null
) {
9
if
(first
instanceof
TreeNode)
10
return
((TreeNode<K,V>)first).getTreeNode(hash, key);
11
do
{
12
if
(e.hash == hash &&
13
((k = e.key) == key || (key !=
null
&& key.equals(k))))
14
return
e;
15
}
while
((e = e.next) !=
null
);
16
}
17
}
18
return
null
;
19
}
|
(1)HashMap 的查找方法是 get(),它通过计算指定 key 的哈希值后,调用内部方法 getNode();
(2)这个 getNode() 方法就是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1) &hash
)得到 key 所在的桶的头结点,如果头节点恰好是红黑树节点,
就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。
(3)getTreeNode 方法使通过调用树形节点的 find()方法进行查找:
1
final
TreeNode<K,V> getTreeNode(
int
h, Object k) {
2
return
((parent !=
null
) ? root() :
this
).find(h, k,
null
);
3
}
|
(4)由于之前添加时已经保证这个树是有序的,因此查找时基本就是折半查找,效率很高。
(5)这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断 key 是否相等,相等就直接返回;不相等就从子树中递归查找。
3.3JDK1.8 VS JDK1.7 扩容机制
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。
四、JDK1.7 VS JDK1.8 的性能
4.1put操作
1.hash比较均匀的时候(负载因子时0.75导致的)
次数
|
10
|
100
|
1000
|
10000
|
100000
|
---|---|---|---|---|---|
JDK1.7时间(ns) | 1100 | 720 | 832 | 914 | 912 |
JDK1.8时间(ns) | 1019 | 1023 | 1188 | 267 | 115 |
2.hash不均匀的时候
次数
|
10
|
100
|
1000
|
10000
|
100000
|
---|---|---|---|---|---|
次数
|
10
|
100
|
1000
|
10000
|
100000
|
JDK1.7时间(ns) | 2500 | 14310 | 8151 | 14137 | 154319 |
JDK1.8时间(ns) | 3765 | 38144 | 60707 | 1182 | 373 |
4.2get操作
1.hash比较均匀的时候
次数
|
10
|
100
|
1000
|
10000
|
100000
|
---|---|---|---|---|---|
JDK1.7时间(ns) | 900 | 550 | 627 | 302 | 626 |
JDK1.8时间(ns) | 2773 | 1047 | 318 | 94 | 13 |
2hash不均匀的时候
次数
|
10
|
100
|
1000
|
10000
|
100000
|
---|---|---|---|---|---|
JDK1.7时间(ns) | 2000 | 14950 | 4294 | 2167 | 16447 |
JDK1.8时间(ns) | 3430 | 3932 | 2028 | 767 | 19 |
参考链接:
(1)红黑树的性质:http://blog.csdn.net/cyp331203/article/details/42677833
(2)JDK1.8 HashMap性能的提升:http://blog.csdn.net/lc0817/article/details/48213435/