🌈个人主页: Aileen_0v0
🔥热门专栏: 华为鸿蒙系统学习|计算机网络|数据结构与算法
💫个人格言:“没有罗马,那就自己创造罗马~”
文章目录
内存可见性,引起的线程不安全
具体代码:
import java.util.Scanner;
public class Demo23 {
private static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (count == 0) {
; // 循环体中, 啥都没有.
// System.out.println("t1");
}
System.out.println("t1 执行结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
- t1线程是读取,t2线程是修改,即使它们之间的操作并不相同,但是依旧会产生内存可见性问题
- while循环会执行两条指令
- load指令和cmp指令
- 虽然,上面的代码不能正常使t1退出循环,结束线程的锅确实和多线程有关系,但是另一方面,也是编译器/JVM优化产生的问题。
- 正常情况下,我们需要保证优化操作以后的逻辑是等价的。
- 但是,java的编译器(javac)/JVM在单线程代码中,优化比较靠谱,但是一旦引入多线程,编译器/JVM就没有那么准确了,就像上面的代码一样,JVM无法判断t2线程修改操作何时生效,所以编译器就会对t1线程做出比较激进的优化。
小结:
- 上面问题的本质还是编译器优化引起的,优化掉load操作之后,使得t2线程的修改,未被t1线程感知到,这就是“内存可见性问题”。
- javac:编译器
- java:是jvm的运行环境
如何解决内存可见性问题 -> volatile关键字
- volatile关键字:当编译器不能判定当前时是否需要优化时,程序员就需要通过手动来添加提示,当有了volatile关键字修饰就不会触发优化了(相当于是免死金牌)。
- 通过使用volatile关键字修饰的变量,编译器就能知道,这个变量是"可变的",不能按照上述策略进行优化了
import java.util.Scanner;
public class Demo23 {
private volatile static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (count == 0) {
; // 循环体中, 啥都没有.
//System.out.println("t1");
}
System.out.println("t1 执行结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
- 当我们给变量加上volatile关键字以后,当我们再次输入1就可以暂停t2线程了,就像是裁员一样,告诉老板这个人是大动脉不能裁。
JMM(java memory model 内存模型)
- 产生上面线程安全的问题也可以从 JMM 角度来理解:
- 当 t1 执行的时候,要从工作内存读取 count 的值,而不是从主内存中读取,所以在后续 t2 线程修改 count 时,也是先修改工作内存,同时拷贝到主内存,导致 t1 没有感知到 t2 的修改
- 工作内存区:是由 CPU 寄存器和缓存组成的。
- 主内存(Main Memory):就是内存。
线程的等待通知机制
- 之前学的 join 是等待线程结束
- 等待通知机制,是等待代码中给我们进行显式通知(不一定要结束),更加精细的控制线程之间的执行顺序。
- 在系统内部,线程是抢占式执行的,随机调度,程序员可以通过手段干预。通过“线程等待”方式让线程按照预期的执行顺序去执行。
- 但是,我们无法让某个线程被调度,但是可以主动让某个线程去等待(给别的线程先执行的机会)
- 等待通知可以调整线程之间的执行顺程序,也能解决一些讨厌的问题,比如说:下面的线程饿死。
线程饿死
- “线程饿死”不是死锁,发生线程饿死是因为:某个线程频繁获取释放锁,由于获取的太快,导致其他线程捞不到 CPU 资源,由于系统中的线程调度是无序的,就可能会出现线程饿死的情况。
- **故事时间:**就像是我的舍友有一天拉肚子,我们四个人都想上厕所,结果她一直进进出出占着茅坑,我们就要一直等他上完才能上,这就会导致我们憋死,也就是“线程饿死”,但是也不会一直进进出出,进出个十几次是有可能的
- **线程饿死:**不像死锁那样卡死,可能会卡一会,但是对于程序的执行效率肯定会有影响的。
通过等待通知机制解决线程饿死
- 我们可以通过条件,判定看当前逻辑是否能执行,如果不能执行,就主动 wait(主动进行阻塞),就把执行的机会让给别的线程,避免该线程进行一些无意义的尝试。
- 等到后续时机成熟以后(需要其他线程进行通知),就会让阻塞的线程被唤醒。
- **故事时间:**就像是我和我的舍友们都上厕所,其中一个舍友要大便 ,所以她让我们三个小便的先上,等我们上完再告诉她,让她去上厕所。
代码实现
- wait:是 Object 类提供的方法,任何一个对象,都有这个方法。
public class Demo24 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("等待之前");
object.wait();
System.out.println("等待之后");
}
}
wait 机制的使用
- wait 机制的使用一定要加上锁
- wait 内部不仅仅做的是阻塞等待,还要解锁
- wait 是解锁的同时进行等待,所以在使用 wait 机制时,要先加上锁,才能释放,因此,wait 必须放到 synchronized 内部使用。
- 所以,为了解决上面的报错,我们需要将 wait 的使用放到 synchronized 里面,要先加锁才能解锁。
public class Demo24 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("等待之前");
synchronized (object){
object.wait();
}
System.out.println("等待之后");
}
}
- 现在我们的线程就进入阻塞等待状态,直到有其他线程通过 notify 关键字唤醒它为止。
- 相比之下,sleep 关键字也是阻塞等待,但是和锁无关。
notify:唤醒阻塞线程(他也要搭配锁来使用)
import java.util.Scanner;
public class Demo25 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
System.out.println("t1 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 等待之后");
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
synchronized (locker){
System.out.println("t2 通知之前");
//借助Scanner控制阻塞,用户输入之前,都是阻塞状态
scanner.next();
locker.notify();
System.out.println("t2 通知之后");
}
});
t1.start();
t2.start();
}
}
先执行 notify 还没有 wait(错过了就再也回不来了~)
import java.util.Scanner;
public class Demo25 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
System.out.println("t1 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 等待之后");
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
synchronized (locker){
System.out.println("t2 通知之前");
//借助Scanner控制阻塞,用户输入之前,都是阻塞状态
//scanner.next();
locker.notify();
System.out.println("t2 通知之后");
}
});
t2.start();
Thread.sleep(100);
t1.start();
}
}
- 所以,通过对于多线程代码,修改一点,执行效果就可能完全不同了。
- 所以在执行 notify 之前要先执行 wait 才能保证线程被唤醒,否则就会错过,导致线程永远不会被唤醒。
对于多个等待线程,notify 只能唤醒其中的一个等待线程(而且还是随机的)
public class Demo26 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
System.out.println("t1 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 等待之后");
}
});
Thread t2= new Thread(() -> {
synchronized (locker){
System.out.println("t2 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2 等待之后");
}
});
Thread t3 = new Thread(() -> {
synchronized (locker){
System.out.println("t3 通知之前");
locker.notify();
System.out.println("t3 通知之后");
}
});
t1.start();
t2.start();
Thread.sleep(50);
t3.start();
}
}
- 一个 notify 只能唤醒一个线程
- 上面代码中三个线程都是指向同一个对象 locker,所以 notify 唤醒的时候只能是随机的,但是如果我们想要指定唤醒的线程,可以按照以下方式来进行操作:
指定唤醒:当有多个 locker 对象时,可拿着对应的对象进行唤醒
- 上面我们的 locker 都是同一个对象,所以当我们通过 t3 唤醒线程时就会随机唤醒 t1 或 t2 线程中的其中一个,如果我们想要指定唤醒对象,可按照如下操作:
- 1.创建多个不同的 locker 对象
- 2.不同的线程使用不同的 locker 对象来唤醒 wait
- 3.唤醒时拿着对应的对象去唤醒
public class Demo26 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
System.out.println("t1 等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 等待之后");
}
});
Thread t2= new Thread(() -> {
synchronized (locker2){
System.out.println("t2 等待之前");
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2 等待之后");
}
});
Thread t3 = new Thread(() -> {
synchronized (locker2){
System.out.println("t3 通知之前");
locker2.notify();
//locker.notify();
System.out.println("t3 通知之后");
}
});
t1.start();
t2.start();
Thread.sleep(50);
t3.start();
}
}
- 我们创建 t2 线程的锁对象,然后拿着 t2 线程的锁对象 locker2 去唤醒 t2 等待线程,这就可以指定唤醒锁对象为 t2 了。
唤醒所有等待线程(notifyAll)
locker.notifyAll();
wait 操作支持带超时间版本的等待
- 就是当调用 wait 传入时间以后,如果超出了传入时间还没被 notify 唤醒这个等待线程,那么系统就会自动调用 notify 来唤醒这个等待线程,如下 t1 线程,如果等待 1000ms 还没有线程唤醒它,系统就会自动调用 notify 去唤醒它。
public class Demo26 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
System.out.println("t1 等待之前");
try {
locker.wait(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 等待之后");
}
});
Thread t2= new Thread(() -> {
synchronized (locker2){
System.out.println("t2 等待之前");
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2 等待之后");
}
});
Thread t3 = new Thread(() -> {
synchronized (locker2){
System.out.println("t3 通知之前");
locker2.notify();
System.out.println("t3 通知之后");
}
});
t1.start();
t2.start();
Thread.sleep(50);
t3.start();
}
}
⭐️总结
- volatile:针对变量,而不是一段代码,只要有代码读取这个变量,都不会触发优化(优化:修改代码)到寄存器中。
- JMM:内存模型
- 工作内存区:是由 CPU 寄存器和缓存组成的。
- 主内存(Main Memory):就是内存。
- 阻塞队列的操作: wait/notify 当线程发生阻塞(wait)等其它线程到、通知(notify),通过协调多线程之间的执行顺序,防止线程饿死
- wait:等待线程
- 当队列为空时,继续 take 时就需要 wait 来阻塞
- take 阻塞就通过 put 唤醒
- 当队列为满时,继续 put 时,也需要使用 wait 来进行阻塞
- put 阻塞就通过 take 唤醒
- 需要搭配条件,来决定是否要 wait,我们最好使用 while 替代 if,来判断条件是否是真的符合了,防止 if 判定一次之后就不干了,而当 wait 被唤醒之后条件是否成立还需要进一步判断。
- 当队列为空时,继续 take 时就需要 wait 来阻塞
- notify:唤醒线程
- 它们都是 Object 这个类提供的方法,我们需要通过 Object 创建对象才能调用它们.
- 它们都要搭配锁来使用
- 特别是:wait,wait 先释放锁,进行等待,并且在等待 notify 通知以后被唤醒,要重新尝试获取锁
- wait:等待线程