文章目录
这里是@那我掉的头发算什么
刷到我,你的博客算是养成了😁😁😁
前言
在初阶内容中我们提到,线程安全问题有五大条件,分别是:线程在cpu上的调度是随机的,多个线程同时修改同一个变量,修改操作不是原子的,内存可见性,指令重排序。上一篇博客中重点讲了前三个,这次我们来聚焦一下内存可见性问题,指令重排序我们将会在单例模式部分详细讲解。
内存可见性
认识内存可见性
java
package Thread11_16;
import java.util.List;
import java.util.Scanner;
public class demo17 {
public static int flag = 1;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag == 1){
}
System.out.println("t1执行完毕");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag的值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
我们来看上述代码的逻辑:t1线程的主要任务是一个循环,条件是flag的值,t2线程主要作用是可以修改flag的值。因为flag刚开始定义时值为1,所以可以推断:在程序开始时,t1线程陷入死循环,直到t2线程将flag的值修改,t1线程打印结果,线程结束。
但是,实际结果真的是如此吗???

t1线程结束的标志是输出"t1执行完毕",然而不仅这个语句没有被打印,前台线程也没有全部结束,进程仍然在运行。很明显,程序因该是遇到bug了。


通过可视化工具可以得出:首先t2线程结束了,t1线程阻塞在第十行代码。也就是说,t2线程对flag值的修改并没有被t1线程读取到。
原因:
其实,我们平时自己写的程序,他们在编译器里面实际的运行状况如顺序与我们所预期的不一样。因为研究JDK的大佬,他们设计的解释器实际上考虑了对我们所写的编程的优化:在不改变原有的代码的逻辑的前提下,对代码进行调整,使得代码的执行效率变高。
然而,在多线程中,不改变原有的代码逻辑可能会被编译器误判:可能有些优化确实改变了逻辑,但是编译器没有反应过来,产生了错误。

拿这个循环来说,其实从指令级别的角度来说,这个循环由两个指令组成,真实的循环场景应该如下:
while(true){
load
cmp
}
其中,load指令是从内存中读取数据放入寄存器,cmp指令是将寄存器中的值与"1"作比较。我们知道,从内存中读取数据的耗时与寄存器处理数据的耗时的差别很大,甚至能达到几千倍。短时间内,这个循环执行了很多次,JVM在执行过程中感受到:load反复执行的结果是一样的。既然每次读取load的值都是一样的,不如把读内存中的值优化成读寄存器中的值。后续load操作都是读寄存器中的值。但是线程t2在修改时修改的是内存中flag的值,t1却不再重新从内存中读取数据了,所以感知不到flag的改变,因而陷入死循环。
只需要稍稍调整一下原代码:


众所周知,计算机的运算速度是很快的。我们在代码中加入了sleep(1)的语句,虽然只是短短的暂停了一毫秒,对人类来说,甚至感知不到一毫秒的存在。但是对于计算机来说,一毫秒如隔三秋,一毫秒足够计算机执行load+cmp不知道多少遍了。此时优化load对于整个代码来言微不足道,所以不会再改变代码逻辑了。
然而,依靠sleep来解决内存可见性问题太勉强了,使用了sleep会大大降低代码的执行效率,实际场景中,我们可以使用volatile关键字进行解决。
volatile
在 Java 并发编程中,volatile是一个关键字,核心作用是保证共享变量的 "可见性" 和 "有序性",但不保证原子性。
(1)保证可见性
当变量被volatile修饰后:
写操作:线程修改该变量时,会直接把新值同步到主内存,而不是只存在 CPU 缓存里;
读操作:线程读取该变量时,会直接从主内存读取,而不是从自己的 CPU 缓存里读旧值。
对应之前的flag例子:如果flag被volatile修饰,JVM 就不会把 "读 flag" 优化成读寄存器,t1 线程每次都会从主内存读最新的flag值,t2 修改后 t1 能立刻感知到。
(2)禁止指令重排序(保证有序性)
编译器 / CPU 会对普通指令做重排序优化(提升性能),但volatile会禁止这种优化:保证volatile变量的相关指令,执行顺序和代码编写顺序一致。
局限性:不保证原子性
volatile无法保证 "读 - 改 - 写" 这类复合操作的原子性。比如i++(实际是读i→i+1→写回i三步),如果多个线程同时执行i++,即使i是volatile修饰的,也会出现线程安全问题(因为三步操作可能被其他线程打断)。这种场景需要用synchronized、Lock或原子类(如AtomicInteger)。
结合之前的 flag 例子
如果把flag定义为volatile static int flag = 0;,就能解决之前的可见性问题:t2 修改flag后会立刻同步到主内存,t1 每次循环都会从主内存读最新的flag,从而感知到更新并结束循环。


