物件部分初始化:原理以及驗證程式碼(雙重檢查鎖與volatile相關)

Yuasin發表於2020-10-23

物件部分初始化:原理以及驗證程式碼(雙重檢查鎖與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 reference to 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的時候會出現以下三種情況

  1. 執行緒A還未將引用X指向物件,執行緒B獲得的X是null;
  2. 初始化完成,執行緒B使用的物件是正確的物件;
  3. 引用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的單例模式中證明這個問題,只要能證明物件部分初始化問題存在即可。

程式碼設計思路:

  1. 亂序重排發生在物件初始化中,需要有一個執行緒儘可能多的進行類的初始化,好讓其他執行緒能儘量捕捉到問題(static class Initialize)
  2. 需要許多個執行緒不斷的呼叫被初始化的類,並且判斷這個類是否有被正確初始化(static class GetObject)
  3. 存在一個類作為被初始化的物件(class PartiallyInitializedObject)
  4. 存在一個類持有上面物件的引用,執行緒通過這個類進行物件初始化並且給引用賦值,也通過這個類獲取到引用(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時才會進行輸出並且打斷當前執行緒,而在Builderinitialize()中能很明顯的看到,物件的n值是大於等於5並且小於25,即永遠不可能為0。但輸出的結果卻證明了GetObject執行緒在某些時刻確實能得到為0的n值。程式碼剩餘的細節這裡就不再贅述。

到這一步就能夠說明確實存在指令重排序而導致的物件部分初始化問題,由於synchronizedvolatile保證可見性和有序性的原理並不相同,所以在DCL單例模式這種特殊的情況下,synchronized也不能很好的確保正確。當然,由於種種原因,DCL單例模式已經基本被棄用了,這篇文章只做一些相關的探討。

參考

https://wiki.sei.cmu.edu/confluence/display/java/TSM03-J.+Do+not+publish+partially+initialized+objects

https://stackoverflow.com/questions/7855700/why-is-volatile-used-in-double-checked-locking

https://en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.new

相關文章