目录
- 一.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 唤醒需要报异常.