并发中的变量可见性
变量可见性、线程安全问题根因
保证变量可见性的方式
synchronized关键字解密
volatile关键字解密
总结
并发中的变量可见性问题
什么是并发中的变量可见性问题呢? 一个线程对共享变量值的修改,能够及时地被其他线程看到。下面通过一个小例子加以说明,代码逻辑就是,通过共享变量,在一个线程中控制另一个线程的执行流程。请问:线程会停止循环,打印出i的值吗?
public class VisibilityDemo {
// 状态标识
private static boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (VisibilityDemo.flag) {
i++ ;
}
System.out.println(i);
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 设置flag为false, 使上面的线程结束while循环
VisibilityDemo.flag = false;
System.out.println("flag被置为false了");
}
}
打印的结果:flag被置为false了
按照代码逻辑,不是2秒后,终止循环,要打印出i的值吗?为什么没有按剧本走呢?说明while循环没有终止,在主线程更改了变量flag的值,但是在new的子线程中,变量法flag的值根本没有更新,也就是说共享变量flag的值在子线程不可见,并发中线程能不能看到变量的最新值,这就是并发中的变量可见性的问题。
那么问题来了,为什么会不可见?怎样才能可见呢?
- 方式一:synchronize关键字
public class VisibilityDemo {
// 状态标识
private static boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (VisibilityDemo.flag) {
// i++;
// 方式一
synchronized(this) {
i++ ;
}
}
System.out.println(i);
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 设置flag为false, 使上面的线程结束while循环
VisibilityDemo.flag = false;
System.out.println("flag被置为false了");
}
}
打印的结果:flag被置为false了
100317209
- 方式二:volatile关键字
public class VisibilityDemo {
// 状态标识
// private static boolean flag = true;
// 方式二
private static volatile boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (VisibilityDemo.flag) {
i++;
}
System.out.println(i);
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 设置flag为false, 使上面的线程结束while循环
VisibilityDemo.flag = false;
System.out.println("flag被置为false了");
}
}
打印的结果:
flag被置为false了
-1760723396
这两种方式,都使new的子线程看见了变量flag值的改变,但是一个打印的是一个正的整数,一个打印的是一个负的整数,这又是为什么呢?负数是因为i++已经溢出了,i++执行的次数已经大于int类型的最大值了,说明volatile效率比synchronize效率高。那么问题又来了,为什么使用synchronize和volatile变量就可见了呢?
变量可见性、线程安全问题根因
为了回答这个问题,我们需要了解Java内存模型(Java memory mode 简称JMM)的操作规范
- 共享变量必须存放在主内存中;
- 线程有自己的工作内存,线程只可操作自己的工作内存
- 线程操作共享变量,需要从主内存读取(拷贝)到工作内存,改变值后需要从工作内存同步(写入)到主内存中。
相信很容易让人联想到java虚拟机(Java virtual machine 简称JVM ),这与JVM的内存中的堆区,栈区等是否有联系呢?首先JMM内存模型是java语言规定的,JVM是JVM规范定的,前者是一种逻辑概念,后者一种物理划分,在学习JVM的相关知识的时候,线程私有的栈区,本地方法栈,程序计数器,对应的就是图中工作内存,而线程共享的堆区,方法区,对应的就是主内存。
想象一下,如何让线程2使用A时看到的是最新值?
- 线程1修改A后必须立马同步回主内存
- 线程2使用A前需要重新从主内存读取到工作内存
疑问1:使用前不会重新从主内存读取到工作内存吗?
疑问2:修改后不会立马同步到主内存吗?
的的确确,确确实实,实实在在是不会。相信前面的变量可见性的例子,已经很好的说明了这个问题。这是因为java内存模型中同步交互协议,规定了8种原子操作:
原子操作:是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束。
- lock(锁定):将主内存中的变量锁定,为一个线程所独占
- unlock(解锁):将lock加的锁定解除,此时其它的线程可以有机会访问此变量
- read(读取):作用于内存变量,将主内存中的变量值读取到工作内存当中
- load(载入):作用于工作内存变量,将read读取的值保存到工作内存中的变量副本中
- use(使用):作用于工作内存变量,将值传递给线程的代码执行引擎
- assign(赋值):作用于工作内存变量,将执行引擎处理返回的值重新赋值给变量副本
- store(存储):作用于工作内存变量,将变量副本的值传送到主内存中
- write(写入):作用于主内存变量,将store传送过来的值写入到主内存的共享变量中
java内存模型-同步交互协议,操作规范还有另外两条:
- 将一个变量从主内存复制到工作内存要顺序执行read、load操作;要将变量从工作内存同步到主内存要顺序执行store、write操作。只要求顺序执行,不一定是连续执行
- 做了assign操作,必须同步回主内存,不能没做assign,同步回主内存
具体流程如下图:
lock和unlock很好理解,如果多个线程同时操作,有可能一个线程在read,一个线程在write,数据会出现严重错误。从主内存读取到工作内存中实际上是分两步的,首先是从主内存read到寄存器中,然后从寄存器中load到工作内存中,use和assign就是使用变量,进行一些列的运算赋值,然后通过store先返回到寄存器,然后write到工作内存中。这每一步都是原子操作,但是从主内存到读到工作内存需要两个步骤read和load,从工作内存写入主内存也是分两步,由两个操作合在一起,它还是原子操作吗?不是,CPU执行read后,它就让出时间片,没有执行load,在这段时间,别人可能改了它的值。正是由于java的内存模型,它固有的这个问题,导致了线程安全问题和变量的可见性问题。
保证变量可见性的方式
1.final变量
// final不可变变量
private final int var = 1;
2.synchronized
while (xxx) {
synchronized(this) {
i++;
}
}
3.用volatile修饰
// 状态标识
private static volatile boolean is = true;
synchronized关键字解密
可见性
synchronized语义规范
-
1.进入同步块前,先清空工作内存中的共享变量,从主内存中重新加载
-
2.解锁前必须把修改的共享变量同步回主内存
synchronized是如何做到线程安全的?
-
1.锁机制保护资源共享,只有获得锁的线程才可以操作共享资源
-
2.synchronized语义规范保证了修改共享资源后,会同步回主内存,就做到了线程安全。
synchronized一定能保证可见性和线程安全吗?
不一定,只有多个线程抢的是同一把锁,才能保证可见和性线程安全。如果不是同一把锁,两个线程的同步代码块都可以操作共享资源,线程1已经修改了共享变量的值,但是线程1还没有解锁,但是线程2此时进入同步代码块,共享变量还是初始值,并不是线程1修改后的值,这显然不能保证可见性和线程安全。
原子性
原子性:通常指多个操作不存在只执行一部分的情况,如果全部执行完成那没毛病,如果只执行了一部分,那对不起,你得撤销(即事务中的回滚)已经执行的部分。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。
一个变量在同一时刻只允许一条线程对其进行lock操作,获取对象锁,互斥排他性达到两个同步块串行执行。通过这种控制线程串行执行间接的实现了原子性。
synchronize无禁止指令重排。
volatile关键字解密
volatile语义规范:
- 使用volatile变量时,必须重新从主内存加载,并且read、load是连续的
- 修改volatile变量后,必须马上同步回主内存,并且store、write是连续的
可见性
保证了read、load和use的操作连续性,assign、store和write的操作连续性,从而达到工作内存读取前必须刷新主存最新值;工作内存写入后必须同步到主存中。读取的连续性和写入的连续性,看上去像线程直接操作了主内存。
非原子性
volatile本身并不对数据运算处理维持原子性,强调的是读写及时影响主内存。
volatile禁止指令重排序
指令重排:为了提高性能,编译器和和处理器通常会对指令进行指令重排序。
图中的三个重排位置可以调换的,根据系统优化需要进行重排。遵循的原则是单线程重排后的执行结果要与顺序执行结果相同。
内存屏障指令:volatile在指令之间插入内存屏障,保证按照特定顺序执行和某些变量的可见性。
volatile就是通过内存屏障通知cpu和编译器不做指令重排优化来维持有序性。
线程A和线程B的部分代码:
jvm优化指令重排序后,代码的执行顺序可能如下:
当两个线程并发执行时,就可能出现线程B抛空指针异常
当我们在变量上加volatile修饰时,则用到该变量的代码块中就不会进行指令重排序
volatile能保证线程安全吗?
答案是不能,从volatile语义来看,可以肯定的是volatile可以保证可见性,但是它没有锁机制,线程可以并发操作共享资源。如果有3个线程同时访问变量int A = 0,线程1执行 A+1,线程执行A+2,线程执行A+3,如果线程1readA=0,此时还没有将结果同步回主内存,此时线程2,3又访问A = 0,想想看,主内存中A的最终结果,取决于哪个线程最后同步回主内存,这显然无法保证线程安全。
既然如此synchronized可以保证可见性,为什么要用volatile呢?
在有些不需要考虑线程安全的前提下有如下两点:
- 使用volatile比synchronized简单
- volatile性能比synchronized稍好(前面可见性打印i的值就说明了这一点)
volatile的使用场景
volatile的使用范围
- volatile只可修饰成员变量(静态的、非静态)
- 多线程并发下,才需要使用它
典型的使用场景:volatile与synchronize配合使用
public class Singleton {
private volatile static Singleton instance = null;
private Singleton(){}
public static Singleton getInstance(){
if(instance == null){//①
synchronized (Singleton.class) {
if(instance == null){//②
instance = new Singleton();
}
}
}
return instance;
}
}
为什么还要使用volatile来修饰?
按照上边的写法已经对new Singleton();这个操作进行了synchronize操作,已经保证了多线程只能串行执行这个实例化代码。事实上,synchronize保证了线程执行实例化这段代码是串行的,但是Synchronize并不具备禁止指令重排的特性。
而instance = new Singleton(); 主要做了3件事情:
(1) java虚拟机为对象分配一块内存x。
(2) 在内存x上为对象进行初始化 。
(3) 将内存x的地址赋值给instance 变量。
如果编译器进行重排为:
(1) java虚拟机为对象分配一块内存x。
(2) 将内存x的地址赋值给instance 变量。
(3) 在内存x上为对象进行初始化 。
第一种情况,无volatile修饰:此时,有两个线程执行getInstance()方法,加入线程A进入代码的注释中的第②处,synchronized代码块的非空判断,并执行到了重排指令的(2),与其同时线程B刚好代码注释中的第①处,synchronized代码块外面的if判断。此时,instance有线程A把内存地址x地址赋值给了instance,那么instance已经不为空只是没有初始化完成,线程B就返回了一个没有完成初始化的instance,最终使用时候会出现空指针的错误。
第二种情况,有volatile修饰:instance因为被volatile的禁止指令重排的特性,那只会安装先初始化对象再赋值给instance这样顺序执行,这样就能保证返回正常的实例化的对象。
总结
-
volatile具有可见性和有序性(禁止指令重排序),不能保证原子性。
-
volatile在特定情况下线程安全,比如自身不做非原子性运算。
-
synchronize通过获取对象锁,保证代码块串行执行,间接保证原子性
-
synchronize无禁止指令重排能力。
-
synchronize只有多个线程抢的是同一把锁,才能保证可见和性线程安全
-
DCL双重锁校验单例操作需要volatile和synchronize保证线程安全。