问题:退不出的循环
先看一个现象,main线程对run变量的修改对于t线程不可见,导致了t线程无法停止
static boolean run=true;
public static void main(String[] args) throws InterruptedException{
Thread t=new Thread(()->{
while(run){
//...
}
});
t.start();
sleep(1);
run=false; //主线程中将run的值改为false,线程t中的while循环会不会如预想的停止???
}
答案是:不会,主线程中将run的值改为false,线程t中的while循环不会如预想的停止。
为什么呢?分析:
1)初始状态,t线程刚开始从主内存中读取了run的值到工作内存中。
2)因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存到自己的工作内存中的高速缓存中,减少对主存中run的访问,提高效率。
3)1秒之后,main线程改变了run的值,并同步到主内存中,而t线程是从自己工作内存中的高速缓存中读取的run的值,结果永远是旧的值。
解决方法--volatile
volatile(易变关键字):它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存查找变量的值,必须从主存中读取变量的值。
上述例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况,上例从字节码理解是这样的:
注意:synchronized语句块可以保证代码块的原子性和可见性,但是缺点是synchronized是属于重量级操作,性能相对比较低。
以上是说到了volatile保证了共享变量的可见性,但是volatile还有一个重要作用就是保证有序性,那么有序性到底是什么???
有序性
JVM会在不影响代码结果的正确性的前提下,可以调整语句的执行顺序,思考以下一段代码:
static int i;
static int j;
//在某个线程内执行如下赋值操作
i=...;
j=...;
可以看到,无论是先执行i还是先执行j,对最终结果不会产生影响。所以,上面的代码在真正执行的时候既可以是
i=...;
j=...;
也可以是
j=...;
i=...;
这种特性称之为指令重排,多线程下的指令重排会影响正确性。但是为什么会有指令重排这种现象??在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在80-90年代占据了计算机架构的重要地位。
指令重排之诡异的结果
int num=0;
boolean ready=false;
//线程1执行此方法
public void actor1(I_Result r){
if(ready){
r.r1=num+num;
}else{
r.r1=1;
}
}
//线程2执行此方法
public void actor2(I_Result r){
num=2;
ready=true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
情况1:线程1先执行,这个时候ready=false,所以进入else分支,结果为1
情况2:线程2先执行num=2,但是还没来得及执行ready=true,线程1执行,还是进入else分支,结果为1
情况3:线程2执行到ready=true,线程1执行,这回进入id分支,结果为4(因为num=2已经执行过了)
但是还有一种情况结果为0!!!!
这种情况下是:线程2先执行ready=true,切换到线程1,进入if分支,相加为0,再切换为线程2执行num=2!!!这个时候是线程2在执行actor2()方法中的两条语句时发生了指令重排。
那么这种情况的解决方法还是volatile,volatile修饰的变量,可以禁止指令重排!!!!
volatile原理
volatile的底层实现原理是内存屏障:
- 对volatile变量的写指令后会加入写屏障
- 对volatile变量的读指令前会加入读屏障
如何保证可见性
写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r){
num=2;
ready=true; //ready是volatile赋值带写屏障
//写屏障
}
而读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r){
//读屏障
//ready是volatile读取值带读屏障
if(ready){
r.r1=num+num;
}else{
r.r1=1;
}
}
如何保证有序性
- 写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排时,不会将读屏障之后的代码排在读平展之前
还是那句话,不能解决指令交错
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面
- 而有序性的保证也只是保证了本线程内相关的代码不被重排
double-checked locking(双重检验锁)问题
以著名的double-checked locking单例模式为例
public final class Singleton{
private Singleton(){}
private static Singleton INSTANCE=null;
public static Singleton getInstance(){
if(INSTANCE==null){
// 首次访问会同步,而之后的使用没有synchronized
synchronized(Singleton.class){
if(INSTANCE==null){
INSTANCE=new Singleton();
}
}
}
}
}
以上实现的特点是:
- 懒惰实例化
- 首次使用synchronized()才使用synchronized加锁,后续使用时无需加锁
- 有隐含的,但是很关键的一点:第一个if使用了INSTANCE变量,是在同步块之外的
在多线程环境下,上面的代码是有问题的,getInstance()方法中代码对应的字节码为:
其中
- 17表示创建对象,将对象引入栈 //new Singleton
- 20表示复制一份对象引用 //引用地址
- 21表示利用一个对象引用,调用构造方法
- 24表示利用一个对象引用,赋值给static INSTANCE
也许jvm会优化为:先执行24,再执行21。如果两个线程t1,t2按如下时间序列执行
关键在于0:getstatic这行代码在monitor控制之外,这个时候t1还没有完全将构造方法执行完毕,如果在构造方法中执行要很多初始化操作,那么t2拿到的将是一个未初始化完毕的单例。
对INSTANCE使用volatile修饰即可,可以禁用指令重排,但是要注意在JDK 5以上的版本的volatile才会真正有效。
double-checked locking解决
对INSTANCE使用volatile修饰即可:
public final class Singleton{
private Singleton(){}
private static volatile Singleton INSTANCE=null;
public static Singleton getInstance(){
if(INSTANCE==null){
// 首次访问会同步,而之后的使用没有synchronized
synchronized(Singleton.class){
if(INSTANCE==null){
INSTANCE=new Singleton();
}
}
}
}
}
读写volatile变量的时候会加入内存屏障,保证下面两点:
- 可见性
写屏障保证在该屏障之前的线程对共享变量的改动都同步到主存中
而写屏障保证在该屏障之后的线程对共享变量的读取,加载的是主存中最新的数据
- 有序性
写屏障会确保指令重排的时候,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排的时候,不会将读屏障之后的代码排在读屏障之后
更底层的读写变量时使用lock指令(锁总线)来确保多核cpu之间的可见性与有序性。