Java基础--线程安全问题
并发编程主要关注三个问题:安全性,活跃性,性能问题。其中安全性问题是最基本的要求。
什么是线程安全问题?
简单理解,就是在多线程环境下,对共享变量存在写操作时,共享变量能否正常读写的问题。
public class TestConcurrentSafe {
// 共享变量
static int i = 0;
public static void main(String[] args) {
for (int j = 0; j < 5000; j++) {
Thread t1 = new Thread(() -> i++);
t1.start();
}
System.out.println(i);
}
}
创建5000个线程,每个线程对静态成员变量做i++
操作。由于i++
操作并不是原子操作,所以最后得到的结果不一定是5000。这就是线程不安全。
线程安全的微观原因:可见性,原子性,顺序性问题
由于CPU,内存,I/O设备之间巨大的速度差异,我们的计算计体系结构引入了CPU缓存,分时系统,和编译优化。
- CPU 增加了缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
其中CPU的缓存带来了多线程之间的可见性问题,CPU分时复用带来了指令的原子性问题,而编译优化带来了指令顺序性问题。
CPU缓存与可见性问题
对单核CPU来说,不存在变量的可见性问题,CPU对缓存中变量做任何修改,其他线程都能立刻“看到”。而对于多核CPU来说,CPU有各自的缓存,核心之间的缓存内的变量是互相看不见的。这就带来了可见性问题。
比如上图中,三个CPU分别有自己的L1缓存,当执行i++
操作时,CPU1将结果写入自己的L1缓存。而其他CPU需要从内存里读取i的值,看不到L1缓存中的结果,最后结果比预想的结果小。这就是缓存带来的可见性问题。
线程切换带来原子性问题
即便解决了可见性问题,线程安全还包括还有线程切换带来的原子性问题。
对于i++
这个操作,在CPU指令级别,可以看做三个指令
- 从内存取i的值,load
- CPU执行i=i+1
- 将新值写会内存,save
由于CPU是分时执行,也就是说一个线程只能执行一段时间,即时间片。线程的执行不一定什么时候就会失去CPU的使用权,交给别的线程,这就是线程切换。
编译优化带来有序性问题
编译器为了性能优化,会对代码的执行顺序做调整,但是不影响最终结果。
但是有时会引起安全性的Bug.
以双重校验锁实现的单例为例:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在获取单例的时候,会校验instance == null
,然后对单例类进行加锁。加完锁之后,还要再对instance == null
做一次校验。这是为了防止这样一种异常情况:线程A和线程B同时调getInstance
,线程A拿到锁,B被阻塞。线程A对实例做初始化,然后释放锁。B拿到锁,如果不判断一次instance == null
,线程B会重新new一个实例,这就生成两个单例了;所以拿到锁之后,再进行一次instance == null
的判断,如果已经不为null,则不再初始化。
似乎看上去没有问题,但是其实还有个指令重排序的问题。问题就在new Singlton()
这行代码上。
一行高级语言代码可能对应多条指令。上述代码的字节码如下:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
其中
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 引用地址
- 21 表示利用一个对象引用,调用构造方法
<init>
- 24 表示利用一个对象引用,赋值给 static INSTANCE
指令重排序后有可能先执行24,后执行21,这在单线程环境下看是没什么问题,但是多线程环境下,线程A执行完24引用赋值后发生了线程切换,线程B使用instance对象,发现虽然instance的引用不为null,但是还没有初始化变量,导致使用时发生空指针异常。这就是指令重排序导致的问题。
如何解决可见性问题,有序性问题和原子性问题?
1.可见性问题和有序性问题
可见性问题是由缓存问题导致的,有序性问题是由指令重排序导致的,所以最直接的想法就是禁用缓存和重排序。但是完全禁用缓存,性能会下降,所以应该按需禁用。这个工作由JVM来做。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。
2.原子性问题
原子性问题主要的解决办法就是互斥,也就是加锁,即对共享变量的写操作,线程之间是互斥的,一次只能有一个线程对共享变量执行写操作。