volatile
volatile 这个关键字和内存可见性问题是密切相关的, 这里用一段代码来演示内存可见性问题
import java.util.Scanner;
class MyCounter {
public int flag = 0;
}
public class ThreadDemo15 {
public static void main(String[] args) {
MyCounter counter = new MyCounter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码定义了一个 MyCounter 类, 这个类中有一个 flag 变量, 初始值为 0.
在 main 方法中实例化了 MyCounter 类, 并且创建了两个线程 t1 和 t2.
- 在 t1 线程中有一个 while 循环, 循环条件是 counter.flag == 0
- 在 t2 线程中让用户在控制台中输入一个整数, 在输入完之后将这个整数赋值给 flag
- 这个案例的预期结果为: 当 t2 读取控制台中的整数之后将值赋给 flag 变量, t1 线程中的循环检测 flag 的值不为 0 的时候, t1 结束循环.
但是当运行了这个案例, 输入了一个整数之后, 发现 t1 线程并没有从循环中走出来
这明显与预期结果不符合. 这个情况, 就叫做 内存可见性问题
- 我们可以来分析一下 t1 线程中所进行的操作
在这个循环中, 按照汇编的角度来理解, 主要会进行两步操作
- load, 把内存中的 flag 值读取到寄存器中
- cmp, 把寄存器中的值与 0 进行比较, 根据比较的结果, 决定程序下一步该怎么执行
站在计算机的角度来看这个 while 循环, 当程序启动的时候, 这个循环一秒就能执行 数百万次 以上
在计算机的视角中, load 和 cmp 这两个操作所执行的速度相对比, load 的速度会慢非常多, 由于 load 执行的速度太慢了, 再加上反复 load 多次的结果都是一样的, 这时 JVM 就做出一个大胆的决定: 它判定没人会修改 flag 变量的值, 就不再进行重复 load 的操作了, 干脆只读取一次就好… (这个行为就是编译器优化)
- 由于 JVM 在多线程环境下对于这种情况的判断可能存在误差, 在编译器优化的时候就改变了代码的预期结果, 这就引来了内存可见性问题
而想要实际解决这个问题, 就要程序员手动干预了, 可以给 flag 变量加上 volatile 关键字, 就是在告诉编译器: 这个变量是易变的, 让 JVM 不敢对这个易变的变量进行激进的优化
class MyCounter {
public volatile int flag = 0;
}
再次运行, 结果和预期相符
wait & notify
在多线程环境下, 线程最大的问题就是: 抢占式执行, 随机调度. 所以各个线程之间执行的顺序就是由操作系统来调度的, 但是在实际开发中, 往往需要让程序按照指定的顺序进行运行.
结合之前学习的 join 和 sleep 让线程阻塞或者休眠也能在一定程度上固定线程执行的顺序, 但是在复杂的场景中, 就会显得有些无力…
但是配合上 wait 和 notify, 就能很好的解决上述问题
wait 和 notify 方法都是 Object 类中的方法
wait
wait 方法的作用是进行阻塞
当某个线程调用了 wait 方法的时候, 就会进入阻塞 (WAITING) 状态
public class ThreadDemo16 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
object.wait();
}
}
使用 wait 方法, 要抛出 InterruptedException 异常, 这个异常很多带有阻塞功能的方法都带 (都是可以被 interrupt 方法通过这个异常来唤醒)
wait 方法不带上任何参数, 就是死等, 等待到有其他的线程来将它唤醒
先运行一下这个程序
发现编译器抛出了一个异常 (非法锁状态异常), 为什么会有这个异常呢? 让我们来了解一下 wait 方法是做什么的:
- 先释放锁
- 进行阻塞等待
- 收到通知后, 重新尝试获取锁, 并且在获取到锁之后, 继续往下执行
在了解 wait 要进行的操作之后, 就能发现上述程序中一个很重要的问题: 那就是 “没有锁”
这边都没给 wait 加锁, 还谈何释放锁, 所以编译器就会抛出这样一个异常
所以, 很重要的一点就是:
在 Java 中, wait 需要搭配 synchronized 来使用
public class ThreadDemo16 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("wait 之前");
object.wait();
System.out.println("wait 之后");
}
}
}
可以看到运行结果
- 控制台中打印了 “wait 之前” 之后, 调用了 wait 方法, 使 main 线程进行了阻塞操作, 那么要如何唤醒这个 wait 方法并执行下一步呢?
接下来介绍一下 notify 关键字
notify
notify 关键字用来唤醒被 wait 阻塞的线程. 该方法也必须在 synchronized 代码块中才能使用
public class ThreadDemo17 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
Thread t1 = new Thread(() -> {
// 这个线程负责进行等待
System.out.println("t1: wait 之前");
try {
synchronized (o) {
o.wait();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1: wait 之后");
});
Thread t2 = new Thread(() -> {
System.out.println("t2: notify 之前");
synchronized (o) {
o.notify();
}
System.out.println("t2: notify 之后");
});
t1.start();
Thread.sleep(1000);
t2.start();
}
}
注意:
调用 wait 和 notify 的两个对象必须是同一个对象, 并且两个方法外围包裹的 synchronized 代码块所指定的锁对象也必须相同, 这样 notify 才能正确的唤醒阻塞这个线程的 wait 方法
运行程序查看结果
可以看到, 先调用 wait 使线程阻塞, 在 notify 之后就能唤醒对应的 wait 所阻塞的线程
但是在一个大型项目中, 程序的执行逻辑非常复杂, 在这种背景下, 程序中的 wait 可能会出现没有 notify 来唤醒它的情况, 这就导致这个线程会被一直阻塞在那里, 无法被操作系统进行调度.
为了解决上面的场景, wait 提供了两个版本的方法
方法 | 作用 |
---|---|
wait() | 阻塞当前线程, 并且只能被 notify 方法所唤醒 |
wait(long miles) | 指定了最大等待时间, 阻塞当前线程, 可以被 notify 方法唤醒, 或者阻塞到等待的最大时间之后自动唤醒 |
notifyAll
notify 方法是唤醒一个呗同一个对象所调用的 wait 方法所阻塞的线程, 但是当有多个线程被同一个对象所调用的 wait 方法阻塞的时候, 此时执行 notify 方法就会随机唤醒其中的某一个线程.
此时若想要唤醒所有的被同一对象所阻塞的线程, 就可以使用 notifyAll 方法
当有多个线程被同时唤醒, 这些线程都需要重新去竞争锁
wait 和 sleep 的区别
- 相同点:
都可以让一个线程进入阻塞状态 - 不同点:
- wait 是 Object 类中的方法, join 是 Thread 类的类方法
- wait 需要搭配锁 (synchronized) 来使用, 而 sleep 不需要
- wait 被调用后当前线程进入 BLOCKED 状态并释放锁, 并可以通过 notify 和 notifyAll 方法进行唤醒, 而 sleep 被调用后当前线程进入 TIMED_WAITING 状态, 不涉及锁相关的操作