天天看點

白話Java鎖--線程安全--無同步實作方案線程安全對象釋出線程安全實作方法總結

此篇文章主要是為了介紹除了互斥同步、阻塞同步保證線程安全之外,還有無同步的方式實作線程安全,并且介紹一些相關的基礎理論知識,友善我們後面對各種鎖的了解。

線程安全

首先介紹一下,什麼是線程安全。其實就是多個線程通路一個對象的時候,如果不考慮這些線程運作時環境的排程和交替運作,也不需要進行額外的同步,或者調用方法的任何其他協調操作,調用這個對象都可以獲得正确的結果,那麼這個對象就是線程安全的。

對象釋出

使對象能夠在目前作用域之外的代碼中所使用。

例如:通過類的非私有方法傳回對象的引用

public List list = new ArrayList();
	
public List getList() {
	return this.list;
}

           

例如:通過公有的靜态變量釋出對象

public static List list = new ArrayList();
           

通過上面兩種方式釋出對象後,對象就能夠在目前作用域之外的代碼中使用,其他類中的代碼就能夠共享這個對象,也就是共享資源,此時如果多線程情況下就會有并發問題,這種情況下就叫做不安全的對象釋出,就需要同步。

不安全的對象釋出–概述

例如:

private String[] states = new String[] { "AK", "AL" };
 
public String[] getStates() {
    return states;
}
           

如果按照上述方法進行釋出,就會有問題,因為任何調用者都可以改變這個數組的内容。states本應該是私有的變量,但是卻被釋出了,如果是多線程修改或者通路的話就會存在并發問題,即使可能沒有并發問題,但是同樣存在誤用的風險,某個線程可能會誤用這個變量。

因為存在這種不安全的對象釋出的情況,是以需要封裝這種機制,将這個變量進行包裹(封裝),封裝後進行并發控制,讓程式猿能夠正确定位并發的變量,減少無意中破壞共享變量的行為,否則如果變量都敞開了,誰都沒法控制變量。這也是封裝存在的意義之一。

例如:構造方法内隐含引用

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
    
    void doSomething(Event e) {
		do...
	}

}
           

當 ThisEscape 在執行個體化本身的時候,同時建立了EventListener對象,而EventListener中調用doSomething()方法的時候,會隐式的建立ThisEscape對象,也就是實際上執行的是this.doSomething()語句。

不安全的對象釋出–對象逸出

當某個不應該釋出的對象被釋出時,這種情況就是逸出。

例如:釋出某個對象的時候可能會間接的釋出某個其他對象,比如List list=new ArrayList(),在釋出list時,同樣會把Person對象釋出了。

例如:

private final String[] states = new String[] { "AK", "AL" };
 
public String[] getStates() {
    return states;
}
           

任何調用getStates()方法的代碼都可以修改這個數組的内容,數組states已經逸出了他所在的作用域。

例如:隐式的this引用逸出

public class ThisEscape {
  public ThisEscape(EventSource source) {
    source.registerListener(new EventListener() {
      public void onEvent(Event e) {
        doSomething(e);
      }
    });
  }

  void doSomething(Event e) {
  }

  interface EventSource {
    void registerListener(EventListener e);
  }

  interface EventListener {
    void onEvent(Event e);
  }

  interface Event {
  }
}
           

當執行個體化ThisEscape對象時,會調用source的registerListener方法,這時便啟動了一個線程,而且這個線程持有了ThisEscape對象(調用了this.doSomething方法),但此時ThisEscape對象卻沒有執行個體化完成(還沒有傳回一個引用),此時造成了一個this引用逸出,即還沒有完成的執行個體化ThisEscape對象的動作,卻已經暴露了對象的引用。其他線程通路還沒有構造好的對象,可能會造成意料不到的問題。

這個意料不到的問題可能是:

public class ThisEscape {

	private int intState;
    private String stringState;

    public ThisEscape(EventSource source) {
      source.registerListener(new EventListener() {
        public void onEvent(Event e) {
          doSomething(e);
        }
      });

		//執行到這裡時,new 的EventListener就已經把ThisEscape對象隐式釋出了,而ThisEscape對象尚未初始化完成
        
        intState=10;//ThisEscape對象繼續初始化....
        stringState = "hello";//ThisEscape對象繼續初始化....
        
        //執行到這裡時, ThisEscape對象才算初始化完成...

    }

    void doSomething(Event e) {
    }

    interface EventSource {
      void registerListener(EventListe  ner e);
    }

    interface EventListener {
      void onEvent(Event e);
    }

    interface Event {
    }
}
           

主要關注一下構造方法,EventListener通過this隐式的持有ThisEscape對象,但是此時的ThisEscape對象可能還沒有初始化完成,這時的ThisEscape對象是一個尚未構造完成的對象,通路到的成員變量可能是預設值,就會導緻程式出錯。

不安全的對象釋出–不安全的延遲初始化

例如:

public class Resource {
    private int x;
    private String y;