wait-notify
众所周知,操作系统对于线程是随机调度的,但是在实际开发中,我们肯定是希望可以协调线程的执行顺序。我们可以让后执行的线程阻塞等待,等先执行的线程执行好了再通知它,让他继续执行。
我们之前学习到join的作用也是等待另一个线程执行结束再执行。但是这两种执行的逻辑是不一样的。join是等另一个线程完全运行结束了才能运行,但是wait不一样,wait是另一个线程通知他他就可以继续运行,与另一个线程是否运行完没关系。这里的notify的作用就是通知作用。
应用场景
线程饿死:
规定:当一个线程同时拥有cpu资源和锁时这个线程才可以运行。
此时,一个线程拿到了锁,进入cpu运行任务,但是,需要的cpu资源没有被释放,所以没有运行的条件,任务没有执行。完成了临界区任务后,此时线程会把锁释放掉。这一时刻,其他竞争这个锁的线程处于阻塞状态,这个刚刚释放锁的线程处于就绪态,虽然其他线程接下来会进入就绪态,但是慢第一个线程一步。根据线程的随即调度原理,下一个拿到锁的,其实大概率还是这个线程。因此,这一个线程反复拿到锁释放锁,陷入无尽循环,而其他的线程迟迟拿不到锁,任务被耽搁,最终"饿死"。

首先,wait和notify都是Object提供的方法,任何一个对象都可以使用。因为首先wait和notify都需要提前加锁才可以使用,并且只有加了同一个锁对象的前提下,wait和notify才能配合使用。所以我们可以使用锁对象来调用wait和notify。此外,我们在使用wait时需要抛Interrupted异常,也就意味着他跟sleep一样,中断时会被强行唤醒,所以实际应用中一般用while循环二次检测wait(这个后面再讲)。

wait在使用时,首先进行的第一个微操作就是释放锁,这也是为什么他能解决线程饿死的原因:自己阻塞时,将锁释放掉,让其他能够运行的线程拿到锁先运行再说。当阻塞结束后,线程会重新加锁。所以使用wait时需要先加锁(sychronized)。这也是wait和sleep的一大不同,sleep是"抱着锁睡觉",阻塞时不释放锁,所以没办法解决线程饿死问题。
配合使用
java
package Thread11_16;
import java.util.Scanner;
public class demo19 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
System.out.println("wait之前");
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("wait之后");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个数字,通知t1退出阻塞");
int flag = scanner.nextInt();
synchronized (locker){
locker.notify();
}
});
t1.start();
t2.start();
}
}

我们这里的输入操作,其实相当于一个阻塞,因为计算机也不知道用户什么时候会输入,但是用户的输入肯定是要优先处理的操作,所以只好一直等着,也就是阻塞。在此处的用处是,用户一直不输入,后续的通知操作就没法完成,相当于让用户决定什么时候通知t1。
我们上面提到,wait操作涉及先释放锁,阻塞完了再加上锁的内容,所以需要先加锁,才可以执行wait。但是notify并不涉及到释放锁,按理说不需要刻意加锁。但是在java中,给notify加锁是强制要求的。


需要注意的是,此处的锁对象必须一致,才会有阻塞通知的机制,如果不一样,这两个线程将毫无关联。
wait操作必须在notify之前才有用!

如果我们加入一段代码让线程1先阻塞十秒钟,保证我是先notify再wait阻塞。可以看到:

