JavaEE中volatile、wait和notify详解

Java
224
0
0
2023-07-31
目录
  • 一.volatile 关键字. 
  • 1.volatile 能保证内存可见性问题
  • 2.volatile 不能保证原子性 
  • 二.wait和notify
  • 1.wait方法
  • 2.notify方法
  • 3.wait和sleep的对比
  • 总结 

一.volatile 关键字. 

1.volatile 能保证内存可见性问题

什么是内存可见性?

可见性指 , 一个线程对内存的修改 , 能够及时的被其他线程看到.

Java内存模型(JMM):Java虚拟机规范中定义了Java内存模型 , 目的是屏蔽一切硬件和操作系统的内存访问差异 , 以实现Java程序在各种平台下都能达到一致的并发效果.

  • 线程之间的共享变量存在主内存(Main Memory)
  • 每一个线程都有自己的"工作内存"(寄存器)
  • 当线程要读取一个共享变量时 , 会把共享变量从主内存拷贝到工作内存, 再从工作内存中读取数据.
  • 当线程要修改共享变量时 , 也先修改工作内存中的副本 , 最后同步到主内存中.

由于每个线程都有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的副本 , 此时修改线程 t1 的工作内存中的值 , 线程 t2 的工作内存不一定及时发生变化.这时代码就容易发生问题.

此时引出两个问题:

  • 为什么要这么多内存
  • 为什么要拷贝多次

1) 为什么要这么多内存?

实际并没有这么多的内存 , 这只是Java规范中的一个术语 , 是术语抽象的叫法.

所谓主内存才是真正硬件角度的内存 , 而所谓工作内存 , 则是指CPU的寄存器和高速缓存(cache).至于为什么起名工作内存 , 一方面是为了表述简单 , 另一方面也是避免涉及到硬件的细节和差异 , 例如有的CPU可能没有cache , 有的还存在很多个 , 因此Java就使用工作内存一言蔽之了.

2) 为什么要多次拷贝?

因为CPU访问寄存器和高速缓存的速度 , 比访问寄存器快了3-4个数量级.

如果要连续10次读取同一个数据 , 不断从内存中访问就很慢 , 那么如果第一次从内存中读取到寄存器 , 后面9次从寄存器中读取就会快很多.

  • volatile 修饰的变量 , 能够保证内存可见性.

代码示例:

创建两个线程 t1 和 t2 , t1 线程循环重复快速读取flag , t2 线程对 flag 进行修改.按照预期结构 , 如果我们修改 t2 线程中的 flag 变为非0 , t1 线程就会循环结束.

class MyCounter{
    public int flag =;
}
public class ThreadDemo {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t = new Thread(()->{
            while (myCounter.flag ==){
                //循环重复快速读取
            }
            System.out.println("循环结束");
        });
        Thread t = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个数");
            myCounter.flag = scanner.nextInt();
        });
        t.start();
        t.start();
    }
}

结果与我们预期并不相符 , 对 flag 作出修改后 , t1 线程并没有循环结束. 

通过 jconsole 查看 t1 线程还在执行 , 而 t2 线程已执行完毕. 

结合内存可见性问题 , 答案显而易见. 一个线程读 , 一个线程改 , 会产生线程不安全问题.从汇编的角度来理解 , 执行下面这段代码分为两个步骤:

  • load 把内存中的值读到寄存器中.
  • cmp 把寄存器的值和0进行比较 , 根据比较结果决定下一步往哪执行(条件循环指令)

上述循环操作在寄存器中 , 执行速度极快(1秒钟执行百万次以上) , 循环这么多次 , 在 t2 真正修改前 , load 得到的执行结果都一样.另一方面 load 相比于 cmp 操作速度慢非常多 , 再加上反复 load 的结果都一样 , JVM 就会认为没有人改 flag 的值 , 从此不再从内存中 load flag 的值 , 直接读取寄存器中保存的 flag , 这时JVM/编译器的一种优化方式 , 但由于多线程的复杂性 , 判定可能存在误差.

解决方式:

此时为了避免上述情况 , 就需要程序员手动干预 , 可以给 flag 这个变量加上 volatile 关键字.意思是告诉编译器这个变量是"易变" , 一定要每次都从内存中重新 load 这个变量 , 不能再进行激进的优化了.

class MyCounter{
    public volatile int flag =;
}

2.volatile 不能保证原子性 

volatile 与 synchronized 有本质的区别 , synchronized 保证原子性 , volatile 保证的是内存可见性.

代码示例:

这是最初演示线程安全的代码 , 两个线程分别对 count 自增5万次.

  • 去掉修饰 add 方法的 synchronized 关键字.
  • 给 count 变量加上 volatile 关键字. 

最终代码执行结果并不是预期的10w次. 

