深入理解JVM(③)再談執行緒安全

紀莫發表於2020-07-15

前言

我們在編寫程式的時候,一般是有個順序的,就是先實現再優化,並不是所有的牛P程式都是一次就寫出來的,肯定都是不斷的優化完善來持續實現的。因此我們在考慮實現高併發程式的時候,要先保證併發的正確性,然後在此基礎上來實現高效。所以執行緒安全是高併發程式首先需要保證的。

執行緒安全定義

對於執行緒安全的定義可以理解為:當多個執行緒同時訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那就稱這個物件是執行緒安全的
這個定義是很嚴謹且有可操作性,它要求執行緒安全的程式碼都必須具備一個共同特徵:程式碼本身封裝了所有必要的正確性保障手段(互斥、同步等),令呼叫者無須關心多執行緒下的呼叫問題,更無須自己實現任何措施來保證多執行緒環境下的正確呼叫。

Java中的執行緒安全

要討論Java中的執行緒安全,我們要以多個執行緒之間存在共享資料訪問為前提。我們可以不把執行緒安全當作一個非真即假的二元排他選項來看待,而是按照執行緒安全的“安全程度”由強至弱來排序,將Java中各操作共享的資料分為以下五類:不可變、絕對執行緒安全、相對相對安全、執行緒相容和執行緒對立

不可變

Java記憶體模型中,不可變的物件一定是執行緒安全的,無論物件的方法實現還是方法的呼叫者,都不需要再進行任何執行緒安全保障措施。在學習Java記憶體模型這一篇文章中我們在介紹Java記憶體模型的三個特性的可見性的時候說到,被final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有吧“this”的引用傳遞出去,那麼在其他執行緒中就能看見final欄位的值。並且外部可見狀態永遠都不會改變,永遠都不會看到它在多個執行緒之中處於不一致的狀態。“不可變”帶來的安全性是最直接、最純粹的。

在Java中如果共享資料是一個基本型別,那麼在定義時使用final修飾它就可以保證它是不可變的。如果共享資料是一個物件,那就需要物件自行保證其行為不會對其狀態產生任何影響才行。例如java.lang.String類的物件例項,它的substring()、replace()、concat()這些方法都不會影響它原來的值,只會返回一個新構造的字串物件。
保證物件行為不影響自己狀態的途徑有很多種,最簡單的一種就是把物件裡面帶有狀態的變數都宣告為final,這樣在建構函式結束後,他就是不可變的。
例如java.lang.Integer建構函式。

/**
 * The value of the {@code Integer}.
 *
 * @serial
 */
private final int value;

/**
 * Constructs a newly allocated {@code Integer} object that
 * represents the specified {@code int} value.
 *
 * @param   value   the value to be represented by the
 *                  {@code Integer} object.
 */
public Integer(int value) {
    this.value = value;
}

除了String之外,還有列舉型別以及java.lang.Number的部分子類,如LongDouble等數值包裝型別、BigIntegerBigDecimal等大資料型別。

絕對執行緒安全

絕對執行緒安全是能夠完全滿足上面的執行緒安全的定義,這個絕對執行緒安全的定義是很嚴格的:“不管執行時環境如何,呼叫者都不需要任何額外的同步措施”。Java的API中標註自己是執行緒安全的類,大多數都不是絕對的執行緒安全。
例如java.util.Vector是一個執行緒安全的容器,相信所有的Java程式設計師對此都不會有異議,因為它的add()、get()、和size()等方法都被synhronized修飾。但是這樣並不意味著呼叫它的時候,就永遠不再需要同步手段了。

public class VectorTest {

    private static Vector<Integer> vector = new Vector<Integer>();

    public static void main(String[] args){
        while (true){
            for (int i=0;i<10;i++){
                vector.add(i);
            }

            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0;i<vector.size();i++){
                        vector.remove(i);
                    }
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i=0;i<vector.size();i++){
                        System.out.println(vector.get(i));
                    }
                }
            });

            removeThread.start();
            printThread.start();

            while (Thread.activeCount() > 20);
        }
    }

}

執行結果:

Exception in thread "Thread-653" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 18
	at java.util.Vector.get(Vector.java:748)
	at com.eurekaclient2.test.jvm3.VectorTest$2.run(VectorTest.java:33)
	at java.lang.Thread.run(Thread.java:748)

通過上述程式碼的例子,就可以看出來,儘管Vector的get()、remove()和size()方法都是同步的,但是在多執行緒的環境中,如果呼叫端不做額外的同步措施,使用這段程式碼仍然是不安全的。因為在併發執行中,如果提前刪除了一個元素,而後面還要去列印它,就會丟擲陣列越界的異常。
如果非要這段程式碼正確執行下去,就必須把removeThreadprintThread進行加鎖操作。