    public Resource(int x, String y) {
        this.x = x;
        this.y = y;
    }
}

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getResource() {
        if (resource == null) {
            resource = new Resource(10,"hello");//不安全的釋出
        }
        return resource
    }
}
           

上面這種不安全的釋出主要是由于執行過程中發生的指令重排造成的。(如果對指令重排不了解的可以看我的文章白話記憶體屏障(Memory Barrier)與volatile關鍵字),就是編譯器為了提高執行效率并行的政策。

那麼指令重排發生在哪裡呢?主要是在resource = new Resource(10,“hello”);這句話上,實際上這句代碼大概分為三個步驟:

  • 為Resource配置設定記憶體空間
  • 将resource指向配置設定的記憶體空間
  • 調用構造函數初始化對象

這三個步驟不是原子的,如果執行到第二步,還沒有進行初始化或者初始化還沒有完成,但是此時對象的指針已經不是null了,其他線程再進行判斷的時候發現不為空,就會拿着這個沒有初始化完成的對象進行操作,那麼這個其他線程就會收獲一個錯誤的結果。如圖:

白話Java鎖--線程安全--無同步實作方案線程安全對象釋出線程安全實作方法總結

安全的釋出

既然存在不安全的對象釋出,那麼如何保證對象安全的釋出呢?

如何解決問題,首先要找到出現問題的原因,出現這種不安全的原因就在于,多線程情況下,線程拿到的對象可能是其他線程沒有初始完成的對象,是以解決也很簡單讓線程拿到其他線程初始化完成的對象就可以了。此時使用final關鍵字就可以了,final關鍵字的作用就是一旦對象的引用對其他線程可見了,那麼其final成員必須正确的指派了,是以通過final,就如同對對象的建立或通路加鎖了一般,天然保證了對象的安全釋出。

public class Resource {
    private final int x;
    private final String y;
    public Resource(){x=10;y="hello"}
    public Resource(int x, String y) {
        this.x = x;
        this.y = y;
    }
}

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getResource() {
        if (resource == null) {
            resource = new Resource();//安全的釋出!
        }
        return resource;
    }
}
           
白話Java鎖--線程安全--無同步實作方案線程安全對象釋出線程安全實作方法總結

安全的釋出–final底層原理

知道了final能夠保證對象的安全釋出,那麼底層是如何處理的呢?

對于指令重排序遵循以下規則:

  1. 在構造函數内對一個final域的寫入,與随後把這個被構造對象的引用指派給一個引用變量,這兩個操作之間不能重排序。

(先寫入final變量,後調用該對象引用)

原因:編譯器會在final域的寫之後,插入一個StoreStore屏障

  1. 初次讀一個包含final域的對象的引用,與随後初次讀這個final域,這兩個操作之間不能重排序。

(先讀對象的引用,後讀final變量)

編譯器會在讀final域操作的前面插入一個LoadLoad屏障

通過第一條規則,可以知道對象的引用指派操作完成在final寫之後,是以在多線程的情況下,對于final的讀取一定是已經寫完的,通過final保證了線程的安全。

同理,對于成員為final的讀取一定是在對象引用的讀取完之後才能讀取。

有點類似于門,在裝修的時候(寫入),需要先把房間先裝修好,才能關門(賦給引用變量)。在進入房間的時候(讀取)需要先開門(讀取引用),再進入房間(讀取final内容)。

final 域是引用類型

如果成員變量是final類型的引用變量,對于引用的成員域的寫入與指令重排沒有任何關系。

安全釋出舉例–單例設計模式

單例模式分為懶漢式和餓漢式,那麼餓漢式如何在多線程情況下保證單例對象的安全釋出呢?

public class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
        return instance;
    }
}
           

為了避開過多的同步操作,使用DCL(Double Check Lock)雙重檢查

public class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
    return instance;
    }
}
           

但是這用DCL同樣存在問題,存在的問題就是在多線程情況下,如果Singleton存在成員變量,多個線程下,可能會讀取到對象的預設值,就是對象的不安全釋出引起的。

是以對于DCL的修正就是将對象裡面的每個元素都聲明為final的。也可以将對象的引用聲明為volatile的,因為對于volatile對象的讀取和修改一定是最新的,是不允許指令重排的,可以保證讀取到的值是最新值。

是以最終版:

public class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
    return instance;
    }
}
           

關于懶漢模式,簡單的說一下

private static Singleton instance = new Singleton();
           

因為是在類裝載時進行建立的,是以是線程安全的不會有對象不安全釋出的情況。但是同樣存在如果沒有實際調用,會造成資源浪費的問題

順帶說一下,最安全的模式是枚舉方式,其實和懶漢模式差不多

線程安全實作方法

保證線程安全的方法有三種:

  • 互斥同步
  • 非阻塞同步
  • 無同步方案

之前的文章已經了解了互斥同步方案和非阻塞同步(CAS),這個主要聊的是無同步方案。

無同步方案又有幾種實作方案:

  • 對象不共享
  • 線程本地變量
  • 不可變對象

