HashMap
HashMap的底层数据结构是啥?
版本不同,底层数据结构不同。
- jdk1.7之前,HashMap底层数据结构是【数组+链表】
- jdk1.8之后,HashMap底层数据结构是【数组+链表/红黑树】
好好的数组+链表为啥要引入红黑树?
假设一种极端情况,插入的n个数据hash值都相同,那么其索引也就相同。那么哈希表就会退化成链表,搜索的时间复杂度为O(n)。性能会降低。引入红黑树后性能最坏时O(log2n)
你再理解下哈希表的好处吧
比如有一串数据[a,e,r,h,s,y,p,d]
如果要查找a
,只能一个一个的比较,最多会比较8次;
如果使用hash表的话,在put时,先会a
计算出a
的二次hash=97,然后mod16=1,然后将a
存进数组下标为1处。
查找的时候以同样的方法计算出a
的索引,就可以快速定位到你想要的数据了,最好的时间复杂度为O(n)。
HashMap链表过长怎么办?
不就是链表长吗,我要么给你把链表长度减短一点或者链表转成红黑树
扩容
转成红黑树
在数组扩容时,元素的下标会重新计算。But,扩容也不一定缩小链表长度,如图:
别慌,这个时候就可以树化了。树化有两个条件:【链表长度大于8 && 数组长度大于等于64】。
树化一定时万不得已时才触发,否则HashMap会优先考虑使用扩容。
树化后一般都会减少查找次数,因为红黑树嘛,检查次数最多不超过树的高度。
HashMap中链表的长度有可能超过8。
树化的两个条件是啥?
见上个问题
为啥不一上来就树化?
-
没必要,元素很少的情况下,链表查找代价<(树化+树查找)代价
-
而且树节点的内存占用>链表
树化的原因是防止链表超长时性能下降(树化是偶然情况)
树化阈值为什么是8呢?
选择8是为了让树化的几率足够小
hash值如果足够随机,则在hash表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率时0.000000006
树的退化来啦 哈哈哈哈
- 在扩容时,如果树的元素个数<=6,那么树会被拆分成链表;
或者- 移出节点时,如果移出之前,root、root.left、root.left.left、root.right有一个为null,那么就会退化成链表;
HashMap什么时候扩容?
当插入的元素数量>阈值时便会扩容
- HashMap容量(capacity):默认 16。
- HashMap负载因子(loadFactor):默认 0.75。
- 负载因子=size / capacity 这里的size是填入HashMap的元素的个数,而不是数组(桶)的填充数量。比如数组的长度是16 ,占用桶的数量可能只有10,但是因为链表的存在可以存入12个元素。当存入第13个元素后时才会进行扩容,注意是 添加元素后。
- HashMap阈值(threshold):阈值=容量*加载因子。默认 12。当元素数量>阈值时便会扩容。
HashMap扩容时都干了些啥?
为每个元素重新计算桶下标
讲讲HashMap的扩容机制
当xxxx(见上),就会触发扩容机制。**扩容到原来的2倍。**但是HashMap的容是上限的,为Integer.MAX_VALUE
。如果超出了这个数,就不再增长,且阈值为Integer.MAX_VALUE
。
数组是无法扩容的,所以只能新建一个大数组,然后把数据copy过去。扩容方法resize()
/**
* Rehashes the contents of this map into a new array with a
* larger capacity. This method is called automatically when the
* number of keys in this map reaches its threshold.
*
* If current capacity is MAXIMUM_CAPACITY, this method does not
* resize the map, but sets threshold to Integer.MAX_VALUE.
* This has the effect of preventing future calls.【这有阻止未来调用的效果】
* @param newCapacity the new capacity, MUST be a power of two;【一定是2的幂】
* must be greater than current capacity unless current
* capacity is MAXIMUM_CAPACITY (in which case value
* is irrelevant).
*/
void resize(int newCapacity) { // newCapacity为新的容量
// 小数组,临时过度下
Entry[] oldTable = table;
// 扩容前的容量
int oldCapacity = oldTable.length;
// MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30
if (oldCapacity == MAXIMUM_CAPACITY) {
// 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1
threshold = Integer.MAX_VALUE;
return;
}
// 初始化一个新的数组(大容量)
Entry[] newTable = new Entry[newCapacity];
// 把小数组的元素转移到大数组中
// void transfer(Entry[] newTable, boolean rehash)
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 引用新的大数组
table = newTable;
// 重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
<<
是什么,见《计算机科普常识》
transfer
用来将小数组的数据copy到新数组中去。
/**
* Transfers all entries from current table to newTable.
*/
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;
//是否要重新计算hash
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//根据大数组的容量和键的hash计算元素在数组的下标
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
HashMap索引的计算方法是啥?
先调用对象的hashcode()方法,然后再调用HashMap中的int hash(int hash)方法进行二次hash,二次hash是为了综合高位数据,使得hash分布更加均匀。最后再进行&(capactiy-1)得到索引
注意:二次hash是为了配合容量是2的n次幂这一设计前提,如果hash表的容量不是2的n次幂,则不需要进行二次hash
HashMap容量为何是2的n次幂?
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
注意
- 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
- 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable
讲讲put方法的流程
- HashMap是懒惰创建数据的,首次使用才会创建数组
- 计算索引(桶下标)
- 如果还没有人占用,就创建node占位后返回
- 如果下标已经被人占用,
- 已经是TreeNode走红黑树的添加或者更新
- 是普通Node,走链表的添加或者更新,如果链表长度超过阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过就进行扩容
1.7与1.8的区别
-
链表插入节点时,1.7 是头插法,1.8 是尾插法
-
1.7 是大于等于阈值且该索引下没有空位时才扩容,而 1.8 是大于阈值就扩容
-
1.8 在扩容计算 Node 索引时,会优化(旧的hash值跟当前的数组容量按位与,如果是0,说明不用动位置; 不是0,旧索引+旧容量)
扩容(加载)因子为何默认是 0.75f?
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
HashMap下的并发问题
- 扩容死链(1.7会存在)
- 数据错乱(1.7,1.8 都会存在)
HashMap并发下的数据错乱是怎么回事?
数据错乱(1.7,1.8 都会存在)
- 代码参考
day01.map.HashMapMissData
,具体调试步骤参考视频
public class HashMapMissData {
public static void main(String[] args) throws InterruptedException {
HashMap<String, Object> map = new HashMap<>();
Thread t1 = new Thread(() -> {
map.put("a", new Object()); // 97 => 1
}, "b");
Thread t2 = new Thread(() -> {
map.put("1", new Object()); // 49 => 1
}, "a");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(map);
}
}
1.7中HashMap的扩容死链是怎么回事?
扩容死链(1.7 会存在)
1.7 源码如下:
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;
}
}
}
前提说明
在链表迁移时并不会创建新的对象,上图其实只有两个对象:a和b。链表迁移只是改变了引用而已。
死链产生的根源:迁移前顺序是a、b,迁移后顺序是b、a。
- e 和 next 都是局部变量,用来指向当前节点和下一个节点
- 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移
- 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移
- 第一次循环
- 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
- e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
- 当循环结束是 e 会指向 next 也就是 b 节点
- 第二次循环
- next 指向了节点 a
- e 头插节点 b
- 当循环结束时,e 指向 next 也就是节点 a
- 第三次循环
- next 指向了 null
- e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
- 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出
HashMap中key的设计
- HashMap 的 key 可以为 null,但 Map 的其他实现则不然
- 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变),否则有可能找不到对象
- key 的 hashCode 应该有良好的散列性
如果 key 可变,例如修改了 age 会导致再次查询时查询不到
@Test
public void test02161254(){
Person person = new Person("稚浩",21);
HashMap<Object, Object> map = new HashMap<>();
map.put(person,new Object());
System.out.println(map.get(person));
person.setAge(18);
System.out.println(map.get(person));
}
java.lang.Object@4dcbadb4
null
hashcode()的设计
hashcode()设计的最终目标是达到较为均匀的散列效果,每个对象的 hashCode 越独特越好
String 对象的 hashCode() 设计
- 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
- 字符串中的每个字符都可以表现为一个数字,称为 S i S_i Si,其中 i 的范围是 0 ~ n - 1
- 散列公式为: S 0 ∗ 3 1 ( n − 1 ) + S 1 ∗ 3 1 ( n − 2 ) + … S i ∗ 3 1 ( n − 1 − i ) + … S ( n − 1 ) ∗ 3 1 0 S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0 S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310
- 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
- 即 $32 ∗h -h $
- 即 2 5 ∗ h − h 2^5 ∗h -h 25∗h−h
- 即 h ≪ 5 − h h≪5 -h h≪5−h
de() 设计**
- 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
- 字符串中的每个字符都可以表现为一个数字,称为 S i S_i Si,其中 i 的范围是 0 ~ n - 1
- 散列公式为: S 0 ∗ 3 1 ( n − 1 ) + S 1 ∗ 3 1 ( n − 2 ) + … S i ∗ 3 1 ( n − 1 − i ) + … S ( n − 1 ) ∗ 3 1 0 S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0 S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310
- 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
- 即 $32 ∗h -h $
- 即 2 5 ∗ h − h 2^5 ∗h -h 25∗h−h
- 即 h ≪ 5 − h h≪5 -h h≪5−h