【JavaEE】多线程之线程安全(volatile篇),wait和notify

目录

内存可见性问题

volatile关键字

从JMM的角度来看内存可见性

 wait和notify

wait

notify-notifyAll


内存可见性问题

首先运行一段代码,线程t1 用 Mycount.flag 作为标志符,当不为0的时候就跳出循环,线程t2 通过输入来改变 Mycount.flag 标志符,从而控制线程t1 的循环。对于运行结果,我们的预期是:当输入一位不为0的数时,线程t1 应该停止循环。

import java.util.Scanner;

class MyCount{
    public int flag = 0;
}
public class ThreadDemo14 {
    public static void main(String[] args) {
        MyCount myCount = new MyCount();
        Thread t1 = new Thread(()->{
            while (myCount.flag == 0) {

            }
            System.out.println("t1循环结束");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCount.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 

 从上述运行结果和 jconsole查询也可以看出,这与预期结果并不相符,在输入1 后,线程t2 已经执行完了,已经销毁了,线程t1 仍然处在循环中,依然存在。

对于线程t1 中 while (myCount.flag == 0),使用汇编来解析主要分为两步:

1. load:把内存中的flag的值,读取到寄存器中;

2. cmp:把寄存器的值,和 0 进行比较,根据比较结果,决定下一步的执行方向(条件跳转指令)

CPU针对寄存器的操作,要比内存操作快很多;计算机对于内存的操作,要比硬盘快很多

因此在线程t2 真正输入之前,线程t1 循环了很多次,且 load 得到的的结果都是一样的,另一方面,load 操作和 cmp 操作相比,速度慢很多。

由于 load 执行速度太慢(相比于 cmp 来说),再加上反复 load 到的结果都一样,JVM 就做出了一个优化的决策:就是不再重复读取的 load 了,只读取一次。这也是编译器优化的一种方式。这就导致了上述问题的出现,即使线程t2 修改了标志符,但是线程t1 仍然在循环中。

因此,内存可见性问题,可以理解为:一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改。此时线程读取到的值,不一定是修改之后的值。也就是读线程没有感知到变量的变化。

归根结底,也是编译器/ jvm 在多线程环境下优化时产生了误判。 

volatile关键字

因此,针对上述问题,我们可以手动干预,对变量添加 volatile 关键字。本质上就是解决编译器优化问题,告诉编译器,这个变量是易变的,因此每次都应该重新读取这个变量的内存内容,也就不再进行激进的优化了。(volatile 只能修饰变量)

此时再去运行程序,也就可以达到预期要求了。 

 所以说,volatile 也是解决了一种线程安全问题。

局部变量只能在当前的方法里使用,出了方法变量就没了,方法内部的变量在 "栈" 这样的内存空间上。每一个线程都有自己的栈空间。即使是同一个方法,在多个线程中被调用,这里的局部变量也会处在不同的栈空间中,本质上也是不同变量。

从这里我们也可以看出,volatile 是不修饰方法里的变量的,因为方法里的局部变量,只能在当前线程使用,不能多线程之间同时读取或者调用,也就是天然规避了线程安全问题。

一个程序,如果针对同一个变量,在两个线程中,一个读,一个写,就应该考虑 volatile 。

而加上 volatile 之后,效果也可见,牺牲了运行速度,换来了准确率。 

从JMM的角度来看内存可见性

Java Memory Model - java内存模型

从JMM 的角度重新表述内存可见性问题:

java程序里,有主内存,每个线程还有自己的工作内存(线程t1 和线程t2 的工作内存不是同一个东西)

t1 线程进行读取的时候,只读取了工作内存的值;

t2 线程进行修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中。

但是由于编译器优化,导致线程t1 没有重新的从主内存同步数据到工作内存,所以读到的结果就是 “修改之前” 的结果。

主内存:这里所说的主内存,就可以等价于我们所说的内存;

工作内存: 也称为工作存储区。工作内存就并非是内存,而是指 CPU 上存储数据的单元。(寄存器,缓存 cache)

缓存 cache

寄存器存储空间小,读写速度块,价格高;

内存存储空间大,读写速度慢,价格便宜;(相比于寄存器来说)

于是,就引出了 cache:存储空间居中,读写速度居中,价格居中;

因此,当CPU 要读取一个内存数据的时候,可能是直接读取内存,也可能是读取 cache,也能是读取寄存器;

工作内存(工作存储区)也一般指:CPU的寄存器 + CPU的 cache

(缓存一般分为L1,L2,L3三级缓存的,L1,L2是在CPU中的,这是对于之前的CPU;现在的CPU也有L3的专属空间了。)

 wait和notify

线程最大的问题,就是抢占式执行,随机调度。

但正常情况下,都不喜欢随机性的东西,因此也就有了一些方法,来控制线程之间的执行顺序。虽然线程在内核里的调度是随机的,但是可以通过一些 api 让线程主动阻塞,主动放弃CPU,来给其他线程让路。

例如,有 t1 和 t2 两个线程,希望 t1 先干活,干的差不多了,再让 t2 来运行。这时候就可以让 t2 先 wait(进入阻塞状态,主动放弃CPU),等 t1 干的差不多了,再通过 notify 通知 t2,将 t2 唤醒,让 t2 干活。

在这时候,大家可能就要问了,在这种场景下,join或者sleep不行么?

使用 join ,则必须 t1 彻底执行完,t2 才能运行,但是如果是要求 t1 先干 50% ,就让 t2 干活的话,join 就无能为力了;

使用sleep,要指定一个休眠时间,对于程序运行的时间,是很难估计的,所以也不合适。

因此在这种情况下,wait/notify 是更好的选择。 

wait

wait做的事情:
  1. 使当前执行代码的线程进行等待 . ( 把线程放到等待队列中 )
  2. 释放当前的锁
  3. 满足一定条件时被唤醒 , 重新尝试获取这个锁
wait 结束等待的条件 :
  1.  其他线程调用该对象的 notify 方法。
  2. wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本 , 来指定等待时间 )。
  3. 其他线程调用该等待线程的 interrupted 方法 , 导致 wait 抛出 InterruptedException 异常 。 

当一个线程调用 wait 的时候,就会进入阻塞,此时就处在 WAITING 的状态。 

wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
因为 wait 操作是释放锁,然后进入阻塞等待,在收到notify通知后,重新获取锁,继续执行。所以说,得先有锁,才可以进行接下来的释放锁操作。
当一个线程 wait 后,这个线程会释放锁,然后进入 WAITING 状态,此时其他线程是可以获取到释放的锁对象的。
这样在执行到  wait  之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个唤醒的方法notify()

notify-notifyAll

notify 方法是唤醒等待的线程
1. 方法 notify() 也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的 其它线程,对其发出通知notify ,并使它们重新获取该对象的对象锁。
2. 如果有多个线程等待,则由线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到 ")
wait 和 notify 的使用对象,应该是相同的,notify 只能唤醒在同一个对象上等待的线程。

针对下面代码进行分析: 

public class ThreadDemo16 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t1 = new Thread(()->{
            //这个线程负责进行等待
            System.out.println("wait之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("wait之后");
        });
        //这个线程负责进行通知
        Thread t2 = new Thread(()->{
            System.out.println("notify之前");
            synchronized (object) {
                //notify 务必要获取到锁,才能进行通知
                System.out.println(" t1 在等待,t2正在做任务 ");
                object.notify();
            }
            System.out.println("notify之后");
        });

        t1.start();
        //为了保证t1先执行,也就是为了 先wait再notify,以免notify的时候,object对象没有在wait
        //此处写的 sleep 1000 是大概率会让当前 t1 先执行 wait 的
        //但是也避免不了有时候的极端情况,可能 t2 先执行 notify
        Thread.sleep(1000);
        t2.start();
    }
}

t1 线程负责执行 wait ,t2 线程负责唤醒 处在WAITING 状态的 t1线程。创建 object 对象后,在线程t1 中对 object 进行加锁,然后进行 wait 操作。此时 t1 线程释放锁,并进入 WAITING状态。线程t2 获取到锁,开始执行,执行完任务后,调用 object.notify 来唤醒 object 对象,此时线程t1 重新获取到锁,继续执行。

因为线程调度的不确定性,不能保证线程t2 notify的时候,t1线程一定处于 WAITING状态,此时就相当于notify空打一炮,就属于是无效通知。所以让线程t1 先 start 后,等待1秒再 start 线程t2。保证t1线程先执行 wait,t2 线程后执行 notify,这样才是有意义的。

notify 方法只是唤醒某一个等待线程 . 使用  notifyAll  方法可以一次唤醒所有的等待线程。
虽然是同时唤醒 3 个线程 , 但是这 3 个线程需要竞争锁 . 所以并不是同时执行 , 而仍然是有先有后的执行。

wait notify notifyAll 都是 Object 类的方法  

对于wait,有两个版本,带参数和不带参数;

带参数,则是指定了最大等待时间;不带参数就是死等;

 wait 带有等待时间的版本,看起来和sleep有一些相似,但实际还是有区别的,虽然都能指定等待时间,也都能被提前唤醒(wait 是使用notify唤醒的,sleep是使用interrupt唤醒的)

但notify唤醒wait,是正常的业务逻辑,不会有任何异常;

interrupt唤醒sleep,则是出异常了;

如果当前有多个线程在等待object对象,此时有一个线程 object.notify() ,此时会随机唤醒一个等待的线程。但其实可以规避这种不确定性的情况,可以使用多组不同的对象,来决定线程之间的执行顺序。

例如有三个线程,希望先执行线程1,在执行线程2,最后执行线程3 

这个时候就可以创建 object1,供线程1,2使用;

创建 object2,供线程2,3使用;

线程3:object2.wait() ,等待线程2 执行 object2.notify() 完后唤醒再进行执行;

线程2:object1.wait() ,等待线程1 执行 object1.notify() 完后唤醒再进行执行。执行完自己的任务后 object2.notify() 来唤醒线程3 执行;

线程1:执行自己的任务,执行完后,object1.notify() 来唤醒线程2 执行;

代码演示: 

// 有三个线程,分别只能打印 A,B,C 控制三个线程固定按照 ABC 的顺序进行打印
public class ThreadDemo18 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(()->{
            System.out.println("A");
            synchronized (locker1) {
                locker1.notify();
            }
        });

        Thread t2 = new Thread(()->{
            synchronized (locker1) {
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("B");
            synchronized (locker2) {
                locker2.notify();
            }
        });

        Thread t3 = new Thread(()->{
            synchronized (locker2) {
                try {
                    locker2.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("C");
        });

        t2.start();
        t3.start();
        Thread.sleep(100);
        t1.start();                     // t1 最后执行是为了防止 t1 在 notify 的时候 t2 还没有 wait ,那就进入死等了

    }
}

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

PlLI-

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

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

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

打赏作者

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

抵扣说明:

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

余额充值