【併發編程】安全發佈對象與防止對象逸出(緣由與防禦方法)

發佈對象與對象逸出

首先來明確一下發布與逸出的概念。java

發佈對象:使一個對象可以被當前範圍以外的代碼所使用。數組

對象逸出:是一種錯誤的發佈。當一個對象尚未構造完成時就使它被其餘線程所見。安全

在平常開發中,咱們常常須要發佈對象,好比經過類的非私有方法返回對象的引用,或者經過公有靜態變量發佈對象。bash

下面來coding演示一下多線程

首先咱們編寫一個不安全發佈對象的例子函數

@Slf4j
@NotThreadSafe
public class UnsafePublish {

    private String[] states = {"a", "b", "c"};

    public String[] getStates(){
        return states;
    }

    public static void main(String[] args) {
        UnsafePublish unsafePublish = new UnsafePublish();
        log.info("{}", Arrays.toString(unsafePublish.getStates()));

        unsafePublish.getStates()[0] = "d";
        log.info("{}", Arrays.toString(unsafePublish.getStates()));
    }
}
複製代碼

輸出結果性能

能夠看到statusabc被改爲了dbcthis

代碼解讀spa

這個類經過getStates()這個public方法發佈了類的域,在類的任何外部的線程均可以訪問這些域,這樣的發佈對象實際上是不安全的,由於咱們沒法保證其餘線程會不會修改這個域從而致使這個類狀態的錯誤。能夠看到在main方法裏經過new發佈了一個這個類的實例,而後就能夠經過這個類提供的public方法直接獲得它的私有域status的引用,獲得這個引用以後就能夠在其餘任何線程裏直接去修改這個數組裏的值,這樣一來當我在其餘線程使用這個數組裏的值的時候,它的數據就是不徹底肯定的,所以這樣發佈的對象就是線程不安全的。線程

下面是對象逸出的例子

@Slf4j
public class Escape {

    private int thisCanBeEscape = 0;

    public Escape() {
        new InnerClass();
    }

    private class InnerClass {

        public InnerClass() {
            log.info("{}", Escape.this.thisCanBeEscape);
        }
    }

    public static void main(String[] args) {
        new Escape();
    }
}
複製代碼

輸出結果

能夠看到輸出結果就是thisCanBeEscape的值。

代碼解讀

這個內部類的實例裏包含了對封裝實例隱含的引用,這樣就在對象沒有被正確構造完成以前它就會被髮布可能有不安全的因素在裏面。一個致使this在構造期間逸出的錯誤,它是在Escape構造函數的過程當中啓動了一個線程,不管是隱式仍是顯式的啓動都會形成this引用的逸出,新線程總會在所屬對象構造完畢以前看到這個引用。若是要在構造函數中建立線程,那麼應該採用一個專有的start或初始化的方法來統一啓動線程,這裏這個例子能夠採用工廠方法和私有構造函數來完成對象建立和監聽器的註冊等。

不正確發佈可變對象會致使線程看到的被髮布對象的引用是最新的,而被髮布對象的狀態倒是過時的。若是一個對象是可變對象必定要安全發佈才能夠。

安全發佈對象的四種方法

  • 1.在靜態初始化函數中初始化一個對象的引用。

  • 2.將對象的引用保存到volatile類型域或者AtomicReference對象中。

  • 3.將對象的引用保存到某個正確構造對象的final類型域中。

  • 4.將對象的引用保存到一個由鎖保護的域中。

懶漢模式

下面經過單例模式的背景來實現發佈線程安全的對象

首先咱們來寫一個普通的懶漢式單例

/** * className SingletonExample1 * description TODO * 懶漢式單例 * 單例的實例在第一次使用時建立 * * @author ln * @version 1.0 * @date 2019-07-12 20:52 */
@Slf4j
public class SingletonExample1 {

    /** * 私有構造函數 */
    private SingletonExample1(){
    }

    /** * 單例對象 */
    private static SingletonExample1 instance = null;

    /** * 靜態工廠方法 * @return */
    public static SingletonExample1 getInstance() {
        if (instance == null) {
            instance = new SingletonExample1();
        }
        return instance;
    }

}
複製代碼

這段單例是線程不安全的,緣由在於getInstance方法中的if判斷,與以前的計數例子相同,如有兩個線程同時訪問這個方法,都訪問到if判斷時這個實例都是null,那麼兩個線程都會去new一個新的實例,這樣的話就致使了私有構造函數被調用兩次(這裏是出錯的緣由),這兩個線程拿到的實例是不同的。

可能有些讀者會疑惑這裏的方法只是爲了拿到實例,就算初始化了兩次也不會有什麼影響。其實問題在與實例化的過程當中調用了兩次構造函數,在真正實現時構造函數中可能會作不少操做,如對資源的處理、運算等,這時若是運算兩次就可能會出現錯誤。這裏只是經過一個簡單的示例來講明它是否運行了兩次,運行了兩次只能說明是線程不安全,而線程不安全並不能必定致使很差的現象,有時甚至不會有什麼影響。這裏咱們知道這樣寫是線程不安全的就能夠了。

餓漢模式

/** * className SingletonExample1 * description TODO * 餓漢式單例 * 單例的實例在類裝載時建立 * * @author ln * @version 1.0 * @date 2019-07-12 20:52 */
@Slf4j
@ThreadSafe
public class SingletonExample2 {

    /** * 私有構造函數 */
    private SingletonExample2(){
    }

