HashMap模拟多线程下死循环场景
知识点
hash
把任意长度的输入通过一种算法(散列),变成固定长度的输出,这个输出值就是散列值。这种转换是一种压缩映射,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值,容易产生哈希冲突。
处理冲突方法:
- 开放寻址法
- 再散列法
- 链地址法
常用hash算法的介绍:
- MD4
- MD5:它对输入仍以512位分组,其输出是4个32位字的级联
- SHA-1及其他
位运算
位与 & (1&1=1 0&0=0 1&0=0)
位或 | (1|1=1 0|0=0 1|0=1)
位非 ~ (~1=0 ~0=1)
位异或 ^ (1^1=0 1^0=1 0^0=0)
有符号右移 >>(若正数,高位补0;负数,高位补1)
有符号左移<<
无符号右移>>>(不论正负,高位均补0)
取模 a % (2n) 等价于 a & (2n - 1),所以在map里的数组个数一定是2的乘方数,计算key值在哪个元素中的时候,就用位运算来快速定位。
以16为例,16-1=15 = …0000,1111(低位数后四位),任何数与1111进行与运算后,都是该数0~15范围以内的值。
hash扩容
在多线程环境下,使用HashMap进行put操作时调用扩容方法引起死循环,导致CPU利用率接近100%。是因为多线程会导致HashMap的Entry链表在扩容时形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
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;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//Entry[]数组扩容两倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
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);
}
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;
}
}
}
综合来说,HashMap一次扩容的过程:
- 取当前table的2倍作为新table的大小
- 根据算出的新table的大小new出一个新的Entry数组来,名为newTable
- 轮询原table的每一个位置,将每个位置上连接的Entry,算出在新table上的位置,并以链表形式连接
- 原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了新的table上,HashMap中的table指向newTable
模拟死循环
往table[0] put 两个元素 3和7,模拟多线程下扩容场景,线程1执行到 Entry<K,V> next = e.next;
挂起,等线程2扩容完继续执行。
public class 模拟HashMap头插法死循环<K,V> {
static int capacity = 1;
private CountDownLatch latch = new CountDownLatch(2);
Entry<?,?>[] EMPTY_TABLE = {};
//仅使用table[0]的链表用于测试
Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
public static void main(String[] args) throws InterruptedException {
模拟HashMap头插法死循环<String,String> test = new 模拟HashMap头插法死循环<String,String>();
// 模拟20次多线程扩容场景
// for(int i=0;i<20;i++){
// System.out.println("第"+(i+1)+"次扩容结果:");
test.latch = new CountDownLatch(2);
test.table = (Entry<String, String>[]) test.EMPTY_TABLE;
test.put(0,"7","7");
test.put(0,"3","3");
Thread 线程1 = new Thread(() -> {
test.resize(capacity);
}, "线程1");
Thread 线程2 = new Thread(() -> {
test.resize(capacity);
}, "线程2");
线程1.start();
线程2.start();
test.latch.await();
Entry entity = test.table[0];
//输出5次就跳出循环
int count=0;
while(entity!=null && count<6){
System.out.println(entity.key + " , next:"+ ((entity.next!=null)?entity.next.key:"null"));
entity = entity.next;
count++;
}
// }
}
//扩容方法
void resize(int newCapacity) {
Entry[] oldTable = table;
Entry[] newTable = new Entry[capacity];
transfer(newTable);
table = newTable;
latch.countDown();
}
void transfer(Entry[] newTable) {
int newCapacity = newTable.length;
int count =0;
// 1. 遍历老table
for (Entry<K,V> e : table) {
// 2. 如果元素不为空,遍历Entry元素
while(null != e) {
Entry<K,V> next = e.next;
if(Thread.currentThread().getName().equals("线程1") && count++==0){
//等待线程2创建完成,当线程1挂起后,保证线程2大概率能拿到CPU使用权
try {
Thread.sleep(500);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
Thread.yield();
}
e.next = newTable[0];
// 3. 元素放入新table
newTable[0] = e;
// 4. 继续遍历Entry子节点
e = next;
}
}
}
public V put(int hash,K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(capacity);
}
for (Entry<K,V> e = table[0]; 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;
return oldValue;
}
}
createEntry(hash, key, value, 0);
return null;
}
/*
* 以下复制的HashMap源码
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
}
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
table = new Entry[toSize];
}
static class Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
void recordAccess(HashMap<K,V> m) {
}
void recordRemoval(HashMap<K,V> m) {
}
}
}
输出结果
3 , next:7
7 , next:3
3 , next:7
7 , next:3
3 , next:7
7 , next:3
HashMap之所以在并发下的扩容造成死循环,是因为多个线程并发进行扩容时,因为一个线程先期完成了扩容,将原Map的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。于是形成了一个环形链表,当get表中不存在的元素时,造成死循环。
常见问题
HashMap底层数据结构
JDK7:数组+链表
JDK8: 数组+链表+红黑树
JDK8中的HashMap为什么要使用红黑树?
当元素个数小于一个阈值时,链表整体的插入查询效率要高于红黑树,当元素个数大于此阈值时,链表整体的插入查询效率要低于红黑树。此阈值在HashMap中为8
JDK8中的HashMap什么时候将链表转化为红黑树?
当发现链表中的元素个数大于8之后,还会判断一下当前数组的长度,如果数组长度小于64时,此时并不会转化为红黑树,而是进行扩容。只有当链表中的元素个数大于8,并且数组的长度大于等于64时才会将链表转为红黑树。
上面扩容的原因是,如果数组长度还比较小,就先利用扩容来缩小链表的长度。
JDK8中HashMap的put方法的实现过程?
- 根据key生成hashcode
- 判断当前HashMap对象中的数组是否为空,如果为空则初始化该数组
- 根据逻辑与运算,算出hashcode基于当前数组对应的数组下标 i
- 判断数组的第i个位置的元素(tab[i])是否为空
a. 如果为空,则将key,value封装为Node对象赋值给tab[i]
b. 如果不为空:
ⅰ. 如果put方法传入进来的key等于tab[i].key,那么证明存在相同的key
ⅱ. 如果不等于tab[i].key,则:- 如果tab[i]的类型是TreeNode,则表示数组的第i位置上是一颗红黑树,那么将key和value插入到红黑树中,并且在插入之前会判断在红黑树中是否存在相同的key
- 如果tab[i]的类型不是TreeNode,则表示数组的第i位置上是一个链表,那么遍历链表寻找是否存在相同的key,并且在遍历的过程中会对链表中的结点数进行计数,当遍历到最后一个结点时,会将key,value封装为Node插入到链表的尾部,同时判断在插入新结点之前的链表结点个数是不是大于等于8,如果是,再判断数组的长度是否大于等于64,如果是时则将链表改为红黑树,否则仅仅扩容。
ⅲ. 如果上述步骤中发现存在相同的key,则根据onlyIfAbsent标记来判断是否需要更新value值,然后返回oldValue
- modCount++
- HashMap的元素个数size加1
- 如果size大于扩容的阈值,则进行扩容
JDK8中HashMap的get方法的实现过程
- 根据key生成hashcode
- 如果数组为空,则直接返回空
- 如果数组不为空,则利用hashcode和数组长度通过逻辑与操作算出key所对应的数组下标 i
- 如果数组的第i个位置上没有元素,则直接返回空
- 如果数组的第1个位上的元素的key等于get方法所传进来的key,则返回该元素,并获取该元素的value
- 如果不等于则判断该元素还有没有下一个元素,如果没有,返回空
- 如果有则判断该元素的类型是链表结点还是红黑树结点
a. 如果是链表则遍历链表
b. 如果是红黑树则遍历红黑树 - 找到即返回元素,没找到的则返回空
JDK7与JDK8中HashMap的不同点
- JDK8中使用了红黑树
- JDK7中链表的插入使用的头插法(扩容转移元素的时候也是使用的头插法,头插法速度更快,无需遍历链表,但是在多线程扩容的情况下使用头插法会出现循环链表的问题,导致CPU飙升),JDK8中链表使用的尾插法(JDK8中反正要去计算链表当前结点的个数,反正要遍历的链表的,所以直接使用尾插法)
- JDK7的Hash算法比JDK8中的更复杂,Hash算法越复杂,生成的hashcode则更散列,那么hashmap中的元素则更散列,更散列则hashmap的查询性能更好,JDK7中没有红黑树,所以只能优化Hash算法使得元素更散列,而JDK8中增加了红黑树,查询性能得到了保障,所以可以简化一下Hash算法,毕竟Hash算法越复杂就越消耗CPU
- 扩容的过程中JDK7中有可能会重新对key进行哈希(重新Hash跟哈希种子有关系),而JDK8中没有这部分逻辑
- JDK8中扩容的条件和JDK7中不一样,除开判断size是否大于阈值之外,JDK7中还判断了tab[i]是否为空,不为空的时候才会进行扩容,而JDK8中则没有该条件了
- JDK8中还多了一个API:putIfAbsent(key,value)
- JDK7和JDK8扩容过程中转移元素的逻辑不一样,JDK7是每次转移一个元素,JDK8是先算出来当前位置上哪些元素在新数组的低位上,哪些在新数组的高位上,然后在一次性转移