本文是学习博客:试阿里,HashMap 这一篇就够了之后的记录,也有一些包括自己的理解
哈希表
什么是哈希表
如线性表、树等查询(折半查找、二叉排序树查找、B树查找),这类查找都属于比较类型的查找,也就是说查询效率完全取决于比较次数。为什么要比较呢,因为我们在存储元素位置的时候,完全是随机的。
哈希表是另一种查询方式,是将存储元素与存储位置建议一个对应关系,这样就可以不通过比较查找元素,类似于生活中的字典,通过关键字直接找到关键字的页码。
哈希==关系映射==f(x)=x
举例说明,关键字是我们的省份,映射关系为取首字母第一个字母,根据字母顺序,找到对应的存储下标
x | BEIJING | TIANJIN | HEBEI | SHANXI | SHANGDONG | GUANGZHOU |
---|---|---|---|---|---|---|
f(x) | 02 | 20 | 08 | 19 | 19 | 07 |
存储结构
- 如果我们要查找BEIJING,通过关键字关系映射,找到下标02,找到元素
- 对于不同的key,但是对应的f(x)相同,如SHANXI 、SHANGDONG 都对应19,这种现象叫
哈希冲突
,哈希冲突是不能避免的,只能在冲突的时候想办法 - 由哈希算法构成的数据集合就是哈希表
哈希函数的构造方法
方法有几种,这里只讲HashMap使用的方法,除留余数法
即H(key) = key MOD p
举例,关键字:28、35、63、77、105,p=21
关键字 | 28 | 35 | 63 | 77 | 105 |
---|---|---|---|---|---|
哈希地址 | 7 | 14 | 0 | 14 | 0 |
处理冲突的方法
方法有几种,这里只讲HashMap使用的方法,链地址法
,当发生哈希冲突,采取数组+链式(当然HashMap有自己的优化)
哈希表的查找分析
哈希表的查找效率取决于链表长度,如果链表元素特别多,那么就是链表遍历算法了。所有我们要控制哈希冲突的概率,即当哈希表中元素达到哈希表的长度一定比列时,将要扩容,减少哈希冲突的概率,即哈希表的装填因子
注:HashMap取值默认为0.75
基于了以上理论知识,我们开启HashMap学习,采取以阅读源码java.util.HashMap
为主,主要弄清几个重要方法的生命周期,对应代码为JDK 1.8
HashMap
为了理解HashMap,我提前学习了这些知识,帮助理解
HashMap存储结构
JDK 1.8 之前是由“数组+链表”组成,如下图所示
JDK 1.8 “数组+链表+红黑树”组成,如下图所示
问题一,为什么要引入红黑树?
答:链表查询效率是O(n),红黑树是一颗平衡树,查询效率是O(logn),当哈希冲突造成结点过多时候,链表的查询效率低,引入红黑树优化
问题二,为什么还要链表,数组+红黑树不行吗?
答:不行,红黑树在构建的时较复杂,设计结点平衡调整等操作,且相比链表需要多余的存储空间,当冲突结点比较少时,链表综合性能优于红黑树
问题三,当结点发生哈希冲突时,什么时候用链表?什么时候用红黑树?
参考代码变量
// 链表转红黑树结点阀值(treeifyBin)
static final int TREEIFY_THRESHOLD = 8;
// 链表转红黑树数组最小容量阀值
static final int MIN_TREEIFY_CAPACITY = 64;
// 红黑树转链表阀值(untreeify)
static final int UNTREEIFY_THRESHOLD = 6;
对应四个生命周期
- 当相同地址冲突结点小于8的,此时为链表
- 当相同地址冲突结点大于8的,但数组容量小于64,先扩大数据容量,不立刻转红黑树
- 当相同地址冲突结点大于8的,但数组容量大于64,链表转红黑树
- 当相同地址冲突结点原来是红黑树,由于数组扩容后,冲突结点小于等于6时候,红黑树转链表
为什么会选择6 、8个数为参考呢?源码注释给出来解释:随机的hashcode下,哈希地址是遵循泊松分布的,参考:(http://en.wikipedia.org/wiki/Poisson_distribution)
相同哈希地址冲突个数 | 概率 |
---|---|
0: | 0.60653066 |
1: | 0.30326533 |
2: | 0.07581633 |
3: | 0.01263606 |
4: | 0.00157952 |
5: | 0.00015795 |
6: | 0.00001316 |
7: | 0.00000094 |
8: | 0.00000006 |
大于8 | 小于千万分之一 |
通过概率我们可以得知,当结点为6和8时候,上下结点的概率相差非常之大,比如理论情况下,要达到链表转红黑树条件,节点数超过千万级别
HashMap重要属性
- 存储结点table 数组
transient Node<K,V>[] table;
,- 结点Node是链表类型
class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; ... }
- 结点Node是可以转换成红黑树结点TreeNode,TreeNode是Node的子类
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V> static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; ... } } 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; ... }
table 数组存储结构如下所示
2. 其他属性
// table.length: 容量
transient Node<K,V>[] table;
// HashMap 已经存储的节点个数
transient int size;
// 扩容阈值,当 HashMap 的个数达到该值,触发扩容
int threshold;
// 负载因子,扩容阈值 = 容量 * 负载因子。
final float loadFactor;
大致思路是上述描述,细节有所不同,截图部分重要代码做以说明
前面我们已经回顾了哈希表原理,那么上述四个重要属性则是理所当然了,所有掌握一些必要的理论还是有必要的。
默认值
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
-
数组容量属性说明,HashMap 的容量必须是2的N次方,当然HashMap支持传入一个初始化容量,当不满足2的N次方,代码会转换
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
下面就来看看怎么转换的,由于HashMap没有定义容量变量,可通过table.length得到容量,但是初始化HashMap不会立刻去构造
transient Node<K,V>[] table;
,代码通过threshold扩容阀值变量曲线救国,先当做容量用,初始化的的时候再换回来(恶心啊,不肯多定义一个变量)public HashMap(int initialCapacity, float loadFactor) { this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
threshold 和容量纠正回来
下面看看tableSizeFor
是如何做到将不是2的N次方数据转换成2的N次方的
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;
}
代码其实很简单,思路也比较简单,其实非常好理解,没有大家说的那么复杂,我解释一下思想
假设我们是使用的32位存储空间,最高位为1
代码 | 对应二进制 |
---|---|
初始值n | 1000 0000 0000 0000 0000 0000 0000 0000 |
n |= n >>> 1; | 1100 0000 0000 0000 0000 0000 0000 0000 |
n |= n >>> 2; | 1111 0000 0000 0000 0000 0000 0000 0000 |
n |= n >>> 4; | 1111 1111 0000 0000 0000 0000 0000 0000 |
n |= n >>> 8; | 1111 1111 1111 1111 0000 0000 0000 0000 |
n |= n >>> 16; | 1111 1111 1111 1111 1111 1111 1111 1111 |
通过上述运算,我们可以看出,通过运算,最高位后面的数字都会被1填充,也就是说,我们只要把握好最高位
的控制就可以了,最后通过+1,这个数字一定是2的N次方
举例说明一下
- 例子一)当入参为16时候,对应二进制为00010000,执行
int n = cap - 1;
后变成00001111,再经过逻辑运算,最高位1后面都填1,还是00001111,最后执行n + 1;
,变成了最终容量大小00010000=16 - 例子二)当入参为25时候,对应二进制为00011001,执行
int n = cap - 1;
后变成00011000,再经过逻辑运算,最高位1后面都填1,变成00011111,最后执行n + 1;
,变成了最终容量大小00100000=32
HashMap插入put流程
这个图是参考原作者,自己跟着源代码画了一个,老实说,如果只看这个图很容易忘记的,建议最好还是跟着源码看一遍吧。代码入口:java.util.HashMap#putVal
如何计算哈希地址
查看源代码,任何关键字的地址入库都是hash函数
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
继续更代码发现最终使用的对象的hashCode,并右移动16位,做异或运算(不同为1,相同为0),后面一起讲原因
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
理论片我们讲了,HashMap使用的方法,除留余数法
,但是HashMap有改进,查看代码
p = tab[i = (n - 1) & hash])
我们可以得出哈希地址 index=(容量-1) & hashCode
,使用的与运算取模,类似于除留余数法
,前面我们讲解了hashCode进行了一个(h = key.hashCode()) ^ (h >>> 16)
操作,是因为容量太小的时候,hashCode的高位都参与不到运算
HashMap resize流程
这个图是参考原作者,自己跟着源代码画了一个,老实说,如果只看这个图很容易忘记的,建议最好还是跟着源码看一遍吧。代码入口:java.util.HashMap#resize
如何理解(e.hash & oldCap) == 0
新哈希地址判断
前面我们已经知道了,哈希地址计算:index = (Cap - 1) & hash
,当我们扩容以后出现了newCap = oldCap << 1;即newCap = oldCap * 2;
通过哈希地址的计算公式我们可以看出,由于我们扩容是左移动一位,也就是说Cap - 1
会把原来多了一个1,举例oldCap-1 是0000 1111,newCap -1是0001 1111,也就说只要判断hashCode对应的oldCap 位置是否是1即可,即(e.hash & oldCap) == 0
由于新老Cap是2倍关系,所以新老位置相比旧位置就是一个oldCap的距离,比较巧妙
HashMap线程安全问题
HashMap 不是线程安全的,在并发下存在数据覆盖、遍历的同时进行修改会抛出 ConcurrentModificationException 异常等问题
JDK 1.8 之前还存在死循环问题(原作者讲解的比较多,但是我这里更加巧妙的理解思路,下面来看看)
- JDK 1.7 扩容采用的是“头插法”
- JDK 1.8 之后采用的是“尾插法”
也就说头插法在并发下会出现问题,那就来看看怎么造成的在这里插入代码片
JDK 1.7.0 的扩容代码,代码只是说明是头插法,不用看
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) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
头插法举例,假设我们有数据1,2, 3,演示过程如下
那么我们现在就来简化问题,问题变成循环一个链表,采用“头插法”的倒序元素,即如图所示
多线程情况复现
步骤一)线程一先执行到标记到第一个元素,此时切换到步骤线程二执行,线程二很给力啊,直接执行完了(真6,DOGE)
步骤二)现在轮到线程一执行了,这个时候我们再对照代码看一下,重要的1/2/3步骤,标记了
方法标记1)Entry<K,V> next = e.next;(这个也是退出循环的条件e = next;)
方法标记2)e.next = newTable[i];(newTable[i]就是HEAD)
方法标记3)newTable[i] = e;(newTable[i]就是HEAD)
此时已经构成环了,就是这回事,同时也会退出循环,转换完毕,懂了吧
那么死循环是在哪里发生的呢
java.util.HashMap#get
方法,(e = e.next) != null永远不会发生,这代码跑CPU非常厉害
总结(这段直接抄的,没啥好说的)
JDK 1.8 的主要优化有以下几点:
- 底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。
- 计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
- 优化了 hash 值的计算方式,老的通过一顿瞎JB操作,新的只是简单的让高16位参与了运算
- 扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环
- 扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。
其他