【Java系列】详解多线程(三)—— 线程安全(下篇)

个人主页:兜里有颗棉花糖
欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 兜里有颗棉花糖 原创
收录于专栏【Java系列专栏】【JaveEE学习专栏】
本专栏旨在分享学习Java的一点学习心得,欢迎大家在评论区交流讨论💌

目录

  • 一、内存可见性
  • 二、volatile关键字
    • Java内存模型图(JMM)
    • synchronized能否保证内存可见性
  • 三、wait和notify
    • 使用notify方法唤醒线程
  • 四、wait和sleep之间的区别

一、内存可见性

我们先来看一下什么是内存可见性问题,通过一段代码来进行演示:

import java.util.Scanner;
public class Demo13 {
    public static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {
                ;
            }
            System.out.println("t1线程执行结束!!!");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入isQuit的值:");
            isQuit = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们期望的是上述代码中如果我们输入的值是0的话那么t1线程结束,如果输入的值是非0的话,t1线程将继续无限循环下去,可是实际的程序运行结果如下图(运行结果显示t1线程并没有结束而是继续无限循环下去):

我们通过jconsole.exe程序来看看程序运行中的t1线程的状态,如下图:

上图中的Thread-0线程就是代码中的t1线程,所以可以发现t1线程()依然是在执行执行过程中。

所以上述代码终究还是出现了bug,出现了线程安全问题,具体来说是内存可见性引起的线程安全问题。

我们首先要直到程序在编译运行的时候,java编译器和jvm会对我们写的代码进行优化,即我们的代码在实际执行的时候java编译器和jvm会在原有代码的代码逻辑不变的情况下可能会对我们编写的源代码进行修改以提高代码的执行效率。
编译器优化本质上是依靠代码来对我们写的代码进行智能的判断并进行优化,这个优化在绝大部分的情况都是很好的,都能够保证代码逻辑不变的基础上提高我们代码的执行效率,但是如果是在多线程的情况下编译器优化对我们代码做出的修改很有可能就是错误的(使程序中原有的程序发生改变)。

上述代码中的while判断站在指令的角度其实是有两个指令操作的:load(读取内存)、条件判断(条件成立则代码继续跳转到一个地方继续执行、条件不成立则代码就会跳转到另外一个地方去执行)。
另外寄存器的操作速度是极快的,而都内存操作速度就非常慢了(1次都读内存操作相当于10000次寄存器操作),所以两个操作的时间差值是极大的。
由于寄存器和读取内存之间的速度差异是非常大的,所以编译器就会对代码做出优化:即直接把load(读取内存)的操作给优化掉了,优化掉load之后,只执行第一次load操作,后续将不再执行load操作,而是直接拿寄存器中的数据进行比较判断了。但是代码中是有两个线程的,t2线程对isQuit的值进行了修改,遗憾的是t1线程由于省略了后续的load操作则无法感知到isQuit的值已经被修改了(简单来说就是t1线程无法感知到t2线程的修改),所以就出现了上述的内存可见性问题。

二、volatile关键字

Java中引入了volatile关键字来解决上述代码中的内存可见性问题。

通过volatile来修改一个变量,之后编译器就知道volatile修饰的这个变量是易变的,不再按照编译器优化的方式(即忽略后续的读内存,而直接读取寄存器中的数据),所以就能够保证t1线程在循环过程中始终能够读取到内存中的数据。
代码修改后如下:
此时我们再来看代码的执行结果:
volatile关键字可以保证内存可见性(禁止某一变量的读操作被优化到读寄存器中)

另外,编译器什么时候对我们的代码进行优化有时候的确是挺叫人头疼的,我们如果对上述代码进行稍微改动一下的话就不会触发编译器优化了,如下图:

上述代码中,我们加了sleep让线程t1休眠了10mm之后,此时就会影响到while循环的执行速度,当执行速度变慢之后编译器就不打算对上述代码进行优化了,此时我们不加上volatile修饰isQuit变量的话,线程t1是能够该知道线程t2通过修改isQuit变量引起的内存变化的,所以代码最终运行结果如下(和加上volatile修饰变量的运行结果是一样的):

Java内存模型图(JMM)