class Counter{
    public volatile int count;
    public void add(){
            count++;
    }
}
public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t = new Thread(()->{
            for (int i =; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t = new Thread(()->{
            for (int i =; i < 50000; i++) {
                counter.add();
            }
        });
        t.start();
        t.start();
        try {
            t.join();
            t.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("count = "+counter.count);
    }

二.wait和notify

由于线程的特性是抢占式执行随机调度 , 因此线程之间的先后执行顺序难易预知 , 但实际开发中我们希望合理的协调多个线程之间的先后执行顺序.

完成这个协调工作主要涉及三个方法:

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

Tips:wait(),notify(),notifyAll()都是Object类的方法.

通过上述介绍可以发现 , wait 和 notify 与 join 和 sleep 在功能上有极大的重合之处 , 那么为什么还要开发 wait 和 notify 呢?

因为 , 使用 join 就必须等待一个线程彻底执行完才能换另一个线程. 如果我们想让线程1执行50% , 然后立即执行线程2 , 显然 join 达不到这个效果. 而且使用 sleep 必须指定休眠多长时间 , 但线程1执行完毕需要花费多少时间并不好估计.所以使用 wait 和 notify 可以更好的解决上述问题.

1.wait方法

wait做的事情:

  • 先释放锁
  • 进行阻塞等待
  • 收到通知后 , 重新尝试获取获取这个锁 , 并且在获取这个锁后 , 继续往下执行.

代码示例:

 public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        object.wait();
    }

运行该代码出现异常 , 这是因为执行 wait 操作 , 需先获取当前线程的锁 , 而当前线程并没加锁 , 所以会出现非法锁状态异常.这就好比 , 我的一个朋友还没收到offer就已经开始挑选公司.

修改后代码:

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

通过运行结果可以得知 , 代码执行到object.wait()就进入阻塞.实际上在阻塞状态之前 , wait 已经释放了锁 , 此时其他线程可以获取到object对象的锁 , 等到 wait 被唤醒后再尝试获取这个锁.

举个例子就是滑稽老铁去ATM机取钱 , 当他进入银行网点后锁上门开始操作ATM机 , 结果发现ATM机没钱 , 由于银行外还有排队等待办理其他业务的人 , 他只能打开锁后出去(相当于 wait 释放锁的操作) , 等待运钞车来存钱(相当于 wait 的阻塞等待) , 当运钞车把钱存进银行 , 站在外面排队等待的滑稽老铁 , 又要和其他竞争进入银行的机会.(重新尝试获取这个锁) , 进入银行后执行取钱操作(重新加锁后继续执行其他操作).

wait结束等待的条件

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时.(wait 有一个带参方法 , 可以指定等待时间)
  • 其他线程调用该等待线程的 Interrupted 方法 , 导致 wait 抛出InterruptException异常.

2.notify方法

notify 方法是唤醒等待的线程.

  • notifty 方法同样需要在加锁的方法和加锁的代码块中调用 , 该方法是用来唤醒那些因调用 wait方法而阻塞等待的线程 , 通知它们重新获取对象锁.
  • 如果有多个线程调用同一对象处于等待 , 则由线程调度器 , 随机挑选一个呈 wait 状态的线程唤醒.
  • 在 notify 方法执行完毕后 , 当前线程不会立即释放该对象锁 , 要等待执行 notify 方法的线程彻底退出加锁代码块后才会释放锁对象.

代码示例:

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        Thread t = new Thread(() -> {
            //这个线程负责进行等待
            System.out.println("t: wait 之前");
            try {
                synchronized (object) {
                    object.wait();
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("t: wait 之后");
        });
        Thread t = new Thread(() -> {
            System.out.println("t: notify 之前");
            //notify务必获取锁才能通知
            synchronized (object) {
                object.notify();
            }
            System.out.println("t: notify 之后");
        });
        t.start();
//此时让 wait 先执行,防止 notify 空打一炮.
        Thread.sleep();
        t.start();
    }
}

观察代码执行结果明显符合预期. 

为什么 notify 方法也要在同步方法或同步代码块中?

同步方法或同步代码块指的是 , 加锁的方法或加锁的代码块.

代码示例:

假设我们要实现一个阻塞队列 , 如果不加同步代码块实现方法如下:

class BlockingQueen{
    Queue<String> queue = new LinkedList<>();
    Object lock = new Object();
    public void add(String data){
        queue.add(data);
        lock.notify();
    }
    public String take() throws InterruptedException {
        while (queue.isEmpty()){
            lock.wait();
        }
        //返回队列的头结点
        return queue.remove();
    }
}

这段代码的核心思想是 , 当队列为空时使用lock.wait()阻塞 , 如果调用add()方法添加元素时再采用lock.notify()唤醒.这段代码可能产生以下问题:

  • 一个消费者调用 take() 方法获取数据 , 但queue.isEmpty() , 于是反馈给生产者.
  • 在消费者调用 wait 之前 , 由于CPU的调度 , 消费者线程被挂起 , 生产者调用add() , 然后notify().
  • 之后消费者调用wait().由于错误的条件判断导致 wait 调用在 notify 之后.
  • 在这种情况下 , 消费者就会一直被挂起 , 生产者也不再生产 , 这个阻塞队列就有问题.

由此看来 , 在调用 wait 和 notify 这种会挂起的操作时 , 需要一种同步机制保证

3.wait和sleep的对比

理论上 wait 和 sleep 没有可比性 , 因为 wait 常用于线程间通信 , sleep 则是让线程阻塞一段时间 , 唯一的相同点是都可以让线程放弃执行一段时间.

  • 1.wait 需要搭配 synchronized 关键字使用 , 而sleep则不需要.
  • 2.wait 是object 方法 , sleep则是Thread类的静态方法.
  • 3.wait 被notify 唤醒属于正常的业务范畴 , sleep 被Interrupt 唤醒需要报异常.