JavaEE初阶--------第四章 线程安全问题的原因和解决方案

系列文章目录

第四章 线程安全问题的原因和解决方案



前言

为什么会存在线程安全问题?有些代码,在单个线程环境下去执行,完全正确。但是如果同样的代码,让多个线程去同时执行,此时就可能会出现 bug了。这种就是“线程安全问题”。


一、观察线程不安全

public class Demo2 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

大家可以看一下上面的代码,我们创建了两个线程,两个线程都是对 count 变量自增5000次,最终我们预期的效果应该是输出10000。

在这里插入图片描述
在这里插入图片描述

但是现实结果和预想的却不是一样的,而且,惊奇地发现,两次运行的结果还不一样,可以说是每次运行的结果都不相同。那出现这两个问题到底是什么原因呢?

  • count++ 这个操作,本质上,是分成三步进行的(站在CPU 的角度上,通过三个指令来实现的):
    1、load 把数据从内存读到 CPU 寄存器上
    2、add 把寄存器中的数据进行 +1
    3、save 把寄存器中的数据保存到内存中

由于多个线程之间的调度顺序是“随机”的,就会导致在有些调度顺序下,上述的逻辑就会出现问题。
在这里插入图片描述
其实,这里的执行顺序有无数种情况。我们来分析下第三种情况,t1 线程先 load 后,t2 线程也load ,随着 t2 线程进行 add和 save ,那么内存中 count 也就变成了1,紧接着 t1 线程进行 add ,但是之前 t1 线程进行 load 的是0,那么此时由0变成1,save 以后还是1,最终,内存上并不是预期的结果2,而是1。这就相当于在自增过程中,两个线程的结果没有往上累加,而是各自独立运行。所以,这也就为什么程序每次运行的结果都不相同。而且一定是小于10000的数字。


二、产生线程安全问题的原因

  1. 操作系统中,线程的调度顺序是随机的(抢占式执行)(最重要的原因)
  2. 两个线程,针对同一个变量进行修改
  3. 修改操作,不是原子的
    此处的 count++ 就属于是非原子的操作(先读后修改)
    类似的,如果一段逻辑中,需要根据一定的条件来决定是否修改,也是存在类似问题
  4. 内存可见性问题
  5. 指令重排序问题

三、解决方案

要想解决线程安全问题,就需要从这几个原因入手。
所以就要想办法让 count++ 成为“原子”的------加锁!!!

  • 最常见的办法—就是使用 synchronized 关键字
    在这里插入图片描述

synchronized 在使用的时候,要搭配一个代码块{ },进入就会加锁,出了就会解锁。在已经加锁的状态下,另一个线程尝试加相同的一个锁,就会产生“锁冲突/锁竞争”,后一个线程就会阻塞等待,一直等到前一个线程解锁为止

public class Demo2 {
    private static int count = 0;

    Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {

        Object locker = new Object();

        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

在这里插入图片描述
因为这里我们给 t1 线程和 t2 线程加同样的锁,所以这里 t2 线程会一直阻塞等带 t1 线程执行完毕,t2线程才会开始执行。那么这样线程安全问题就迎刃而解了!


四、volatile 关键字

public class Demo5 {
    private static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
           while (isQuit == 0){
            //循环体里啥都没干
               //此时意味着这个循环,一秒钟会执行很多很多次
           }
            System.out.println("t1 退出!");
        });
        t1.start();