對象不共享和不可變對象實質上是從根本上就不使用可以共享的變量,不共享不可變就不會産生安全問題了(隻有死人才不會說話)

對象不共享好了解,就是不使用共享資源。

不可變對象就是一點被建立,對象的屬性值就不可以改變,任何對他的改變都會生成一個新的對象。

順便提一下,生成不可變對象的類就叫做不可變類(Immutable Class),例如String、基本類型的包裝類、BigInteger、BigDecimal等。

不可變類

  • 不能被繼承,類的聲明為final,或者使用靜态工廠聲明構造器為private。如果類可以被繼承會破壞類的不可變性機制,隻要繼承類覆寫父類的方法并且繼承類可以改變成員變量的值,那麼一旦子類以父類的形式出現時,不能保證目前類是否可變。
  • 使用private和final修飾符來修飾類的屬性
  • 不提供任何可以修改對象狀态的方法(包括set方法和其他任何可以改變狀态的方法)

如果成員屬性為可變對象屬性

  • 不要提供更改可變對象的方法
  • 不要共享對可變對象的引用。因為使用者可以在不可變類之外通過修改可變對象的值
  • 有必要的時候,方法傳回的是可變對象的副本而不是原對象
private final Date date;

public Date getDate() {
    return new Date(date.getTime());
}

private final int[] myArray;  

public int[] getArray() {  
    return myArray.clone();   
} 
           

不可變類優點

  • 構造、測試和使用都很簡單
  • 産生的不可變對象是線程安全的,線上程之間可以互相共享,不需要特殊機制保證同步,因為對象的值是無法改變的,可以降低并發的錯誤的可能性。
  • 不可變對象是可以被重複使用的,可以将他們緩存起來重複使用,就像String一樣。可以通過靜态工廠方法提供類似于valueOf()這樣的方法,從緩存中傳回一個已經存在的不可變對象,而不是重新建立一個。

不可變類缺點

最大的缺點就是建立對象的開銷很大,每一個操作都會産生一個新的對象,制造大量的垃圾,不能被重複利用,用完後就扔,會制造很多垃圾,給垃圾回收帶來很大麻煩。

線程本地變量

線程本地變量實際上利用的就是線程封閉技術,那麼什麼是線程封閉呢?

線程封閉

線程封閉其實就是把對象封裝到一個線程裡,隻有這個線程才能看到這個對象,那麼這個對象即使不是線程安全的,也不會出現任何線程安全方面的問題。因為已經從根本上解決了多線程并發的問題,就沒有共享變量。

線程封閉應用

在Connection連接配接資料庫的時候,Connection對象在實作的時候并沒有對線程安全做太多的處理,jdbc的規範裡也沒有要求Connection對象必須是線程安全的。

實際在伺服器應用程式中,線程從連接配接池擷取了一個Connection對象,使用完再把Connection對象傳回給連接配接池,由于大多數請求都是由單線程采用同步的方式來處理的,并且在Connection對象傳回之前,連接配接池不會将它配置設定給其他線程,沒有和其他線程的競争。這是将Connection對象封閉在了線程裡面,這樣我們的Connection對象即使不是線程安全的,但是它通過線程封閉做到了線程安全。

線程封閉的種類

  1. Ad-hoc 線程封閉:

Ad-hoc線程封閉是指,維護線程封閉性的職責完全由程式實作來承擔。Ad-hoc線程封閉是非常脆弱的,因為沒有任何一種語言特性,例如可見性修飾符或局部變量,能将對象封閉到目标線程上。事實上,對線程封閉對象(例如,GUI應用程式中的可視化元件或資料模型等)的引用通常儲存在公有變量中。

說白了就是通過程式代碼進行控制,是以不推薦使用。

  1. 堆棧封閉(最多出現 , 全局變量就容易存在并發問題):

堆棧封閉其實就是方法中定義局部變量。不存在并發問題。多個線程通路一個方法的時候,方法中的局部變量都會被拷貝一份到線程的棧中(Java記憶體模型),是以局部變量是不會被多個線程所共享的。

說白了就是棧幀隻保證了壓棧的時候所使用的局部變量的安全,但是不能保證全局變量的安全,對于全局變量來說,還是存線上程安全問題。

  1. ThreadLocal(線程本地變量) :

直接聯想java的ThreadLocal即可,内部維護了一個map,key是每個線程名稱,value就是需要封閉的對象,每個線程在操作的時候都會到map中尋找自己的對象,每個操作都是基于自己線程的,是以他是線程安全的。

總結

寫這篇文章主要就是為了了解一下實作線程安全的方案中的無同步方案,但實際上感覺就是從根本上就不建立線程不安全的場景,不可變對象就是不建立共享資源,進而沒有線程不安全的場景;線程本地變量就是把共享資源放到每個線程裡面去,進而不共享,是以沒有線程不安全的場景。

是以實際上也就了解了一下對象的釋出和逸出,還有線程封閉的知識。