何為安全發布,又何為安全初始化?
- 2020 年 3 月 12 日
- 筆記
前言
很多時候我們需要跨執行緒共享對象,若存在並發我們必須以執行緒安全的方式共享對象,此時將涉及到我們如何安全初始化對象從而進行安全發布,本節我們將來討論安全初始化、安全發布,文中若有錯誤之處,還望批評指正。
安全發布
按照正常敘述邏輯來講,我們應該首先討論如何安全初始化,然後再進行安全發布分析,在這裡呢,我們採取倒敘的方式,先通過非安全發布的方式討論所出現的問題,然後最後給出如何進行安全初始化,如下,我們以單例模式為例。
public class SynchronizedCLFactory { private Singleton instance; public Singleton get() { synchronized (this) { if (instance == null) { instance = new Singleton(); } return instance; } } } public class Singleton { }
如上提供了用於獲取Singleton實例的公共方法,我們通過同步關鍵字保持執行緒安全,無論有多少個執行緒在請求一個Singleton,也不管當前狀態如何,所有執行緒都將獲得相同的Singleton實例,Singleton初始化在第一次請求Singleton時發生,而不是在初始化Singleton類時發生,至於是否惰性初始化並不是我們關注的重點,同時將對程式碼塊加鎖,使得Singleton狀態的開銷盡量保持最小。為了更加嚴謹而使得單例必須具備唯一實例,我們進行DCL(Double Check Lock),我們可能會進行如下改造:
public class SynchronizedCLFactory { private Singleton instance; public Singleton get() { if (instance == null) { synchronized (this) { if (instance == null) { instance = new Singleton(); } } } return instance; } } public class Singleton { }
或許我們認為成功完成進行第一步判斷之後,就可以正確初始化Singleton實例,然後可以將其返回,其實這是錯誤的理解,因為Singleton實例僅對構造執行緒完全可見,而無法保證在其他執行緒中能夠正確看到Singleton實例,因為正在與初始化Singleton實例執行緒存在競爭,再者,即使最終已獲得非空實例,也並不意味著我們能正確觀察其內部狀態,從JMM角度來看,在Singleton構造函數中的初始化存儲與讀取Singleton欄位之間沒有發生任何事情,我們也可以看到,在第一步判斷和最後一步返回並沒有進行任何同步的讀取,Java記憶體模型的目的之一是允許對普通讀取進行重排序(reordering),否則性能開銷將可想而知,在規範方面,讀取操作可以通過競爭觀察無序寫入,這是針對每個讀取動作決定的,而與其他什麼動作已經讀取同一位置沒有任何關係,在如上示例中,這意味著即使通過第一步判斷可以讀取非空實例,但程式碼隨後繼續返回它,然後又讀取了一個原始值,並且可以讀取將返回的空的實例。安全發布與常規發布在一個關鍵點上有所不同:安全發布使發布之前編寫的所有值對觀察發布對象的所有執行緒可見,它大大簡化了關於動作,命令等JMM約定規則。所以接下來我們來講講安全發布之前的動作即安全初始化。
安全初始化
在初始化共享對象時,該對象必須只能由構造它的執行緒訪問,但是,一旦初始化完成,就可以安全的發布對象即使該對象對其他執行緒可見,Java記憶體模型(JMM)允許多個執行緒在初始化開始後但結束之前觀察對象,因此,我們寫程式時必須防止發布部分初始化的對象,該規則禁止在初始化結束之前發布對部分初始化的成員對象實例的引用,特別適用於多執行緒程式碼中的安全性,在對象構造期間不要讓this引用轉義,以防止當前對象的this引用轉義其構造函數。如下程式碼示例在Foo類的initialize方法中構造一個Holder對象,Holder對象的欄位由其構造函數初始化。
public class Foo { private Holder holder; public Holder getHolder() { return holder; } public void initialize() { holder = new Holder(42); } } public class Holder { private int n; public Holder(int n) { this.n = n; } }
如果執行緒在執行initialize方法之前使用getHolder方法訪問Holder類,則該執行緒將觀察到未初始化的holder程式欄位,接下來如果一個執行緒調用initialize方法,而另一個調用getHolder方法,則第二個執行緒可以觀察這幾種情況之一:holder的引用為空、完全實例化的Holder對象中的n為42,具有未初始化n的部分初始化的Holder對象,其中包含欄位的n默認值0,其主要原因在於,JMM允許編譯器在初始化新的Holder對象之前為新的Holder對象分配記憶體,並將對該記憶體的引用分配給holder欄位,換句話說,編譯器可以對holder實例欄位的寫入和初始化Holder對象的寫入(即this.n = n)進行重排序,以至於使前者優先出現,這將出現一個競爭,在此期間其他執行緒可以觀察到部分初始化的Holder對象實例。在對象構造函數完成其初始化之前,不應發布對對象實例的引用,這會在對象構造期間造成this引用逸出。我們繼續往下看如何正確的進行安全初始化。
同步機制
我們使用方法同步可以防止發布對部分初始化的對象的引用,程式碼如下:
public class Foo { private Holder holder; public synchronized Holder getHolder() { return holder; } public synchronized void initialize() { holder = new Holder(42); } }
我們將上述初始化和獲取實例化的變數holder這兩種方法進行同步,可確保它們不能同時執行,如果一個執行緒恰好在getHolder方法的執行緒之前調用initialize方法,則同步的initialize方法將始終首先完成,這是因為synchornized關鍵字在兩個執行緒之間建立了事前發生(happens-before)的關係,因此調用getHolder方法的執行緒將看到完全初始化的Holder對象或缺少的Holder對象,也就是說,holder將包含空引用,這種方法保證了對不可變成員和可變成員的完全正確發布。
final關鍵字
JMM保證將聲明為final的欄位的完全初始化的值安全發布到每個執行緒,這些執行緒在不早於對象構造函數結尾的某個時間點讀取這些值,如下程式碼示例:
public class Foo { private final Holder holder; public Foo(){ holder = new Holder(42); } public Holder getHolder() { return holder; } }
但是,此解決方案需要將新的Holder實例holder分配在Foo的構造函數中進行,根據Java語言規範,在構造期間讀取final欄位:構造該對象的執行緒中對象的final欄位的讀取是根據通常的事前發生(happens-before)規則對構造函數中該欄位的初始化進行排序的,如果在構造函數中設置了欄位之後才進行讀取,則它將看到為final欄位分配的值,否則,它將看到的是默認值,因此,在Foo類的構造函數完成之前,對Holder實例的引用應保持未發布狀態。
final關鍵字和執行緒安全組合
我們知道在java中有一些集合類提供對包含元素的執行緒安全訪問,當我們將Holder對象插入到這樣的集合中時,可以確保在將其引用變為可見之前對其進行完全初始化,比如如下Vector集合。
public class Foo { private final Vector<Holder> holders; public Foo() { holders = new Vector<>(); } public Holder getHolder() { if (holders.isEmpty()) { initialize(); } return holders.elementAt(0); } public synchronized void initialize() { if (holders.isEmpty()) { holders.add(new Holder(42)); } } }
將holder欄位聲明為final,以確保在進行任何訪問之前始終創建對象Holder的集合Vector,可以通過調用同步的initialize方法安全地對其進行初始化,以確保僅將一個Holder對象添加到Vector中,如果在initialize方法之前調用,那麼getHolder方法通過有條件地調用initialize方法來避免空指針從而取消引用的可能性,儘管getHolder方法中的isEmpty方法調用的是從不同步的上下文(允許多個執行緒決定必須調用初始化)進行的,但是仍然可能導致競爭條件,而這種競爭條件可能導致向Vector集合中添加第二個對象,同步的initialize方法還在添加新的Holder對象之前檢查holder是否為空,並且最多一個執行緒可以隨時執行initialize方法,因此,只有第一個執行initialize方法的執行緒才能看到一個空的Vector集合,而getHolder方法可以安全地忽略其自身的任何同步。
靜態初始化
我們將holder欄位靜態初始化,以確保該欄位引用的對象在其引用變為可見之前已完全初始化,如下:
class Foo { private static final Holder holder = new Holder(42); public static Holder getHolder() { return holder; } }
我們需要將holder欄位聲明為final,以記錄該類的不變性,根據Java語言規範,靜態final欄位:類初始化的規則確保任何讀取靜態欄位的執行緒將與該類的靜態初始化同步,這是可以設置靜態final欄位的唯一位置,因此,對於靜態final欄位,JMM中不需要特殊規則。
不可變對象(final關鍵字、volatile引用)
JMM保證在發布的對象變得可見之前,對象的所有final欄位都將完全初始化,通過聲明final使得Holder類變得不可變, 另外將holder欄位聲明為volatile以確保對不可變對象的共享引用的可見性,只有在完全初始化Holder之後,才能確保對調用getHolder方法的任何執行緒可見Holder的引用。
class Foo { private volatile Holder holder; public Holder getHolder() { return holder; } public void initialize() { holder = new Holder(42); } } final class Holder { private final int n; public Holder(int n) { this.n = n; } }
如上將holder聲明為volatile且Holder類是不可變的,如果輔助欄位不是可變的,則將違反確保對不可變對象的共享引用的可見性,推薦提供公共靜態工廠方法來返回Holder的新實例,這種方法允許在私有構造函數中創建Holder實例。不可變對象永遠執行緒安全,volatile確保其可見性使得共享對象能夠完全正確安全發布。
可變對象(執行緒安全和volatile引用)
當Holder雖可變但執行緒安全時,可以通過在Holder類的volatile中聲明holder欄位來安全地發布它:
class Foo { private volatile Holder holder; public Holder getHolder() { return holder; } public void initialize() { holder = new Holder(42); } } final class Holder { private volatile int n; private final Object lock = new Object(); public Holder(int n) { this.n = n; } public void setN(int n) { synchronized (lock) { this.n = n; } } }
需要進行同步以確保在初始發布之後可變成員的可見性,因為Holder對象可以在其構造後更改狀態,同步setN方法以確保n欄位的可見性,如果Holder類的同步不正確,則在Foo類中聲明volatile的holder將只能保證Holder初始發布的可見性,可見性保證將排除後續狀態更改的可見性,因此,僅可變引用不足以發布不是執行緒安全的對象,如果Foo類中的holder欄位未聲明為volatile,則必須將n欄位聲明為volatile,以在n的初始化與將Holder寫入holder欄位之間建立事先發生(happens-before)的關係,僅當無法信任調用方(類Foo)時將Holder類聲明為volatile時才需要這樣做,因為Holder類被聲明為公共類,所以它使用私有鎖來進行同步,使用私有的final鎖定對象來同步可能與不受信任的程式碼。
那麼問題來了, 聲明對象的volatile能與聲明基本類型的volatile提供同樣的保證嗎?如果有可變或執行緒安全的對象我們是否有十分充足的理由聲明為volatile呢?聲明對象的volatile不能提供與聲明基本類型的volatile提供相同的保證,對於可變的對象應禁止聲明為volatile,而是設置同步,因為同步化主要強調的是原子性,其次才是可見性,但volatile主要保證的是可見性,所以可變對象和volatile一同使用時會出現陷阱(僅當共享對象已完全構造或不可變時,才可以使用volatile安全發布),所以當通過正確性推斷出可見性時,應該避免使用volatile變數,volatile主要是用來確保它所引用對象的可見性或用於標識重要的生命周期事件(比如初始化或關閉)的發生。
如果一個對象不是不可變的,那麼它必須要被安全發布,如何確保其他執行緒能夠看到對象發布時的狀態,必須解決對象發布後其他執行緒對其修改的可見性問題,為了安全發布對象,對象的引用以及對象的狀態必須同時對其他執行緒可見,一個正確創建的對象可通過:通過靜態初始化器初始化對象的引用(因為JMM可確保共享對象已完全構造)、將對象引用存儲到volatile或AtomicReference、將對象引用存儲到正確創建的對象的final域中、將對象引用通過鎖機制保護。
總結
Java允許我們始終可以以安全發布的方式聲明對象即為我們提供了安全初始化的機會,安全初始化使觀察該對象的所有讀者都可以看到構造函數中初始化的所有值,而不管對象是否被安全發布,如果對象中的所有欄位都是final,並且構造函數中未初始化的對象沒有逸出,那麼Java記憶體模型(JMM)將更加對此提供強有力保障,我們應時刻謹記共享對象在進行安全發布之前必須避免被部分初始化即局部創建對象。