文章目录
一、前言
volatile,这个关键字,是JDK提供给我们使用的,之前也总结过一篇文章不过有点浅显,只是说明一下它的特性,这篇文章打算详细地总结下相关的概念与底层细节。
之前的总结:JUC(8)Java内存模型-JMM和Volatile关键字
volatile的特性:
- 1、保证可见性 (这就要涉及JMM)
- 2、不保证原子性
- 3、禁止指令重排
二、CPU的乱序执行
CPU在执行代码时是乱序的,这里说的乱序是指代码编译后的指令的执行顺序,也就是说指令是乱序执行的。
为什么要打乱顺序呢?答:是为了提高CPU的执行效率。
这一块,具体的介绍可以看之前的读书笔记:为什么要进行指令重排呢?
在此,也可以给出一个代码示例来验证下:
执行下面代码,如果CPU没有乱序执行的话,那么 a = 1
必然在 x = b
前面,b = 1
必然在 y = a
的前面
我们可以得到什么结论,也就是 x
和 y
肯定不能同时为 0
。
但是实际情况是,我们会遇到 x
和 y
同时为 0
的情况。(可能要循环几百万次才能遇到一次)
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程two. 可根据自己电脑的实际性能适当调整等待时间.
//sleep(100000);
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();
two.start();
one.join();
two.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result);
break;
}
三、乱序可能会造成的问题
先说答案,会引起数据错误(数据安全问题)。
用个例子来验证吧,背景:DCL单例模式加了volatile关键字。
class T{
int m = 8;
}
T t = new T();
反编译汇编码:
0 new #2 <T>
3 dup
4 invokespeecial # 3 <T.<init>>
7 astore_1
8 return
12345
对汇编码逐步分析:
new #2 <T>
:创建m = 0
的对象并且栈帧中有一个引用指向该对象(此时m=0)dup
:在我们的栈帧中复制一份引用(此时m=0)invokespecial #3 <T.<init>>
:弹出一个栈帧中的值,实例化它的构造方法(此时m=8)astore_1
:将我们栈帧的引用赋值给 t,这里1
指的是我们本地变量表中的第一位(此时m=8)
因为乱序的存在,当我们的 astore_1
在我们的 invokespeecial # 3 <T.<init>>
执行前执行,会导致我们的将我们没有实例化的对象赋值给 t
,所以m = 0
。此时如果去使用t,肯定会造成数据错误。
所以为了避免这种现象,我们要对 DCL
加 volatile
,那问题来了,我们 volatile
是怎么保证有序性的呢?
四、如何禁止指令重排序?
对于禁止指令重排序,从以下四个方面来谈:
- 代码层面
- 字节码层面
- JVM层面
- CPU层面
4.1 Java 代码层面
直接加一个 volatile 关键字即可
public class TestVolatile {
public static volatile int counter = 1;
public static void main(String[] args) {
counter = 2;
System.out.println(counter);
}
}
4.2 字节码层面
在字节码层面,当对 volatile 进行反编译后,我们可以看到 VCC_volatile
我们对上述代码进行反编译,得到其 字节码
通过javac TestVolatile.java
将类编译为class文件,再通过 javap -v TestVolatile.class
命令反编译查看字节码文件
这里我们只展示这段代码得字节码:public static volatile int counter = 1;
public static volatile int counter;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
// 下面为初始化counter时的字节码
0: iconst_2
1: putstatic #2 // Field counter:I
4: getstatic #3 // Field
- descriptor:代表参数的类型和修饰符
- flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE:标志
- putstatic:对静态属性进行操作
descriptor
,关于字节码的类型对应如下:B基本类型byte
C基本类型char
D基本类型double
F基本类型float
I基本类型int
J基本类型long
S基本类型short
Z基本类型boolean
V特殊类型void
L对象类型,以分号结尾,如Ljava/lang/Object;
对于数组类型,每一位使用一个前置的[字符来描述,如定义一个java.lang.String[][]类型的维数组,将被记录为[[Ljava/lang/String;
我们后续的操作都可以通过 ACC_VOLATILE 这个标志来知道该变量已被 volatile 所修饰
4.3 HotSpot 源码层面
对于带有 volatile 修饰的变量,我们的 JVM 是怎么去实现的呢?
这里涉及到四个屏障:StoreStore,StoreLoad,LoadStore,LoadLoad
JSR内存屏障:
- 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 确实是这样实现的,我们一起来看一下具体的实现吧。
Java中,静态属性属于类的。操作静态属性,对应的指令为 putstatic
我们以 openjdk8 根路径 jdk\src\hotspot\share\interpreter\zero
路径下的 bytecodeInterpreter.cpp
文件中,处理 putstatic
指令的代码:
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变量的赋值逻辑
}
}
这里贴一下 cache->is_volatile()
的源码,路径:jdk\src\hotspot\share\utilities\accessFlags.hpp
// 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; }
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; }
我们看一下赋值 obj->release_long_field_put(field_offset, STACK_LONG(-1))
的源代码:
jdk\src\hotspot\share\oops\oop.inline.hpp
jlong oopDesc::long_field_acquire(int offset) const
{
return Atomic::load_acquire(field_addr<jlong>(offset));
}
void oopDesc::release_long_field_put(int offset, jlong value)
{
Atomic::release_store(field_addr<jlong>(offset), value);
}
我们前往 jdk\src\hotspot\share\runtime\atomic.hpp
看一下 Atomic::release_store
的方法:
inline T Atomic::load_acquire(const volatile T* p) {
return LoadImpl<T, PlatformOrderedLoad<sizeof(T), X_ACQUIRE> >()(p);
}
template <typename D, typename T>
inline void Atomic::release_store(volatile D* p, T v) {
StoreImpl<D, T, PlatformOrderedStore<sizeof(D), RELEASE_X> >()(p, v);
}
我们可以清楚的看到,const volatile T* p 和 volatile D* p
在调用的时候,直接使用了C/C++
的 volatile
关键字
我们继续往下看,在我门执行完参数的赋值后,会有这个一个操作:OrderAccess::storeload();
我们观察 jdk\src\hotspot\share\runtime
的 orderAccess.hpp
文件,发现有这么一段代码:
// barriers 屏障
static void loadload();
static void storestore();
static void loadstore();
static void storeload();
static void acquire();
static void release();
static void fence();
我们可以清楚的看到,这就是我们之前提到的 JVM 的读写屏障
当然,我们还要看其在 linux_x86
实现方式,在 jdk\src\hotspot\os_cpu\linux_x86
的 orderAccess_linux_x86.hpp
下
// A compiler barrier, forcing the C++ compiler to invalidate all memory assumptions
static inline void compiler_barrier() {
__asm__ volatile ("" : : : "memory");
}
inline void OrderAccess::loadload() { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore() { compiler_barrier(); }
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::acquire() { compiler_barrier(); }
inline void OrderAccess::release() { compiler_barrier(); }
4.4 CPU层面
在上面的代码中,我们可以看到,最关键的是这一行代码:__asm__ volatile ("" : : : "memory");
__asm__
:用于指示编译器在此插入汇编语句- volatile :告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化。即:原原本本按原来的样子处理这这里的汇编。
- (“” : : : “memory”):memory 强制 gcc 编译器假设 RAM 所有内存单元均被汇编指令修改,这样 cpu 中的 registers 和 cache 中已缓存的内存单元中的数据将作废。cpu 将不得不在需要的时候重新读取内存中的数据。这就阻止了 cpu 又将 registers, cache 中的数据用于去优化指令,而避免去访问内存。
简单概括:告诉我们的CPU,别瞎几把给我优化了,我就要串行执行。
这样我们可以看到,这些指令都是通过更改CPU的 寄存器 和 缓存
来保持有序性的。
还有,我们观察这些方法,会发现有一个叫 fence() 的方法,我们观察一下这个方法:
inline void OrderAccess::fence() {
// 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
compiler_barrier();
}
我们可以看到,我们的方法不建议我们使用我们的原语指令 mfence(内存屏障)
,因为 mfence 的资源消耗要比 locked
资源消耗的多。
补充:(Intel 的原语指令:
mfence内存屏障
,ifence读屏障
,sfence写屏障
)
直接判断是不是 AMD64
来对其不同的寄存器 rsp\esp
做处理
“lock; addl $0,0(%%rsp)”:在 rsp 寄存器上加一个 0) 指令是一个 Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个 CPU。
好了,到这里,我们的 volatile
基本差不多了。
五、 hanppens-before原则 与 hanppens-before原则
虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。
- 程序顺序原则:一个线程内保证语义的串行性。
- volatile规则:volatile变量的写先于读发生,这保证了volatile变量的可见性。
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
- 传递性:A先于B,B先于C,那么A必然先于C。
- 线程启动规则 :线程的start()方法先于它的每一个动作。
- 线程终止规则 :线程的所有操作先于线程的终结(Thread.join())。
- 线程中断规则 :线程的中断(interrupt())先于被中断线程的代码。
- 对象终结规则 :对象的构造函数的执行、结束先于finalize()方法。
这些原则都是为了保证指令重排不会破坏原有的语义结构。
as if serial 表达的就是:不管如何重排序,单线程执行的结果不会改变。
ok,结束!