引言
只要你使用Java进行开发工作,那么在漫长的工作生涯中或多或少都需要面对高并发问题以及涉及使用JUC工具类,笔者当年从OTA互联网行业进入Java的世界,在负责库存管理时偶尔会遇到高并发协同而导致的系统吞吐下降,于是深入研究无锁、轻量级锁等并发技术,开始阅读JUC工具类的实现逻辑,就会遇到这个在Atomic相关类中的方法lazySet。初次遇到这个方法肯定是摸不着头脑,不能明白Doug Lea设计这个方法的初衷,搞不清楚它和普通的set方法到底有什么不同。当想要自己一探究竟时,没翻两行代码就遇到了native关键字,直接被拦截在门外。不死心那就只能上网搜索开始“人云亦云”,遂收藏几个博客链接,默认自己掌握了其中的奥秘,起码面试的时候有了“你来我往”的谈资……所以你是不是也这样子?那就跟着笔者来一探究竟吧!
一、putOrderedXXX和volatile set的实现原理
因为hotspot虚拟机分为解释执行和JIT编译执行,我们从这两种不同的执行方式来分析它们的实现差异。
1. 解释执行
为了可以控制寄存器的行为,实现TOS(top of stack cache)栈顶缓存技术和各种fast版本的字节码指令(如使用fast_iload指令替代iload指令)等优化机制,hotspot虚拟机使用汇编模版来生成字节码对应的汇编代码,而不是使用C++代码来实现各种字节码。
putOrderedXXX的实现
我们以putOrderedObject为例子:
UNSAFE_ENTRY(void,Unsafe_SetOrderedObject(JNIEnv*env,jobject unsafe,jobject obj,jlong offset,jobject x_h))UnsafeWrapper("Unsafe_SetOrderedObject");oop x=JNIHandles::resolve(x_h);oop p=JNIHandles::resolve(obj);// 通过obj的字段offset获取对应的目标地址void*addr=index_oop_from_field_offset_long(p,offset);// 内存release语义OrderAccess::release();// 保存x_h到目标地址if(UseCompressedOops){oop_store((narrowOop*)addr,x);}else{oop_store((oop*)addr,x);}// 内存fence语义OrderAccess::fence();UNSAFE_END这里出现了操控内存顺序语义的函数,我们先了解一下编译器重排序和cpu重排序,才能明白如何实现内存操作的顺序。由于编译器为了提高代码执行效率,会对没有读写依赖关系的操作进行顺序打乱,而编译后的代码顺序(已重排)进入cpu进行执行时,又会受到cpu执行单元和乱序流水线的影响,在cpu侧执行时再次进行重排执行。这两种代码执行的重排序都是为了提高代码执行效率,但是某些场景(特别在多线程下)我们并不需要这种优化,它会导致我们的代码执行出现错误,所以需要操控内存顺序的手段(如内存屏障)来保证代码的逻辑符合我们的预期。
我们只需要关注四种基本内存屏障操作:
Load1(s); LoadLoad; Load2: Load1之前的加载操作*不能*重排序到Load2之后 Store1(s); StoreStore; Store2: Store1之前的存储操作*不能*重排序到Store2之后 Load1(s); LoadStore; Store2: Load1之前的加载操作*不能*重排序到Store2之后 Store1(s); StoreLoad; Load2: Store1之前的存储操作*不能*重排序到Load2之后由于涉及内存语义和cpu重排序,我们以x86架构的cpu为例:
inlinevoidOrderAccess::release(){volatilejint local_dummy=0;}inlinevoidOrderAccess::fence(){if(os::is_MP()){#ifdefAMD64__asm__volatile("lock; addl $0,0(%%rsp)":::"cc","memory");#else__asm__volatile("lock; addl $0,0(%%esp)":::"cc","memory");#endif}}特别说明,x86架构的cpu由于TSO内存模型只实现了StoreLoad的重排序,所以只要针对其进行限制就可以让cpu不再进行任何重排序操作。
我们可以看看以上两个函数编译后的代码:release():
movl $0, -4(%rsp)fence():
lock; addl $0,0(%rsp)就是这么朴实无华的两个操作,看似不起眼,实则力挽狂澜:
release()函数首先使用volatile关键字禁止编译器进行重排序优化,这是一个编译器内存屏障,release()函数之前的内存读写都无法重排序到release()之后,再者,movl $0, -4(%rsp)本身就是一个对内存的写操作,所以release()相当于一个StoreStore/LoadStore内存屏障;而x86不会对StoreStore/LoadStore进行重排序,所以不需要额外对cpu执行进行任何操控。fence()函数首先使用__asm__ volatile()带volatile的汇编内嵌禁止编译器进行重排序优化,而addl $0,0(%rsp)是一个对内存进行读写和逻辑加法的双重操作,但由于它对内存地址对应的值加0,并不会更改内存里的值,非常巧妙地利用volatile关键字实现了StoreLoad内存屏障;而x86会对StoreLoad进行重排序,所以需要额外对cpu执行进行干预,我们可以使用x86架构的cpu指令mfence指令来实现全内存屏障,保证所有mfence指令之前的指令不会重排序到mfence指令之后,但是这个指令的成本太高,hotspot使用lock前缀即在普通指令之前增加lock指令来实现StoreLoad内存屏障。(lock前缀请参考x86的操作手册)
volatile set的实现
主要对应在类对象字段的set操作上,解释执行时生成汇编代码的入口如下:
TemplateTable::putfield(intbyte_no)整体调用链
putfield(byte_no) └─► putfield_or_static(byte_no, is_static=false) ├─ resolve_cache_and_index() // 解析常量池缓存 ├─ load_field_cp_cache_entry() // 加载字段元数据 ├─ 按类型分发写入字段 └─ volatile_barrier() // volatile 内存屏障第一步:解析常量池缓存resolve_cache_and_index
resolve_cache_and_index(byte_no,cache,index,sizeof(u2));- 从字节码流(BCP + 1)读取常量池缓存索引。
- 检查缓存项中是否已存储当前字节码(即是否已解析过):
- 已解析:直接跳到
resolved标签,跳过InterpreterRuntime::resolve_get_put调用。 - 未解析(首次执行):调用
InterpreterRuntime::resolve_get_put,由运行时完成字段查找、访问权限检查、类初始化等工作,并将结果写入常量池缓存。
- 已解析:直接跳到
第二步:加载字段元数据load_field_cp_cache_entry
load_field_cp_cache_entry(obj,cache,index,off,flags,is_static);从常量池缓存项中读取三个关键信息:
| 寄存器 | 含义 |
|---|---|
off(rbx) | 字段在对象中的字节偏移量(来自f2_offset) |
flags(rax) | 字段的栈顶缓存类型标识(tos_state)和volatile 标志 |
obj(rcx) | 仅 static 时使用,存放Klass的 mirror 对象 |
第三步:提取 volatile 标志
__movl(rdx,flags);__shrl(rdx,ConstantPoolCacheEntry::is_volatile_shift);__andl(rdx,0x1);将volatile标志位单独保存到rdx,供最后的内存屏障判断使用。
第四步:按字段类型分发写入
从flags中提取tos_state(栈顶缓存类型标识),通过一系列分支判断跳转到对应的类型处理分支:
flags >> tos_state_shift & mask → 类型判断每个分支的逻辑为:
1. __ pop(Xtos) // 从操作数栈弹出值到 rax/xmm0 2. pop_and_check_object(obj) // 弹出对象引用到 rcx,并做 null check 3. __ movX(field, rax) // 将值写入 [obj + off] 4. patch_bytecode(...) // 将字节码改写为快速版本 5. __ jmp(Done)各类型对应的写入指令:
| 类型 | 写入指令 | 说明 |
|---|---|---|
btos(byte) | movb(field, rax) | 写 1 字节 |
ztos(boolean) | andl(rax,1)+movb | 取最低位后写 1 字节 |
atos(reference) | do_oop_store(...) | 写对象引用,触发写屏障(GC) |
itos(int) | movl(field, rax) | 写 4 字节 |
ctos(char) | movw(field, rax) | 写 2 字节 |
stos(short) | movw(field, rax) | 写 2 字节 |
ltos(long) | movq(field, rax) | 写 8 字节 |
ftos(float) | movflt(field, xmm0) | 写 4 字节浮点 |
dtos(double) | movdbl(field, xmm0) | 写 8 字节浮点 |
其中field的地址计算为:Address(obj, off, times_1)=rcx + rbx,即对象基址 + 字段偏移。
第五步:volatile 内存屏障
__testl(rdx,rdx);__jcc(Assembler::zero,notVolatile);volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad|Assembler::StoreStore));__bind(notVolatile);- 若字段是
volatile,在写入完成后插入StoreLoad + StoreStore屏障(StoreLoad 在 x86 上对应lock addl),保证写操作对其他线程立即可见。 - 注意:写入之前的 LoadStore + StoreStore 屏障在代码中被注释掉了(
[jk] not needed currently),因为 x86 的 TSO 内存模型天然保证了 Store 不会被重排到前面的 Load 之前。
由于解释执行的汇编代码没有经过c++编译器编译而是直接通过汇编模版生成的,所以这里不需要处理编译器重排序的问题,只要解决cpu重排序的问题就可以了。
2. 编译执行
hotspot的编译部分由于实现了分层编译,分别由C1和C2两种编译器来实现了不同层级的编译功能,我们跳过C1直接研究C2生成汇编的逻辑。当提到C2编译器时,这里需要提及一个叫intrinsic的机制,即提前手写汇编代码,然后通过类名和方法签名把Java方法映射成手写汇编代码,从而跳过编译器编译的机制。为什么要跳过编译器编译呢?由于某些特殊case的代码直接手写汇编的执行效率明显高于编译器按部就班地进行编译优化的产物,所以通过使用这种机制保证更优的代码编译和执行效率。
putOrderedXXX的编译实现
putOrderedXXX的编译实现正好使用了intrinsic机制,我们还是以putOrderedObject为例子:
boolLibraryCallKit::inline_unsafe_ordered_store(BasicType type){...insert_mem_bar(Op_MemBarRelease);insert_mem_bar(Op_MemBarCPUOrder);// Ensure that the store is atomic for longs:constboolrequire_atomic_access=true;Node*store;if(type==T_OBJECT)// reference stores need a store barrier.store=store_oop_to_unknown(control(),base,adr,adr_type,val,type,MemNode::release);else{store=store_to_memory(control(),adr,val,type,adr_type,MemNode::release,require_atomic_access);}insert_mem_bar(Op_MemBarCPUOrder);}内存屏障组合
insert_mem_bar(Op_MemBarRelease);// ① Release 屏障insert_mem_bar(Op_MemBarCPUOrder);// ② CPU 顺序屏障(前)// ... store ...insert_mem_bar(Op_MemBarCPUOrder);// ③ CPU 顺序屏障(后)三个屏障的作用:
| 屏障 | 类型 | 语义 |
|---|---|---|
MemBarRelease | 多 线程 可见 | Release 语义:屏障之前的所有读写不能重排到 store 之后,保证其他 CPU 可见 |
MemBarCPUOrder(前) | 仅编译器顺序 | 防止编译器将 store 提前到 MemBarRelease 之前 |
MemBarCPUOrder(后) | 仅编译器顺序 | 防止编译器将后续操作提前到 store 之前 |
MemBarCPUOrder是纯编译器级别的顺序约束(不生成机器指令),用于在 C2 IR 图(理想图)中固定节点顺序,防止 GVN/调度优化等越过屏障移动节点。
所以从代码逻辑上看其只实现了Release语义,而MemBarCPUOrder只是保证前后的代码不重排序。
volatile set的编译实现
voidParse::do_put_xxx(Node*obj,ciField*field,boolis_field){...boolis_vol=field->is_volatile();// If reference is volatile, prevent following memory ops from// floating down past the volatile write. Also prevents commoning// another volatile read.if(is_vol){leading_membar=insert_mem_bar(Op_MemBarRelease);}...if(is_vol){// If not multiple copy atomic, we do the MemBarVolatile before the load.if(!support_IRIW_for_not_multiple_copy_atomic_cpu){Node*mb=insert_mem_bar(Op_MemBarVolatile,store);// Use fat membarMemBarNode::set_store_pair(leading_membar->as_MemBar(),mb->as_MemBar());}}...}内存屏障组合
insert_mem_bar(Op_MemBarRelease);// ① Release 屏障// ... store ...insert_mem_bar(Op_MemBarVolatile);// ② volatile 屏障MemBarVolatile就会生成在解释执行中出现过的lock addl指令。
二、putOrderedXXX和volatile set的区别
在解释执行时,两者相差无几,在性能上没有什么优化,而在C2编译执行时,两者相差较大,前者明显在执行性能上优于后者:
1. 内存屏障语义
| 对比维度 | putOrderedXXX | volatile set |
|---|---|---|
| 写操作前 | Release屏障(StoreStore + LoadStore) | Release屏障(StoreStore + LoadStore) |
| 写操作后 | 无屏障 | StoreLoad屏障(MemBarVolatile/lock addl) |
| 内存语义 | 仅Release语义 | Release + 全屏障语义 |
2. 核心差异:写后屏障
putOrderedXXX:写操作完成后不插入 StoreLoad 屏障,允许后续的 Load 操作被重排序到该写操作之前(从其他线程视角看,写入结果不保证立即可见)。volatile set:写操作完成后强制插入 StoreLoad 屏障(x86 上为lock addl),禁止任何后续读写越过该写操作,保证写入结果对其他线程立即可见。
3. 性能对比
putOrderedXXX | volatile set | |
|---|---|---|
| 开销 | 较低(无lock addl) | 较高(有lock addl) |
| 适用场景 | 只需保证读写操作不被延后到写操作之后(如单生产者队列尾指针更新) | 需要写操作对其他线程立即可见的强一致性场景 |
三、实验验证
我们使用jmh来验证我们的分析结论是否正确:
@Warmup(iterations=1,time=5,timeUnit=TimeUnit.SECONDS)@Measurement(iterations=1,time=5,timeUnit=TimeUnit.SECONDS)@Fork(1)@BenchmarkMode(Mode.Throughput)@OutputTimeUnit(TimeUnit.MILLISECONDS)@State(Scope.Benchmark)publicclassVolatilePerfBenchmark{privatestaticAtomicIntegerint1=newAtomicInteger(1);privatestaticAtomicIntegerint2=newAtomicInteger(1);privatestaticvolatilebooleantest1=false;privatestaticbooleantest2=false;@Benchmarkpublicvoidbaseline()throwsException{}@BenchmarkpublicbooleantestVolatileGet()throwsException{returntest1;}@BenchmarkpublicbooleantestNormalGet()throwsException{returntest2;}@BenchmarkpublicinttestAtomicGet()throwsException{returnint1.get();}@BenchmarkpublicvoidtestVolatileSet()throwsException{test1=true;}@BenchmarkpublicvoidtestAtomicSet()throwsException{int1.set(2);}@BenchmarkpublicvoidtestAtomicSetLazy()throwsException{int2.lazySet(3);}@BenchmarkpublicvoidtestNormalSet()throwsException{test2=true;}publicstaticvoidmain(finalString[]args)throwsException{Optionsopt=newOptionsBuilder().include(VolatilePerfBenchmark.class.getSimpleName())//.addProfiler(StackProfiler.class).result(VolatilePerfBenchmark.class.getSimpleName()+".json").resultFormat(ResultFormatType.JSON).build();newRunner(opt).run();}}最终得到的结果如下:
Benchmark Mode Cnt Score Error Units VolatilePerfBenchmark.baseline thrpt 2728982.687 ops/ms VolatilePerfBenchmark.testNormalSet thrpt 2142444.611 ops/ms VolatilePerfBenchmark.testAtomicSetLazy thrpt 1342074.026 ops/ms VolatilePerfBenchmark.testVolatileSet thrpt 110419.432 ops/ms VolatilePerfBenchmark.testAtomicSet thrpt 110253.050 ops/ms VolatilePerfBenchmark.testNormalGet thrpt 363300.966 ops/ms VolatilePerfBenchmark.testAtomicGet thrpt 301024.312 ops/ms VolatilePerfBenchmark.testVolatileGet thrpt 361102.161 ops/ms可以参考如下的图表得到更直观的观测结果:
读写操作性能差异显著
- 写操作比读操作慢得多(testNormalSet vs testNormalGet:6倍差距)
- volatile读操作与普通读操作性能相当
- lazy写操作比普通写操作慢约1倍
- volatile写操作比普通写操作慢约20倍
- atomic写操作是最慢的,和volatile写操作相差无几
备注,实验使用的工具版本:
- JMH version: 1.20
- VM version: JDK 1.8.0_362, VM 25.362-b1
总结
putOrderedXXX只保证Release 语义(读写不被延后),但不保证写后立即对其他线程可见;volatile set在此基础上额外插入StoreLoad 屏障,保证写操作对所有线程立即可见,代价是更高的性能开销。