【内存可见性与线程不安全问题解析】

](https://img-home.csdnimg.cn/images/20220524100510.png#pic_center)
🌈个人主页: Aileen_0v0
🔥热门专栏: 华为鸿蒙系统学习|计算机网络|数据结构与算法
💫个人格言:“没有罗马,那就自己创造罗马~”

内存可见性,引起的线程不安全

具体代码:


import java.util.Scanner;

public class Demo23 {
    private  static int count = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (count == 0) {
                ;      // 循环体中, 啥都没有.
                // System.out.println("t1");
            }
            System.out.println("t1 执行结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            count = scanner.nextInt();
        });

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

  • t1线程是读取,t2线程是修改,即使它们之间的操作并不相同,但是依旧会产生内存可见性问题

  • while循环会执行两条指令
    • load指令和cmp指令
  • 虽然,上面的代码不能正常使t1退出循环,结束线程的锅确实和多线程有关系,但是另一方面,也是编译器/JVM优化产生的问题。
  • 正常情况下,我们需要保证优化操作以后的逻辑是等价的。
  • 但是,java的编译器(javac)/JVM在单线程代码中,优化比较靠谱,但是一旦引入多线程,编译器/JVM就没有那么准确了,就像上面的代码一样,JVM无法判断t2线程修改操作何时生效,所以编译器就会对t1线程做出比较激进的优化。

小结:

  • 上面问题的本质还是编译器优化引起的,优化掉load操作之后,使得t2线程的修改,未被t1线程感知到,这就是“内存可见性问题”。
    • javac:编译器
    • java:是jvm的运行环境

如何解决内存可见性问题 -> volatile关键字

  • volatile关键字:当编译器不能判定当前时是否需要优化时,程序员就需要通过手动来添加提示,当有了volatile关键字修饰就不会触发优化了(相当于是免死金牌)。

  • 通过使用volatile关键字修饰的变量,编译器就能知道,这个变量是"可变的",不能按照上述策略进行优化了

import java.util.Scanner;

public class Demo23 {
    private volatile static int count = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (count == 0) {
                ;      // 循环体中, 啥都没有.
                //System.out.println("t1");
            }
            System.out.println("t1 执行结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            count = scanner.nextInt();
        });

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

  • 当我们给变量加上volatile关键字以后,当我们再次输入1就可以暂停t2线程了,就像是裁员一样,告诉老板这个人是大动脉不能裁。

JMM(java memory model 内存模型)

  • 产生上面线程安全的问题也可以从 JMM 角度来理解:
    • 当 t1 执行的时候,要从工作内存读取 count 的值,而不是从主内存中读取,所以在后续 t2 线程修改 count 时,也是先修改工作内存,同时拷贝到主内存,导致 t1 没有感知到 t2 的修改
    • 工作内存区:是由 CPU 寄存器和缓存组成的。
    • 主内存(Main Memory):就是内存。

线程的等待通知机制

  • 之前学的 join 是等待线程结束
  • 等待通知机制,是等待代码中给我们进行显式通知(不一定要结束),更加精细的控制线程之间的执行顺序。
  • 在系统内部,线程是抢占式执行的,随机调度,程序员可以通过手段干预。通过“线程等待”方式让线程按照预期的执行顺序去执行。
  • 但是,我们无法让某个线程被调度,但是可以主动让某个线程去等待(给别的线程先执行的机会)
  • 等待通知可以调整线程之间的执行顺程序,也能解决一些讨厌的问题,比如说:下面的线程饿死。

线程饿死

  • “线程饿死”不是死锁,发生线程饿死是因为:某个线程频繁获取释放锁,由于获取的太快,导致其他线程捞不到 CPU 资源,由于系统中的线程调度是无序的,就可能会出现线程饿死的情况。
    • **故事时间:**就像是我的舍友有一天拉肚子,我们四个人都想上厕所,结果她一直进进出出占着茅坑,我们就要一直等他上完才能上,这就会导致我们憋死,也就是“线程饿死”,但是也不会一直进进出出,进出个十几次是有可能的
  • **线程饿死:**不像死锁那样卡死,可能会卡一会,但是对于程序的执行效率肯定会有影响的。

通过等待通知机制解决线程饿死

  • 我们可以通过条件,判定看当前逻辑是否能执行,如果不能执行,就主动 wait(主动进行阻塞),就把执行的机会让给别的线程,避免该线程进行一些无意义的尝试。
  • 等到后续时机成熟以后(需要其他线程进行通知),就会让阻塞的线程被唤醒。
    • **故事时间:**就像是我和我的舍友们都上厕所,其中一个舍友要大便 ,所以她让我们三个小便的先上,等我们上完再告诉她,让她去上厕所。

代码实现

  • wait:是 Object 类提供的方法,任何一个对象,都有这个方法。

public class Demo24 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("等待之前");
        object.wait();
        System.out.println("等待之后");
    }
}

wait 机制的使用

  • wait 机制的使用一定要加上锁
  • wait 内部不仅仅做的是阻塞等待,还要解锁
  • wait 是解锁的同时进行等待,所以在使用 wait 机制时,要先加上锁,才能释放,因此,wait 必须放到 synchronized 内部使用。
  • 所以,为了解决上面的报错,我们需要将 wait 的使用放到 synchronized 里面,要先加锁才能解锁。

public class Demo24 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("等待之前");
        synchronized (object){
            object.wait();
        }
        System.out.println("等待之后");
    }
}

  • 现在我们的线程就进入阻塞等待状态,直到有其他线程通过 notify 关键字唤醒它为止。
  • 相比之下,sleep 关键字也是阻塞等待,但是和锁无关。