  • 注意:上图中的工作内存并不是冯诺依曼体系中的内存,而是cpu的寄存器和cpu的缓存在这里统称为工作内存。
  • 上图中的主内存就是我们通常说的内存。

补充一点:为什么Java官方不把工作内存称为cpu中的寄存器和cpu的缓存呢?这主要是因为Java语言是一个可以跨平台的语言,为了支持不同的操作系统,支持不同的硬件设备(比如CPU)。
CPU的架构是有很多种的,比如X86(intel、amd)、arm(苹果手机的m1芯片m2芯片)、mips。不同的CPU架构之间的差异其实挺大的,有的CPU甚至没有缓存、有的只有一级缓存。因此官方就引入了工作内存这个术语来进行表述,同时也可以通过这个术语来屏蔽硬件方面的相关信息。

综上,编译器优化、Java内存模型、多线程等都可能会引发内存可见性问题。而volatile关键字可以保证内存可见性。

synchronized能否保证内存可见性

关于synchronized能够保证内存可见性的问题是由争议的,Java官方并没有明确给这个问题设定答案,一方面也不能通过代码去很好的验证说明这个问题。
我们对上文的代码进行修改来进行举例,代码如下:

//去掉 flag 的 volatile
//t1线程循环内部加上synchronized关键字进行修饰, 并借助counter对象进行加锁
static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (true) {
            synchronized (counter) {
                if (counter.flag != 0) {
                    break;
               }
           }
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}

运行结果如下:

上述代码并没有触发编译器优化,原因可能是因为加锁操作让t1的while循环变慢了,也可能是因为synchronized保证了内存可见性。但是究竟是哪种原因我们并没有一个很好的方法来进行能判断。
关于这个问题最好持有一个保留意见比较好。

三、wait和notify

我们知道,多线程的调度是随机的,线程与线程之间是抢占式执行的,因此线程之间的执行顺序我们是很难预料到的。但是作为开发者很多时候我们希望多个线程可以按照我们规定的执行顺序去进行执行,以便完成线程之间的相互配合工作。

waitnotify是多线程编程中的用来协调线程执行顺序的重要工具。

  • wait(): 让当前线程进入等待状态。
  • notify(): 唤醒在当前对象上等待的线程。
  • wait, notify都是Object类的方法。


上图中,wait这里有一个异常(InterruptedException)需要我们处理,这里直接抛出异常即可。
wait引起线程阻塞之后可以使用interrupt方法把线程唤醒,即打断当前线程的阻塞状态。

我们通过一段代码进行举例:

wait在执行的时候会做三件事情:

  • 第一步:解锁操作:object.wait会尝试对object对象进行解锁操作。
  • 第二步:线程进入阻塞等待状态。
  • 当被其它线程唤醒之后就会尝试重新加锁,加锁成功之后wait就执行完毕了,然后继续执行其它代码逻辑即可。

在 Java 中,每个对象都有一个监视器锁(monitor)。线程只有在获得了对象的监视器锁后才能执行 wait()、notify()、notifyAll() 等方法。如果线程尝试在未持有该对象的监视器锁时调用这些方法,就会抛出 IllegalMonitorStateException 异常。

代码改正之后如下图:

上图代码之所以什么都没有打印出来是因为代码执行到wait的时候线程出现了阻塞。我们可以通过jconsole.exe程序来看到:
代码中的wait会一直阻塞等待到其它线程进行notify。

使用notify方法唤醒线程

这里我们通过一段代码进行举例(使用notify方法唤醒线程):

public class Demo16 {
    private static Object object = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (object) {
                while(true){
                    System.out.println("t1 wait 开始!!!");
                    try {
                        object.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("t1 wait 结束!!!");
                }
            }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            while(true) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (object) {
                    System.out.println("t2 notify开始!!!");
                    object.notify();
                    System.out.println("t2 notify结束!!!");
                }
            }
        });
        t2.start();
    }
}

运行结果如下:

代码解释:在代码中,有两个线程 t1 和 t2。线程 t1 在获取 object 对象的监视器锁后,调用了 object.wait() 进入等待状态。线程 t2 在执行的过程中每隔一秒就获取 object 对象的监视器锁,然后调用 object.notify() 方法来唤醒等待中的线程。

上述代码注意事项如下:

  • 要想让notify唤醒wait,就需要保证wait和notify使用的是同一个对象调用的。
  • 这一点需要注意:wait和notify都需要放到synchronized之内,即使notify不涉及到解锁操作,但是java强制要求notify必须要放到synchronized内部。(系统中的原生API并没有这样的要求)
  • 如果进行notify操作的时候,另一个线程并没有处于wait状态,此时notify的操作并没有任何的副作用。
  • 倘若在某个场景中有n个线程正在wait,外加一个线程负责notify操作,那么此时调用一次notify只能唤醒一个线程,但是唤醒的线程是随机的。当然这里java提供了另外一个方法:notifyAll,此方法可以唤醒所有处于wait中的线程
  • 如果我们想唤醒某个指定的线程,就可以让不同的线程使用不同的对象来进行wait操作,像唤醒谁就可以使用对应的对象来notify。

四、wait和sleep之间的区别

  • sleep达到一定时间之后就会被唤醒,也可以被interrupt提前唤醒。
  • wait默认下会进行“死等”,直到其它线程对其进行notify唤醒(此唤醒相当于顺理成章的唤醒,唤醒之后继续执行其它任务);而sleep是能够被interrupt提前唤醒的(此唤醒相当于告知该线程要结束了,线程需要进入收尾工作)。
  • 协调多个线程之间的执行顺序,优先考虑使用wait、notify;而不是sleep。
  • wait是用于线程之间通信的,而sleep只是让某一线程阻塞一段时间。
  • wait 需要搭配synchronized;使用sleep不需要。
  • wait是Object的方法;sleep是 Thread静态方法

好了,本文到这里就结束了,希望友友们可以支持一下一键三连哈。嗯,就到这里吧,再见啦!!!

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
乘风的头像乘风管理团队
上一篇 2023年12月19日
下一篇 2023年12月19日

相关推荐