JDk1.7 HashMap源码解析——线程安全问题
作者:互联网
Jdk1.7的HashMap, 在多线程环境下,扩容的时候可能会形成环状链表导致死循环和数据丢失问题。
HashMap在扩容的流程
- 扩容相关常量
-
DEFAULT_LOAD_FACTOR
: 默认负载因子,这个参数是判断扩容时的重要参数,当Map中的元素的数量达到最大容量乘上负载因子时,就会进行扩容。如果在构造方法中没有指定,那么默认就是0.75。这个0.75是个非常合理的值,如果负载因子等于1,那么只有元素数量达到最大容量的时候才会进行扩容,导致每一个桶的链表长度都过长,运行效率变低。如果负载因子等于0.5,那么Map每存储一半的元素就扩容,浪费内存空间。 -
threshold
: 容量达到阈值时(threshold = 初始容量 * 加载因子),在 put 数据时,就会扩容,相当于实际使用的容量。 -
table
:存储Entry也就是我们存储的key,value的对象数组,扩容时会 new 一个新的数组,长度为老数组的一倍,然后逐一将这个table的元素移至新的数组,然后将新的数组覆盖原数组来实现扩容。
/**
`* 默认负载因子
`*/`
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
`* Entry数组
`*/`
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* 容量, 默认达到 size * 0.75 就会扩容
*/
transient int size;
- 扩容的条件
当我们新增元素(put 方法)的时候,需要去判断是否能够存下这个元素,如果存的下就存,存不下就扩容再存。
- 扩容流程
以 put 方法 为例
public V put(K key, V value) {
// 判断是否是空表
if (table == EMPTY_TABLE) {
// 初始化, 强制把 初始化容量 转换为 2 的整次幂
inflateTable(threshold);
}
// 判断是否是空值
if (key == null)
return putForNullKey(value);
// 获取 hash 值
int hash = hash(key);
// 获取 索引
int i = indexFor(hash, table.length);
// 遍历 指定索引下的链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 判断put 的key是否已经存在
// 如果存在,则替换
// 并返回 旧值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 如果 put 的key不存在,则添加进去
// 此方法会判断是否要扩容
addEntry(hash, key, value, i);
return null;
}
addEntry
方法, 会判断数组当前容量是否已经超过的阈值,例如,假设当前的数组容量是16,加载因子为0.75,即超过了12,并且刚好要插入的索引处有元素,这时候就需要进行扩容操作,可以看到resize
扩容大小是原数组的两倍,仍然符合数组的长度是2的指数次幂。
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);
}
resize
方法, 首先,如果这个HashMap的容量已经非常大了,新的长度会大于我们预设的最大容量,这时直接return;来终止这个方法。如果没有,后面会进行数组的转移操作,即transfer
方法。
initHashSeedAsNeeded
方法, 主要是判断一下是否需要初始化散列,尽量避免HashMap的值太过集中不够散列。
这里 预设的最大容量是 MAXIMUM_CAPACITY = 1 << 30
, 就是 MAXIMUM_CAPACITY = 2 ^ 30
, 如果达到最大 预设容量, 那么,HashMap 可以使用的 的容量就是 Integer.MAX_VALUE
。
/**
* 扩容
*/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 达到最大值,无法扩容
if (oldCapacity == MAXIMUM_CAPACITY) {
// 设置为 HashMap 的最大容量
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);
}
transfer
方法, 把原来数组的值逐一复制到这个新的数组, 首先是遍历table数组,如果遍历到Entry不为空,我们进入while循环,进行链表操作,每次操作结束都将进入循环的e用e.next覆盖,直至链表到达尾部。
/**
*
* @param newTable 新数组的引用
* @param rehash true代表需要重新获取 hash 值
*/
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 的新数组 是 数组A;线程 B 的新数组 是 数组B。
假设,线程 A 运行到
Entry<K,V> next = e.next;
的代码时阻塞了, 因为线程 A 被阻塞了,其后面的代码就没法继续执行了,而此时线程 B 也进入方法进行扩容,扩容后的结果就是单线程时扩容后的结果,此时相比于扩容前的HashMap,原数组的链表元素的位置已经调换。
线程 B 的图示 是就是简单的单线程扩容,就只画出执行后的结果图;
线程 A 的图示 依赖于 线程 B 的结果,每个图示,代表一次 while 循环后的结果。
图示:
可以看到,最后出现了环,并且 数据 8 丢失了。
标签:扩容,hash,HashMap,JDk1.7,数组,源码,key,table,Entry 来源: https://blog.csdn.net/weixin_44730681/article/details/112055560