不知朋友们在编写多线程代码时,对于共享内存变量是否很好的处理呢,接下来我们将介绍volatile语义、特性、和使用。
2.4.1 volatile的特性
Java提供了一个稍弱的同步机制,即volatile关键字,用来保证变量更新后,对其他线程可见。由于没有完整的、正确的认识它,导致许多程序员不习惯使用,遇到并发问题直接上synchronized来保证线程安全,其实这样是可以的,不会出现线程安全问题。也有很多程序员知道它有可见性,然后只要出现多线程共享变量时都用volatile来修饰,其实这就是没有完整的了解volatile特性带来的误操作,如果被volatile修饰的变量在多线程中有复合操作,一定会出现线程安全问题的,例如2-7代码清单所示。
代码清单2-7 VolatileError.java
代码语言:javascript复制@Slf4j
public class VolatileError {
private volatile static int value = 0;
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 1; i <= 10000; i ) {
executorService.execute(() -> {
addValue();
});
}
executorService.shutdown();
log.info(value "");
}
private static void addValue() {
value ;
}
}
控制台打印value结果:16:33:28.319 [main] INFO com.px.book.VolatileError - 9996。
这个结果是我预期内的结果,我不知道他会输出9996,但是我知道结果绝对小于10000。我们其实能想象到这段代码的意图应该是多线程把0自然数自增10000次,他想得到的结果应该是10000。多线程的操作一定要满足3个原则,原子性、可见性、有序性。很显然多线程对volatile 操作不具备原子性,简而言之,volatile变量有以下特性。
可见性:对于一个volatile变量的读,任意线程总能看到它最新的结果值。
有序性:happen-before第3点明确说明,对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
原子性:除volatile 这种操作不具备原子性,任意单次读写具有原子性。
为了记忆简单,许多程序员只记住volatile 具有可见性、有序性,这不是严谨的说法。
我们有既要用volatile 又要保证被修饰的变量具有原子可不可以呢?当然可以了,我们需要一个具有原子性的锁来控制就完美解决。如代码2-8代码清单所示。
代码清单2-8 VolatileRight .java
代码语言:javascript复制@Slf4j
public class VolatileRight {
private volatile static int value = 0;
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 1; i <= 10000; i ) {
executorService.execute(() -> {
addValue();
});
}
executorService.shutdown();
log.info(value "");
}
private synchronized static void addValue() {
value ;
}
}
控制台打印value结果:17:03:21.156 [main] INFO com.px.book.VolatileRight - 10000。
2.4.2 volatile保障有序性
volatile 可以保障有序性的另一种语义是volatile 禁止重排,当然,在JSR-133之前的Java内存模型中是允许volatile变量与普通变量重排序的,读者可以翻到重排序章节,对照2-6 Reorder.java代码清单中,并发情况下可能就会被重排序成下列时序来执⾏:
1. 线程A写volatile变量,设置flag为true;
2. 线程B读同⼀个volatile,读取到flag为true,并进入if中;
3. 线程B读普通变量,读取到 a = 0,并计算i=0;
4. 线程A修改普通变量,设置 a = 1;
这显然是一个错误的结果,重排序竟然对volatile 的读-写下了手。因此在JSR-133增强 了volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。volatile对于保障有序性或者说重排序的原理是什么呢?对于volatile语义的变量,自动进行lock 和 unlock操作包围对变量volatile的读写操作。因此我们还是以2-6 Reorder.java代码清单为例来说明,上面语句执行顺序可以表示为以下:
thread1 thread2
1 :write a=1 5:lock(m)
2 :lock(m) 6:read flag
3 :write flag=true 7:unlock(m)
4 :unlock 8 :if(flag==true)
9: int i = 1 * 1;
下表摘自《Java并发编程的艺术》,此处揭秘volatile 的实现原理。
那么 Volatile 是如何来保证可见性的呢?在 x86 处理器下通过工具获取 JIT 编译器生成的汇编指令来看看对 Volatile 进行写操作 CPU 会做什么事情。如表2-5所示。
表2-5 volatile 生成的汇编指令
Java 代码: | instance = new Singleton();//instance 是 volatile 变量 |
---|---|
汇编代码: | 0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp); |
这里介绍一下如何把java文件生成汇编语言。其实简单使用的话JDK就能帮我们做,但是无论是哪个版本都想要安装Hsdis插件才可以,而网上的文章大多提供的下载地址都是错误的,笔者环境MacOS,jdk11,目前可在:https://github.com/evolvedmicrobe/benchmarks/blob/master/hsdis-amd64.dylib 下载,如有变动请联系作者,笔者备份了一份。下载下来后,将其放置到jre lib目录下即可。
上面一切工作准备就绪,可以有两种方式生成汇编代码。
1.使用命令
java -XX: UnlockDiagnosticVMOptions -XX: PrintAssembly xxx(xxx是class文件)
在IDEA配置VM options,打印汇编指令,如下图。见图2-10。
-XX: UnlockDiagnosticVMOptions -XX: PrintAssembly
图2-10
这种方式,在运行程序时,直接在控制台打印汇编指令。如图2-11所示。
图2-11
有 volatile 变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查 IA-32 架构软件开发者手册可知,lock 前缀的指令在多核处理器下会引发了两件事情。
l 将当前处理器缓存行的数据会写回到系统内存。
l 这个写回内存的操作会引起在其他 CPU 里缓存了该内存地址的数据无效。
处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了 Volatile 变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
这两件事情在 IA-32 软件开发者架构手册的第三册的多处理器管理章节(第八章)中有详细阐述。
Lock 前缀指令会引起处理器缓存回写到内存。Lock 前缀指令导致在执行指令期间,声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占使用任何共享内存。(因为它会锁住总线,导致其他 CPU 不能访问总线,不能访问总线就意味着不能访问系统内存),但是在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。在 8.1.4 章节有详细说明锁定操作对处理器缓存的影响,对于 Intel486 和 Pentium 处理器,在锁操作时,总是在总线上声言 LOCK#信号。但在 P6 和最近的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言 LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32 处理器和 Intel 64 处理器使用 MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和 Intel 64 处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。例如在 Pentium 和 P6 family 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。
上面所述的是volatile实现原理,涉及到处理器相关知识,所以我简化此处摘录内容。
Lock前缀的指令在多核处理器具有以下作用:
1.将当前处理器缓存行的数据写回到系统内存中。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号,在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。
2.这个写回的操作会使其他在CPU里缓存了该内存地址的数据无效。处理器能够使用嗅探技术保证它的内部缓存、系统内存与其他处理器的缓存的数据在总线上保持一致性。
注意:volatile关键字在可见性方面仅仅是保证读线程能够读取到共享变量的相对新值,对于引用型变量,volatile关键字并不能保证线程能够读取到相应对象的字段(实例变量、静态变量)、元素的相对新值。
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),编译器在生成字节码指令时,会在指令中插入这个内存屏障来禁止特定处理器的重排序,因此JMM规定了4个内存屏障插入策略。如表2-6所示。
l 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
l 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
l 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
l 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
表2-6 volatile 内存屏障说明
内存屏障 | 说明 |
---|---|
StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 |
StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 |
LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 |
LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 |
上述我们介绍了部分JMM内存模型原子操作,下面我们把所有JMM内存模型操作指令列出。这些操作指令就是用来将一个变量从主内存拷贝到工作内存、从工作内存再同步到主内存的实现细节。
1. lock 锁定 : 把主内存中的一个变量标志为一个线程独享的状态。
2. unlock 解锁 : 把主内存中的一个变量释放出来。
3. read 读:将主内存中的变量读到工作内存中。
4. load 加载:将工作内存中的变量加载到副本中。
5. use 使用:当执行引擎需要使用到一个变量时,将工作内存中的变量的值传递给执行引擎。
6. assign 赋值:将执行引擎收的的值赋值给工作内存中的变量。
7. store 存储:将工作内存中的变量的值传到主内存中。
8. write 写入:将store得到值放到主内存的变量中。
上面所述主要内容就是,如果要把一个变量从主内存复制到工作内存,就要顺序地执行read、load操作,想要把变量从工作内存同步回主内存中,就要顺序地执行store、write操作,上述两种操作必须顺序执行,必须连续操作,例如read a、read b、load b、load a是允许的。JMM要执行8个原则操作,必须还要满足一下规则,其实就是规定8个原子操作的执行顺序。
1. read和load,store和write必须同时出现,并且按照顺序执行。
2. 不允许线程丢弃最后一个assign操作,在进行assign操作后,必须进行store和write。
3. 如果一个线程的工作内存中的一个变量没有发生assign操作,则不能够发生store和write。
4. 在工作内存中,如果对一个变量使用use或者store操作,则必须先执行assign和load操作。
5. 一个主内存中的变量在同一时刻只能被一个线程进行lock操作,但是这个线程可以进行多次lock操作,并且执行相应次数的unlock操作后,变量才会被解锁。
6. 如果对一个量进行lock操作,将会清空此变量在其他线程工作内存中的值,这些线程在使用之前必须进行load或assign操作。
7. 一个线程如果没有对一个变量进行lock操作,则这个线程也不能对这个变量进行unlock操作。
8. 一个线程在进行unlock操作之前,必须先执行store和write操作。
2.4.3 volatile的性能
volatile的读、写操作都不会导致上下文切换,因此volatile的开销比锁要小。
写一个volatile变量会使该操作以及该操作之前的任何写操作的结果对其他处理器是可同步的,因此volatile变量写操作的成本介于普通变量的写操作和在临界区内进行的写操作之间。
volatile变量读操作的成本也介于普通变量的写操作和在临界区内进行的写操作之间。因为volatile变量的值每次都需要从高速缓存或者主内存中读取,而无法被暂存在寄存器中,从而无法发挥访问的高效性。
2.4.4 volatile应用场景
使用volatile变量作为状态标志。应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据。此时使用volatile的好处是一个线程能够‘通知’另外一个线程某种事件的发生,而这些线程又无须因此而使用锁,从而避免了使用锁的开销。
使用volatile保障可见性,使用final也可以保证数据内存的可见性(由于章节设计不对final进行分析)。在该场景中,多个线程共享一个可变状态变量,其中一个线程更新了该变量之后,其他线程无须加锁的情况下也能够看到该更新。
使用volatile变量替代锁。volatile变量并非锁的替代品,但是在一定的条件下他比锁更合适。多个线程共享一组可变状态变量的时候,我们可以把一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。
使用volatile实现简易版读写锁(非复合操作读写)。这种简易版读写锁仅涉及一个共享变量并且仅允许一个线程读取这个共享变量时其他线程可以更新该变量。