notify:唤醒阻塞线程(他也要搭配锁来使用)

import java.util.Scanner;

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

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

                System.out.println("t1 等待之后");
            }
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            synchronized (locker){
                System.out.println("t2 通知之前");
                //借助Scanner控制阻塞,用户输入之前,都是阻塞状态
               scanner.next();
               locker.notify();
                System.out.println("t2 通知之后");
            }
        });

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

先执行 notify 还没有 wait(错过了就再也回不来了~)

import java.util.Scanner;

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

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

                System.out.println("t1 等待之后");
            }
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            synchronized (locker){
                System.out.println("t2 通知之前");
                //借助Scanner控制阻塞,用户输入之前,都是阻塞状态
               //scanner.next();
               locker.notify();
                System.out.println("t2 通知之后");
            }
        });

        t2.start();
        Thread.sleep(100);
        t1.start();
        
    }
}

  • 所以,通过对于多线程代码,修改一点,执行效果就可能完全不同了。
  • 所以在执行 notify 之前要先执行 wait 才能保证线程被唤醒,否则就会错过,导致线程永远不会被唤醒。

对于多个等待线程,notify 只能唤醒其中的一个等待线程(而且还是随机的)

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

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

        Thread t2= new Thread(() -> {
            synchronized (locker){
                System.out.println("t2 等待之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t2 等待之后");
            }
        });


        Thread t3 = new Thread(() -> {
            synchronized (locker){
                System.out.println("t3 通知之前");
                locker.notify();
                System.out.println("t3 通知之后");
            }
        });

        t1.start();
        t2.start();
        Thread.sleep(50);
        t3.start();
    }
}

  • 一个 notify 只能唤醒一个线程

  • 上面代码中三个线程都是指向同一个对象 locker,所以 notify 唤醒的时候只能是随机的,但是如果我们想要指定唤醒的线程,可以按照以下方式来进行操作:

指定唤醒:当有多个 locker 对象时,可拿着对应的对象进行唤醒

  • 上面我们的 locker 都是同一个对象,所以当我们通过 t3 唤醒线程时就会随机唤醒 t1 或 t2 线程中的其中一个,如果我们想要指定唤醒对象,可按照如下操作:
    • 1.创建多个不同的 locker 对象
    • 2.不同的线程使用不同的 locker 对象来唤醒 wait
    • 3.唤醒时拿着对应的对象去唤醒
public class Demo26 {
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Object locker2 = new Object();

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

        Thread t2= new Thread(() -> {
            synchronized (locker2){
                System.out.println("t2 等待之前");
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t2 等待之后");
            }
        });


        Thread t3 = new Thread(() -> {
            synchronized (locker2){
                System.out.println("t3 通知之前");
                locker2.notify();
                //locker.notify();
                System.out.println("t3 通知之后");
            }
        });

        t1.start();
        t2.start();
        Thread.sleep(50);
        t3.start();
    }
}

  • 我们创建 t2 线程的锁对象,然后拿着 t2 线程的锁对象 locker2 去唤醒 t2 等待线程,这就可以指定唤醒锁对象为 t2 了。

唤醒所有等待线程(notifyAll)

locker.notifyAll();

wait 操作支持带超时间版本的等待

  • 就是当调用 wait 传入时间以后,如果超出了传入时间还没被 notify 唤醒这个等待线程,那么系统就会自动调用 notify 来唤醒这个等待线程,如下 t1 线程,如果等待 1000ms 还没有线程唤醒它,系统就会自动调用 notify 去唤醒它。
public class Demo26 {
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Object locker2 = new Object();

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

        Thread t2= new Thread(() -> {
            synchronized (locker2){
                System.out.println("t2 等待之前");
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t2 等待之后");
            }
        });


        Thread t3 = new Thread(() -> {
            synchronized (locker2){
                System.out.println("t3 通知之前");
                locker2.notify();
                System.out.println("t3 通知之后");
            }
        });

        t1.start();
        t2.start();
        Thread.sleep(50);
        t3.start();
    }
}

⭐️总结

  • volatile:针对变量,而不是一段代码,只要有代码读取这个变量,都不会触发优化(优化:修改代码)到寄存器中。
  • JMM:内存模型
    • 工作内存区:是由 CPU 寄存器和缓存组成的。
    • 主内存(Main Memory):就是内存。
  • 阻塞队列的操作: wait/notify 当线程发生阻塞(wait)等其它线程到、通知(notify),通过协调多线程之间的执行顺序,防止线程饿死
    • wait:等待线程
      • 当队列为空时,继续 take 时就需要 wait 来阻塞
        • take 阻塞就通过 put 唤醒
      • 当队列为满时,继续 put 时,也需要使用 wait 来进行阻塞
        • put 阻塞就通过 take 唤醒
      • 需要搭配条件,来决定是否要 wait,我们最好使用 while 替代 if,来判断条件是否是真的符合了,防止 if 判定一次之后就不干了,而当 wait 被唤醒之后条件是否成立还需要进一步判断。
    • notify:唤醒线程
    • 它们都是 Object 这个类提供的方法,我们需要通过 Object 创建对象才能调用它们.
    • 它们都要搭配锁来使用
    • 特别是:wait,wait 先释放锁,进行等待,并且在等待 notify 通知以后被唤醒,要重新尝试获取锁

](https://img-home.csdnimg.cn/images/20220524100510.png#pic_center)
](https://img-home.csdnimg.cn/images/20220524100510.png#pic_center)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

I'mAileen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值