Java DCL 单例模式真的需要对变量加 Volatile 吗?
原文鏈接:https://zhuanlan.zhihu.com/p/385271959
目錄
- 代碼展示
- DCL分析
- DCL單例變量加volatile關鍵字的原因
- Java對象創(chuàng)建過程
- volatile修飾單例變量的原因
- 不同角度下的對象創(chuàng)建原理
- 從C++角度分析對象創(chuàng)建
- 從Java角度分析對象創(chuàng)建
- 小結(jié)
- CPU模型與DCL
- 完整的Java DCL實例
代碼展示
對于單例模式來說,我們?yōu)榱吮WC一個類的實例在運行時只有一個,所以我們首先將構(gòu)造器私有化,禁止在其他地方創(chuàng)建該類的對象,同時我們將單例對象保存在該類的靜態(tài)變量中,當我們需要單例對象時,可以調(diào)用getObj方法來獲取對象,在該方法中我們首先判斷obj是否為空,如果不為空直接返回,否則使用synchronized加鎖后繼續(xù)判斷是否為空,若仍然不為空那么我們創(chuàng)建新的對象。詳細代碼如下所示,代碼中筆者用數(shù)字標號將代碼切割為5個部分。
public class Singleton {// 1public volatile static Singleton obj;private int a;private Singleton() {a=3;}public static Singleton getObj() {// 2if (obj == null) {// 3synchronized (Singleton.class) {// 4if (obj == null) {// 5obj = new Singleton();}}}return obj;}public static void main(String[] args) {getObj();} }DCL分析
對于標號為2的地方我們使用if判斷是為了增加性能,因為我們并不是每次都需要上鎖后判斷,這會降低性能,因為創(chuàng)建對象只是在第一次訪問時才會創(chuàng)建。在標號為3處我們使用synchronized對當前類對象上鎖,保證了多線程并發(fā)安全,這將會只允許一個線程進入其中創(chuàng)建對象,其他線程則等待。在標號為4處我們再次判斷對象是否為空,這是因為如果在外層標號為2處,同時有多個線程判斷obj為空,那么將會有多個線程阻塞在標號為3處的synchronized鎖處,雖然只有一個線程能進入,但是當進入創(chuàng)建對象的線程創(chuàng)建完對象后,會喚醒阻塞在標號3處的線程,這時線程進入,就需要再次判斷單例對象是否已經(jīng)被其他線程創(chuàng)建。在標號為5處我們創(chuàng)建了單例對象。DCL的很多博客,包括有朋友向筆者展示Doug Lea與其他人編寫的《Java并發(fā)編程實戰(zhàn)》一書,展示DCL必須要在標號為1處加上volatile,那這是為什么呢?我們來繼續(xù)分析。
DCL單例變量加volatile關鍵字的原因
對于Volatile的解釋,筆者在《從C語言聊聊JMM內(nèi)存可見性》一文中已經(jīng)詳細講解,這里不做過多解釋,文章鏈接:https://www.bilibili.com/read/cv9518280。這里我們只是簡單描述下volatile的語義,在java中該語義保證了可見性并保證了有序性,也即禁止指令重排,那么我們看到DCL的代碼中使用了synchronized關鍵字,而該關鍵字底層通過moniter_enter和monitor_exit兩個字節(jié)碼來完成,該字節(jié)碼自身已經(jīng)完成的可見性,所以我們這里使用volatile肯定不是因為可見性而使用得,那么只有一個答案,那就是禁止指令重排。那么為何需要禁止指令重排呢?
Java對象創(chuàng)建過程
我們先來看一段代碼,僅僅只是在main方法中創(chuàng)建了一個對象obj,并將其存入局部變量obj中,其中Demo對象定義了一個實例變量a,同時在構(gòu)造器中初始化了a變量為3。詳細代碼如下。
public class Demo{private int a = 0;public Demo(){a=3;}public static void main(String[] args){Demo obj = new Demo();} }那么我們來看生成的對應字節(jié)碼信息,我們看到首先通過new指令創(chuàng)建了class Demo對象,隨后使用dup復制了一個對象引用,隨后使用字節(jié)碼指令invokespecial調(diào)用該對象的方法,該方法也即構(gòu)造方法,隨后調(diào)用astore_1指令,將剩余的一個引用保存至局部變量表為索引為1的slot中。
public static void main(java.lang.String[]);descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:stack=2, locals=2, args_size=10: new #3 // class Demo3: dup4: invokespecial #4 // Method "<init>":()V7: astore_18: returnvolatile修飾單例變量的原因
那么問題就出現(xiàn)在如下字節(jié)順序中,我們看到創(chuàng)建的對象需要分為兩步,創(chuàng)建對象實例,調(diào)用實例構(gòu)造函數(shù),假如我們不加上volatile,那么將會調(diào)用astore_1指令重排序到invokespecial之前,從而導致外部線程雖然拿到了單例對象,但是該對象是不完整的,因為其構(gòu)造函數(shù)還未調(diào)用,那么這時它的成員變量應該是0,而不是3。
new dup invokespecial #4 astore_1不同角度下的對象創(chuàng)建原理
那么我們此時僅僅只是站在字節(jié)碼指令的角度去看待該問題,我們知道字節(jié)碼是交給虛擬機執(zhí)行的,如果沒有底層的匯編指令支撐,那么我們沒法了解到確切的真相:astore_1真的會和invokespecial指令重排嗎?甚者很多博客和書籍說編譯器會導致指令重排。那么我們現(xiàn)在就來通過C++和Java匯編的角度來看看,是否編譯器會導致重排序。
從C++角度分析對象創(chuàng)建
我們從C++層面,通過調(diào)整編譯器為最大優(yōu)化級別,看看是否編譯器會導致創(chuàng)建對象過程和調(diào)用對象構(gòu)造函數(shù)的過程重排序,C++和Java畢竟創(chuàng)建對象都是這么做的,但是C++可以將new運算符重載,Java不行。我們來看代碼,同樣我們創(chuàng)建一個類為Singleton,同時也聲明了成員變量a,在構(gòu)造器中將其初始化為3,為了保證生成的匯編代碼簡單,筆者這里把mutex上鎖的代碼去了,畢竟C++可不知道什么synchronized關鍵字,不過這并不影響我們研究問題的本質(zhì)。代碼實現(xiàn)如下。
using namespace std; class Singleton { public:int a;static Singleton* getObj() { if ( obj == NULL ) obj = new Singleton(); return obj; } private: Singleton(){a=3;}; static Singleton * obj; }; ? int main(){Singleton *p=Singleton::getObj();return 1; }接下來我們用gcc -S -O4 -mno-sse demo.cpp -lstdc++命令,開啟最高級別優(yōu)化編譯該代碼,隨后我們來看生成的匯編指令,我們看到在main方法中代碼被編譯器優(yōu)化為直接取類Singleton的靜態(tài)變量地址直接判斷是否為null,如果不為null直接返回,否則調(diào)用.L6處代碼繼續(xù)執(zhí)行。我們看到call _Znwm用于創(chuàng)建對象內(nèi)存地址,而movl $3, (%rax)則是構(gòu)造器中的賦值操作,將3放入rax所指的內(nèi)存地址空間中,隨后調(diào)用movq %rax, _ZN9Singleton3objE(%rip)將該對象地址放入靜態(tài)變量obj中。那么我們看到,在最高級別的優(yōu)化下,編譯器并不會將構(gòu)造器的調(diào)用和放置對象地址的操作重排序。
main:cmpq $0, _ZN9Singleton3objE(%rip) ; 看看靜態(tài)變量obj是否為null(C++非零即真)je .L6 ; 如果為0,那么跳轉(zhuǎn)到.L6處執(zhí)行movl $1, %eax ; 直接返回1ret .L6:pushq %rax ; 保存rax信息到棧上movl $4, %edicall _Znwm ; 調(diào)用函數(shù),開辟對象內(nèi)存,也即new操作符movl $3, (%rax) ; 當call _Znwm 返回后,rax寄存中保存值為開辟的內(nèi)存地址,此時將3放入該地址中movq %rax, _ZN9Singleton3objE(%rip) ; 將創(chuàng)建的對象內(nèi)存地址放入靜態(tài)變量obj的地址中movl $1, %eax ; 將返回值放入eax中popq %rdx ; 彈出rdxret ; 返回從Java角度分析對象創(chuàng)建
我們來看Java代碼,同樣為了保證生成的匯編代碼簡單,筆者這里去掉了加鎖的操作,畢竟我們只是看看編譯器是否會導致指令重排,因為加了synchronized關鍵字只是保證了互斥性和可見性,但是synchronized關鍵字內(nèi)的互斥代碼并不能保證有序性。
public class Singleton {public static Singleton obj;int a;private Singleton() {a = 3;}public static Singleton getObj() {if (obj == null) {obj = new Singleton();}return obj;}public static void main(String[] args) {getObj();} }我們來看匯編代碼,這里我們使用-XX:TieredStopAtLevel=4指定編譯層級為4最高等級優(yōu)化,
0x0000000003556a2f: jae 0x0000000003556a9f ; 調(diào)用new操作創(chuàng)建對象 0x0000000003556a5c: mov %rax,%rbp ;*new ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) 保存創(chuàng)建的對象地址放入rbp中我們看到以上代碼為創(chuàng)建對象過程,由于其中創(chuàng)建對象需要獲取到元數(shù)據(jù)信息metadata,然后將對象放入操作數(shù)棧等等步驟,所以其中包含較多匯編代碼,筆者這里去掉了不需要的匯編,只保留這兩句。我們只需要關注這一句jae 0x0000000003556a9f,我們繼續(xù)看該地址的操作。
0x0000000003556a9f: movabs $0x7c0060828,%rdx ; {metadata('org/com/msb/dcl/Singleton')} 0x0000000003556aa9: xchg %ax,%ax 0x0000000003556aab: callq 0x00000000035512e0 ; OopMap{off=208} ;*new ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) ; {runtime_call} 這里就是調(diào)用創(chuàng)建對象的方法地址 0x0000000003556ab0: jmp 0x0000000003556a5c ;*new ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) // 創(chuàng)建完畢后跳轉(zhuǎn)到該地址接下來我們繼續(xù)看0x0000000003556a5c之后的代碼,
0x0000000003556a5c: mov %rax,%rbp ;*new ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) 0x0000000003556a5f: mov %rbp,%rdx 0x0000000003556a62: nop 0x0000000003556a63: callq 0x00000000031d61a0 ; OopMap{rbp=Oop off=136} ;*invokespecial <init> ; - org.com.msb.dcl.Singleton::getObj@10 (line 19) ; {optimized virtual_call} 調(diào)用<init>方法,該方法也即對象的構(gòu)造器 0x0000000003556a68: mov %rbp,%r10 0x0000000003556a6b: shr $0x3,%r10 0x0000000003556a6f: movabs $0x66b6acc08,%r11 ; {oop(a 'java/lang/Class' = 'org/com/msb/dcl/Singleton')} 0x0000000003556a79: mov %r10d,0x68(%r11) ; 將對象的地址放入到類靜態(tài)變量obj中,0x68為obj偏移量 0x0000000003556a7d: movabs $0x66b6acc08,%r10 ; {oop(a 'java/lang/Class' = 'org/com/msb/dcl/Singleton')} 0x0000000003556a87: shr $0x9,%r10 0x0000000003556a8b: mov $0x10741000,%r11d 0x0000000003556a91: mov %r12b,(%r11,%r10,1) 0x0000000003556a95: lock addl $0x0,(%rsp) ;*putstatic obj ; - org.com.msb.dcl.Singleton::getObj@13 (line 19) synchronized的monitor_exit保證可見性的操作 ? 0x0000000003556a9a: jmpq 0x00000000035569ff小結(jié)
由此我們從C++的角度,Java的角度分析,得到結(jié)論:編譯器將不會導致指令重排序。這也就是為什么在C++的單例模式中沒有對單例對象加上volatile關鍵字的原因,我們在《從C語言聊聊JMM內(nèi)存可見性》一文中知道,volatile對于C類語言來說只是禁止編譯器重排序的手段,既然編譯器不會干擾對于new操作符分配內(nèi)存、調(diào)用構(gòu)造器、賦值這三步的步驟,那么我們并不需要使用它。
CPU模型與DCL
接下來我們來看看,既然編譯器不會導致該指令重排,那么還有另外一種原因:CPU模型導致的重排現(xiàn)象。我們來看C++ DCL的這段匯編代碼,我們知道m(xù)ovl $3, (%rax)是構(gòu)造器中的操作,那么如果CPU在執(zhí)行過程中,將指令重排執(zhí)行將movq %rax, _ZN9Singleton3objE(%rip),也即對象寫入到了內(nèi)存中,這時就會導致半對象的產(chǎn)生。
call _Znwm ; 調(diào)用函數(shù),開辟對象內(nèi)存,也即new操作符 movl $3, (%rax) ; 當call _Znwm 返回后,rax寄存中保存值為開辟的內(nèi)存地址,此時將3放入該地址中 movq %rax, _ZN9Singleton3objE(%rip) ; 將創(chuàng)建的對象內(nèi)存地址放入靜態(tài)變量obj的地址中那么我們知道這是兩步寫入操作,在TSO模型下并不會產(chǎn)生問題,因為CPU MOB(內(nèi)存順序緩沖區(qū)訪問模型)為TSO模型下,只有storeload重排序現(xiàn)象,但是如果我們在其他模型,比如:PSO、RMO下,那么將會導致storestore亂序。那么這時我們就需要指令屏障來保證指令結(jié)果寫入順序,而Java的volatile語義恰好滿足了這一條件,同理我們在Java生成的匯編代碼也滿足這種現(xiàn)象。所以我們在前面使用volatile就是使用了它屏蔽底層模型,保證了完整的順序,但是這樣真的好嗎?附上一個JMM模型與CPU MMO模型的關系圖。
完整的Java DCL實例
我們來看去掉了volatile的單例模式,讀者可以看看上面的圖中,我們看到TSO模型下會導致storestore亂序,那么我們只需要一點小小的改動,就能完成保證了高性能,同時也能保證寫入順序的操作。代碼如下。
public class Singleton {public static Singleton obj;public static final Unsafe UNSAFE = MyUtils.getUnsafe();int a;private Singleton() {a = 3;}public static Singleton getObj() {if (obj == null) {synchronized (Singleton.class) {if (obj == null) {// 1Singleton obj = new Singleton();// 2 寫屏障保證局部變量obj的寫入順序與全局變量的寫入有序性UNSAFE.storeFence();// 3Singleton.obj = obj;}}}return obj;}public static void main(String[] args) {getObj();} }我們知道,只需要保證寫入順序即可,這時我們將volatile修飾符去掉,同時我們在標號為1處首先將創(chuàng)建的單例對象保存到局部變量中,隨后加上storeFence屏障,保證局部變量和全局變量的寫順序,這時就避免了會導致storestore內(nèi)存順序的CPU上寫寫的順序性。那么為何去掉volatile,用unsafe的內(nèi)存屏障呢?考慮下volatile的語義:volatile變量讀后面加上loadload、loadstore屏障,寫之前加上storestore屏障,寫之后加上storeload屏障。那么我們在對象創(chuàng)建完畢后,需要這些屏障嗎?答案肯定是否定的。所以我們不需要使用volatile關鍵字,通過unsafe的屏障就能完成同樣的工作。這種現(xiàn)象在Linux內(nèi)核中非常常見,不允許使用volatile,因為它禁止了編譯器在使用這些變量時的優(yōu)化,而對于內(nèi)核來說,它必須要滿足高性能,這時就要求:不能使用volatile關鍵字,當需要指令順序時,采用編譯器屏障(:::“memory”)或者指令屏障(lfence,sfence,mfence,lock前綴)。所以我們這里使用storefence避免了storestore的重排序現(xiàn)象,不同的CPU下的MOB模型,也即內(nèi)存順序緩沖區(qū)訪問模型的不同,將會導致不同程度下的loadload、loadstore、storeload、storestore現(xiàn)象,當然我們現(xiàn)在最常見的就是TSO模型,比如x86等等。那么我們這里使用storeFence保證了局部變量的寫入和全局變量的寫入順序性,即可完善單例模型下的高性能操作,因為我們在讀單例變量時實在不需要讀屏障,同時在TSO模型下由于不存在storestore的亂序,所以storeFence就等同于空操作,更進一步的提升性能。這里附上Java Volatile語義描述圖。
總結(jié)
以上是生活随笔為你收集整理的Java DCL 单例模式真的需要对变量加 Volatile 吗?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: leetcode 485,487,100
- 下一篇: leetcode 279. Perfec