目录
可能你还会想问为啥不直接用hashcode%table.length 而用hash & (table.length - 1)?
HashMap底层数据结构
首先HashMap分为两个版本,一个是JDK1.7版本下和JDK1.8版本(部分图片来源于网络)
1.7采用数组、链表
1.8采用数组、链表、红黑树
由图可以看到主要的数据结构就是数组加链表,可以把数组叫做桶位,每一个桶位存放的是链表的头节点,每一个头节点是一个Node类型(1.7叫Entry,一个意思)的节点。1.8引入了红黑树,引入红黑树的原因是在hash冲突严重,链表较长时查询的时间复杂度为O(n),而红黑树的查询效率是O(logn),也就是可以在哈希冲突严重的情况下尽可能保证查询效率。触发树化的条件是链表元素个数大于8,并且数组长度大于64(小于64的形况下通过进行数组扩容也可以减轻单个桶位内的hash冲突,避免树化)。
//桶位数组
transient修饰的原因:为了避免数组中过多空值被序列化,浪费资源,跟ArrayList内部数组用transient修饰一个道理。
Node有四个属性,如图
为什么使用红黑树?
为什么不用二叉树?
二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)。
为什么不用平衡二叉树
平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,这在某些情况下可能导致更高的维护成本。
采用红黑树是一种折中的方案,采用一种近似平衡的机制避免极端情况的出现,同时也能保证调平的效率。另外红黑树的插入、删除、查找的效率都是O(logn)。
红黑树需满足的五条特性:
- 节点是红色或黑色
- 根是黑色
- 叶子节点(外部节点,空节点)都是黑色,这里的叶子节点指的是最底层的空节点(外部节点),下图中的那些null节点才是叶子节点,null节点的父节点在红黑树里不将其看作叶子节点
- 红色节点的子节点都是黑色
-
- 红色节点的父节点都是黑色
- 从根节点到叶子节点的所有路径上不能有 2 个连续的红色节点
- 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
对于红黑树的详细内容可以参考https://blog.csdn.net/cy973071263/article/details/
HashMap put的流程
先看个流程图有个大致的思路:
再来看看源码(看源码不要钻牛角尖)
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,table 被延迟到插入新数据时再进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果桶中不包含键值对节点引用,则将新键值对节点的引用存入桶中即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
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;
}
// 条件为 true,表示当前链表包含要插入的键值对,终止遍历
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 判断要插入的键值对是否存在 HashMap 中
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 键值对数量超过阈值时,则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
减少hash冲突在设计上的小心思
(看完这部分你能理解为什么数组长度一定为2的n次幂,hash函数的作用,为什么用&而不用%)
- hash扰动函数
- 数组长度需要是2的整数次幂
- hash & (n-1)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//拿到 key 的 hashcode,是一个 32 位的 int 类型的数值,
//然后让 hashcode 的高 16 位和低 16 位进行异或操作。
下文有详细解释
/**
* 计算出大于等于参数的第一个2的幂次方
* 例如:1返回1,3返回4,8返回8,9返回16,125返回128,
* 如果参数大于默认最大值,则容量取默认最大值。
*/
static final int tableSizeFor(int cap) {
int n = cap - 1; //容量减1,为了防止初始化容量已经是2的幂的情况,最后有+1运算。如果cap已经是2的幂, 又没有执行这个减1操作,则执
// 行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。
n |= n >>> 1; //将n无符号右移一位再与n做或操作
n |= n >>> 2; //将n无符号右移两位再与n做或操作
n |= n >>> 4; //将n无符号右移四位再与n做或操作
n |= n >>> 8; //将n无符号右移八位再与n做或操作
n |= n >>> 16; //将n无符号右移十六位再与n做或操作
//如果入参cap为小于或等于0的数,那么经过cap-1之后n为负数,n经过无符号右移和或操作后仍未负
//数,所以如果n<0,则返回1;如果n大于或等于最大容量,则返回最大容量;否则返回n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//注:
//a |= b 即为 a = a|b
//>>> 是无符号右移运算符 无论正负,右移后,高位填充0
hash & (n-1)
先从hash & (n-1)开始说。
其作用就相当于 hash % n,n 为数组的长度,比如说数组长度是 16,hash 值为 20,那么 20 % 16 = 4,也就是说 20 这个元素应该放在数组的第 4 个位置;hash 值为 23,那么 23 % 16 = 7,也就是说 23 这个元素应该放在数组的第 7 个位置。& 操作的结果就是哈希值的高位全部归零,只保留 n 个低位,用来做数组下标访问。
比如说( hash & 2^4) 的结果实际上是取 hash 的低 4 位,这四位能表示的取值范围刚好是 0000 到 1111,也就是 0 到 15,正好是数组长度为 16 的下标范围。以初始长度 16 为例,16-1=15。2 进制表示是0000 0000 0000 0000 0000 0000 0000 1111。和某个哈希值做 & 运算,结果就是截取了最低的四位。
可能你想问为什么一大串哈希值只取后四位?岂不是会加剧hash冲突?
(h = key.hashCode()) ^ (h >>> 16)就是为了解决这个问题
它的作用就是让hash的高16位参与低16位的运算,也就是对hash值进行扰动。
举个例子:
第一个数:h1 = 0001 0010 0011 0100 0101 0110 0111 1000
第二个数:h2 = 0001 0010 0011 0101 0101 0110 0111 1000
如果没有 hash 函数,直接取低 4 位,那么 h1 和 h2 的低 4 位都是 1000,也就是两个数都会放在数组的第 8 个位置。
来看一下 hash 函数的处理过程。
①、对于第一个数h1的计算:
原始: 0001 0010 0011 0100 0101 0110 0111 1000
右移: 0000 0000 0000 0000 0001 0010 0011 0100
异或: ---------------------------------------
结果: 0001 0010 0011 0100 0100 0100 0100 1100
②、对于第二个数h2的计算:
原始: 0001 0010 0011 0101 0101 0110 0111 1000
右移: 0000 0000 0000 0000 0001 0010 0011 0101
异或: ---------------------------------------
结果: 0001 0010 0011 0101 0100 0100 0100 1101
通过上述计算,我们可以看到h1和h2经过h ^ (h >>> 16)操作后得到了不同的结果。
现在,考虑数组长度为 16 时(需要最低 4 位来确定索引):
对于h1的最低 4 位是1100(十进制中为 12)
对于h2的最低 4 位是1101(十进制中为 13)
这样,h1和h2就会被分别放在数组的第 12 个位置和第 13 个位置上,避免了哈希冲突。
可能你还会想问为啥不直接用hashcode%table.length 而用hash & (table.length - 1)?
其实这就是HashMap的一种优化策略,让我们来深入了解一下。
对于 HashMap 来说,它需要通过 hash % table.length 来确定元素在数组中的位置,这种做法可以在很大程度上让元素均匀的分布在数组中。
先给出结论:在数组长度为2的n次幂时,hash % table.length = hash & (length - 1)
比如说 9 % 4 = 1,9 的二进制是 1001,4 - 1 = 3,3 的二进制是 0011,9 & 3 = 1001 & 0011 = 0001 = 1。
再比如说 10 % 4 = 2,10 的二进制是 1010,4 - 1 = 3,3 的二进制是 0011,10 & 3 = 1010 & 0011 = 0010 = 2。
当数组的长度不是 2 的 n 次方时,hash % length 和 hash & (length - 1) 的结果就不一致了。
比如说 7 % 3 = 1,7 的二进制是 0111,3 - 1 = 2,2 的二进制是 0010,7 & 2 = 0111 & 0010 = 0010 = 2。
从二进制角度看hash/2^n=hash>>n
备注:<< 左移相当于hash*2^n(二的n次方),>> 右移相当于hash/2^n(2的n次方)。
比如15/4 = 3 余 1 二进制角度看 15/4=15>> 2^2 = 1110 右移2位 得出 0011(对应二进制的3),被移除掉1的就是余数。
0010 0101(37) >> 3(8=2^3) = 0000 0100 (4) 移除掉的为0101(5) 即:37/8(2的三次方) 商为4,余数为 5 与37%8的值相等。
hash%length的操作是求hash除以2^n,在二进制中的结果就是hash的二进制的最低n位。所以取模的时候高于n位的二进制位没有对取模做贡献。
hash&(length-1)实际就是保留hash二进制表示的低n位,其高位被置为0。
注:& 与运算:两个操作数中位都为 1,结果才为 1,否则结果为 0。
举个例子,hash 为 14,数组长度为 2^3,也就是 8,n=3。
1110 (hash = 14)
& 0111 (length - 1 = 7)
----
0110 (结果 = 6)
//保留了后三位,其高位被置为0,后三位的值为6(0+2+4)
结论:当在数组长度为2的n次幂时,hash % table.length = hash & (length - 1),没有直接使用取模运算的原因是位运算的速度远高于取模运算,因为计算机本质就是二进制计算。
小总结:看到这你就应该能理解hash()方法的作用,以及hashmap的数组大小为何需要为2的整数次幂,以及为什么取余不直接使用hash%table.length而使用hash&(length-1)。
解决hash冲突的办法
主要有三种方法
①、再哈希法
同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。
(这里我想到了布隆过滤器的原理,通过多个hash算法对新添加的值做预判断,对于新添加的值多重hash过后落入的节点不为空,说明该值可能在集合中存在,但是存在误判的概率。而如果多重hash判断结果为空,那集合中一定不存在该元素。布隆过滤器最经典的适用场景就是在Redis中解决缓存击穿的问题,但是布隆过滤器的缺点也很明显,占用内存、无法删除、误判。)
②、拉链法
也就是所谓的链地址法,当发生哈希冲突的时候,使用链表将冲突的元素串起来。HashMap 采用的正是拉链法。
③、开放地址法
遇到哈希冲突的时候,就去寻找下一个空的槽。有 3 种方法:
- 线性探测:从冲突的位置开始,依次往后找,直到找到空槽。
- 二次探测:从冲突的位置 x 开始,第一次增加1^2个位置,第二次增加 2^2个位置,直到找到空槽。
- 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数。
如何判断key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
①、hashCode() :首先,使用key的hashCode()方法计算key的哈希码。由于不同的key可能有相同的哈希码,hashCode()只是第一步筛选。
②、equals() :当两个key的哈希码相同时,HashMap还会调用key的equals()方法进行精确比较。只有当equals()方法返回true时,两个key才被认为是完全相同的。
③、==:当然了,如果两个key的引用指向同一个对象,那么它们的hashCode()和equals()方法都会返回true,所以在 equals 判断之前会优先使用==运算符判断一次。
经典面试题:为什么重写equals()必须重写hashcode()?
自己之前总结的一段话直接copy过来:==是比较两个对象的地址是否相同,equals在不被重写的情况下就是使用==比较,但是在Spring重写的equals方法中,就是先通过==比较是否是一个对象,不是再通过遍历判断两个字符串的内容是否相等。hashcode是标识这个对象的码,相同的对象拥有相同的hashcode,不同的对象hashcode可能相等,也就是hash冲突,而在hash表中,如果我们通过equasl判断两个对象相同,那么必须有相同的hashcode否则就会出现equals认为相同的两个对象落入在两个不同的槽位中。
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;
// 如果 table 不为空,表明已经初始化过了
if (oldCap > 0) {
// 当 table 容量超过容量最大值,则不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 按旧容量和阈值的2倍计算新容量和阈值的大小
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
} else if (oldThr > 0) // initial capacity was placed in threshold
/*
* 初始化时,将 threshold 的值赋值给 newCap,
* HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
*/
newCap = oldThr;
else { // zero initial threshold signifies using defaults
/*
* 调用无参构造方法时,桶数组容量为默认容量,
* 阈值为默认容量与默认负载因子乘积
*/
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr 为 0 时,按阈值计算公式进行计算
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 创建新的桶数组,桶数组的初始化也是在这里完成的
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;
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);
// 将分组后的链表映射到新桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
注意到它是如何判断单个槽位内节点扩容后是高位还是低位了么?
下面我来说一下为什么可以这么判断。
//newCap = oldCap << 1新数组大小是原数组大小的两倍,
//比如16->32也就是2^4->2^5,原数组是通过hash二进制位的后四位判断桶位,新数组就是通过后五位判断桶位,
//所以对于原数组中的元素,只需要判断hash二进制位倒数第五位是1还是0,
//是1对应的桶位就是原桶位加原数组大小,0就是原桶位。
举例
0001 0101 (假设hash对应二进制后八位)
&
0000 1111 (假设数组大小为16 那么(tab-1)二进制)
-------------
0000 0101 (那么对应桶位为1+0+4)=5
0001 0101
&
0001 1111(扩容后数组大小变为32对应的(tab-1)二进制)
--------------
0001 0101(对应桶位为 16+0+4+0+1)=21=16+5(由此你应该不难看出实际落入哪个桶位取决于倒数第五位)
所以扩容算法中使用e.hash & oldCap 判断倒数第n位是0还是1
0001 0101
&
0001 0000
------------
0001 0000 第五位为1所以槽位为原槽位加16
为何扩容的负载因子为0.75?
HashMap 会在存储的键值对数量超过阈值(即容量 * 加载因子)时进行扩容。
结论是在空间成本和时间成本做了一个均衡。
假设负载因子较大为0.9,那么数组基本被占满才会触发扩容,我们知道hash元素的分布采用的是除留取余法,而且hash值也细心的处理过,节点落入桶位一般情况比较均匀,也就是说,当数组快被填满时,hash冲突也比较严重,而hash冲突严重就影响了单个槽位内的查找,删除,插入的效率。对时间不友好。
相反,如果负载因子比较小为0.5,那么就可能会频繁的触发扩容,对空间不友好
所以默认为0.75是一个相对平衡的选择。
为何树化的阈值为8?
那就顺便说一下这个问题,其实本质思路是一样的。
红黑树的节点需要保存左右子孩子节点和颜色,大小大概是普通节点的两倍,所以转红黑树就相当于空间换时间。
链表的查询,插入,查找效率都为O(logn),当节点过多,hash冲突严重,效率很低。
其实选择8是统计学给出的答案,理想情况下,使用随机哈希码,链表里的节点符合泊松分布,出现节点个数的概率是递减的,节点个数为 8 的情况,发生概率仅为0.00000006。
红黑树转回链表的阈值为什么是 6,而不是 8?是因为如果这个阈值也设置成 8,假如发生碰撞,节点增减刚好在 8 附近,会发生链表和红黑树的不断转换,导致资源浪费。
HashMap是线程安全的么?
答案当然是否定的,因为在hashmap中没有任何同步手段。HashTable是线程安全的HashMap,但他就是对HashMap的每个方法加了synchronized关键字保证并发场景的线程安全。但是你知道的,这样的同步效率很低,并发度为1。HashTable已经不再被推荐使用,通常使用大哥李的CurrentHashMap做并发场景下的HashMap。
并发场景下HashMap的问题
JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap的transfer(){头插法},具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。
JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap的putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
1.7死循环
1.7死循环的产生过程。
//jdk 1.7的transfer方法,HashMap的扩容操作
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
(详细过程用图文方式不太好理解,这里简单描述下)
假设有两个线程T1和T2,定义好各自的e和next后T2CPU时间片用尽,T2挂起。但T2的e和next指向不会变。
T1执行完该桶位的链表从新分配。因为1.7采用头插法,所以链表会翻转。
当T2重新获取到时间片后继续执行操作,会将a接着头插,就形成了循环链表。
1.8数据覆盖
1.8中修复了这个问题采用尾插法,但是在并发put时也会产生数据覆盖问题,很好理解,就是T1,T2线程都判断完要执行插入时,T1线程CPU时间片耗尽,T2执行完插入,T1重新获取时间片后,就会把T2线程插入的数据覆盖,造成数据丢失。(比较简单,一笔带过)。
其实主要原因是HashMap是一个线程不安全的集合类,我们如果在多线程下使用难免会出现各种问题。对于线程安全的map可以使用Collections.synchronizedMap,本质也是使用synchronized锁来进行互斥。(HashTable就是synchronized来搞,但因为效率太低已经不被推荐使用)。通常我们使用JUC下的ConcurrentHashMap。他核心思想就是分段锁来提高并发度,还有CAS乐观锁加持提升性能....................(他能聊的太多,就不放在该篇文章,后续会出相关文章去聊)