1. 程式人生 > >何為安全釋出,又何為安全初始化?

何為安全釋出,又何為安全初始化?

前言

很多時候我們需要跨執行緒共享物件,若存在併發我們必須以執行緒安全的方式共享物件,此時將涉及到我們如何安全初始化物件從而進行安全釋出,本節我們將來討論安全初始化、安全釋出,文中若有錯誤之處,還望批評指正。

安全釋出

按照正常敘述邏輯來講,我們應該首先討論如何安全初始化,然後再進行安全釋出分析,在這裡呢,我們採取倒敘的方式,先通過非安全釋出的方式討論所出現的問題,然後最後給出如何進行安全初始化,如下,我們以單例模式為例。

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)將更加對此提供強有力保障,我們應時刻謹記共享物件在進行安全釋出之前必須避免被部分初始化即區域性建立物件。