對象部分初始化:原理以及驗證程式碼(雙重檢查鎖與volatile相關)
- 2020 年 10 月 23 日
- 筆記
- JAVA, Design pattern, concurrent, DesignPattern
對象部分初始化:原理以及驗證程式碼(雙重檢查鎖與volatile相關)
對象部分初始化被稱為 Partially initialized objects / Partially constructed objects / Incompletely initialized objects
這三種不同的說法描述的是同一種情況,即指令重排序(reorder)導致未完全初始化的對象被使用,這會導致某些錯誤的發生。
文章純原創,轉載請表明地址
對象初始化過程
要理解對象部分初始化,那就要先理解對象初始化。
package Singleton;
public class NewObject {
public static void main(String[] args) {
NewObject newObject = new NewObject();
}
}
上面是一個非常簡單的新建對象程式碼,newObject欄位指向堆中新建立的對象,將上面程式碼反編譯成位元組碼。
0 new #2 <Singleton/NewObject>
3 dup
4 invokespecial #3 <Singleton/NewObject.<init>>
7 astore_1
8 return
閱讀位元組碼
1. new
根據Oracle官方文檔描述,第0行(以行前標記為準) 的new指令進行了如下操作
Memory for a new instance of that class is allocated from the garbage-collected heap, and the instance variables of the new object are initialized to their default initial values (§2.3, §2.4). The objectref, a
referenceto the instance, is pushed onto the operand stack.
翻譯一下就是,該指令為指定類的實例在堆中分配了記憶體空間,並且將這個新對象的實例變數進行了默認初始化,即 int 類型為 0, boolean類型為 false。並且該指令還將一個指向該實例的引用推入操作數棧中。
而dup複製一份操作數棧頂的值,並且推入棧中 。
2. invokespecial
這個指令比較複雜,此處只需要知道該指令在此處調用了對象的初始化函數 NewObject.<init>,對象初始化會按照靜態變數、靜態初始化塊->變數、初始化塊->構造器等順序進行初始化,這個不是關鍵,關鍵是初始化在此時進行。該指令結束後對象會被正確的初始化。
3. astore
該指令將操作數棧頂的值儲存到局部變數表中,astore_1在此處代表的就是將值儲存到變數newObject中。
如果變數不是聲明在方法中,而是聲明在類中,那指令會變為putfield 。無論變數聲明在何處,使用哪個指令,目的是為了將操作數棧頂的值儲存到它該去的地方。
指令重排下的對象初始化
初始化的過程看起來沒有任何問題,按照123的順序執行的話在使用對象引用時對象一定是初始化完成的,但是為了效率,當今的CPU是」流水線「執行指令,即指令順序輸入,亂序執行,CPU在確保最終結果的前提下會按照最高效率的方式執行指令,而不是順序的執行。
在對象初始化的過程中,CPU很可能的執行順序是132,即 new astore invokespecial。
如果是在單執行緒的情況下,132的執行順序不會造成什麼問題,因為CPU會保證不在invokespecial完成前使用對象。
但是在多執行緒的情況下就不一樣了,亂序執行會導致執行緒A在對象初始化完成前就將引用X指向了堆中的對象,這個引用X是共享資源,其他執行緒也能看的到這個變數。執行緒B並不知道執行緒A中發生了什麼,當執行緒B需要使用引用X的時候會出現以下三種情況
- 執行緒A還未將引用X指向對象,執行緒B獲得的X是null;
- 初始化完成,執行緒B使用的對象是正確的對象;
- 引用X指向了堆中的對象,但是執行緒A中進行的初始化未完成,執行緒B使用的對象是部分初始化的對象。
Show me the code
對象部分初始化的問題最開始是在學習單例設計模式、雙重檢查鎖(Double-check-lock)的過程中了解到的,DCL由於指令重排序,不在對象上加volatile關鍵字就會導致對象部分初始化問題。原理問題在中國外各種部落格和論壇上都有描述,也都大同小異。
但困擾我的關鍵在於沒有找到能給出DCL不加volatile會出問題的程式碼,換句話說,大家談的都是理論,沒有部落格/文章/回答能夠用程式碼說明這個問題確實存在。
根據維基百科的描述,這個問題是非常難以再現的。
Depending on the compiler, the interleaving of threads by the scheduler and the nature of other concurrent system activity, failures resulting from an incorrect implementation of double-checked locking may only occur intermittently. Reproducing the failures can be difficult.
在我嘗試親手復現錯誤的程式碼時,我發現如果要把測試放在單例類中,則一次運行時只能對對象進行一次初始化,其他執行緒只有在這一次初始化的間隙中有機會調用「不正確」的對象,在這種情況下我可能手動把程式跑上三天三夜都沒辦法復現一次這個問題。
於是換了一個思路,並不需要在DCL的單例模式中證明這個問題,只要能證明對象部分初始化問題存在即可。
程式碼設計思路:
- 亂序重排發生在對象初始化中,需要有一個執行緒儘可能多的進行類的初始化,好讓其他執行緒能盡量捕捉到問題(static class Initialize)
- 需要許多個執行緒不斷的調用被初始化的類,並且判斷這個類是否有被正確初始化(static class GetObject)
- 存在一個類作為被初始化的對象(class PartiallyInitializedObject)
- 存在一個類持有上面對象的引用,執行緒通過這個類進行對象初始化並且給引用賦值,也通過這個類獲取到引用(class Builder)
程式碼
mport java.util.Random;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
class PartiallyInitializedObject{
static long counter;
// final field will avoid partiallyInitializedObject
// final long id = counter++;
public int n;
public PartiallyInitializedObject(int n){
this.n = n;
}
}
class Builder{
public int createNumber = 0;
public AtomicInteger getNumber = new AtomicInteger(0);
Random rand = new Random(47);
//private volatile PartiallyInitializedObject partiallyInitializedObject;
private PartiallyInitializedObject partiallyInitializedObject;
public PartiallyInitializedObject get(){
getNumber.incrementAndGet();
return partiallyInitializedObject;
}
public void initialize(){
partiallyInitializedObject = new PartiallyInitializedObject(rand.nextInt(20)+5);
createNumber++;
}
}
public class PartiallyInitialized {
static class Initialize implements Runnable{
Builder builder;
public Initialize(Builder builder){
this.builder = builder;
}
@Override
public void run() {
while(!Thread.interrupted()){
builder.initialize();
}
}
}
static class GetObject implements Runnable{
static int count =0;
final int id = count++;
CyclicBarrier cyclicBarrier;
Builder builder;
public GetObject(CyclicBarrier c, Builder builder){
cyclicBarrier = c;
this.builder = builder;
}
@Override
public void run() {
while (!Thread.interrupted()) {
PartiallyInitializedObject p = builder.get();
if (p.n == 0) {
System.out.println("Thread " + id +" Find Partially Initialized Object " + p.n);
Thread.currentThread().interrupt();
}
}
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println("Thread " + id +" Interrupted");
}
}
public static void main(String[] args) throws BrokenBarrierException, InterruptedException{
// first initialize(), second get()
// 1 initialize(), 9 get()
Builder builder = new Builder();
CyclicBarrier cyclicBarrier = new CyclicBarrier(10);
ExecutorService exec = Executors.newFixedThreadPool(10);
exec.execute(new Initialize(builder));
for(int i=0; i<9; i++){
exec.execute(new GetObject(cyclicBarrier, builder));
}
// exec.execute(new Initialize(builder));
try {
cyclicBarrier.await(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
System.out.println("No Partially Initialized Object Found");
}
exec.shutdownNow();
System.out.println("Builder create "+builder.createNumber +" Object And Try to get "+ builder.getNumber.get()+ " times");
}
}
Builder 類中的變數partiallyInitializedObject不使用volatile修飾時輸出如下
Thread 5 Find Partially Initialized Object 13
Thread 3 Find Partially Initialized Object 23
Thread 0 Find Partially Initialized Object 6
Thread 1 Find Partially Initialized Object 10
Thread 2 Find Partially Initialized Object 11
Thread 8 Find Partially Initialized Object 23
Thread 4 Find Partially Initialized Object 14
Thread 6 Find Partially Initialized Object 6
Thread 7 Find Partially Initialized Object 24
Thread 7 Interrupted
Thread 5 Interrupted
Thread 3 Interrupted
Thread 8 Interrupted
Thread 0 Interrupted
Thread 6 Interrupted
Thread 4 Interrupted
Thread 2 Interrupted
Thread 1 Interrupted
Builder create 46736 Object And Try to get 231239 times
Builder 類中的變數partiallyInitializedObject使用volatile修飾時輸出如下
No Partially Initialized Object Found
Builder create 7661170 Object And Try to get 72479637 times
Thread 3 Interrupted
Thread 7 Interrupted
Thread 0 Interrupted
Thread 6 Interrupted
Thread 1 Interrupted
Thread 8 Interrupted
Thread 5 Interrupted
Thread 2 Interrupted
Thread 4 Interrupted
java.util.concurrent.BrokenBarrierException
at java.util.concurrent.CyclicBarrier.dowait(CyclicBarrier.java:207)
at java.util.concurrent.CyclicBarrier.await(CyclicBarrier.java:362)
at Singleton.PartiallyInitialized$GetObject.run(PartiallyInitialized.java:66)
......
程式碼中在執行緒池在執行調用GetObject執行緒之前先執行Initialize的執行緒,如果把exec.execute(new Initialize(builder));放到GetObject的執行緒後面,那就會出現之前說的三種情況中的第一種:GetObject獲得的引用為空。
觀察程式碼和輸出,在GetObject執行緒中,只有當對象PartiallyInitializedObject.n的值為0時才會進行輸出並且打斷當前執行緒,而在Builder的initialize()中能很明顯的看到,對象的n值是大於等於5並且小於25,即永遠不可能為0。但輸出的結果卻證明了GetObject執行緒在某些時刻確實能得到為0的n值。程式碼剩餘的細節這裡就不再贅述。
到這一步就能夠說明確實存在指令重排序而導致的對象部分初始化問題,由於synchronized和volatile保證可見性和有序性的原理並不相同,所以在DCL單例模式這種特殊的情況下,synchronized也不能很好的確保正確。當然,由於種種原因,DCL單例模式已經基本被棄用了,這篇文章只做一些相關的探討。
參考
//wiki.sei.cmu.edu/confluence/display/java/TSM03-J.+Do+not+publish+partially+initialized+objects
//stackoverflow.com/questions/7855700/why-is-volatile-used-in-double-checked-locking
//en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java
//docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.new