Thread removeThread = new Thread(new Runnable() {
    @Override
    public void run() {
        synchronized (vector){
            for (int i=0;i<vector.size();i++){
                vector.remove(i);
            }
        }
    }
});

Thread printThread = new Thread(new Runnable() {
    @Override
    public void run() {
        synchronized (vector){
            for(int i=0;i<vector.size();i++){
                System.out.println(vector.get(i));
            }
        }
    }
});

相對執行緒安全

相對執行緒安全就是我們通常意義上所講的執行緒安全,它需要保證對這個物件單詞的操作時執行緒安全的,我們在呼叫的時候不需要進行額外的保證措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。上面的程式碼例子就是相對執行緒安全的案例。

執行緒相容

執行緒相容是指物件本身並不執行緒安全的,但是可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用。Java類庫API中大部分的類都是執行緒相容的,如ArrayListHashMap等。

執行緒對立

執行緒對立是指不管呼叫端是否採用了同步措施,都無法在多執行緒環境中併發是使用程式碼。由於Java語言天生就支援多執行緒的特性,此案從對立這種排斥多執行緒的程式碼時很少出現的,而且通常都是有害的,應當儘量避免。

執行緒安全的實現方法

Java虛擬機器為實現執行緒安全,提供了同步和鎖機制,在瞭解了Java虛擬機器執行緒安全措施的原理與運作過程,再去用程式碼實現執行緒安全就不是一件困難的事情了。

互斥同步

互斥同步(Mutual Exclusion & Synchronization)是一種常見也是最主要的併發正確性保障手段。
同步是指多個執行緒併發訪問共享資料時,保證共享資料在同一時刻只被一條執行緒使用
互斥是指實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和訊號量(Semaphore)都是常見的互斥實現方式

在Java裡,最基本的互斥同步手段就是synchronized關鍵字,這是一種塊結構的同步語法。在Java程式碼裡如果synchronized明確指定了物件引數,那就以這個物件的引用作為reference;如果沒有明確指定,那將根據synchronized修飾的方法型別(如例項方法或類方法),來決定是取程式碼所在的物件例項還是取型別對應的Class物件來作為執行緒要持有的鎖。

在使用sychronized時需要特別注意的兩點:

  • synchronized修飾的同步塊對同一條執行緒來說是可重入的。這意味著同一執行緒反覆進入同步塊也不會出現自己把自己鎖死的情況。
  • synchronized修飾的同步塊在持有鎖的執行緒執行完畢並釋放鎖之前,會無條件地阻塞後面其他執行緒的進入。這意味著無法像處理某些資料庫中的鎖那樣,強制已獲取鎖的執行緒釋放鎖;也無法強制正在等待鎖的執行緒中斷等待或超時退出。

除了synchronized關鍵字以外,自JDK5起,Java類庫中新提供了java.util.concurrent包(J.U.C包),其中java.util.concurrent.locks.Lock介面便成了Java的另一種全新的互斥同步手段。

重入鎖(ReentrantLock)是Lock介面最常見的一種實現,它與synchronized一樣是可重入的。在基本用法是,ReentrantLocksynchronized很相似,只是程式碼寫法上稍有區別而已。
但是ReentrantLocksynchronized相比增加了一些高階特性,主要有以下三項:

  • 等待可中斷:是指當持有鎖的執行緒長期不釋放的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情。可中斷特性對處理執行時間非常長的同步很有幫助。
  • 公平鎖:是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。
    synchronized是非公平鎖,ReentrantLock在預設情況系也是非公平鎖,但可以通過建構函式的引數設定成公平鎖,不過一旦設定了公平鎖,ReentrantLock效能急劇下降,會明顯影響效能。
  • 鎖繫結多個條件:是指一個ReentrantLock物件可以同時繫結多個Condition物件。在synchronized中,鎖物件的wait()跟它的notify()或者notifyAll()方法配合可以實現一個隱含條件,如果要和多於一個的條件關聯的時候,就不得不額外新增一個鎖;而ReentrantLock則無須這樣做,多次呼叫newCondition()方法即可。

雖然說ReentrantLock比synchronized增加了一些高階特性,但是從JDK6對synchronized做了很多的優化後,他倆的效能其實幾乎相差無幾了。並且在以下的幾種情況下雖然synchronized和ReentrantLock都可以滿足需求時,建議優先使用synchronized

  • synchronized是在Java語法層面的同步,清晰簡單。並且被廣泛熟知,但J.U.C中的Lock介面並非如此。因此在只需要基礎的同步功能時,更推薦synchronized
  • Lock應該確保在finally塊中釋放鎖,否則一旦受同步保護的程式碼塊中丟擲異常,則有可能永遠不釋放持有的鎖。
  • 儘管在JDK5時代ReentrantLock曾經在效能上領先過synchronized,但這已經是十多年之前的勝利。從長遠看,Java虛擬機器更容易針對synchronized來進行優化,因為Java虛擬機器可以線上程和物件的後設資料中記錄synchronized中鎖的相關資訊。

