Java DCL 单例模式真的需要对变量加 Volatile 吗?
原文鏈接:https://zhuanlan.zhihu.com/p/385271959
目錄
- 代碼展示
- DCL分析
- DCL單例變量加volatile關鍵字的原因
- Java對象創建過程
- volatile修飾單例變量的原因
- 不同角度下的對象創建原理
- 從C++角度分析對象創建
- 從Java角度分析對象創建
- 小結
- CPU模型與DCL
- 完整的Java DCL實例
代碼展示
對于單例模式來說,我們為了保證一個類的實例在運行時只有一個,所以我們首先將構造器私有化,禁止在其他地方創建該類的對象,同時我們將單例對象保存在該類的靜態變量中,當我們需要單例對象時,可以調用getObj方法來獲取對象,在該方法中我們首先判斷obj是否為空,如果不為空直接返回,否則使用synchronized加鎖后繼續判斷是否為空,若仍然不為空那么我們創建新的對象。詳細代碼如下所示,代碼中筆者用數字標號將代碼切割為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判斷是為了增加性能,因為我們并不是每次都需要上鎖后判斷,這會降低性能,因為創建對象只是在第一次訪問時才會創建。在標號為3處我們使用synchronized對當前類對象上鎖,保證了多線程并發安全,這將會只允許一個線程進入其中創建對象,其他線程則等待。在標號為4處我們再次判斷對象是否為空,這是因為如果在外層標號為2處,同時有多個線程判斷obj為空,那么將會有多個線程阻塞在標號為3處的synchronized鎖處,雖然只有一個線程能進入,但是當進入創建對象的線程創建完對象后,會喚醒阻塞在標號3處的線程,這時線程進入,就需要再次判斷單例對象是否已經被其他線程創建。在標號為5處我們創建了單例對象。DCL的很多博客,包括有朋友向筆者展示Doug Lea與其他人編寫的《Java并發編程實戰》一書,展示DCL必須要在標號為1處加上volatile,那這是為什么呢?我們來繼續分析。
DCL單例變量加volatile關鍵字的原因
對于Volatile的解釋,筆者在《從C語言聊聊JMM內存可見性》一文中已經詳細講解,這里不做過多解釋,文章鏈接:https://www.bilibili.com/read/cv9518280。這里我們只是簡單描述下volatile的語義,在java中該語義保證了可見性并保證了有序性,也即禁止指令重排,那么我們看到DCL的代碼中使用了synchronized關鍵字,而該關鍵字底層通過moniter_enter和monitor_exit兩個字節碼來完成,該字節碼自身已經完成的可見性,所以我們這里使用volatile肯定不是因為可見性而使用得,那么只有一個答案,那就是禁止指令重排。那么為何需要禁止指令重排呢?
Java對象創建過程
我們先來看一段代碼,僅僅只是在main方法中創建了一個對象obj,并將其存入局部變量obj中,其中Demo對象定義了一個實例變量a,同時在構造器中初始化了a變量為3。詳細代碼如下。
public class Demo{private int a = 0;public Demo(){a=3;}public static void main(String[] args){Demo obj = new Demo();} }那么我們來看生成的對應字節碼信息,我們看到首先通過new指令創建了class Demo對象,隨后使用dup復制了一個對象引用,隨后使用字節碼指令invokespecial調用該對象的方法,該方法也即構造方法,隨后調用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修飾單例變量的原因
那么問題就出現在如下字節順序中,我們看到創建的對象需要分為兩步,創建對象實例,調用實例構造函數,假如我們不加上volatile,那么將會調用astore_1指令重排序到invokespecial之前,從而導致外部線程雖然拿到了單例對象,但是該對象是不完整的,因為其構造函數還未調用,那么這時它的成員變量應該是0,而不是3。
new dup invokespecial #4 astore_1不同角度下的對象創建原理
那么我們此時僅僅只是站在字節碼指令的角度去看待該問題,我們知道字節碼是交給虛擬機執行的,如果沒有底層的匯編指令支撐,那么我們沒法了解到確切的真相:astore_1真的會和invokespecial指令重排嗎?甚者很多博客和書籍說編譯器會導致指令重排。那么我們現在就來通過C++和Java匯編的角度來看看,是否編譯器會導致重排序。
從C++角度分析對象創建
我們從C++層面,通過調整編譯器為最大優化級別,看看是否編譯器會導致創建對象過程和調用對象構造函數的過程重排序,C++和Java畢竟創建對象都是這么做的,但是C++可以將new運算符重載,Java不行。我們來看代碼,同樣我們創建一個類為Singleton,同時也聲明了成員變量a,在構造器中將其初始化為3,為了保證生成的匯編代碼簡單,筆者這里把mutex上鎖的代碼去了,畢竟C++可不知道什么synchronized關鍵字,不過這并不影響我們研究問題的本質。代碼實現如下。
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++命令,開啟最高級別優化編譯該代碼,隨后我們來看生成的匯編指令,我們看到在main方法中代碼被編譯器優化為直接取類Singleton的靜態變量地址直接判斷是否為null,如果不為null直接返回,否則調用.L6處代碼繼續執行。我們看到call _Znwm用于創建對象內存地址,而movl $3, (%rax)則是構造器中的賦值操作,將3放入rax所指的內存地址空間中,隨后調用movq %rax, _ZN9Singleton3objE(%rip)將該對象地址放入靜態變量obj中。那么我們看到,在最高級別的優化下,編譯器并不會將構造器的調用和放置對象地址的操作重排序。
main:cmpq $0, _ZN9Singleton3objE(%rip) ; 看看靜態變量obj是否為null(C++非零即真)je .L6 ; 如果為0,那么跳轉到.L6處執行movl $1, %eax ; 直接返回1ret .L6:pushq %rax ; 保存rax信息到棧上movl $4, %edicall _Znwm ; 調用函數,開辟對象內存,也即new操作符movl $3, (%rax) ; 當call _Znwm 返回后,rax寄存中保存值為開辟的內存地址,此時將3放入該地址中movq %rax, _ZN9Singleton3objE(%rip) ; 將創建的對象內存地址放入靜態變量obj的地址中movl $1, %eax ; 將返回值放入eax中popq %rdx ; 彈出rdxret ; 返回從Java角度分析對象創建
我們來看Java代碼,同樣為了保證生成的匯編代碼簡單,筆者這里去掉了加鎖的操作,畢竟我們只是看看編譯器是否會導致指令重排,因為加了synchronized關鍵字只是保證了互斥性和可見性,但是synchronized關鍵字內的互斥代碼并不能保證有序性。
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最高等級優化,
0x0000000003556a2f: jae 0x0000000003556a9f ; 調用new操作創建對象 0x0000000003556a5c: mov %rax,%rbp ;*new ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) 保存創建的對象地址放入rbp中我們看到以上代碼為創建對象過程,由于其中創建對象需要獲取到元數據信息metadata,然后將對象放入操作數棧等等步驟,所以其中包含較多匯編代碼,筆者這里去掉了不需要的匯編,只保留這兩句。我們只需要關注這一句jae 0x0000000003556a9f,我們繼續看該地址的操作。
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} 這里就是調用創建對象的方法地址 0x0000000003556ab0: jmp 0x0000000003556a5c ;*new ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) // 創建完畢后跳轉到該地址接下來我們繼續看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} 調用<init>方法,該方法也即對象的構造器 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) ; 將對象的地址放入到類靜態變量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小結
由此我們從C++的角度,Java的角度分析,得到結論:編譯器將不會導致指令重排序。這也就是為什么在C++的單例模式中沒有對單例對象加上volatile關鍵字的原因,我們在《從C語言聊聊JMM內存可見性》一文中知道,volatile對于C類語言來說只是禁止編譯器重排序的手段,既然編譯器不會干擾對于new操作符分配內存、調用構造器、賦值這三步的步驟,那么我們并不需要使用它。
CPU模型與DCL
接下來我們來看看,既然編譯器不會導致該指令重排,那么還有另外一種原因:CPU模型導致的重排現象。我們來看C++ DCL的這段匯編代碼,我們知道movl $3, (%rax)是構造器中的操作,那么如果CPU在執行過程中,將指令重排執行將movq %rax, _ZN9Singleton3objE(%rip),也即對象寫入到了內存中,這時就會導致半對象的產生。
call _Znwm ; 調用函數,開辟對象內存,也即new操作符 movl $3, (%rax) ; 當call _Znwm 返回后,rax寄存中保存值為開辟的內存地址,此時將3放入該地址中 movq %rax, _ZN9Singleton3objE(%rip) ; 將創建的對象內存地址放入靜態變量obj的地址中那么我們知道這是兩步寫入操作,在TSO模型下并不會產生問題,因為CPU MOB(內存順序緩沖區訪問模型)為TSO模型下,只有storeload重排序現象,但是如果我們在其他模型,比如:PSO、RMO下,那么將會導致storestore亂序。那么這時我們就需要指令屏障來保證指令結果寫入順序,而Java的volatile語義恰好滿足了這一條件,同理我們在Java生成的匯編代碼也滿足這種現象。所以我們在前面使用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處首先將創建的單例對象保存到局部變量中,隨后加上storeFence屏障,保證局部變量和全局變量的寫順序,這時就避免了會導致storestore內存順序的CPU上寫寫的順序性。那么為何去掉volatile,用unsafe的內存屏障呢?考慮下volatile的語義:volatile變量讀后面加上loadload、loadstore屏障,寫之前加上storestore屏障,寫之后加上storeload屏障。那么我們在對象創建完畢后,需要這些屏障嗎?答案肯定是否定的。所以我們不需要使用volatile關鍵字,通過unsafe的屏障就能完成同樣的工作。這種現象在Linux內核中非常常見,不允許使用volatile,因為它禁止了編譯器在使用這些變量時的優化,而對于內核來說,它必須要滿足高性能,這時就要求:不能使用volatile關鍵字,當需要指令順序時,采用編譯器屏障(:::“memory”)或者指令屏障(lfence,sfence,mfence,lock前綴)。所以我們這里使用storefence避免了storestore的重排序現象,不同的CPU下的MOB模型,也即內存順序緩沖區訪問模型的不同,將會導致不同程度下的loadload、loadstore、storeload、storestore現象,當然我們現在最常見的就是TSO模型,比如x86等等。那么我們這里使用storeFence保證了局部變量的寫入和全局變量的寫入順序性,即可完善單例模型下的高性能操作,因為我們在讀單例變量時實在不需要讀屏障,同時在TSO模型下由于不存在storestore的亂序,所以storeFence就等同于空操作,更進一步的提升性能。這里附上Java Volatile語義描述圖。
總結
以上是生活随笔為你收集整理的Java DCL 单例模式真的需要对变量加 Volatile 吗?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: leetcode 485,487,100
- 下一篇: leetcode 279. Perfec