volatile简介
volatile
是jvm提供的最轻量级的同步机制(相比于synchronized
,其要轻量很多)
当一个变量定义为volatile
后,其具备两种特性:
- 此变量对所有线程的可见性
- 可见性:当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
- 禁止指令重排序优化
- 指令重排序:JVM为了进行优化,会对变量赋值等操作进行一系列的优化,其只保证了所有依赖赋值结果的地方都能获取到正确的结果,但不能保证该变量赋值操作的顺序与程序代码中的执行顺序一致。
- 注意:重排序优化是机器级的优化操作,不是是Java源代码层面进行的。
可见性
普遍变量
首先,为什么普通变量不能做到可见性呢?
这里需要引入JMM(Java内存模型)。
-
什么是JMM?
由于在不同平台上内存模型的差异,可能同一个程序在一个平台上并发情况下可以正常运行,而在另一个平台上并发访问就出错,因此需要针对各种平台来实现一个统一的规范。由此,JVM规范了自身的JAVA内存模型(即JMM)来屏蔽操作系统的内存访问差异。
-
JMM简介
JMM的知识点较多,这里只做简单介绍。
首先上图
JMM规定了所有的变量都存储在主内存,而每条线程有自己的工作内存;线程的工作内存保存了该线程所使用到的变量的拷贝(注意:线程的工作内存只拷贝了对象的引用、和正在访问的对象中的某个字段,并不会完全拷贝此对象),线程都所有操作都是在自己的工作内存中进行的;
(注意:JMM与JVM中内存区域的堆栈等区域不是一个层次的内存划分,读者不要混淆)
okay,引入完毕,回到刚才的问题,为什么普通变量不能做到可见性呢?
由上图及介绍可以知道,普通变量的值传递需要通过主内存来完成,例如:线程A修改一个变量的值,需要先向主内存回写后,另一个线程B等到A回写完成后再读取,才能够读取到变量的新值。
volatile修饰的变量
volatile
怎样实现可见的呢?
有如下java代码
public class Test16 {
private volatile int a=0;
public void update() {
a = 1;
}
public static void main(String[] args) {
}
}
通过hsdis+jitwatch工具查看其汇编码(查看步骤见:here),如下:
......
0x000000000295156d: lock addl %rdi,(%rdx)
......
可以看到在volatile
修饰的变量处,执行了lock addl....
步骤,这个操作的lock作用把主内存的变量标示为独占内存的变量,此时会使得本CPU的Cache写入内存,同时令其他CPU或别的内核其cache失效,当其他CPU发现cache失效后,会从内存中重读该变量数据,即可以获取当前最新值。
通过以上的步骤,使用前面的volatile
变量的修改对其他CPU立即可见。
-
除了
volatile
之外,synchronized
和final
关键字也可以保证可见性synchroinzed
:变量执行unlock操作(将处于锁定状态的变量释放出来,释放后其他线程才可以使用此变量)之前,必须先把此变量同步到主内存中。final
:保障构造函数中对象不溢出的情况下,其他线程拿到的是初始化后的final
对象。
volatile保证有序性就安全了吗
有如下例子:
package com.hpsyche;
/**
* @author hpsyche
* Create on 2019/12/24
*/
public class Test17 {
public static volatile int race=0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads=new Thread[50];
for(int i=0;i<50;i++){
threads[i]=new Thread(new Runnable() {
@Override
public void run() {
for(int i1 = 0; i1 <1000; i1++){
race++;
}
}
});
threads[i].start();
}
for(int i=0;i<50;i++){
threads[i].join();
}
System.out.println(race);
}
}
运行结果:发现race最终变量小于50000;
通过javap反编译,查看字节码:
0: iconst_0
1: istore_1
2: iload_1
3: bipush 50
5: if_icmpge 31
8: new #2 // class java/lang/Thread
11: dup
12: new #3 // class com/hpsyche/Test17$1
15: dup
16: invokespecial #4 // Method com/hpsyche/Test17$1."<init>":()V
19: invokespecial #5 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
22: invokevirtual #6 // Method java/lang/Thread.start:()V
25: iinc 1, 1
28: goto 2
31: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
34: getstatic #8 // Field race:I
37: invokevirtual #9 // Method java/io/PrintStream.println:(I)V
40: return
可以看到,race++被不是一个原子操作,其有istore\iload\iinc等组成,执行这些指令是,其他线程有可能已经将race的值改变了,导致最终getstatic时同步到主内存的数据偏小。
此时我们可以将race++操作加上synchroinzed
,或者使用jdk提供的AtmoicInteger
类来确保race++的线程安全。
有序性
volatile怎么实现有序性
上文已经提过:重排序优化是指过程不保证,结果保证的一系列优化过程。而volatile关键字禁止了指令优化,那么其是怎样实现的呢?
在上文举例中提到汇编码:lock addl...
,其中的lock还有一个作用,其相当于一个内存屏障(重排序时不能将后面的指令排序到内存屏障之前),内存屏障其通过一系列的屏障策略来实现有序。
关于内存屏障的更多细节可见:here
- 除了
volatile
外,synchroinzed
也可实现线程间操作的有序性,因为加了synchroinzed
后,一个变量在同一个时刻只允许一个线程对其进行lock,也就保证了访问的先后性。
volatile典型使用
DCL实现的单例模式(双重检查加锁)
public class Singleton{
private volatile static Singleton instance=null;
private Singleton(){}
public static Singleton getInstance(){
//先检查实例是否存在,如果不存在才进入下面的同步块
//避免synchroinzed资源的消耗
if(instance==null){
//同步块,线程安全的创建实例
synchronized(Singleton.class){
//检查实例是否存在,如果不存在才真正的创建实例
if(instance==null){
instance=new Singleton();
}
}
}
return instance;
}
}
上面的例子已经似乎可以保证单例了,那为什么还需要加volatile
呢?
首先要理解new Singleton()
做了什么。new一个对象有几个步骤。
1.看class对象是否加载,如果没有就先加载class对象;
2.分配内存空间,初始化实例;
3.调用构造函数;
4.返回地址给引用。
而cpu为了优化程序,可能会进行指令重排序,打乱这3,4这几个步骤,导致实例内存还没分配,就被使用了,当在并发的情况下,就可能出现线程B引用了线程A中还没有被完全初始化的变量。
而加了volatile
之后,就保证new
不会被指令重排序。
总结
关于volatile
关键字还有很多深入的细节,由于才疏学浅,这里我也只是简单的聊了下其特性,如果不足之处欢迎评论指出。
参考文献
《深入理解Java虚拟机》(第二版)——周志明