目录
1. 线程不安全问题
2. 线程不安全的原因
3. 解决线程不安全问题
线程安全问题是多线程编程必须考虑的重要问题,也因为其难以理解与处理,故而程序员也尝试发明更多的编程模型来处理并发编程,如多进程、多线程、actor、csp等等;
我们知道,操作系统调度线程是抢占式执行,这样的随机性可能会导致程序执行出现一些bug,如果由于这样的调度的随机性使得代码出现了bug,则认为代码是不安全的,如果没有引入bug,则认为代码是安全的;
线程不安全的典型案例:使用两个线程对同一个整型变量进行自增操作,每个线程自增五万次:
class Counter{
//保存两个线程要自增的变量
public int count = 0;
public void increase(){
count++;
}
}
public class Demo1 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
counter.increase();
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<50000;i++){
counter.increase();
}
});
t1.start();
t2.start();
//在main线程中打印两个线程自增结束后得到的count结果
//t1、t2执行结束后再打印count结果
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行三次,输出结果与预期并不相符且多次运行多次不同:
注:1.t1.join()与t2.join()谁先谁后均可:
线程是随机调度的,t1、t2线程的结束前后是未知的,
如果t1先结束,则先令main线程等待t1结束,待t1结束后再令main线程等待t2结束;
如果t2先结束,仍先令main等待t1结束,t2结束了t1还未结束,main线程仍然在等待t1结束,等t1结束后,t2已经结束了,则此时t2.jion()立即返回;
2.站在CPU角度来看,count++实际上是3个CPU指令:
第一步:将内存中count值加载到CPU寄存器中;(load)
第二步:寄存器中的值将其+1;(add)
第三步:把寄存器中的值写回到内存的count中;(save)
由于抢占式执行,两个线程同时执行这三个指令的时候顺序上充满了随机性,只有当两个线程的三条指令串型执行的时候才会符合预期,只要三条指令出现交错,就会出现错误,如:
(1)根本原因:线程是抢占式执行,线程间的调度充满随机性;
(2)修改共享数据:多个线程对同一个变量进行修改操作,才会导致线程不安全问题;
当多个线程分别对不同的多个变量进行操作,或是多个线程对同一个变量进行读操作,都不会导致线程不安全问题;
(3)操作的原子性问题:针对变量的操作不是原子性的,就会导致线程不安全问题,如上文示例中,自增操作其实是3条指令;
当操作是原子性的,如读取变量的值就只对应一条机器指令,就不会导致线程不安全问题;
(4)内存可见性问题:java编译器的优化操作使得在某些情况下线程之间出现信息不同步问题:
如线程t1一直在高速循环进行读操作,线程t2不定时进行修改操作,此时由于t1的高速访问可能无果,就会停止将数据从内存中读至寄存器中再进行读取,而直接从寄存器中读取,此时若t2线程进行修改操作,就会由于内存可见性问题而使两个线程信息不同步,出现安全问题,示例代码如下:
import java.util.Scanner;
public class Demo2 {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
while(0 == isQuit){
}
System.out.println("Thread t has finished.");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("Please input the value of isQuit: ");
isQuit = scanner.nextInt();
System.out.println("Thread main has finished.");
}
}
输出结果为:
并未输出"Thread t has finished."说明t线程并未结束;
(5)指令重排序问题:指令重排序也是编译器优化的一种操作,编译器在某些情况下可能调整代码的先后顺序来提高程序的效率,单线程通常不会出现问题,但在多线程代码中,可能就会误判导致线程安全问题;
对应上文的线程不安全问题原因,思考解决线程不安全问题的方法:
(1)线程调度的随机性问题:无法从代码层面进行改进的;
(2)多线程修改同一变量问题:部分情况下可调整代码结构,使不同线程操作不同变量;
(3)变量操作的原子性问题:加锁操作将多个操作打包为一个原子性操作;
(4)内存可见性问题:
① 使用synchronized关键字可以保证内存可见性,被synchronied修饰的代码块,相当于手动禁止了编译器的优化;
② 使用volatile关键字可以保证内存可见性,禁止编译器做出上述优化:
import java.util.Scanner;
public class Demo2 {
private static volatile int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
while(0 == isQuit){
}
System.out.println("Thread t has finished.");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("Please input the value of isQuit: ");
isQuit = scanner.nextInt();
System.out.println("Thread main has finished.");
}
}
此时输出结果为:
(5)指令重排序问题:synchronized关键字可以禁止指令重排序;
注:synchronized解决多线程修改同一变量问题代码示例:
使用锁后,就将线程间乱序的并发变成了一个串型操作,并发性降低但会更安全;
虽然效率有所降低但相较于单线程程序,还是能分担步骤压力,效率还是较高的;
java中加锁的方式有很多种,最常使用的是synchronized关键字:
class Counter{
public int count=0;
synchronized public void increase(){
count++;
}
}
public class Demo1 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
counter.increase();
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<50000;i++){
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
输出结果为:
注:(1)在increase()方法前加上synchronized修饰,此时进入方法就会自动加锁,离开方法就会自动解锁;
(2)当给一个线程加锁成功时,其他线程尝试加锁就会触发阻塞等待,此时对应的线程就处于clocked状态;
(3)阻塞状态会一直持续到占用锁的线程解锁为止,时间轴缩略图如下:
(3)synchronized可以保证操作的原子性,保证内存可见性,还可以禁止指令重排序;