HashMap源码分析
序号 | 内容 | 链接地址 |
---|---|---|
1 | HashMap的继承体系,HashMap的内部类,成员变量 | https://blog.csdn.net/weixin_44141495/article/details/108327490 |
2 | HashMap的常见方法的实现流程 | https://blog.csdn.net/weixin_44141495/article/details/108329558 |
3 | HashMap的一些特定算法,常量的分析 | https://blog.csdn.net/weixin_44141495/article/details/108305494 |
4 | HashMap的线程安全问题(1.7和1.8) | https://blog.csdn.net/weixin_44141495/article/details/108250160 |
5 | HashMap的线程安全问题解决方案 | https://blog.csdn.net/weixin_44141495/article/details/108420327 |
6 | Map的四种遍历方式,以及删除操作 | https://blog.csdn.net/weixin_44141495/article/details/108329525 |
7 | HashMap1.7和1.8的区别 | https://blog.csdn.net/weixin_44141495/article/details/108402128 |
文章目录
HashMap源码分析系列 – HashMap的继承体系,内部类,成员变量
Jdk1.7HashMap线程安全问题 (全过程分析)
我们都知道Jdk1.7的HashMap
存在安全问题,在多线程环境下,扩容的时候可能会形成环状链表导致死循环的问题,别问我们为什么知道,面试题啊!
这篇帖子我来讲一下Jdk1.7HashMap
在扩容时的线程安全问题
首先我们看一下HashMap
在扩容的流程
代码流程
- 扩容相关常量
-
DEFAULT_LOAD_FACTOR:默认负载因子,这个参数是判断扩容时的重要参数,当Map中的元素的数量达到最大容量乘上负载因子时,就会进行扩容。如果在构造方法中没有指定,那么默认就是0.75。这个0.75是个非常合理的值,如果负载因子等于1,那么只有元素数量达到最大容量的时候才会进行扩容,导致每一个桶的链表长度都过长,运行效率变低。如果负载因子等于0.5,那么Map每存储一半的元素就扩容,浪费内存空间。
-
size:容量达到阈值时(table数组长度乘加载因子就是阈值),发送扩容。
-
table:存储Entry也就是我们存储的key,value的对象数组,扩容时会生成一个新的数组,长度为此数组的一倍,然后逐一将这个table的元素移至新的数组,然后将新的数组覆盖原数组来实现扩容。
/**
* 默认负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* Entry数组
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* 容量
*/
transient int size;
- 扩容的条件
什么时候执行扩容方法?我们设想,什么时候我们会需要去判断Map的容量是否太多?当然是添加的时候,当我们新增元素的时候,需要去判断是否能够存下这个元素,如果存的下就存,存不下就扩容再存。
- 扩容的流程
我们拿put方法举例
public V put(K key, V value) {}
首先我们有一些条件判断,包括是否需要初始化,是否是空值,当然我们今天的重点是扩容,当不需要初始化,不是空值,我们走addEmpty
方法。
/**
* 添加
*/
public V put(K key, V value) {
//判断是否是空表
if (table == EMPTY_TABLE) {
//初始化
inflateTable(threshold);
}
//判断是否是空值
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//封装所需参数,准备添加
addEntry(hash, key, value, i);
return null;
}
此方法时判断是否需要扩容,如果不扩容,我们执行创建Entry方法。这里我们看到,扩容方法传入了一个参数,也就是扩容之后的新长度,是默认Entry数组长度的两倍,也就是容量翻倍,我们走进这个方法
/**
* 添加条目
*
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
//判断是否需要扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容
resize(2 * table.length);
//重新计算hash值
hash = (null != key) ? hash(key) : 0;
//计算所要插入的桶的索引值
bucketIndex = indexFor(hash, table.length);
}
//执行新增Entry方法
createEntry(hash, key, value, bucketIndex);
}
这里会进行一些条件判断,如果这个HashMap
的容量已经非常大了,新的长度会大于我们预设的最大容量,这时直接return;来终止这个方法。如果程序不走这步,我们看到HashMap
新建了一个数组,长度是newCapacity
也就是之前我们传入的2 * table.length
,然后执行transffer
方法和initHashSeedAsNeeded
方法。initHashSeedAsNeeded
方法主要是判断一下是否需要初始化散列终止,其实就是怕你HashMap
的值太过集中不够散列。我们不关注这个方法的实现细节,我们走进transfer方法。
/**
* 调整
*
* @param newCapacity 新容量
*/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//达到最大值,无法扩容
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
//将数据转移到新的Entry[]数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化散列种子
//覆盖原数组
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
前面的方法时判断是否要扩容和创建新长度的数组,我们知道这个数组是空的,我们需要把原来数组的值逐一复制到这个新的数组中,这里是比较经典的链表操作,相信刷过力扣的都能实现这个链表操作。首先是遍历table数组,如果遍历到Entry不为空,我们进入while循环,每次操作结束都将进入循环的e用e.next覆盖,直至链表到达尾部,即e!=null 但是 e.next==null。
/**
* 转移
*
* @param newTable 新表格
* @param rehash 重新处理
*/
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;
}
}
}
链表插入过程
Jdk7的底层数据结构是数组加链表,一条链表又叫桶。我们以一个简单的长度为2的数组扩容到长度为4的数组为例。
- 标记下一个节点
Entry<K,V> next = e.next;
此时我们循环里面的e代表图中的10,也就是头节点,next代表头节点的next,也就是图中的9
- 改变next指向
e.next = newTable[i];
我们看到10和9之间的指向消失了,但是9,8不会被垃圾回收,因为我们的next指向了9,由于是第一次插入,newTable[i]实际上等于NULL,没有图中的指向关系,这里我们为了方便理解。
- 覆盖原值
newTable[i] = e;
我们看到有两个10,为什么呢?应为你的10是存在原数组中的,原数组有对这个存放10的Entry的引用,此时我们又用这个10覆盖了新数组的newTable[i]值,多了一次引用,所以原来的10也存在。
- 准备下一次的插入
e = next;
之前我们有对9的引用,所以9不会被垃圾清除,我们用9覆盖e,准备下一次的插入操作。
5.下一次插入
这次我直接快速演示过程了,不小心把10在新数组的那几帧剪掉了。
6.小总结
这就是我们所说的头插法,当然今天的问题主要是头插法在多线程环境如何导致死循环的。
大家学习多线程的时候应该都有做过卖票系统的线程安全的案例吧,就是说同时两个线程进入一个方法,导致出卖票超出最大票数。
7.线程安全问题出现的时机
当我们有多个线程操作一个Map的时候可能出现同时进行扩若的情况,我们看看具体情况。
这是扩容的准备,transfer方法是搬家,我们看到只有transfer方法执行完毕才会修改threshold
的值,而threshold
阈值是判断是否进行扩容的重要变量,也就是由之前提到的容量*负载因子计算的到的,所以很有可能同时有多个线程进入了resize方法,这是出现安全隐患的先决条件。
/**
* 调整
*
* @param newCapacity 新容量
*/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//新数组
Entry[] newTable = new Entry[newCapacity];
//搬家
transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化散列种子
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
我们假设一号线程在执行完Entry<K,V> next = e.next;
这行代码之后被挂起,失去执行权。我们的二号线程进入transfer
这个方法,执行完全部流程之后,一号线程才被唤醒,继续执行。
/**
* 转移
*
* @param newTable 新表格
* @param rehash 重新处理
*/
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;
}
}
}
链表死循环形成过程
我们假设new table1是一号线程创建的新数组,new table2是二号线程创建的新数组,一号线程的e我标为e1,next标为next2。
我们执行二号线程的全部流程。
我们看到一号线程的e1和next1始终指向10和9。
这是二号线程执行完毕了,轮到一号线程了
我们看到我们原来标记的Entry都跑到了新的数组,安全问题已经出现了!我们按照流程走一遍。
Entry<K,V> next = e.next;
这行代码我们一号线程挂起前就执行了,我们执行其他操作:(太晚了,我搬运了!)
通过设置断点让Thread1和Thread2同时Debug到transfer方法的首行,注意此时两个线程已经成功添加数据,放开Thread1的断点至transfer方法的Entry next=e.next
;这一行,然后放开Thread2的断点,让Thread2进行resize
之后Thread1被被调度回来继续执行后面代码
至此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];
//判断是否出现死循环
if (e.next!=null && e.next.next!=null && e.next.next==e){
System.out.println("bug!");}
newTable[i] = e;
e = next;
}
}
}
测试方法
我们线程多一点,put次数多一点,让扩容出错的概率高一点,我们跑一下代码
public static void testJdk7HashMap(){
final Jdk7HashMap<Integer, Integer> map = new Jdk7HashMap<>();
Runnable runnable=new Runnable() {
@Override
public void run() {
for (int i = 0; i < 11111111; i++) {
map.put(i,i);
}
}
};
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
}
之前录屏,IDEA都挂了!
JDK1.8中的线程不安全
那么Jdk1.8就安全了?
根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到transfer
函数,因为JDK1.8直接在resize
函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。
为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段JDK1.8中的put操作代码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
123456789101112131415161718192021222324252627282930313233343536373839404142
其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有就是代码的第38行处有个++size
,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap
的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
总结
- Jdk8虽然采用尾插法告别了这个问题,但是Jdk8的
HashMap
也线程安全问题,在单线程的情况下,这些集合容器都能发挥其应有的功效,所以我们多线程情况下要有相应的解决策略才行!