概述
volatile 关键字用于将 Java 变量标记为“存储在主内存中”,更准确地说,对 volatile 变量的每次读取都将从计算机的主内存中读取,而不是从 CPU 缓存中读取,并且对 volatile 变量的每次写入都将写入主内存,而不仅仅是 CPU 缓存。
可见性
volatile关键字保证跨线程变量更改的可见性。
在多线程对普通的共享变量进行操作的过程中,出于性能原因,每个线程可能会在处理变量时将变量从主内存复制到 CPU 缓存中。如果计算机包含多个 CPU,则每个线程可能在不同的 CPU 上运行,这就意味着,每个线程都可以将变量复制到不同 CPU 的 CPU 缓存中,如下图:
对于普通的共享变量,Java 虚拟机 (JVM) 无法保证何时将数据从主内存读取到 CPU 缓存,或将数据从 CPU 缓存写入主内存,这可能会导致几个问题,我将在以下部分中解释这些问题。
想象一下这样一种情况,两个或多个线程可以访问一个共享对象,该对象包含一个声明如下的计数器变量:
public class SharedObject {
public volatile int counter = 0;
}
如果有线程1会对counter变量递增,而线程 1、线程 2 可能会不断读取counter变量来输出,JVM无法保证何时将counter变量的值从 CPU 缓存写回主存,这意味着线程2在CPU2的缓存中的变量值可能与主内存中的不同,就会导致不同的线程取出的counter是不一样的,这种情况如下图所示:
原理
首先我们来了解以下JMM中的数据原子操作:
- read(读取):从主内存读取数据
- load(载入):将主内存读取到的数据写入工作内存
- use(使用):从工作内存读取数据来计算
- assign(赋值):将计算好的值从新赋值到工作内存中
- store(存储):将工作内存数据写入到主内存
- write(写入):将store过去的变量值赋值给主内存中的变量
- lock(锁定):将主内存变量加锁,标识为线程独占状态
- unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
Java中的volatile关键字是通过调用C语言实现的,而在更底层的实现上,即汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。
具体的实现原理是在硬件层面上通过MESI(缓存一致性协议):多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。
有序性
出于性能原因,是允许 Java VM 和 CPU 对程序中的指令重新排序,只要指令的最终的语义保持不变。
比较经典的例子就是new一个对象的过程:
public class Instance {
private static Instance instance;
private Instance() {}
public static Instance getInstance() {
if (instance == null) {
instance = new Instance();
}
}
}
在 instance = new Instance() 初始化的过程中会包含三个步骤:
// 1、分配对象的内存空间
memory = allocate();
// 2、初始化对象
ctorInstance(memory);
// 3、设置 instance 指向对象的内存空间
instance = memory;
其中2跟3是无直接关联的,所以指令重排可随意调换23的执行顺序,如果此时线程1正在 处理 instance = new Instance(),而线程2 正在处理 if (instance == null) ,那么就有可能因为先执行了3导致线程2的判断为false从而获取到一个未初始化完的instance,这样会导致一系列空指针问题。
Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作缺少 happens-before 的关系,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
如下是 happens-before 的8条原则,摘自 《深入理解Java虚拟机》。
- 程序顺序序规则:如果程序中操作A在操作B之前,那么线程中操作A将在操作B之前执行。
- 监视器锁规则:一个 解锁操作先行发生于后面对同一个锁的 加锁操作;
- volatile 变量规则:就是如果一个线程先去写一个volatile变量,然后另一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终结规则:线程中所有的操作都必须在其他线程检测到该线程已经结束之前执行,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 终结器规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始;
- 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
而上面一大串,我们关注volatile的规则即可。
我们知道线程1相当于写操作,而线程2相当于读操作,也就是说instance对象如果加上volitile修饰后,当线程2在处理 if (instance == null) 时,因为会实时得到线程1对instance的改变的值,也就是线程1会按照123的步骤去执行,所以线程2也不会取到一个半成品的instance(因为对象的指向是第三步才干的)。
当然,因为volatile不支持原子性,所以线程2有可能会再去new一次instance,这时候跟synchronized配合即可解决,这里不展开说明。
原理
volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。
硬件层面的“内存屏障”:
- sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见
- lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
- mfence:即全能屏障(modify/mix Barrier ),兼具sfence和lfence的功能
- lock 前缀:lock不是内存屏障,而是一种锁。执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。
JMM层面的“内存屏障”:
- LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
JVM的实现会在volatile读写前后均加上内存屏障,在一定程度上保证有序性。如下所示:
LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
从例子出发理解volatile
从Java代码、字节码、Jdk源码、汇编层面去解析volatile的原理。
上一段最简单的代码,volatile用来修饰Java变量:
public class TestVolatile {
public static volatile int counter;
public static void main(String[] args){
counter = 1;
}
}
通过javac TestVolatile.java将类编译为class文件,再通过javap -v TestVolatile.class命令反编译查看字节码文件。
public class TestVolatile
minor version: 0
major version: 59
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #8 // TestVolatile
super_class: #2 // java/lang/Object
interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // TestVolatile.counter:I
#8 = Class #10 // TestVolatile
#9 = NameAndType #11:#12 // counter:I
#10 = Utf8 TestVolatile
#11 = Utf8 counter
#12 = Utf8 I
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 SourceFile
#18 = Utf8 TestVolatile.java
{
public static volatile int counter;
descriptor: I
flags: (0x0049) ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
public TestVolatile();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_1
1: putstatic #7 // Field counter:I
4: return
LineNumberTable:
line 4: 0
line 5: 4
}
可以看到,修饰counter字段的public、static、volatile关键字,在字节码层面分别是以下访问标志: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
volatile在字节码层面,就是使用访问标志:ACC_VOLATILE来表示,供后续操作此变量时判断访问标志是否为ACC_VOLATILE,来决定是否遵循volatile的语义处理。
上面编译后的字节码,有putstatic和getstatic指令(如果是非静态变量,则对应putfield和getfield指令)来操作counter字段。那么对于被volatile变量修饰的字段,是如何实现volatile语义的,从下面的源码看起。
1、在jdk根路径/hotspot/src/share/vm/interpreter路径下的bytecodeInterpreter.cpp文件中,处理putstatic和putfield指令的代码:
CASE(_putfield):
CASE(_putstatic):
{
// .... 省略若干行
// ....
// Now store the result 现在要开始存储结果了
// ConstantPoolCacheEntry* cache; -- cache是常量池缓存实例
// cache->is_volatile() -- 判断是否有volatile访问标志修饰
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) { // ****重点判断逻辑****
// volatile变量的赋值逻辑
if (tos_type == itos) {
obj->release_int_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == atos) {// 对象类型赋值
VERIFY_OOP(STACK_OBJECT(-1));
obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
} else if (tos_type == btos) {// byte类型赋值
obj->release_byte_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ltos) {// long类型赋值
obj->release_long_field_put(field_offset, STACK_LONG(-1));
} else if (tos_type == ctos) {// char类型赋值
obj->release_char_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == stos) {// short类型赋值
obj->release_short_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ftos) {// float类型赋值
obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
} else {// double类型赋值
obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
}
// *** 写完值后的storeload屏障 ***
OrderAccess::storeload();
} else {
// 非volatile变量的赋值逻辑
if (tos_type == itos) {
obj->int_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == atos) {
VERIFY_OOP(STACK_OBJECT(-1));
obj->obj_field_put(field_offset, STACK_OBJECT(-1));
OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
} else if (tos_type == btos) {
obj->byte_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ltos) {
obj->long_field_put(field_offset, STACK_LONG(-1));
} else if (tos_type == ctos) {
obj->char_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == stos) {
obj->short_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ftos) {
obj->float_field_put(field_offset, STACK_FLOAT(-1));
} else {
obj->double_field_put(field_offset, STACK_DOUBLE(-1));
}
}
UPDATE_PC_AND_TOS_AND_CONTINUE(3, count);
}
2、重点判断逻辑cache->is_volatile()方法,在jdk根路径/hotspot/src/share/vm/utilities路径下的accessFlags.hpp文件中的方法,用来判断访问标记是否为volatile修饰。
// Java access flags
bool is_public () const { return (_flags & JVM_ACC_PUBLIC ) != 0; }
bool is_private () const { return (_flags & JVM_ACC_PRIVATE ) != 0; }
bool is_protected () const { return (_flags & JVM_ACC_PROTECTED ) != 0; }
bool is_static () const { return (_flags & JVM_ACC_STATIC ) != 0; }
bool is_final () const { return (_flags & JVM_ACC_FINAL ) != 0; }
bool is_synchronized() const { return (_flags & JVM_ACC_SYNCHRONIZED) != 0; }
bool is_super () const { return (_flags & JVM_ACC_SUPER ) != 0; }
// 是否volatile修饰
bool is_volatile () const { return (_flags & JVM_ACC_VOLATILE ) != 0; }
bool is_transient () const { return (_flags & JVM_ACC_TRANSIENT ) != 0; }
bool is_native () const { return (_flags & JVM_ACC_NATIVE ) != 0; }
bool is_interface () const { return (_flags & JVM_ACC_INTERFACE ) != 0; }
bool is_abstract () const { return (_flags & JVM_ACC_ABSTRACT ) != 0; }
bool is_strict () const { return (_flags & JVM_ACC_STRICT ) != 0; }
3、下面一系列的if…else…对tos_type字段的判断处理,是针对java基本类型和引用类型的赋值处理。如:
obj->release_byte_field_put(field_offset, STACK_INT(-1));
对byte类型的赋值处理,调用的是jdk根路径/hotspot/src/share/vm/oops路径下的oop.inline.hpp文件中的方法:
// load操作调用的方法
inline jbyte oopDesc::byte_field_acquire(int offset) const
{ return OrderAccess::load_acquire(byte_field_addr(offset)); }
// store操作调用的方法
inline void oopDesc::release_byte_field_put(int offset, jbyte contents)
{ OrderAccess::release_store(byte_field_addr(offset), contents); }
4、OrderAccess是定义在jdk根路径/hotspot/src/share/vm/runtime路径下的orderAccess.hpp头文件下的方法,具体的实现是根据不同的操作系统和不同的cpu架构,有不同的实现。
5、步骤3中对变量赋完值后,程序又回到了一系列的if…else…对tos_type字段的判断处理之后。有一行关键的代码:OrderAccess::storeload(), 即只要volatile变量赋值完成后,都会走这段代码逻辑。它依然是声明在orderAccess.hpp头文件中,在不同操作系统或cpu架构下有不同的实现。
orderAccess_linux_x86.inline.hpp是linux系统下x86架构的实现:
inline void OrderAccess::storeload() {fence();}
inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
代码**lock; addl $0,0(%%rsp) **其中的addl $0,0(%%rsp) 是把寄存器的值加0,相当于一个空操作(之所以用它,不用空操作专用指令nop,是因为lock前缀不允许配合nop指令使用)
lock前缀,会保证某个处理器对共享内存(一般是缓存行cacheline,这里记住缓存行概念,后续重点介绍)的独占使用。它将本处理器缓存写入内存,该写入操作会引起其他处理器或内核对应的缓存失效。通过独占内存、使其他处理器缓存失效,达到了“指令重排序无法越过内存屏障”的作用。
可以注意到上面storeload,在前面的章节 有序性-原理里的JMM层面的“内存屏障”里提及到了,所以其他三个也有相应的函数:
inline void OrderAccess::loadload() {acquire();}
inline void OrderAccess::storestore() {release();}
inline void OrderAccess::loadstore() {acquire();}
运行上面的main方法时,加上JVM的参数:-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly,就可以看到它的有关lock的汇编输出。