Atomic类lazySet的奥秘
引言只要你使用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 xJNIHandles::resolve(x_h);oop pJNIHandles::resolve(obj);// 通过obj的字段offset获取对应的目标地址void*addrindex_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_dummy0;}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_staticfalse) ├─ resolve_cache_and_index() // 解析常量池缓存 ├─ load_field_cp_cache_entry() // 加载字段元数据 ├─ 按类型分发写入字段 └─ volatile_barrier() // volatile 内存屏障第一步解析常量池缓存resolve_cache_and_indexresolve_cache_and_index(byte_no,cache,index,sizeof(u2));从字节码流BCP 1读取常量池缓存索引。检查缓存项中是否已存储当前字节码即是否已解析过已解析直接跳到resolved标签跳过InterpreterRuntime::resolve_get_put调用。未解析首次执行调用InterpreterRuntime::resolve_get_put由运行时完成字段查找、访问权限检查、类初始化等工作并将结果写入常量池缓存。第二步加载字段元数据load_field_cp_cache_entryload_field_cp_cache_entry(obj,cache,index,off,flags,is_static);从常量池缓存项中读取三个关键信息寄存器含义off(rbx)字段在对象中的字节偏移量来自f2_offsetflags(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(...)写对象引用触发写屏障GCitos(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_accesstrue;Node*store;if(typeT_OBJECT)// reference stores need a store barrier.storestore_oop_to_unknown(control(),base,adr,adr_type,val,type,MemNode::release);else{storestore_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_volfield-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_membarinsert_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*mbinsert_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. 内存屏障语义对比维度putOrderedXXXvolatile set写操作前Release屏障StoreStore LoadStoreRelease屏障StoreStore LoadStore写操作后无屏障StoreLoad屏障MemBarVolatile/lock addl内存语义仅Release语义Release 全屏障语义2. 核心差异写后屏障putOrderedXXX写操作完成后不插入 StoreLoad 屏障允许后续的 Load 操作被重排序到该写操作之前从其他线程视角看写入结果不保证立即可见。volatile set写操作完成后强制插入 StoreLoad 屏障x86 上为lock addl禁止任何后续读写越过该写操作保证写入结果对其他线程立即可见。3. 性能对比putOrderedXXXvolatile set开销较低无lock addl较高有lock addl适用场景只需保证读写操作不被延后到写操作之后如单生产者队列尾指针更新需要写操作对其他线程立即可见的强一致性场景三、实验验证我们使用jmh来验证我们的分析结论是否正确Warmup(iterations1,time5,timeUnitTimeUnit.SECONDS)Measurement(iterations1,time5,timeUnitTimeUnit.SECONDS)Fork(1)BenchmarkMode(Mode.Throughput)OutputTimeUnit(TimeUnit.MILLISECONDS)State(Scope.Benchmark)publicclassVolatilePerfBenchmark{privatestaticAtomicIntegerint1newAtomicInteger(1);privatestaticAtomicIntegerint2newAtomicInteger(1);privatestaticvolatilebooleantest1false;privatestaticbooleantest2false;Benchmarkpublicvoidbaseline()throwsException{}BenchmarkpublicbooleantestVolatileGet()throwsException{returntest1;}BenchmarkpublicbooleantestNormalGet()throwsException{returntest2;}BenchmarkpublicinttestAtomicGet()throwsException{returnint1.get();}BenchmarkpublicvoidtestVolatileSet()throwsException{test1true;}BenchmarkpublicvoidtestAtomicSet()throwsException{int1.set(2);}BenchmarkpublicvoidtestAtomicSetLazy()throwsException{int2.lazySet(3);}BenchmarkpublicvoidtestNormalSet()throwsException{test2true;}publicstaticvoidmain(finalString[]args)throwsException{OptionsoptnewOptionsBuilder().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 testNormalGet6倍差距volatile读操作与普通读操作性能相当lazy写操作比普通写操作慢约1倍volatile写操作比普通写操作慢约20倍atomic写操作是最慢的和volatile写操作相差无几备注实验使用的工具版本JMH version: 1.20VM version: JDK 1.8.0_362, VM 25.362-b1总结putOrderedXXX只保证Release 语义读写不被延后但不保证写后立即对其他线程可见volatile set在此基础上额外插入StoreLoad 屏障保证写操作对所有线程立即可见代价是更高的性能开销。