目录
内存可见性问题
volatile关键字
从JMM的角度来看内存可见性
wait和notify
wait
notify-notifyAll
内存可见性问题
首先运行一段代码,线程t1 用 Mycount.flag 作为标志符,当不为0的时候就跳出循环,线程t2 通过输入来改变 Mycount.flag 标志符,从而控制线程t1 的循环。对于运行结果,我们的预期是:当输入一位不为0的数时,线程t1 应该停止循环。
import java.util.Scanner;
class MyCount{
public int flag = 0;
}
public class ThreadDemo14 {
public static void main(String[] args) {
MyCount myCount = new MyCount();
Thread t1 = new Thread(()->{
while (myCount.flag == 0) {
}
System.out.println("t1循环结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
myCount.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
从上述运行结果和 jconsole查询也可以看出,这与预期结果并不相符,在输入1 后,线程t2 已经执行完了,已经销毁了,线程t1 仍然处在循环中,依然存在。
对于线程t1 中 while (myCount.flag == 0),使用汇编来解析主要分为两步:
1. load:把内存中的flag的值,读取到寄存器中;
2. cmp:把寄存器的值,和 0 进行比较,根据比较结果,决定下一步的执行方向(条件跳转指令)
CPU针对寄存器的操作,要比内存操作快很多;计算机对于内存的操作,要比硬盘快很多
因此在线程t2 真正输入之前,线程t1 循环了很多次,且 load 得到的的结果都是一样的,另一方面,load 操作和 cmp 操作相比,速度慢很多。
由于 load 执行速度太慢(相比于 cmp 来说),再加上反复 load 到的结果都一样,JVM 就做出了一个优化的决策:就是不再重复读取的 load 了,只读取一次。这也是编译器优化的一种方式。这就导致了上述问题的出现,即使线程t2 修改了标志符,但是线程t1 仍然在循环中。
因此,内存可见性问题,可以理解为:一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改。此时线程读取到的值,不一定是修改之后的值。也就是读线程没有感知到变量的变化。
归根结底,也是编译器/ jvm 在多线程环境下优化时产生了误判。
volatile关键字
因此,针对上述问题,我们可以手动干预,对变量添加 volatile 关键字。本质上就是解决编译器优化问题,告诉编译器,这个变量是易变的,因此每次都应该重新读取这个变量的内存内容,也就不再进行激进的优化了。(volatile 只能修饰变量)
此时再去运行程序,也就可以达到预期要求了。
所以说,volatile 也是解决了一种线程安全问题。
局部变量只能在当前的方法里使用,出了方法变量就没了,方法内部的变量在 "栈" 这样的内存空间上。每一个线程都有自己的栈空间。即使是同一个方法,在多个线程中被调用,这里的局部变量也会处在不同的栈空间中,本质上也是不同变量。
从这里我们也可以看出,volatile 是不修饰方法里的变量的,因为方法里的局部变量,只能在当前线程使用,不能多线程之间同时读取或者调用,也就是天然规避了线程安全问题。
一个程序,如果针对同一个变量,在两个线程中,一个读,一个写,就应该考虑 volatile 。
而加上 volatile 之后,效果也可见,牺牲了运行速度,换来了准确率。
从JMM的角度来看内存可见性
Java Memory Model - java内存模型
从JMM 的角度重新表述内存可见性问题:
java程序里,有主内存,每个线程还有自己的工作内存(线程t1 和线程t2 的工作内存不是同一个东西)
t1 线程进行读取的时候,只读取了工作内存的值;
t2 线程进行修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中。
但是由于编译器优化,导致线程t1 没有重新的从主内存同步数据到工作内存,所以读到的结果就是 “修改之前” 的结果。
主内存:这里所说的主内存,就可以等价于我们所说的内存;
工作内存: 也称为工作存储区。工作内存就并非是内存,而是指 CPU 上存储数据的单元。(寄存器,缓存 cache)
缓存 cache
寄存器存储空间小,读写速度块,价格高;
内存存储空间大,读写速度慢,价格便宜;(相比于寄存器来说)
于是,就引出了 cache:存储空间居中,读写速度居中,价格居中;
因此,当CPU 要读取一个内存数据的时候,可能是直接读取内存,也可能是读取 cache,也能是读取寄存器;
工作内存(工作存储区)也一般指:CPU的寄存器 + CPU的 cache
(缓存一般分为L1,L2,L3三级缓存的,L1,L2是在CPU中的,这是对于之前的CPU;现在的CPU也有L3的专属空间了。)
wait和notify
线程最大的问题,就是抢占式执行,随机调度。
但正常情况下,都不喜欢随机性的东西,因此也就有了一些方法,来控制线程之间的执行顺序。虽然线程在内核里的调度是随机的,但是可以通过一些 api 让线程主动阻塞,主动放弃CPU,来给其他线程让路。
例如,有 t1 和 t2 两个线程,希望 t1 先干活,干的差不多了,再让 t2 来运行。这时候就可以让 t2 先 wait(进入阻塞状态,主动放弃CPU),等 t1 干的差不多了,再通过 notify 通知 t2,将 t2 唤醒,让 t2 干活。
在这时候,大家可能就要问了,在这种场景下,join或者sleep不行么?
使用 join ,则必须 t1 彻底执行完,t2 才能运行,但是如果是要求 t1 先干 50% ,就让 t2 干活的话,join 就无能为力了;
使用sleep,要指定一个休眠时间,对于程序运行的时间,是很难估计的,所以也不合适。
因此在这种情况下,wait/notify 是更好的选择。
wait
wait做的事情:1. 使当前执行代码的线程进行等待 . ( 把线程放到等待队列中 )2. 释放当前的锁3. 满足一定条件时被唤醒 , 重新尝试获取这个锁wait 结束等待的条件 :1. 其他线程调用该对象的 notify 方法。2. wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本 , 来指定等待时间 )。3. 其他线程调用该等待线程的 interrupted 方法 , 导致 wait 抛出 InterruptedException 异常 。
当一个线程调用 wait 的时候,就会进入阻塞,此时就处在 WAITING 的状态。
notify-notifyAll
notify 方法是唤醒等待的线程1. 方法 notify() 也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的 其它线程,对其发出通知notify ,并使它们重新获取该对象的对象锁。2. 如果有多个线程等待,则由线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到 ")
针对下面代码进行分析:
public class ThreadDemo16 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(()->{
//这个线程负责进行等待
System.out.println("wait之前");
try {
synchronized (object) {
object.wait();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("wait之后");
});
//这个线程负责进行通知
Thread t2 = new Thread(()->{
System.out.println("notify之前");
synchronized (object) {
//notify 务必要获取到锁,才能进行通知
System.out.println(" t1 在等待,t2正在做任务 ");
object.notify();
}
System.out.println("notify之后");
});
t1.start();
//为了保证t1先执行,也就是为了 先wait再notify,以免notify的时候,object对象没有在wait
//此处写的 sleep 1000 是大概率会让当前 t1 先执行 wait 的
//但是也避免不了有时候的极端情况,可能 t2 先执行 notify
Thread.sleep(1000);
t2.start();
}
}
t1 线程负责执行 wait ,t2 线程负责唤醒 处在WAITING 状态的 t1线程。创建 object 对象后,在线程t1 中对 object 进行加锁,然后进行 wait 操作。此时 t1 线程释放锁,并进入 WAITING状态。线程t2 获取到锁,开始执行,执行完任务后,调用 object.notify 来唤醒 object 对象,此时线程t1 重新获取到锁,继续执行。
因为线程调度的不确定性,不能保证线程t2 notify的时候,t1线程一定处于 WAITING状态,此时就相当于notify空打一炮,就属于是无效通知。所以让线程t1 先 start 后,等待1秒再 start 线程t2。保证t1线程先执行 wait,t2 线程后执行 notify,这样才是有意义的。
notify 方法只是唤醒某一个等待线程 . 使用 notifyAll 方法可以一次唤醒所有的等待线程。虽然是同时唤醒 3 个线程 , 但是这 3 个线程需要竞争锁 . 所以并不是同时执行 , 而仍然是有先有后的执行。
wait notify notifyAll 都是 Object 类的方法
对于wait,有两个版本,带参数和不带参数;
带参数,则是指定了最大等待时间;不带参数就是死等;
wait 带有等待时间的版本,看起来和sleep有一些相似,但实际还是有区别的,虽然都能指定等待时间,也都能被提前唤醒(wait 是使用notify唤醒的,sleep是使用interrupt唤醒的)
但notify唤醒wait,是正常的业务逻辑,不会有任何异常;
interrupt唤醒sleep,则是出异常了;
如果当前有多个线程在等待object对象,此时有一个线程 object.notify() ,此时会随机唤醒一个等待的线程。但其实可以规避这种不确定性的情况,可以使用多组不同的对象,来决定线程之间的执行顺序。
例如有三个线程,希望先执行线程1,在执行线程2,最后执行线程3
这个时候就可以创建 object1,供线程1,2使用;
创建 object2,供线程2,3使用;
线程3:object2.wait() ,等待线程2 执行 object2.notify() 完后唤醒再进行执行;
线程2:object1.wait() ,等待线程1 执行 object1.notify() 完后唤醒再进行执行。执行完自己的任务后 object2.notify() 来唤醒线程3 执行;
线程1:执行自己的任务,执行完后,object1.notify() 来唤醒线程2 执行;
代码演示:
// 有三个线程,分别只能打印 A,B,C 控制三个线程固定按照 ABC 的顺序进行打印
public class ThreadDemo18 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
System.out.println("A");
synchronized (locker1) {
locker1.notify();
}
});
Thread t2 = new Thread(()->{
synchronized (locker1) {
try {
locker1.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("B");
synchronized (locker2) {
locker2.notify();
}
});
Thread t3 = new Thread(()->{
synchronized (locker2) {
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("C");
});
t2.start();
t3.start();
Thread.sleep(100);
t1.start(); // t1 最后执行是为了防止 t1 在 notify 的时候 t2 还没有 wait ,那就进入死等了
}
}