一、传统 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增加了红黑树部分)实现的
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)根据哈希表中元素个数确定是扩容还是树形化
(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,如果超过,进行扩容。
3.2HashMap 在 JDK 1.8 中新增的操作: 红黑树中查找元素 getTreeNode()
(1)HashMap 的查找方法是 get(),它通过计算指定 key 的哈希值后,调用内部方法 getNode();
(2)这个 getNode() 方法就是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1) &hash
)得到 key 所在的桶的头结点,如果头节点恰好是红黑树节点,
就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。
(3)getTreeNode 方法使通过调用树形节点的 find()方法进行查找:
(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/