内存可见性
我们在最开始讲到线程安全的时候,聊到了关于线程安全问题总共有五种原因,前面我们讲到了三种,还要两种没有涉及到,那么就来聊聊内存可见性引起的线程安全问题。
内存可见性问题指的是在一个线程修改了共享变量的值之后,其他线程是否能够立即看到(即“看到”最新值)这个修改。如果不能,就可能出现内存可见性问题。
import java.util.*;
public class Demo {
public static int flag=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while(flag == 0) {//等待t1线程输入flag的值,只要不为0就能结束
System.out.println("t1线程结束");
}
});
Thread t2=new Thread(()->{
System.out.println("请输入flag的值");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
从之前的内容可知两个线程都写的情况会造成线程安全问题,那么这段代码有一个线程在写,一个线程在读会不会造成线程安全问题?
答案是会的,内存可见性会导致该问题
那么两个线程都进行读会造成线程安全问题?这里的答案是不会。
这段代码想要表现出来的效果是,t1,t2线程同时运行,通过t2线程中输入的flag的值来控制t1线程是否结束。
可是上文我们先后输入了1,0,2......都没能使t1线程结束,这是为什么呢?
我们看while(flag == 0){};这条语句其实有两个指令
①load
cpu从内存中读取flag的值(load)到cpu的寄存器上②访问寄存器
cpu访问寄存器中存储的flag的值,与0进行比较
①中load的操作(读内存),相较于②中访问寄存器的操作,开销要大很多。(访问寄存器的速度是读内存的一万倍)上述while循环中①②这两条指令整体看,执行的速度非常快,等你scanner几秒钟了,我while循环中①②可能都执行几亿次了(cpu的计算能力非常强)
此时JVM就会怀疑,这个①号load 的操作是否还有存在的必要(节省开销),于是经过load试探很多次发现都是一样的,JVM索性就认为load 的值一直都一样(速度太快了,等不到我们scanner输入flag的值),在load一次后,寄存器保存了它的值,然后把load这个操作给优化掉,只留一个访问寄存器的操作指令,访问之前寄存器中保存的值,大大提高循环的执行速度。这就是内存可见化问题会出现的本质原因。
这是第二种理解方法,就是t2先对cpu进行修改,再写会内存,但是t1读取的时候,是直接从内存中加载到CPU中,这时候,t2对内存的修改还没来的及,所以就无法产生影响,这是另一种理解
那么怎么解决该问题呢?我们就用volatile关键词修饰变量。
volatile关键词
对于JVM的优化,都适用于单线程,但不适用于多线程,可能会出现bug。而volatile关键字,是强制性关闭JVM优化,开销是变大了,但是数据更准了。
volatile都是用来修饰变量的
功能①:解决内存可见性问题,每次访问被volatile修饰的变量都要读取内存,而不是优化到寄存器或者缓存器当中
功能②:禁止指令重排序,对于被volatile修饰的变量的操作指令,是不能被重排序的(这个等会会讲)
对于线程指令是否会发生JVM的优化,我们程序员也很难判定是否发生了,所以更需要通过volatile去避免这种可能存在的问题。
指令重排序
指令重排序也是一种在编译器发生的优化过程,它改变了程序原有的指令执行顺序,使程序变得更好。
这在单线程是没问题的,但是在多线程可能会导致bug,所以在多线程中我们需要解决该问题,就要用到volatile修饰重排序操作指令涉及的变量,这样就没问题了。
举个例子,比如要创建一个对象(String s=new String()),可与分为三步:
(1)为s分配一份空间出来创建对象;
(2)初始化这片空间;
(3)使s指向这片空间
在这三步中,第2、3步有可能会发生指令重排现象,创建对象的顺序变为1-3-2(后面单例模式细讲);这个过程就相当于,你买一块房子,你可以选择买精装房也可以买毛坯房(先装修后买还是先买后装修的问题)
wait和notify
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.
完成这个协调工作, 主要涉及到三个方法 wait() / wait(long timeout): 让当前线程进入等待状态. notify() / notifyAll(): 唤醒在当前对象上等待的线程.
wait和notify都是Object提供的方法,所以说任何类都能使用wait和notify
wait和notift是为了解决‘’线程饿死‘’的情况
举个例子,在ATM机上进行取钱,第一位滑稽老铁,去取钱,但是ATM机器中的钱不够,所以他走了,但是他走了以后,又有可能会回来想再试试有没有可能是刚刚操作失误了,这个往复进行,后面的几位滑稽老铁就无法取钱了,这是线程饿死的形象表述
wait ()
wait 做的事情:
1.使当前执行代码的线程进行等待. (把线程放到等待队列中)
2.释放当前的锁(释放后就可以允许其他线程用该锁了)(所以说要使用wait必须先放在synchronized中,有锁后才能释放锁)
3.满足一定条件时被唤醒, 重新尝试获取这个锁
wait 结束等待的条件:
1.其他线程调用该对象的 notify 方法.
2.wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
3.其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
所以说wait是先进行解锁,然后再进行等待,但是要注意的是,这俩个步骤是打包原子的(这个在wait内部已经实现);我们想想,如果不是打包原子的,会有什么问题?
如果不是打包原子的,那么就会先解锁,但是这个线程还没有开始等待会继续执行,但是同时其他的锁线程也会开始竞争 ;如果其他线程在这个时候执行了释放锁操作,但是一号线程还没有及时开始等待,那么一号线程就会错过这个唤醒信号,就会继续等待下去
也就是说,wait是为了提前唤醒,而sleep是为了设定固定时间进行等待,不涉及唤醒。
虽然interrupt也可以唤醒,但是interrupt本质上其实是结束该线程了
wait 需要搭配 synchronized 使用, sleep 不需要.
wait 是 Object 的方法 ,sleep 是 Thread 的静态方法
notify()
notify 方法是唤醒等待的线程.
方法notify()也要在synchronized中调用,该方法是用来通知那些可能等待该对象的对象锁的 其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
如果notify和wait要联动,必须要求notify的调用对象,notify的锁对象,wait的调用对象,wait的锁对象都必须相同。
如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出synchronized之后才会释放对象锁
notifyall()
notify方法只是随机唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.
notifyAll() 比 notify() 更安全,因为它不会随机选择一个线程唤醒,而是让所有线程都有机会重新竞争锁,从而避免了某些线程被永久忽略的问题。所以在大多数场景中,推荐使用 notifyAll()
注意:虽然是同时唤醒所有线程, 但是这些线程需要竞争锁. 所以并不是同时执行, 而仍然是有先有后的依次执行.