1.什么是线程安全问题?
对于什么是线程安全问题,我们可以这样来理解:如果在多线程环境下代码运行的结果符合我们的预期,则说明这个线程是线程安全的,反之,如果代码运行的结果与预期不符,则称代码含有线程安全问题。
2.线程不安全的原因
1.线程在系统中是随机调度,抢占式执行的
2.多个线程修改同一个变量(一个线程修改同一个变量,多个线程读取同一个变量,多个线程修改不同变量都都不存在线程安全问题)
3.线程针对变量的修改操作,不是“原子”的(如果某个代码操作,对应到一个cpu指令,就是原子的,对应到多个cpu指令,就不是原子的,例如赋值语句等等是原子的,++,+=等语句不是原子的)
4.内存可见性问题引起的线程不安全
5.指令重排序引起的线程不安全
3.解决线程安全问题
那么如何解决线程安全问题呢?接下来我们针对原因一个一个来看
3.1 措施→原因1
对于第一个原因,这是无法避免的,我们无法对线程的性质去进行修改
3.2 措施→原因2
对于第二个原因,只能在写代码时尽量避免出现多个线程修改同一变量的情况
3.3 措施→原因3
在探讨如何解决线程安全问题之前,我们先来看一个由于原因3而产生线程安全问题的例子
public class Thread_safe {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for(int i=0;i<10000;i++) {
count++;
}
});
Thread t2=new Thread(()->{
for(int i=0;i<10000;i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
注:如果上述循环次数太少,count值的结果会是正确的,原因可能是在t1线程先于t2线程启动,并且因为循环次数较少,线程执行较快,导致在t2线程启动时,t1线程就已经执行完,所以结果正确
上述代码中t1线程和t2线程都执行了10000次的count++操作,那么理论上输出结果应该为count=20000,然而运行结果如下:
我们发现结果和预期不符,并且在多次运行之后,输出的count的值会不断变化,此时上述代码就产生了线程安全问题,那么具体是如何产生的呢?
首先我们来看count++这个操作,这样一行代码其实是三个cpu指令
1) Load:把内存count中的值,读取到cpu寄存器中
2) Add:把寄存器中的值加1,还是保存在寄存器中
3) Save:把寄存器上述计算后的值,写回到内存count中
由于t1和t2线程是并发执行的,当t1和t2线程同时执行count++时,上述三个cpu指令的执行顺序可能会发生变化,我们来看一个变化的例子:
通过上述执行过程,发现经过两次count++操作,内存中count的值仍然为1,因为在后一次计算时把前一次计算的结果覆盖掉了,当然除了上述一种执行顺序外,还有很多种执行顺序,这些不同的执行顺序都会导致count最后的值产生不同的结果
那么如何解决上述线程安全问题呢?我们可以通过操作,把一系列“非原子”操作,打包成一个“原子”操作,这个操作就是"加锁",我们先来了解一下什么是锁
3.3.1 锁
1.锁设计两个核心操作:加锁与解锁
2.锁的主要特性:互斥
一个线程获取到锁后,另一个线程也尝试加这个锁,就会阻塞等待(也叫做锁竞争或者锁冲突)
3.代码中可以创建多把锁,只有多个线程竞争同一把锁时,才会产生互斥,针对不同的锁,则不会
3.3.2 synchronized关键字
要进行“加锁”操作,我们就需要引入synchronized关键字
synchronized(锁对象){
......//代码
}
当进入到代码块,就是给上述括号中的锁对象进行加锁操作
当出了带代码块,就是给上述括号中的锁对象进行解锁操作
public class Thread_safe {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Object locker=new Object();//创建锁对象lokcer
Thread t1=new Thread(()->{
synchronized (locker) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
Thread t2=new Thread(()->{
synchronized (locker) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
运行结果如下:
可以看到通过使用synchronized关键字,count的值变成了正确的结果,t1线程对locker对象加锁时,t2线程阻塞等待,cpu指令的执行顺序变为下图
注:只有当t1线程和t2线程都针对同一对象加锁才有效,若针对不同锁对象,则count的结果不会发生改变,代码依然存在线程安全问题
3.3.3 死锁
在使用synchronized关键字时,可能会出现死锁现象
死锁产生的场景:
1.锁是不可重入锁,并且一个线程针对一个锁对象连续加锁两次(synchronized是可重入锁,对一个锁对象连续加锁两次不会出现死锁现象)
2.两把线程两把锁(互不相让)
public class Thread_lock { public static void main(String[] args) throws InterruptedException { Object locker1 = new Object(); Object locker2= new Object(); Thread t1 = new Thread(() -> { synchronized (locker1) { try {//引入sleep,更好控制线程的执行顺序,否则可能不会出现死锁现象 Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (locker2) { System.out.println("t1获取了2把锁"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2) { try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (locker1) { System.out.println("t2获取了2把锁"); } } }); t1.start(); t2.start(); t1.join(); t2.join(); } }
3.N个线程,M把锁
死锁产生的必要条件(缺一不可):
1.锁具有互斥特性,一个线程拿到锁后,其他线程就得阻塞等待
2.锁不可抢占,一个线程拿到锁后,除非自己主动释放锁,否则别人抢不走
3.请求和保持 ,一个线程拿到一把锁后,在不释放这个锁的前提下,再尝试获取其他锁
4.循环等待,多个线程获取多个锁的过程中,出现了循环等待,A等待B,B又等待A
为了避免死锁现象的出现,我们在写代码时要尽量不出现锁嵌套的情况,如果一定要进行锁的嵌套,那么可以通过约定加锁的顺序来解决
3.4 措施→原因4
同样的,我们先来看一个由于原因4而产生线程安全问题的例子
import java.util.Scanner;
public class Thread_inner {
private static int count=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while(count==0) {
}
System.out.println("t1执行结束");
});
Thread t2=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("请输入一个整数:");
count=scanner.nextInt();
});
t1.start();
t2.start();
}
}
我们预期的结果是当用户输入非0整数时,t1线程会退出循环,结束进程,但实际上运行结果
可以看到在输入非0整数5后,进程并没有结束,产生上述线程安全问题的原因,就是内存可见性,那么又是怎么产生的呢?
我们把目光放到代码中的while循环上,其实它分为2个指令:
1) Load:从内存读取数据到cpu寄存器
2) cmp:比较 如果条件成立就继续执行,如果条件不成立就跳转到另一个地址来执行
由于循环徐旋转速度很快,导致短时间内出现大量的Load和cmp的反复执行,但Load执行消耗的时间会比cmp多很多,所以在执行过程中,Load速度非常慢并且JVM发现Load每次执行的结果都是一样的(t2线程修改之前),于是JVM就把Load操作优化掉了(只是第一次真正进行Load,后续直接读取Load过的寄存器中的值),导致后续t2修改count时,t1线程感知不到
针对编译器的优化而产生的问题(内存可见性问题),我们引入volatile关键字来解决
3.4.1 volatile关键字
当用volatile修饰变量时,能够让编译器不触发上述优化操作,所以我们只需要修改count变量的定义,添加volatile关键字
private volatile static int count=0;
我们可以看到添加了volatile关键字后,在输入非0整数后,t1线程以及整个进程都能够正常结束
注:volatile关键字和synchronized保证的原子性无关,volatile不保证原子性
3.5 措施→原因5
为了提高效率,在保证逻辑等价的前提下,编译器会根据实际情况生成二进制指令的执行顺序,和你写的代码的顺序可能会存在差别。在单线程环境下,编译器进行指令重排序的操作一般都不会产生问题,而在多线程环境中,可能会出现指令重排序后代码逻辑发生改变,比如我们经常使用的new操作
Student stu = null stu = new Student()
我们以创建一个学生类的实例为例子,第二行代码实际上可以大体上细分成三个步骤:
1.申请内存空间
2.调用构造方法(对内存空间进行初始化)
3.把此时内存空间的地址,赋值给stu引用
1→2→3是正常情况的执行顺序,但在指令重排序的优化策略下,上述执行的过程的过程不一定是1→2→3,也可能是1→3→2(1一定是先执行的),而1→3→2的执行顺序在多线程环境下可能会出现问题
public class Student { public static Object locker = new Object(); public static Student stu = null; public static Student getInstance() { if (stu == null) { synchronized (locker) { if (stu == null) { stu = new Student(); } } } return stu; } }
当t1线程和t2线程同时执行getInstance方法时,可能出现以下情况:
t1执行完上述1,3步骤,此时instance非空,但是指向的对象是一个未初始化的对象,而恰巧此时t2线程开始执行,因为instance非空,没有满足第一层if的条件,直接返回了未初始化完毕的instance对象,如果在后续操作中t2线程利用这个返回的初始化完毕的对象实例调用成员方法或成员变量时可能会出现问题。
对于指令重排序问题,我们依旧需要使用volatile关键字,在添加volatile关键字后,在针对某个对象的读写操作过程中,不会出现指令重排序