深入理解JVM(③)再談線程安全

前言

我們在編寫程序的時候,一般是有個順序的,就是先實現再優化,並不是所有的牛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服務端應用都可以使用線程本地存儲來解決線程安全問題。

.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章