    /** * 單例對象 */
    private static SingletonExample2 instance = new SingletonExample2();

    /** * 靜態工廠方法 * @return */
    public static SingletonExample2 getInstance() {
        return instance;
    }

}
複製代碼

餓漢模式是線程安全的,若是單例類的構造方法中沒有包含過多的操做處理餓漢模式仍是能夠接受的,不然可能引發性能問題。若是使用餓漢模式而沒有實際調用的話會形成資源浪費。

所以使用餓漢模式時必定要考慮兩個問題:

  • 1.私有構造函數在實現時沒有太多的處理。

  • 2.確保這個類在實際過程當中確定會被使用。

線程安全的懶漢模式

懶漢模式在必定條件下也能夠是線程安全的。

只需在以前懶漢模式的實現中的靜態工廠方法前加入synchronized修飾便可

public static synchronized SingletonExample3 getInstance() {
    if (instance == null) {
        instance = new SingletonExample3();
    }
    return instance;
}
複製代碼

可是這種寫法並不推薦,緣由在於這個方法加了`sync`修飾之後,經過同一時間只容許一個線程訪問的方式來保證了線程安全,可是卻有性能上的開銷,而咱們並不但願產生這種開銷。

性能更好的懶漢模式(線程不安全)

接下來咱們繼續修改懶漢模式來解決性能上的問題

上個實現中產生性能問題的緣由在於sync修飾了方法,那麼咱們就把sync放到方法實現裏去

public static SingletonExample4 getInstance() {
    if (instance == null) { //雙重檢測機制
        synchronized (SingletonExample4.class){ // 同步鎖
            if (instance == null){
                instance = new SingletonExample4();
            }
        }
    }
    return instance;
}
複製代碼

在第一次判空之後用sync來鎖這個類,而後再一次判空,這種方法稱爲雙重檢測機制,這種實例的方式也能夠稱爲雙重同步鎖單例模式。

可是這個類並非線程安全的

你們可能會這樣想:第一次判空以後,下面的代碼在同一時間內只有一個線程能夠訪問,而一個線程訪問以後若是instance已經被實例化了第二個線程訪問時發現第二次判空失敗就不會再去實例化了,而後直接返回就能夠了。

這樣看起來彷佛沒有問題,但問題到底出在哪了?

緣由要從CPU的指令開始提及,當咱們執行

instance = new SingletonExample4();
複製代碼

時,CPU會執行3步指令

1.分配對象內存空間 memory = allocate()
2.初始化對象 ctorInstance()
3.設置instance指向剛分配的內存 instance = memory
複製代碼

在完成了這三步後instance就指向了實際分配的內存地址了,就說咱們說的引用。在單線程狀況下上面的方法沒有任何問題,但在多線程狀況下可能會發生指令重排序,因爲23指令沒有先後必要的關係,所以在重排序時CPU的指令順序會變成1-3-2 在這個前提下咱們回來看雙重檢測機制

假設如今有兩個線程AB來調用getInstance方法,這時可能會出現的一種狀況:線程A執行到了instance = new SingletonExample4(); 而線程B剛執行到第一次判空的地方,這時按照1-3-2CPU指令執行順序會出現A正好執行到3的步驟,而線程B在第一次判空的地方會發現這個instance已經指向了一塊內存便會直接返回instance,而在A這邊實際的初始化對象這一步尚未作,線程B在拿到這個尚未作初始化對象的instance以後一旦調用就會出現問題。雖然這種狀況發生的機率不大,但仍是有線程安全風險的。

線程安全且性能更好的懶漢模式

既然出現問題的緣由是發生了指令重排,那麼咱們就不讓它發生指令重排,這時咱們應該想起以前學的關鍵字:volatile

/** * 單例對象 */
private volatile static SingletonExample5 instance = null;

/** * 靜態工廠方法 * @return */
public static SingletonExample5 getInstance() {
    if (instance == null) { //雙重檢測機制
        synchronized (SingletonExample5.class){ // 同步鎖
            if (instance == null){
                instance = new SingletonExample5();
            }
        }
    }
    return instance;
}
複製代碼

這樣咱們就禁止了指令重排,這個類又變成線程安全的了。

枚舉模式(推薦使用)

/** * className SingletonExample1 * description TODO * 枚舉單例 * @author ln * @version 1.0 * @date 2019-07-12 20:52 */
@Slf4j
@ThreadSafe
public class SingletonExample7 {

    /** * 私有構造函數 */
    private SingletonExample7(){
    }

    /** * 靜態工廠方法 * @return */
    public static SingletonExample7 getInstance() {
        return Singleton.INSTANCE.getInstance();
    }

    private enum Singleton {
        INSTANCE;

        private SingletonExample7 singleton;

        //JVM保證這個方法絕對只調用一次
        Singleton() {
            singleton = new SingletonExample7();
        }

        public SingletonExample7 getInstance() {
            return singleton;
        }
    }

}
複製代碼

咱們經過枚舉的值調用枚舉類裏的getInstance方法時,能夠保證這個方法只被實例化一次且是在這個類調用以前初始化的,所以這裏很好地完成了線程安全。

枚舉方式相比於懶漢模式在安全性方面更容易保證,其次相比於餓漢模式,枚舉模式能夠在實際調用時纔開始作最開始的初始化,在後續使用時也能夠直接取到裏面的值,不會形成資源浪費。

Written by Autu.

2019.7.15

相關文章
相關標籤/搜索