1.什么是线程安全问题
代码1如下,创建两个线程,实现并发编程,分别让变量n自增2w次
public class Demo3 {
public static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 20000; i++) {
n++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 20000; i++) {
n++;
}
});
//两个线程同时启动
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(n);
}
}
代码1的输出结果应该是4w,但实际输出结果却相差很大,且无论运行多少次,都很难能输出4w。
代码2如下:
public class Demo3 {
public static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 20000; i++) {
n++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 20000; i++) {
n++;
}
});
t1.start();
t1.join();//等待t1线程执行完毕
t2.start();
t2.join();//等待t2线程执行完毕
System.out.println(n);
}
}
结果是,无论执行多少次代码,结果都是4w,与预想结果相同。
代码2其实就是让多个线程一个一个执行,相当于是单线程编程,输出结果与预想的相同。但在多线程并发编程去执行时,输出结果与预想的却差异很大。这就是线程安全问题。
【总结】
同一段代码,放在多线程并发编程的环境中执行,发生 输出结果 与 预想结果 有差异,放在单线程环境中执行,不会出现差异的情况,称之为线程安全问题。
2. 为什么会发生线程安全问题
站在CPU的角度,对变量n进行自增操作会涉及到三步操作:
(1)将变量加载到内存中(load)
(2)对变量进行++(add)
(3)将变量进行保存(save)。
代码1实现的是多线程并发编程,多个线程交替执行。由于线程的调度是随机的,且执行n++要做的三步操作是非原子操作,这就会导致多个线程同时执行时,这三步操作可能会被穿插执行,出现以下这种情况:
出现以上这种情况时,即使t1和t2线程都对n进行了自增操作,但最终n只自增了一次。这也是导致代码1出现线程安全问题的关键原因。 只有保证每个线程执行修改操作时,不会有其他线程穿插进来,这样才能保证线程安全。即以下这种情况时,才是线程安全的。
由于线程的调度是随机的,每个线程在对变量n进行自增的过程中,我们无法确定多少次自增才是线程安全的,多少次自增是线程不安全的,也因此代码1很难达到预想的结果。
【线程安全问题原因总结】
(1)在操作系统中,线程的调度是随机的,即抢占式执行。
(例如代码1中,t1在对n进行自增时,完成自增的三步操作还只执行了前一或前两步时,t2就开始执行了)
(2)多个线程,对同一个变量进行修改(三个关键词:多个线程,同一变量,修改)
(3)修改操作是非原子的(操作是非原子的,也就是该操作不是一气呵成的)
(4)内存可见性问题(代码1尚未涉及,下面继续说明)
(5)指令重排序问题(代码1尚未涉及,下面继续说明)
3.如何避免线程安全问题
3.1解决 由于修改操作所导致的线程安全问题
想要使得代码是线程安全的,就要从导致线程安全问题的原因入手。其中,原因(1)是操作系统的特征,我们无法改变;有些代码无法避免变量修改操作;因此,要解决由修改变量所导致的线程安全问题,只能从原因(3)入手,即让修改操作变成原子的。 我们可以对关键代码进行加锁,这样当一个线程拿到锁之后,其他线程就无法获得锁,只能阻塞等待。这样就能使线程是安全的。
代码3如下:
public class Demo3 {
public static int n = 0;
public static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (locker){
for (int i = 0; i < 20000; i++) {
n++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker){
for (int i = 0; i < 20000; i++) {
n++;
}
}
});
t1.start();
t2.start();
t1.join();//等待t1线程执行完毕
t2.join();//等待t2线程执行完毕
System.out.println(n);
}
}
代码3的执行结果是,无论运行多少次,输出结果都是4w,说明线程是安全的。
值得注意的是,t1和t2线程去竞争同一把锁时,才能保证代码3是线程安全的,因为只有出现锁竞争时,没拿到锁的线程只能阻塞等待,这样才不会出现线程安全问题。否则每个线程拿的是不同的锁的话,加锁是没有意义的。
3.2解决 由于 内存可见性 和 指令重排序 所导致的线程安全问题
3.2.1 什么是内存可见性
代码4如下:
public class Demo {
public static int quit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (quit == 0){
}
System.out.println("t1结束!");
});
t1.start();
Thread t2 = new Thread(()->{
System.out.println("请输入quit的值:");
Scanner scanner = new Scanner(System.in);
quit = scanner.nextInt();//只要输入非0值,t1线程就会结束
});
t2.start();
}
}
代码4,从代码逻辑上看,当输入非0值时,t1线程线程就会执行结束且输出t1结束,但实际上t1线程一直无法结束,一直在无限循环中 。导致这种情况的原因就是 内存可见性问题。
定义的变量quit会存储到内存中,当代码执行到while循环条件判断时,会有load(去内存中读取quit的值,将quit的值加载到寄存器中)和cmp(读取寄存器中变量的值,比较quit和0是否相等)两条指令,进而决定是否继续循环。
由于代码4中循环体中没有其他代码,while循环在短时间内会循环很多次。
又因为load操作 相比于 直接读取寄存器 要多耗费许多时间(load一次耗费的时间可以cmp数千上万次);
且多次循环判断时,编译器发现quit的值并没有发生变化。
因此编译器做出一个大胆的决定,对代码进行了优化,决定只在第一次循环时才去进行load数据,之后再进行循环判断时直接读取寄存器中的值。
由于以上原因,即使在t2线程改变quit的值,t1没有感知到内存中quit的值发生改变,一直是从寄存器中读取数据,没有去内存load数据,也因此t1线程一直无法结束。这就是内存可见性问题。
【解决内存可见性问题】
解决内存可见性问题也很简单,只需在定义quit时加上volatile关键字,让编译器知道,每次要用quit的值时总是去内存中load,不要直接读取寄存器中的值,即不要对代码进行优化。这样就可以避免由于内存可见性所导致的线程安全问题。
在日常代码中,编译器是否会将代码进行优化,我们也感知不到,因此适当地加上volatile关键字是比较靠谱的选择。
3.2.2 什么是指令重排序
指令重排序,就是在保证代码逻辑不变的前提下,调整指令的执行顺序,从而提高线程执行的效率。在单线程环境中,存在指令重排序时不影响线程正常执行,但在多线程的并发执行的环境下就可能会出现问题。
代码5如下:
class MySingleton3{
//懒汉模式,等到被调用的时候才实例化对象
private static MySingleton3 instance = null;
private static Object locker = new Object();
private static MySingleton3 getInstance(){
//考虑到 if语句 和 实例化对象操作 是 非原子操作,可能会涉及到线程安全问题,则把这两个操作 加锁
//由于只有第一次调用该类时才需要加锁,则先判断是否要加锁
(1) if(instance == null){
synchronized (locker){
(2)if(instance == null){//如果instance是空,则实例化对象
MySingleton3 mySingleton3 = new MySingleton3();
}
}
}
return instance;
private MySingleton3(){}
}
}
在(2)if语句中,new操作会涉及到三条指令:
<1>申请内存空间
<2>在内存空间上构造对象
<3>把内存的地址,赋值给instance
在不影响代码逻辑的前提下,编译器可能按照1,2,3顺序执行,也可能会按照1,3,2顺序执行。instance还是空时,假设线程1执行new操作按照1,3,2顺序执行,刚执行到1,3时,instance指向的还是非法对象,线程2就开始执行了,并且执行(1)if语句,instance为非空,直接返回instance,返回的是非法的instance。这时,就发生了线程安全问题。
【解决指令重排序问题】
同样,在定义instance时加上volatile关键字,就能保证在new操作的过程中,不会发生指令重排序。