在并发基础中我们分析了并发问题的根源是三个问题:原子性、可见性和有序性问题。简单回顾一下这三个特性:
-
原子性:一个具有原子性的操作应该是不可以被打断的,要么全部不执行,要么全部执行,并且中途不会被打断。 -
可见性:CPU 中会有缓存空间,在缓存空间中执行计算时,并没有立刻把计算结果同步到内存中。 -
有序性:编译器为了提高性能,会对代码在编译期间进行优化,对指令进行重排序。生成的指令可能会在执行顺序上与我们代码想要的顺序不同。
在 Java 中,为了解决上述的并发问题,提供了 volatile
关键字。
volatile 的作用
原子性问题
volatile
关键字修饰的变量与普通变量的原子性操作一样,只能保证单独的读或单独的写操作的原子性。而类似 i++
这种操作,是多个操作合并的运算,无法保证原子性。
有序性问题
保证有序性的手段是防止指令重排,Happens-Before 规则中的 volatile
变量规则规定:对一个 volatile
变量的写操作先行发生于后面对这个变量的读操作。
可见性问题
底层通过内存屏障解决可见性问题。
底层实现原理
回顾计算机的内存模型:

因为硬件设备不同部分的读写速度并不相同,所以实际上计算机从硬件架构上设计了三级缓存:
-
CPU 中的缓存 -
高速缓存 -
主内存
当多个处理器的计算都涉及同一块内存区域中的内容时,会导致高速内存中的数据不一致,为了解决这个问题,在高速缓存与主内存之间多了一层缓存一致性协议,在读写时要根据协议来进行操作。
“
关于缓存一致性协议,涉及知识内容比较多,后续再单独讲。
很显然,缓存一致性协议能够解决计算机在硬件层面的可见性问题,除了缓存一致性协议,一般还会做其他的优化方案。而内存屏障就是一种关于内存读写排序的一部分,在不同体系结构下变化很大而不适合推广。
内存屏障
大多数处理器提供了内存屏障指令:
-
完全内存屏障(full memory barrier)确保内存读和写操作;保障了内存屏障前的读写操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的读写操作。
-
内存读屏障(read memory barrier)仅确保了内存读操作;保障了内存屏障前的读操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的读操作。
-
内存写屏障 (write memory barrier) 仅保证了内存写操作。保障了内存屏障前的写操作执行完毕、并且将结果提交到内存之后,再执行晚于屏障的写操作。
简单来说,就是在确保内存屏障前后读写操作都写回主内存后再执行后续读写。
内存屏障可简单分为读屏障和写屏障。两两组合就会有四种情况:
-
read_read
read1 操作
read_read 屏障
read2 操作在 read2 操作及读取操作读取的数据前, read1 读操作一定执行完成。
-
read_write
read 操作
read_write 屏障
write 操作在 write 及后续写入操作执行前, read 操作一定执行完成。
-
write_write
write1 操作
write_write 屏障
write2 操作在 write2 及后续写入操作执行前,保证 write1 的写入操作对其它处理器可见。
-
write_read
write 操作
write_read 屏障
read 操作在 read 及后续所有读取操作执行前,保证 write 的写入对所有处理器可见。
volatile
关键字底层执行了悲观的内存屏障读写策略:
-
在每个 volatile
写操作前插入 write_write 屏障,在写操作后插入 write_read 屏障; -
在每个 volatile
读操作前插入 read_read 屏障,在读操作后插入 read_write 屏障;
这里实际上应该是 read 对应 Load;write 对应 Store ,但从个人角度讲,用 read 和 write 更方便理解:
内存屏障 | 说明 |
---|---|
StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 |
StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 |
LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 |
LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 |
// JDK 底层代码中的内存屏障
static void loadload();
static void storestore();
static void loadstore();
static void storeload();
内存屏障通过在操作之间添加屏障,从而实现了提供可见性和防止指令重排的效果。
可见性
write_write 和 write_read 保证了在写操作执行后,立刻对外部可见。
防止指令重排
happen-before 规则中的 volatile 规则,保证了代码的有序性。而 volatile 关键字底层通过内存屏障确保了读写按顺序执行。
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
代码层面分析
反编译一个 volatile
关键字修饰的属性:
// 代码
volatile boolean x = false;
// 字节码
volatile boolean x;
descriptor: Z
flags: (0x0040) ACC_VOLATILE
该属性配置了一个 ACC_VOLATILE
的 flag ,通过 OpenJDK 源码去查找它的底层实现,在文件 src/hotspot/share/utilities/accessFlags.hpp
中发现了相关逻辑:
// JVM_ACC_VOLATILE = 0x0040
// Java access flags
bool is_volatile () const { return (_flags & JVM_ACC_VOLATILE ) != 0; }
is_volatile()
方法的处理逻辑在 src/hotspot/share/interpreter/zero/bytecodeInterpreter.cpp
中:
if (cache->is_volatile()) {
switch (tos_type) {
case itos:
obj->release_int_field_put(field_offset, STACK_INT(-1));
break;
// ...
case atos: {
oop val = STACK_OBJECT(-1);
VERIFY_OOP(val);
obj->release_obj_field_put(field_offset, val);
break;
}
default:
ShouldNotReachHere();
}
OrderAccess::storeload();
} else {
switch (tos_type) {
case itos:
obj->int_field_put(field_offset, STACK_INT(-1));
break;
// ...
case atos: {
oop val = STACK_OBJECT(-1);
VERIFY_OOP(val);
obj->obj_field_put(field_offset, val);
break;
}
default:
ShouldNotReachHere();
}
}
这里省略了一些分支,都是不同类型类似的处理。通过对比发现,如果是 volatile
变量,调用了 release_XXX_field_put
, 而不是 volatile
变量,则调用 XXX_field_put
。引用类型额外处理,但本质上也是一样的。
release_int_field_put
方法定义在 src/hotspot/share/oops/oop.cpp
中发现:
void oopDesc::release_int_field_put(int offset, jint value) {
Atomic::release_store(field_addr<jint>(offset), value);
}
int_field_put
方法定义则是:
inline void oopDesc::int_field_put(int offset, jint value) {
*field_addr<jint>(offset) = value;
}
release_int_field_put
通过 Atomic
类的 release_store
方法来进行指针赋值;int_field_put
则是直接赋值。区别也就是 Atomic::release_store(...)
,它的定义在 src/hotspot/share/runtime/atomic.hpp
中:
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);
}
在 release_store
方法中的第一个参数声明了 volatile
,所以 Java 的 volatile
关键字本质上是使用了 C/C++ 中的 volatile
关键字的特性。
C 语言中的 volatile 原理
这是一个简单的 C 语言 demo :
#include <stdio.h>
int fn(int num) {
return num + 3;
}
int main() {
int a = 5;
int b = 10;
int c = 20;
scanf("%d", &c);
a = fn(c);
b = a + 1;
printf("%dn", b);
}
通过 gcc -S -masm=intel -O4 xxx.c
将 C 语言代码转换成汇编代码,其中会直接进行 a + 4
:
call _scanf
mov esi, dword ptr [rbp - 4]
add esi, 4 // 【*】这里 add 指令直接进行了 +4 操作
lea rdi, [rip + L_.str.1]
如果没有 scanf
这一行,编译结果直接是:
mov esi, 24
相当于 c + 3 + 1
直接进行了合并。
而如果给 a 加上 volatile
关键字,汇编结果是:
mov eax, dword ptr [rbp - 4]
add eax, 3 // add 加法操作
mov dword ptr [rbp - 8], eax
mov esi, dword ptr [rbp - 8] // mov 传送字或字节.
inc esi // inc 指令 加 1 操作
lea rdi, [rip + L_.str.1] // lea 装入有效地址
xor eax, eax // xor 异或运算
call _printf
xor eax, eax
add rsp, 16
pop rbp
ret
可以看出,fn
方法中的 + 3
操作,单独执行,b = a + 1
也是单独执行。
从这个 demo 中能够分析出一个结论,C 语言的 volatile 关键字的作用告诉编译器,不进行优化处理。也就是防止指令重排的效果。
原文始发于微信公众号(八千里路山与海):Java 多线程并发【6】volatile
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/85120.html