非同步阻塞

互斥同步面臨的主要問題時進行執行緒阻塞和喚醒所帶來的效能開銷,因此這種同步也被稱為阻塞同步(Blocking Synchronized)。從解決問題的角度來看,互斥同步是一種悲觀的併發策略,無論共享的資料是否真的會出現競爭,都會進行加鎖。
隨著硬體指令集的發展,出現了另一種選擇,基於衝突檢測的樂觀併發策略,通俗地說就是不管風險,先進行操作,發生了衝突,在進行補償,最常用的補償就是不斷重試,直到出現沒有競爭的資料為止。使用這種樂觀併發策略不再需要執行緒阻塞掛起,因此這種同步操作被稱為非阻塞同步(Non-Blocking Synchronized)

在進行操作和衝突檢測時這個步驟要保證原子性,硬體可以只通過一條處理器指令就能完成,這類指令常用的有:

  • 測試並設定(Test and Set);
  • 獲取並增加(Fetch and Increment);
  • 交換(Swap);
  • 比較並交換(Compare adn Swap,簡稱CAS)
  • 載入連結/條件儲存(Load-Linked/Store-Conditional,簡稱LL/SC)

Java類庫從JDK5之後才開始使用CAS操作,並且該操作有sun.misc.Unsafe類裡面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供。但是Unsafe的限制了不提供給使用者呼叫,因此在JDK9之前只有Java類庫可以使用CAS,譬如J.U.C包裡面的整數原子類,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe類的CAS操作來實現。直到JDK9,Java類庫才在VarHandle類裡開放了面向使用者程式使用的CAS操作

下面來看一個例子
在這裡插入圖片描述
這是之前的一個例子在驗證volatile變數不一定完全具備原子性的時候的程式碼。20個執行緒自增10000次的操作最終的結果一直不會得到200000。如果按之前的理解就會把race++操作或increase()方法用同步塊包起來。

但是如果改成下面的程式碼,效率將會提高許多。

public class AtomicTest {

    public static AtomicInteger race = new AtomicInteger(0);

    public static void increase(){
        race.incrementAndGet();
    }

    private static final int THREADS_COUNT = 20;
    
    public static void main(String[] args) throws Exception{
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0;i<THREADS_COUNT;i++){
            threads[i] = new Thread(() -> {
                for(int i1 = 0; i1 <10000; i1++){
                    increase();
                }
            });

            threads[i].start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }

        System.out.println(race);
    }

}

執行效果:

200000

使用哦AtomicInteger代替int後,得到了正確結果,主要歸功於incrementAndGet()方法的原子性,incrementAndGet()使用的就是CAS,在此方法內部有一個無限迴圈中,不斷嘗試講一個比當前值大一的新值賦值給自己。如果失敗了,那說明在執行CAS操作的時候,舊值已經發生改變,於是再次迴圈進行下一次操作,直到設定成功為止。

無同步方案

要保證執行緒安全,也不一定非要用同步,執行緒安全與同步沒有必然關係,如果能讓一個方法本來就不涉及共享資料,那它自然就不需要任何同步措施去保證正確性,因此有一些程式碼天生就是執行緒安全的,主要有這兩類:
可重入程式碼:是指可以在程式碼執行的任何時刻中斷它,然後去執行另外一段程式碼,而控制權返回後,原來的程式不會出現任何錯誤,也不會對結果有所影響。
可重入程式碼有一些共同特徵:
不依賴全域性變數、儲存在堆上的資料和公用的系統資源,用到的狀態量都由引數傳入,不呼叫非可重入的方法等
簡單來說就是一個原則:如果一個方法的返回結果是可以預測的,只要輸入了相同的資料,就能返回相同的結果,那它就滿足可重入性的要求,當然也就是執行緒安全的
執行緒本地儲存(Thread Local Storage)如果一段程式碼中所需的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行。如果能,就可以把共享資料的可見範圍限制在同一個執行緒內,這樣無須同步也能保證執行緒之間不出現資料爭用的問題
如大部分使用消費佇列的架構模式,都會將產品的消費過程限制在一個執行緒中消費完,最經典一個例項就是Web互動模式中的“一個請求對應一個伺服器執行緒”的處理方式,這種處理方式的廣泛應用使得很多Web服務端應用都可以使用執行緒本地儲存來解決執行緒安全問題。

.

相關文章