前言
我们都知道HashMap是线程不安全的,但是具体有哪些表现呢?
例子1 死循环
这个是最常在面试中问到的问题,然而其实这个问题已经在java1.8版本被修复了,只在1.7版本之前存在这个问题。
大致原因是在HashMap扩容的时候链表采用了头插法会使链表反序,两个线程同时扩容的话,在某种场景下会出现循环链表导致死循环。网上有很多文章介绍,有一篇写的很好,不再重复
java1.8修复的方法是将头插法改成了尾插法,避免了这个死循环问题。参考文章,也可以看一下resize的代码,很容易理解。
那么java1.8就不会出现死循环问题了吗?不是的,在多线程操作红黑树的时候依然有可能会导致死循环,参考文章,不过这个地方的具体原因没有找到详细文章说明,待证明。
例子2 哈希冲突导致覆盖
场景:如果一个空的HashMap当前的Capacity=8,两个线程分别put进key=1和key=9的数据,在某种场景下可能会导致只有一个值能被传入,且size会增长2。
代码如下,需要单步调试进入
public static void main(String[] args) throws InterruptedException {
final HashMap<Integer, String> hashMap = new HashMap<>(8);
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
hashMap.put(1, "1");
countDownLatch.countDown();
}).start();
new Thread(() -> {
hashMap.put(9, "9");
countDownLatch.countDown();
}).start();
countDownLatch.await();
}
两个断点如下,进行多线程调试(idea断点类型和多线程调试可以在网上搜索)
令两个线程都走到这一行代码,然后分别执行完后边的代码,回到main方法。
最后会发现HashMap的size=2,但是只有一个元素。
这个原因其实就是HashMap保存元素用的是一个Node数组,hash取值相同的Node会放在数组的同一个位置,然后采用拉链法或者是红黑树保存元素。
当数组的某个索引处还没有元素的时候,会去初始化一个元素,然后赋值到该数组的索引处。如果两个线程放的元素hash值相同,且都发现该索引处没有值的时候,都会初始化一个值然后进行赋值,这时候就会出现二者互相覆盖的情况。所以最终只有一个元素被放到了HashMap中,另一个成了垃圾等待回收……
共性
其实要说明HashMap线程不安全,有非常多的表现,但是没有必要一一列举。
之前写的文章中已经说了:线程不安全的本质原因就是有竞态条件。对于共享资源的修改不能做到原子化、对顺序敏感的时候就会导致线程不安全,上面的所有情况根因都是这个,只是表现形式不同而已。
所以,基本上掌握了这个本质,就可以分析所有线程不安全的情况。
HashTable
我们知道,HashTable是线程安全的,它是给所有方法增加了synchronized。
- 这个方法可以达到线程安全吗?可以
- 这种方法的原理是什么?原理是synchronized都需要获取当前实例的锁,static synchronized都需要获取当前类的锁。至于再深入的说,monitorenter和monitorexit这种指令具体怎么实现,就不是很了解了。
- synchronized和static synchronized修饰的两个方法之间是同步的吗?不是,因为需要获取的锁不一样。如下代码所示,func1和func4是同步的,func2和func3是同步的,断点调试会发现当一个线程获取到锁之后,另一个线程在尝试获取锁的时候会进入到MONITOR状态等待锁。
import java.util.HashMap;
import java.util.concurrent.CountDownLatch;
public class H {
public static synchronized void func1() {
System.out.println("func1");
}
public synchronized void func2() {
System.out.println("func2");
}
public void func3() {
synchronized (this) {
System.out.println("func3");
}
}
public void func4() {
synchronized (H.class) {
System.out.println("func4");
}
}
public static void main(String[] args) throws InterruptedException {
H h = new H();
new Thread(() -> {
h.func1();
}).start();
new Thread(() -> {
h.func2();
}).start();
new Thread(() -> {
h.func3();
}).start();
new Thread(() -> {
h.func4();
}).start();
CountDownLatch countDownLatch = new CountDownLatch(1);
countDownLatch.await();
}
}
- HashTable的实现有什么问题?问题很明显,加锁的粒度太粗,我们如果只是为了线程安全,只需要在产生竞态条件的地方加锁就可以了,不需要全部代码加锁,会使性能表现不好。
ConcurrentHashMap
我们知道,多线程下一定要用ConcurrentHashMap。但是它是怎么做到线程安全的呢?这个还是很有意思的。
有一篇ConcurrentHashMap是如何实现线程安全的写的很好
还有看的一个公众号的文章写的很好,记录一下
死磕 java集合之ConcurrentHashMap源码分析(一)
死磕 java集合之ConcurrentHashMap源码分析(二)
死磕 java集合之ConcurrentHashMap源码分析(三)
待自己手撸一遍代码。。
ConcurrentHashMap一定是线程安全的吗?
首先,是的。如果不是,是因为你对ConcurrentHashMap的线程安全有误解。看下面的例子,这段代码不是线程安全的,最终输出的结果未必是2000。
但这不能说明ConcurrentHashMap不是线程安全的,只能说明下面这段代码不是线程安全的
线程安全是需要考虑一个代码范围或者逻辑范围的,在这个范围内如果程序对多线程下各个环节的执行顺序敏感的话,就存在竞态条件,存在竞态条件的代码不做同步的话,就会出现线程不安全的情况。
所以,ConcurrentHashMap提供的方法是线程安全的,但是先get数据、进行计算、然后再put数据这种逻辑是线程不安全的。
public class Test {
static final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public static void increment(String key) {
map.put(key, map.getOrDefault(key, 0) + 1);
}
public static void main(String[] args) throws InterruptedException, IOException {
CountDownLatch latch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
increment("test");
latch.countDown();
}
});
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
increment("test");
latch.countDown();
}
});
latch.await();
System.out.pringln(map.size());
}
}