        Thread t2 = new Thread(() ->{
            System.out.println("请输入 isQuit:");
            Scanner scanner = new Scanner(System.in);
            //一旦用户输入的值不为0,此时就会使 t1 线程执行结束
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

大家观察一下上面的代码,如果输入的 isQuit 的值不为0,那么就可以使线程 t1 执行结束,这是我们预期的结果。那么,事实效果又会怎样呢?

在这里插入图片描述

光标此刻一直在闪,说明程序并没有结束,无论你 isQuit 输入的值是多少,程序此刻都毫无反应。其实,这也是一种线程安全问题的情况。

  • 原因:
    这是编译器进行的代码优化搞出来的 bug。代码优化是一种非常普遍的情况,编译器为了进一步地提高代码的执行效率,会在保持逻辑不变的前提下,调整生成的代码内容。如果是多线程的代码,代码优化就有可能会出现误判,优化之后的逻辑就和之前不一样了

  • 计算机运行的程序/代码,经常要访问数据,这些依赖的数据,往往会存储在内存中(比如定义的变量),CPU 使用这个变量的时候,就会把这个内存中的数据,先读出来,放到 CPU 的寄存器中后再参与运算。CPU 读取内存的这个操作相比是非常慢的

  • 为了解决上述的问题来提高效率,此时的编译器就可能对代码作出优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就提高整体程序的效率了

  • 此处我们写的代码就是“内存可见性”情况引起的。
    在线程 t1 的循环条件中,其实是做了两步操作,首先要进行 load,读取内存中 isQuit 的值到寄存器中,然后通过 cmp 指令比较寄存器的值是否是0,决定是否要继续循环

  • 由于这个循环速度飞快,短时间内就会进行大量的循环。此时,编译器 JVM 就发现虽然进行这么多次 load ,但是 load 出来的结果都一样,并且 load 操作又非常消耗时间,所以,编译器做了一个大胆的决定,只是第一次循环的时候读内存,后续就不再读内存了,而是直接从寄存器中取出 isQuit 的值

  • 所以在上述代码里,t2 线程修改了 isQuit 之后,t1 线程却感知不到 isQuit 变量的变化(感知不到内存的变化)—这就是“内存可见性”问题

在这里插入图片描述

  • 这里,我们就引入 volatile 关键字可以解决“内存可见性”问题。通过这个关键字,告诉编译器不要进行优化
  • 但是,volatile 关键字是无法保证原子性的

五、wait 和 notify

多线程中一个比较重要的机制—协调多个线程的执行顺序

  • wait—等待,让指定线程进入阻塞状态
  • notify—通知,唤醒对应的阻塞状态的线程
public class Demo4 {
    public static void main(String[] args) {
        Object object = new Object();

        Thread t1 = new Thread(() ->{
           synchronized (object){
               System.out.println("wait 之前!");
               try {
                   object.wait();
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               System.out.println("wait 之后!");
           }
        });

        Thread t2 = new Thread(() ->{
            synchronized (object){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                object.notify();
                System.out.println("唤醒!");
            }
        });

        t1.start();
        t2.start();
    }
}

在这里插入图片描述

  • wait 在执行的时候,要做三件事:
    1、释放当前的锁
    2、让线程进入阻塞等待
    3、当线程被唤醒的时候,重新唤醒全部线程
  • wait & notify (需要借助同一个对象)都需要搭配 synchronized 来使用

六、单例模式

单例模式是一个非常经典的设计模式,保证某个类在程序中只存在唯一一份实例,而不会创建出多个实例

  • 饿汉模式:在类加载的时候就创建出实例
class Singleton{
    private static Singleton instance = new Singleton();
    //通过这个方法来获取到刚才是实例
    //后续如果想使用这个类的实例,都是通过 getInstance 方法来获取
    
    public static Singleton getInstance(){
        return instance;
    }
    
    private Singleton(){
        //把构造方法设置为私有,此时类外面的其他代码就无法 new 出这个类的对象了
    }
}
  • 懒汉模式:首次调用 getInstance 的时候,才会真正去创建出实例(如果不调用,就不创建)
class SingletonLazy{
    private static SingletonLazy instance = null;
    
    public static SingletonLazy getInstance(){
        if (instance == null){
            instance = new SingletonLazy();
        }
        return instance;
    }
    
    private SingletonLazy(){
        
    }
}
  • 那么这两种写法,是否是线程安全的?(如果多个线程同时调用 getInstance 是否会出现问题?)
    线程安全的根源在于多个线程同时修改一个变量就可能会产生问题。反而言之,如果多个线程同时读取同一个变量就是没事的。饿汉模式中,getInstance 只是读取了变量并没有进行修改,但是懒汉模式中既涉及到读取又涉及到修改。所以,懒汉模式是线程不安全的。
public static SingletonLazy getInstance(){
        
            synchronized (Singleton.class){
                if (instance == null){
                    instance = new SingletonLazy();
                }
            }
        
        return instance;
    }

于是,我们就要加个锁,将 if 判定和 new 操作 来变成原子的操作。加锁,把 if 和 new 这两个语句,放到一个 synchronized 中

  • 加锁/解锁是一个开销比较高的事情,而懒汉模式的线程不安全只是发生在首次创建实例的时候,因此后续使用的时候,不必再进行加锁了。
  • 所以,我们在在加锁语句的外层,再引入一个 if 条件,判定一下,看看当前这里的锁,是否要加上,如果对象已经有了,线程就安全了,此时就可以不加锁了。如果对象还没有,存在线程不安全的风险,就需要加锁。
 public static SingletonLazy getInstance(){
        if (instance == null){
            synchronized (Singleton.class){
                if (instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
  • 第一个 if 用来判定是否需要加锁
  • 第二个 if 用来判定是否需要 new 对象

指令重排序,可能对咱们上述的代码产生影响

指令重排序:也是编译器优化,编译器为了执行效率,可能会调整原有代码的执行顺序,调整的前提是保持逻辑不变。通常情况下,指令重排序能保证逻辑不变的前提下,把程序执行效率大幅度提高。

  • Java中,new 操作是可能会触发指令重排序的:
    1、申请内存空间
    2、在内存空间上构造对象(构造方法)
    3、把内存的地址,赋值给 instance 引用
    本来的顺序应该就是123来执行,但是指令重排序以后,就可能变成了132的顺序
  • 在多线程的情况下,假设是按照132的顺序来执行。当 t1 线程执行完 1 和 3 的时候,此时 instance 就已经是非空了。但是,此时 instance 指向的是一个还没初始化的非法对象。
  • 此时此刻并未执行2,然后 t2 线程开始执行了,t2 判定 instance == null,条件不成立,于是直接return。进一步的 t2 线程的代码就可能会访问 instance 里面的属性和方法了。这个时候就会出现bug了。
class SingletonLazy{
    private static volatile SingletonLazy instance = null;

    public static SingletonLazy getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if (instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }

    private SingletonLazy(){

    }
}

于是,我们让 volatile 修饰 instance,此时就可以保证 instance 在修改的过程中就不会出现指令重排序的现象了


总结

这章,我们学习了多线程中比较重要的内容—线程安全问题和解决方案。知道了五种产生线程安全问题的原因以及解决方案—加锁。也了解了什么是单例模式,分别有饿汉模式和懒汉模式,什么情况下该加锁来解决线程安全问题,加锁又应该加在哪里。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值