JUC之Volatile
1、谈谈对Volatile的理解
- Volatile是Java虚拟机提供的轻量级的同步机制(三大特性)
- 可见性
- 不保证原子性
- 禁止指令重排
1.1 JMM是什么?
JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程解锁前,必须读取主内存的最新值,到自己的工作内存
- 加锁和解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,此案成间的通讯(传值) 必须通过主内存来完成,其简要访问过程如下图:
1.2 缓存一致性
为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术。
在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。
1)MESI
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
2)总线嗅探
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
3)总线风暴
总线嗅探技术有哪些缺点?
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及Syschonized都是需要根据实际场景的。
1.3 Volatile之可见性
我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的.这就可能存在一个线程AAA修改了共享变量X的值还未写回主内存中时 ,另外一个线程BBB又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.
代码验证:
class Data {
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
// 注意number已用voliate修饰,不保证原子性
public void addPlusPlus() {
number++;
}
}
/**
* voliate保证原子性,及时通知其他线程,主内存的数据已经修改
*/
private static void seeOkByVoliate() {
Data myData = new Data();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 暂停一会线程
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName() + "\t updated number value is " +myData.number);
}, "AAA").start();
// 第二个线程是main线程
while (myData.number == 0){
// main线程一直在这里等待,直至number不等于0
}
System.out.println(Thread.currentThread().getName() + "\t mission is over. number value is:" + myData.number);
}
输出结果:
AAA come in
AAA updated number value is 60
main mission is over. number value is:60
1.4 Volatile之原子性
不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。
1.4.1 Volatile不保证原子性
代码验证(20个线程连续加2000次):
class Data {
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
// 注意number已用voliate修饰,不保证原子性
public void addPlusPlus() {
number++;
}
}
private static void verifyAtomic() {
Data myData = new Data();
for(int i=1; i<=20; i++) {
new Thread(() -> {
for (int j=1; j<=1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程全部都计算完成后,再用main线程取得最终的结果值看是多少
// 默认后台两个线程,垃圾回收线程和main线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t int type,finally number value:" + myData.number);
}
输出结果:
main int type,finally number value:19371
期待结果应为20000,但是实际是19371。
1.4.2 为什么Volatile不满足原子性(数据丢失问题)?
看下图:
下面我们将一个简单的number++操作,转换为字节码文件一探究竟:
public class T1 {
volatile int n = 0;
public void add() {
n++;
}
}
转换后的字节码文件(转码方法):
public class com.moxi.interview.study.thread.T1 {
volatile int n;
public com.moxi.interview.study.thread.T1();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field n:I
9: return
public void add();
Code:
0: aload_0
1: dup
2: getfield #2 // Field n:I
5: iconst_1
6: iadd
7: putfield #2 // Field n:I
10: return
}
我们能够发现 n++这条命令,被拆分成了3个指令:
- 执行getfield 从主内存拿到原始n
- 执行iadd 进行加1操作
- 执行putfileld 把累加后的值写回主内存
假设我们没有加 synchronized那么第一步就可能存在着,三个线程同时通过getfield命令,拿到主存中的 n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000.
1.4.3 如何Volatile解决不满足原子性(数据丢失问题)?
1) 在方法上加入 synchronized
class Data {
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
// 注意number已用voliate修饰,不保证原子性
public synchronized void addPlusPlus() {
number++;
}
}
private static void verifyAtomic() {
Data myData = new Data();
for(int i=1; i<=20; i++) {
new Thread(() -> {
for (int j=1; j<=1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程全部都计算完成后,再用main线程取得最终的结果值看是多少
// 默认后台两个线程,垃圾回收线程和main线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t int type,finally number value:" + myData.number);
}
输出结果:
main int type,finally number value:20000
2) 使用AomicInteger原子类:
class Data {
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
// 注意number已用voliate修饰,不保证原子性
public void addPlusPlus() {
number++;
}
// 使用原子类解决原子性问题
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
atomicInteger.getAndIncrement();
}
}
private static void solveByAtomic() {
Data myData = new Data();
for(int i=1; i<=20; i++) {
new Thread(() -> {
for (int j=1; j<=1000; j++) {
myData.addAtomic();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程全部都计算完成后,再用main线程取得最终的结果值看是多少
// 默认后台两个线程,垃圾回收线程和main线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t atomic type,finally number value:" + myData.atomicInteger);
}
输出结果:
main atomic type,finally number value:20000
1.5 Volatile之禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
单线程环境里面确保最终执行结果和代码顺序的结果一致
处理器在进行重排序时,必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
- 指令重排example 1
public void mySort() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
按照正常单线程环境,执行顺序是 1 2 3 4
但是在多线程环境下,可能出现以下的顺序:
- 2 1 3 4
- 1 3 2 4
上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样。但是指令重排也是有限制的,即不会出现下面的顺序: - 4 3 2 1
因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性。因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行。
- 指令重排example 2
int a,b,x,y = 0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
x = 0; y = 0 |
因为上面的代码,不存在数据的依赖性,因此编译器可能对数据进行重排:
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x = a; | y = b; |
x = 0; y = 0 |
这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性。
1.5.1 Volatile为什么能解决指令重排问题?
olatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象。
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
对volatile变量进行写操作时,会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回主内存。
对volatile变量进行读写操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。
1.6 Volatile在哪里使用过?
1.6.1 单例模式
1)单例模式DCL的问题
单例模式代码:
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
instance = new SingletonDemo();
}
return instance;
}
public static void main(String[] args) {
// 这里的 == 是比较内存地址
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
}
}
输出结果:
但是在多线程的环境下,我们的单例模式是否还是同一个对象了。
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
instance = new SingletonDemo();
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
输出结果:
从上面的结果我们可以看出,我们通过SingletonDemo.getInstance() 获取到的对象,并不是同一个,而是被下面几个线程都进行了创建,那么在多线程环境下,单例模式如何保证呢?
2) 保证单例模式的实例只有一个(引入synchronized关键字)
public synchronized static SingletonDemo getInstance() {
if(instance == null) {
instance = new SingletonDemo();
}
return instance;
}
输出结果:
我们能够发现,通过引入Synchronized关键字,能够解决高并发环境下的单例模式问题。但是synchronized属于重量级的同步机制,它只允许一个线程同时访问获取实例的方法,但是为了保证数据一致性,而减低了并发性,因此采用的比较少。
2) 保证单例模式的实例只有一个(DCL Double Check Lock 双端检锁机制)
public static SingletonDemo getInstance() {
if(instance == null) {
// 同步代码段的时候,进行检测
synchronized (SingletonDemo.class) {
if(instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
输出的结果:
从输出结果来看,确实能够保证单例模式的正确性,但是上面的方法还是存在问题的.
DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入volatile可以禁止指令重排.
原因是在某一个线程执行到第一次检测的时候,读取到 instance 不为null,instance的引用对象可能没有完成实例化。因为 instance = new SingletonDemo();可以分为以下三步进行完成:
- memory = allocate(); // 1、分配对象内存空间
- instance(memory); // 2、初始化对象
- instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null
但是我们通过上面的三个步骤,能够发现,步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
- memory = allocate(); // 1、分配对象内存空间
- instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
- instance(memory); // 2、初始化对象
这样就会造成什么问题呢?
也就是当我们执行到重排后的步骤2,试图获取instance的时候,会得到null,因为对象的初始化还没有完成,而是在重排后的步骤3才完成,因此执行单例模式的代码时候,就会重新在创建一个instance实例.
指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性
所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,这就造成了线程安全的问题。所以需要引入volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性。
private static volatile SingletonDemo instance = null;
双端检锁机制完整版代码:
public class SingletonDemo {
private static volatile SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
// a 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处
synchronized (SingletonDemo.class) //b
{
//c不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入
if(instance == null) {
// d 此时才开始初始化
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
// // 这里的 == 是比较内存地址
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}