JavaEE中volatile、wait和notify详解
Node_Hao 人气:0一.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 = 0; } public class ThreadDemo2 { public static void main(String[] args) { MyCounter myCounter = new MyCounter(); Thread t1 = new Thread(()->{ while (myCounter.flag == 0){ //循环重复快速读取 } System.out.println("循环结束"); }); Thread t2 = new Thread(()->{ Scanner scanner = new Scanner(System.in); System.out.println("请输入一个数"); myCounter.flag = scanner.nextInt(); }); t1.start(); t2.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 = 0; }
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 t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { counter.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { counter.add(); } }); t1.start(); t2.start(); try { t1.join(); t2.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 ThreadDemo3 { public static void main(String[] args) throws InterruptedException { Object object = new Object(); Thread t1 = new Thread(() -> { //这个线程负责进行等待 System.out.println("t1: wait 之前"); try { synchronized (object) { object.wait(); } } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("t1: wait 之后"); }); Thread t2 = new Thread(() -> { System.out.println("t2: notify 之前"); //notify务必获取锁才能通知 synchronized (object) { object.notify(); } System.out.println("t2: notify 之后"); }); t1.start(); //此时让 wait 先执行,防止 notify 空打一炮. Thread.sleep(100); t2.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 唤醒需要报异常.
总结
加载全部内容