首先,在没有wait的时候我notify,并没有产生任何的异常和报错,并不会有什么副作用。其次,我们的wait在十秒后阻塞时,没法被通知唤醒,一直处于阻塞之中。
多个线程wait阻塞,一次notify时是随即唤醒的。
java
package Thread11_16;
import java.util.Scanner;
public class demo20 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
System.out.println("wait1之前");
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("wait1之后");
});
Thread t2 = new Thread(()->{
System.out.println("wait2之前");
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("wait2之后");
});
Thread t3 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个数字,通知线程退出阻塞");
int flag = scanner.nextInt();
synchronized (locker){
locker.notify();
}
});
t1.start();
t2.start();
t3.start();
}
}


不过在实际业务中,一般这些wait都是干同样工作的,唤醒的先后顺序也没有那么重要。
notifyAll--唤醒所有被wait阻塞的线程。


不过虽然是同时唤醒t1,t2,但是唤醒之后还要重新加锁,所以还会涉及到随机调度问题。
wait可以传入时间参数规定阻塞时间

wait 和 join 类似,都提供两种版本:
"死等" 版本(无超时):如locker.wait(),会一直等待直到被 notify 唤醒;
"超时时间" 版本:如locker.wait(10000),最多等待指定时长(例中 10 秒),超时未被 notify 则自动结束等待。
当 wait 引入超时时间后,和 sleep 直观上很像:
两者都有 "等待时间";
两者都能被提前唤醒(wait 靠 notify,sleep 靠 Interrupt)。
关键差异:
使用前提:wait 必须搭配锁(先通过 synchronized 加锁,才能调用 wait);sleep 无需加锁即可使用。
锁的处理(synchronized 内部使用时):wait 会释放当前持有的锁;sleep 不会释放锁(会 "抱着锁睡",导致其他线程无法获取该锁)。
所以说在实际开发中很少用到sleep,因为sleep阻塞纯纯在浪费时间。
总结
这部分内容围绕 Java 并发中的内存可见性与 wait-notify 机制展开:前者是多线程下共享变量修改后其他线程可能无法感知的问题,源于 JVM 的缓存优化,可通过 volatile 关键字保证变量的主内存读写可见性与有序性,但无法保障原子性;后者是协调线程执行顺序的工具,需搭配同一锁对象并在 synchronized 内使用,wait 会释放锁以解决线程饿死问题,notify 随机唤醒一个 wait 线程、notifyAll 唤醒全部,它无需像 join 那样等线程结束,也区别于 sleep(需锁且释放锁),同时 wait 支持死等和超时等待两种版本。
练习题

java
package Thread11_16;
public class demo21 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Object locker3 = new Object();
Thread a = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (locker1){
try {
locker1.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.print("A");
}
synchronized (locker2){
locker2.notify();
}
}
});
Thread b = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (locker2){
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.print("B");
}
synchronized (locker3){
locker3.notify();
}
}
});
Thread c = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (locker3){
try {
locker3.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("C");
}
synchronized (locker1){
locker1.notify();
}
}
});
a.start();
b.start();
c.start();
Thread.sleep(1000);
synchronized (locker1){
locker1.notify();
}
}
}

A只能唤醒B,B只能唤醒C,C只能唤醒A,构成循环。
线程一开始要先阻塞,防止随机调度导致第一次输出顺序有问题。
循环需要提供动力,在主线程先把线程A唤醒一次。
唤醒之前先加一个sleep是为了防止还没阻塞就先通知了。
另一种思路:
其实也可以先通知再阻塞:
java
package Thread11_16;
public class demo22 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Object locker3 = new Object();
Thread a = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.print("A");
synchronized (locker2){
locker2.notify();
}
synchronized (locker1){
try {
locker1.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
Thread b = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.print("B");
synchronized (locker3){
locker3.notify();
}
synchronized (locker2){
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
Thread c = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.println("C");
synchronized (locker1){
locker1.notify();
}
synchronized (locker3){
try {
locker3.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
a.start();
Thread.sleep(1000);
b.start();
Thread.sleep(1000);
c.start();
}
}
只是这个代码最后执行完了进程不会结束,因为最后c处于阻塞状态,不过我们设置一个sleep时间到时候再唤醒一下c就OK了。
