前言
关于HashMap一直是面试当中必问的经典话题,我们不能只为用而用,关于底层的原理,可能出现的问题也要清楚才行,网上有关于HashMap线程安全的讲解也多的是,我为什么要讲这篇文章?因为我想更简单明了一点,经过我自己的理解写的这篇文章带给你的清晰和完整性比大部分的文章更加的明了。
我们都知道HashMap是通过拉链法来解决哈希冲突的,在哈希冲突时,相同哈希值的键值对会通过链表的方式存入数据。
而且都知道HashMap是线程不安全的,在多线程环境下是不建议使用HashMap的,那到底线程不安全主要体现在什么地方,接下来通过本文章对该问题进行讲解。
主要从以下四个方面分析:
- 多线程下扩容造成死循环
- 多线程下扩容会导致元素丢失
- put和get并发时可能导致get为null
- 代码实现多线程产生的环形链表以及丢失数据
多线程下扩容造成死循环
在jdk1.8中对HashMap做了很多的优化,这里首先分析在jdk1.7中的问题,jdk1.7中采用的是头插入的方式来存放链表的,也就是下一个冲突的键值对会放在上一个键值对的前面,扩容的时候就有可能导致出现环形链表,造成死循环。
我们用代码来模拟一下出现死循环的情况:
class HashMapThread extends Thread {
private static AtomicInteger ai = new AtomicInteger();
private static Map<Integer, Integer> map = new HashMap<>();
@Override
public void run() {
while (ai.get() < 50000) {
map.put(ai.get(), ai.get());
ai.incrementAndGet();
}
}
}
public class wwwwww {
public static void main(String[] args) throws ParseException {
HashMapThread thread1 = new HashMapThread();
HashMapThread thread2 = new HashMapThread();
HashMapThread thread3 = new HashMapThread();
thread1.start();
thread2.start();
thread3.start();
}
}
开多个线程不停的进行put操作,并且HashMap与AtomicInteger都是全局共享的,多运行几次代码就会发现这时候出现了死循环。
通过jps和jstack命令查看到底是什么情况导致的死循环情况,结果如下
通过上面报错信息可以知道死循环发生在HashMap的扩容函数中,导致该问题的根源就在transfer方法中,如下图:
resize扩容方法的源码:
// newCapacity是扩容新的容量
void resize(int newCapacity) {
// 旧数组,临时过度下
Entry[] oldTable = table;
// 扩容前的数组容量
int oldCapacity = oldTable.length;
// MAXIMUM_CAPACITY 为最大容量
if (oldCapacity == MAXIMUM_CAPACITY) {
// 容量调整为 Integer 的最大值
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);
}
这里会新建一个更大的数组,并通过transfer 方法用来转移,将旧数组的数据拷贝到新的数组中。
void transfer(Entry[] newTable, boolean rehash) {
// 新的数组容量
int newCapacity = newTable.length;
// 遍历旧数组
for (Entry<K,V> e : table) {
while(null != e) {
// 拉链法,相同 key 上的不同值
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]; // 1
// 放在新数组上
newTable[i] = e; // 2
// 链表上的下一个元素
e = next; // 3
}
}
}
主要注意标注的点1、2、3这里,这里可以看出在转移数据的过程中使用的是头插法,链表的顺序会翻转,这里也是形成死循环的关键地方。
正常的扩容过程
在单线程情况下,正常的扩容过程:
- 假设我们的hash算法是简单的key mod一下表的大小(即数组的长度)。
- 最开始hash表的size=2,key=3,5,7,在mod 2以后都冲突在table[1]中。
- 接下来hash表进行resize,size=4,然后所有的
过程如下图:
未扩容前的数据结构如下:
扩容之后的数据结构如下:
在单线程环境下,一切看起来都没有问题,扩容过程也相当顺利,接下来我们看看多线程环境下会怎么样呢。
多线程情况下的扩容过程
假设现在有两个线程A和B同时进行扩容,这时候就注意在上文的源码里注释为:Entry<K,V> next = e.next;这一行代码(关键代码)
A线程在执行到Entry<K,V> next = e.next;这一行线程就被挂起,那么此刻A线程中:e = 6; next = 8;
线程A挂起后,接着B线程开始进行扩容,假设新的数组中,节点6和节点8还是会产生散列冲突,那么线程B的扩容过程为:
- 申请一个空间为旧数组两倍大的空间
- 将节点6迁移到新数组
- 将节点8迁移到新数组
此时线程B的扩容已经完成,节点8的后继节点为节点6 ,节点6的后继节点为null。
接下来将两个数组进行对比:
此时切换到线程A上,线程A的当前状态:e = 6; next = 8,接着执行Entry<K,V> next = e.next;之后的代码,将e = 6节点迁移至新的数组,并将next = 8的节点赋值给e。扩容并迁移节点6后结果如下图所示:
接着第二次执行while循环时,当前待处理节点e = 8,在执行Entry<K,V> next = e.next;这一行时,由于线程B在扩容时将节点8的后继节点变为节点6,所以next不是为null,而是next = 6。
接着执行第三次while循环,由于节点6的后继节点为null,所以next = null;,执行完第三次while循环的结果为:
循环结束,可以看到已经成为了环状链表,如果这时候执行get()方法查询,就会导致死循环。
在jdk1.8时已经修复了这个问题,扩容时会保持链表原来的顺序,因此不会出现环形链表的情况。
多线程下扩容会导致数据丢失
线程A和线程B同时执行put操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个 key覆盖,从而导致元素的丢失。
接下来看下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)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果新插入结点和table中p结点hash值,key值相同,先将p值赋给e
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);
//判断如果链表长度大于阈值8,将链表转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果新插入结点和e结点(p.next)的hash值,key值相同,直接跳出for循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//找到key值相等的节点,考虑新值覆盖旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//判断是否允许覆盖,value是否为空
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//回调以允许LinkedHashMap后置操作
afterNodeAccess(e);
return oldValue;
}
}
//modCount为操作次数
++modCount;
//判断如果size大于阈值,进行数组扩容
if (++size > threshold)
resize();
//回调以允许LinkedHashMap后置操作
afterNodeInsertion(evict);
return null;
}
重点看这里的两段代码:if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
正常的情况下,线程A和线程B执行put方法后,table的状态如下图:
现在两个线程都执行了if ((p = tab[i = (n - 1) & hash]) == null)这句代码,假设线程A先执行ab[i] = newNode(hash, key, value, null);这段代码,table的状态如下图:
紧接着线程B执行了tab[i] = newNode(hash, key, value, null),那 table的状态如下图:
执行完发现元素3丢失了。
put和get并发时可能导致get为null
线程A执行put时,因为元素个数超出threshold阈值出现扩容,线程B此时执行get,有可能导致这个问题。
接下来看下jdk1.8中resize 方法的部分源码:
final Node<K,V>[] resize() {
//旧数组
Node<K,V>[] oldTab = table;
//旧数组容量/长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧数组扩容阈值
int oldThr = threshold;
//新数组容量及新数组扩容阈值
int newCap, newThr = 0;
//如果旧数组容量大于0
if (oldCap > 0) {
//如果旧数组容量大于等于最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
//则直接修改旧数组扩容阈值为整型数最大值
threshold = Integer.MAX_VALUE;
//返回旧数组容量,不再做其他操作
return oldTab;
}
//若旧数组容量小于最大容量且新数组容量扩大至旧数组容量的2倍后依旧小于最大容量,
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//并且旧数组容量大于等于默认的初始化容量16
//则将新数组阈值扩大至旧数组扩容阈值的2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//若旧数组容量小于等于0,且旧数组扩容阈值大于0(当new HashMap(0)后再put操作时,会执行到这里) //则将旧数组阈值赋给新数组容量
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//若旧数组容量和旧数组阈值均小于0,说明数组需要初始化
newCap = DEFAULT_INITIAL_CAPACITY;//将新数组容量设为默认初始化容量16
//将新数组扩容阈值赋值为默认负载因子0.75乘以默认初始化容量16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//经过上述逻辑后新数组扩容阈值仍为0,说明新数组扩容阈值尚未处理过,但走到这里之前新数组容量已经被处理完了,
//所以需要按照新数组容量负载因子的公式重新计算
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//将新数组扩容阈值赋值给HashMap的扩容阈值字段
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//按照新数组容量创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将创建的新数组赋值给HashMap的数组字段
table = newTab;
//省略部分源码
//返回处理完的新数组
return newTab;
}
在代码Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]处创建了一个新的数组,table = newTab将新创建的空数组赋值给table。
此时线程A执行完table = newTab 之后,线程B中的table此时也发生了变化,此时线程B去get的时候就会get出null,因为元素还没有转移。
代码实现多线程产生的环形链表以及丢失数据
public class Table {
String value;
Table next;
private int hash = 1;
public Table(String value, Table next) {
this.value = value;
this.next = next;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public Table getNext() {
return next;
}
public void setNext(Table next) {
this.next = next;
}
@Override
public String toString() {
if (null != value) {
return value + "->" + (null == next ? null : next.toString());
}
return null;
}
}
transient static Table[] table;
public static void main(String[] args) throws ParseException {
Table c = new Table("7", null);
Table b = new Table("5", c);
Table a = new Table("3", b);
table = new Table[4];
table[1] = a;
System.out.println("初始table值:" + a.toString());
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Table[] newTable = new Table[8];
int count = 1 ;
for (Table e : table) {
if (null != e) {
System.out.println("线程A让出CPU时间片前,操作的table为:" + e.toString());
}
while (null != e) {
Table next = e.next;
try {
Thread.sleep(5000);
} catch (InterruptedException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
System.out.println("=============第"+count+"次循环==============");
System.out.println("线程A继续执行,操作的table为:" + e.toString());
e.next = newTable[1];
newTable[1] = e;
e = next;
count ++;
}
}
table = newTable;
}
});
thread.start();
new Thread(new Runnable() {
@Override
public void run() {
Table[] newTable = new Table[8];
for (Table e : table) {
if (null != e) {
System.out.println("线程B开始,操作的table为:" + e.toString());
}
while (null != e) {
try {
Thread.sleep(500);
} catch (InterruptedException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
Table next = e.next;
e.next = newTable[1];
newTable[1] = e;
e = next;
}
}
System.out.println("线程B扩容完,table为:" + newTable[1].toString());
try {
Thread.sleep(6000);
} catch (InterruptedException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
table = newTable;
}
}).start();
try {
Thread.sleep(20000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("2个线程扩容完毕后,最终table为:" + table[1].value + "->" + table[1].next.value + "->" + table[1].next.next.value);
}
最终运行的结果:
初始table值:3->5->7->null
线程A让出CPU时间片前,操作的table为:3->5->7->null
线程B开始,操作的table为:3->5->7->null
线程B扩容完,table为:7->5->3->null
=============第1次循环==============
线程A继续执行,操作的table为:3->null
=============第2次循环==============
线程A继续执行,操作的table为:5->3->null
=============第3次循环==============
线程A继续执行,操作的table为:3->null
2个线程扩容完毕后,最终table为:3->5->3
从运行的最终结果看3->5->3出现了环形链表,其中元素7丢失了。由此可以看出HashMap不仅出现死循环,而且可能丢失数据。
总结
- 在jdk1.7中,由于扩容时使用头插入法,在多线程环境下可能会形成环状列表,导致死循环。
- 在jdk1.8中改为尾插入法,在多线程环境下可以避免死循环,但是依然避免不了节点丢失的问题。
多线程环境下如何解决这些问题呢?三种解决方案:
- Hashtable替换HashMap
- Collections.synchronizedMap方法将HashMap包装起来
- ConcurrentHashMap替换HashMap
- 当然,官方推荐使用ConcurrentHashMap实现线程安全。
最后
关于ConcurrentHashMap是如何保证线程安全的,看这篇文章
线程安全之ConcurrentHashMap源码分析.