线程安全问题

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操作的过程中,不会发生指令重排序。

  • 23
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值