【JavaEE初阶系列】——带你了解volatile关键字以及wait()和notify()两方法背后的原理

目录

🚩volatile关键字

[🎈volatile 不保证原子性](#🎈volatile 不保证原子性)

[🎈synchronized 也能保证内存可见性](#🎈synchronized 也能保证内存可见性)

🎈Volatile与Synchronized比较

🚩wait和notify

🎈wait()方法

💻wait(参数)方法

🎈notify()方法

🎈解决线程饿死方式

🎈notifyAll()方法


🚩volatile关键字

volatile修饰的变量 能保证内存可见性
在学习Java多线程编程Q里, volatile 关键字 保证内存可见性的要点时,看到网上有些资料是这么说的: 线程修改一个变量,会把这个变量先从主内存读取到工作内存;然后修改工作内存中的值,最后再写回到主内存。
对 内存可见性问题 的表述为: t1 频繁读取主内存(内存),效率比较低,就被优化成直接读自己的工作内存(cpu寄存器);t2 修改了主内存的结果,但由于 t1 没有读主内存,导致修改不能被识别到,最终导致代码出现bug。

计算机运行的程序/代码,经常要访问数据。
这些依赖的数据 往往会存储在内存中去~(定义一个变量,变量就是在内存中)

  • cpu使用这个变量的时候,就会把这个内存中的数据,先读出来,放到cpu的寄存器中。再参与运算(load)
  • cpu读取内存的这个操作,其实非常慢!(快,慢 都是相对的)
  • cpu进行大部分操作,都很快,一旦操作到读/写内存,此时的速度就降下来了。
  • 读内存 >> 读硬盘 快几千倍,上万倍
  • 读寄存器 >> 读内存 快几千倍,上万倍
    **结论:**为了解决上述的问题,提高效率,此时编译器,就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器。减少读内存的次数,也就可以提高整体程序的效率了。

此时我们进行下面的代码段,首先我们默认isQuit是0,t2线程输入isQuit的值,t1线程中如果isQuit一直都是0的话,一直死循环,如果isQuit !=0的时候,我们才判断t1线程结束。

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

此时我输入isQuit是1,然后t1线程理想的结果是跳出循环,然后输出t1线程退出。

但是,当我真正输入1的时候,此时t1线程并没有结束,t1线程正在执行,并且是RUNNABLE状态。很明显,实际效果和预期效果是不一样的,由于多线程引起的,也是线程安全的问题。之前是俩个线程同时修改同一个变量,现在是一个线程读,一个线程修改,也可能出现问题。此处 的问题,就是"内存可见性"情况引起的。

  • 1> load读取内存中的isQuit的值到寄存器中
  • 2>通过cmp指令比较寄存器的值是否是0,决定是否要继续循环

因为读寄存器的速度>>读内存的速度,所以短时间内,就会进行大量的循环,也就是进行大量的load和cmp操作。此时,编译器jvm就发现了,虽然进行了这么多次load但是load出现的结果都是一样的,并且load操作又非常的消耗时间,一次load花的时间相当于上万次的cmp了。所以编译器就做出了优化,只是第一次循环的时候,才读内存,后面都不再读内存了,而是直接从寄存器中,取出isQuit即可。

原本是load读取到内存中到寄存器中,然后cmp指令在寄存器中比较,依次来,但是由于cmp指令速度太快了大于load操作。编译器的初心是好的,它是希望提高程序的效率,但是提高效率的前提是保证逻辑不变。此时由于修改isQuit代码是另一个线程的操作,编译器没有正确的判定,所以编译器以为没人修改isQuit,就做出了上述优化,也就进一步引起了bug了。

后续 t2线程修改isQuit之后,t1感知不到isQuit变量的变化(感知不到内存的变化),所以一直比较,一直死循环。

解决上述 这个问题,volatile就是解决方案,在多线程的环境下,编译器对于是否要进行这样的优化,判定不一定准。就需要程序员通过volatile关键字告诉编译器,你不要优化(优化是算的快,但是算不准)。

这也告诉我们编译器也不是万能的,也会有一些短板的地方,此时就需要程序员进行补充了。只需要给isQuit加volatile关键字修饰,此时编译器自然就会禁止上述优化过程。

此时,程序就可以顺利退出了。


但是还有一种方式,就是让cmp指令比较的速度变慢,让处于休眠状态,这时候,load操作的开销就不大了,优化就没必要了。

public class test1 {
    private static int isQuit=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while (isQuit==0){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1线程退出");
        });

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

但是我们编译器什么时候对其进行优化这是说不清楚的事情,所以用volatile修饰是最靠谱的事情。


🎈volatile 不保证原子性

volatile 和 synchronized 有着本质的区别 . synchronized 能够保证原子性 , volatile 保证的是内存可见性. 代码示例
这个是最初的演示线程安全的代码 .

  • 给 increase 方法去掉 synchronized

  • 给 count 加上 volatile 关键字.

    class Counter{
    volatile public int count = 0;
    void increase() {
    count++;
    }
    }
    public class Test2 {
    public static void main(String[] args) throws InterruptedException {
    Counter counter = new Counter();
    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);
    }
    }

此时的结果依旧不是1w。所以可以证明volatile是不能保证原子性的。


🎈****synchronized 也能保证内存可见性

synchronized 既能保证原子性 , 也能保证内存可见性 . 对上面的代码进行调整:

  • 去掉 isQuit 的 volatile

  • 给 t1 的循环内部加上 synchronized, 并借助object对象加锁.

    public class Test3 {
    private static int isQuit=0;
    public static void main(String[] args) {
    Object object=new Object();
    Thread t1=new Thread(()->{
    while (true){
    synchronized (object){
    if(isQuit!=0){
    break;
    }
    }
    }
    System.out.println("t1线程退出");
    });

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

    }


🎈Volatile与Synchronized比较

  • Volatile是轻量级的synchronized,因为它不会引起上下文的切换和调度,所以Volatile性能更好。
  • Volatile只能修饰变量,synchronized可以修饰方法,静态方法,代码块。
  • Volatile对任意单个变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性。而锁的互斥执行的特性可以确保对整个临界区代码执行具有原子性。
  • 多线程访问volatile不会发生阻塞,而synchronized会发生阻塞。
  • volatile是变量在多线程之间的可见性,synchronize是多线程之间访问资源的同步性。

🚩wait和notify

我们之前学的join方法,它是让一个线程执行完之后,再执行另一个线程,这就是哪个线程调用了join,哪个线程就阻塞。

join控制的是结束的先后顺序,但是理想情况下,是希望在结束前,先后顺序的控制。由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。

就比如在打篮球的时候,球场上每个运动员都是独立的"执行流",可以认为是一个"线程",而完成一个具体的进攻得分动作,则需要多个运动员相互配合,按照一定的顺序执行一定的动作,线程1先"传球",线程2才能"扣球",然后线程1就等待时机。阻塞等待又被唤醒继续执行,这种操作就是再一直的执行程序,而不是先完成一个线程之后第二线程再完成,然后就结束了。

比如,t1和t2两个线程,希望t1先执行,执行的差不多了,在让t2来干.就可以让t2先wait(阻塞,主动放弃cpu),等t1执行的差不多了,再通过notify来通知t2,把t2唤醒,让t2接着干.

  • 使用join,则必须让t1彻底执行完,t2才能运行.如果是希望t1先干50%的活,就让t2开始行动,此时join无能为力.
  • 使用sleep,指定一个休眠的时间.但是t1执行完这些代码,到底花了多少时间,不好估计.
  • 使用wait和notify可以更好的解决上述的问题.

🎈wait()方法

wait进行阻塞, 某个线程调用wait方法,就会进入阻塞(无论是通过哪个对象wait的),此时就处在WAITING状态.

wait,notify和notifyAll这几个类都是Object类的方法 ,所以Java里随便一个对象,都可以有这三种方法.

  • wait() / wait(long timeout): 让当前线程进入等待状态.
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程.

**注意:**wait, notify, notifyAll 都是 Object 类的方法.

注意,wait也需要这个异常,这个异常,很多带有阻塞功能的方法都带.这些方法都是可以被interrupt方法通过这个异常给唤醒的。后续会再阻塞队列中讲到。


此时抛出异常, 非法的监视器状态异常。监视器是synchronized。

我们首先要知道 wait在执行的时候要进行三步骤:

  • 1.释放当前的锁
  • 2.让线程进入阻塞
  • 3.当线程被唤醒的时候,重新获取到锁
    但是首先我们在执行这段代码的时候,我们是释放谁的锁呢?synchronized加锁其实就是把对象头的标记进行操作了, 释放锁的前提是加锁。就比如找工作,你再学校中,学校不让我出去找工作,所以我就不找了,但是前提是你得找到工作了,你才有选择去不去的选择,没有拿到offer之前就想着拒绝去。还比如,一个男生追一个女生,还没追到手都想到了以后和他在一起后孩子的名字都想好了,前提是你得追到手啊,追不到手你取再多名字都不行。
    所以我们要让wait放进synchronized锁里面调用,这样就可以确保wait拿到了锁,你才有释放锁的能力。

    public class wait_notify_test {
    public static void main(String[] args) throws InterruptedException {
    Object object=new Object();
    synchronized (object){
    System.out.println("wait之前");
    // 将wait放到synchronized里面调用,保证确实拿到了这个锁,才能释放锁
    object.wait();
    System.out.println("wait之后");
    }
    }
    }


此时没有报错现象,打印了wait之前代码后,调用wait之后,程序就进入了阻塞状态,因为wait()这种方法无参的是保持死等待的 ,只有等到notify()唤醒才可以执行wait()方法后的程序。

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

💻wait(参数)方法

wait除了默认的无参数版本之外,还有一个带参数的版本。带参数的版本就是指定超时时间,避免wait无休止的等待时间,等到一定的时间,就不会再等待了。

public class notify_wait_test2 {
    public static void main(String[] args) {
        Object object=new Object();
        Thread t1=new Thread(()->{
            synchronized (object){
                System.out.println("wait之前");
                try {
                    object.wait(5000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("wait之后");
            }
        });
        t1.start();
    }
}

🎈notify()方法

我们设计下面的代码,线程t1进行wait(),线程t2进行唤醒wait(),因为wait的唤醒需要其他线程调用该对象的notify方法.首先t2线程睡眠3s,让ti线程阻塞等待一会,之后notify()唤醒了wait(),就开始进行wait()方法后的程序了。

public class notify_wait_test2 {
    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(()->{
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (object){
                System.out.println("进行通知之前");
                object.notify();
            }
        });
        t1.start();
        t2.start();
    }
}

🎈解决线程饿死方式

就拿ATM机来举例子,1号滑稽进去之后,就要取钱,发现ATM里面没钱了,取不了,当1号滑稽释放锁之后,此时其他滑稽开始尝试竞争这个锁,但是刚才的1号滑稽,也能参与竞争这个锁。

所以每次都是1号滑稽进去之后,取不了钱,然后又进去,又取不了钱,又进去,其他线程等待锁,都是阻塞状态,没在cpu上执行,当1号滑稽释放锁之后,这些滑稽想去cpu,还需要有一个系统调度的过程,而1号自身,已经在cpu上执行,没有这个调度的过程了,1号近水楼台先得月,更容易拿到锁得。这就导致了一直是1号滑稽进入ATM机中,循环此处,每次都是取不了钱,但是还是1号滑稽占用了这个线程,这样长此以往就形成了"线程饿死"的状态。


针对上述情况,同样可以使用wait和notify解决,让1号滑稽,在发现没钱的时候,就进行wait(wait内部本身就会释放锁,并且进入阻塞),1号滑稽就不会参与后续的锁竞争了,也把锁释放出来让别人获取。就给其他的滑稽提供了机会了。
wait的过程是等,等待运钞车把钱送过来,运钞车的线程就相当于调用了notify唤醒的线程,这个等的过程,是阻塞的,但是不会占据cpu。


🎈****notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程. 范例:使用notifyAll() 方法唤醒所有等待线程 , 在上面的代码基础上做出修改
调用wait不一定就只有一个线程调用,N个线程都可以调用wait,此时,当有多个线程调用的时候,这些线程都会进入阻塞状态。
唤醒的方式就有2种方法。notifyAll唤醒的时候,wait要涉及到一个重新获取锁的过程,也是需要串行执行的而并不是并行执行。虽然提供了notifyAll,相比之下notify更可控,用的更多一些。


🚩wait 和 sleep 的对比(面试题)

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间。
唯一的相同点就是都可以让线程放弃执行一段时间.
当然为了面试的目的,我们还是总结下:

    1. wait 需要搭配 synchronized 使用. sleep 不需要.
    1. wait 是 Object 的方法 sleep 是 Thread 的静态方法

人拥有可以反复尝试的自由,也拥有停步或者回头的权利。

相关推荐
Kisorge19 分钟前
【C语言】指针数组、数组指针、函数指针、指针函数、函数指针数组、回调函数
c语言·开发语言
路在脚下@1 小时前
spring boot的配置文件属性注入到类的静态属性
java·spring boot·sql
森屿Serien1 小时前
Spring Boot常用注解
java·spring boot·后端
轻口味1 小时前
命名空间与模块化概述
开发语言·前端·javascript
苹果醋32 小时前
React源码02 - 基础知识 React API 一览
java·运维·spring boot·mysql·nginx
晓纪同学2 小时前
QT-简单视觉框架代码
开发语言·qt
威桑2 小时前
Qt SizePolicy详解:minimum 与 minimumExpanding 的区别
开发语言·qt·扩张策略
Hello.Reader2 小时前
深入解析 Apache APISIX
java·apache
飞飞-躺着更舒服2 小时前
【QT】实现电子飞行显示器(简易版)
开发语言·qt
明月看潮生2 小时前
青少年编程与数学 02-004 Go语言Web编程 16课题、并发编程
开发语言·青少年编程·并发编程·编程与数学·goweb