最近在研究JVM,关于《深入理解Java虚拟机》书中的最后一部分,高效并发,做一些笔记整理,以下是关于volatile关键字的知识点:
更新:最近阅读了《Java并发编程的艺术》,对volatile的内存语义做一些整理,主要内容来自3.4
一、volatile定义
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是他并不容易被完全正确完整地理解。
二、两大特性
1.可见性
当一个变量定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立刻得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
即使volatile变量对所有线程是立即可见的,但这并不意味着它在并发下就是安全的,原因是Java里面的运算并非原子操作。这里可以通过一段代码来看:
//volatile变量自增运算
public class VolatileTest{
public static volatile int race=0;
public static void increase(){
race++;
}
public static void main(String[] args){
Thread[] threads=new Thread[10];
for(int i=0;i<10;i++){
threads[i]=new Thread(()->{
for(int i=0;i<10000;i++){
increase();
}
});
thread[i].start();
}
//等待所有累加线程都结束
while(Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}
这段代码中发起了10个线程,每个线程对race变量进行10000次自增操作,如果这段代码正确并发的话,输出的结果应该是100000。但实际上,运行的结果都是小于100000。
其实问题是出在自增运算race++当中,使用javap反编译之后,我们可以发现increase()方法是由4条字节码指令构成的。因此,还是会发生并发失败的问题。客观来说,通过字节码来分析并发问题依然是不严谨的,因为即使编译出来只有一条字节码指令,也不意味着执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
2.禁止指令重排化
普通的变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”。接着我们来看一看指令重排序干预程序并发执行的情况:
Map configOptions;
char[] configText;
//此变量必须为volatile
volatile boolean init=false;
//假设以下代码在线程A中执行
//模拟读取配置,读取完成后将init设置为true以通知其他线程可用
configOptions=new HashMap();
configText=readConfigFile(fileName);
processConfigOptions(configText,configOptions);
init=true;
//假设以下代码在线程B中执行
//等待init为true,代表线程A已经把配置完全加载
while(!init){
sleep();
}
//使用线程A初始化好的配置信息
doSomethingWithConfig();
以上的伪代码在并发的情况下,如果定义init变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A的最后一句先执行,这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。
大多数场景下volatile的总开销依然会比锁低,我们在volatile与锁之中选择的唯一依据是volatile的语义能否满足使用场景的需求。
三、volatile的内存语义
1.volatile的特性
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
简而言之,volatile变量自身具有下列特性。
❑ 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
❑ 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
2.volatile写-读建立的happens-before关系
对程序员来说,volatile对线程的内存可见性的影响比volatile自身的特性更为重要,也更需要我们去关注。
从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。
从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。
3.volatile写-读的内存语义
volatile写的内存语义如下。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义如下。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。
4.volatile内存语义的实现
前文提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。表3-5是JMM针对编译器制定的volatile重排序规则表。
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。
为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
❑ 在每个volatile写操作的前面插入一个StoreStore屏障。
❑ 在每个volatile写操作的后面插入一个StoreLoad屏障。
❑ 在每个volatile读操作的后面插入一个LoadLoad屏障。
❑ 在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
5.JSR-133为什么要增强volatile的内存语义
为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。