Java虛擬機器09——執行緒安全與鎖優化

llldddbbb發表於2019-04-22

執行緒安全

執行緒安全:當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件是執行緒安全的。

java語言中的執行緒安全

Java語言中各種操作共享的資料分為以下5類:不可變、絕對執行緒安全、相對執行緒安全、執行緒相容和執行緒對立

不可變(Immutable)

不可變物件一定是執行緒安全的,無論是物件的方法實現還是方法的呼叫者,都不需要再採取任何的執行緒安全保障措施。只要一個不可變物件被正確地構建出來(沒有發生this逃逸的情況),那其外部的可見狀態永遠也不會改變,永遠也不會看到它在多個執行緒之中處於不一致的狀態。Java中常見的不可變物件有String,java.lang.Number及部分子類(Long、Double和BigDecimal)等

絕對執行緒安全

絕對執行緒安全:一個類不管執行時環境如何,呼叫者都不需要任何額外的同步措施。在JavaAPI中,標註自己是執行緒安全的類,大多數都不是絕對的執行緒安全。

java.util.Vector是一個執行緒安全的容器,因為它的add()、get()和size()這類方法都是被synchronized修飾的,儘管這樣效率很低,但確實是安全的。但是,即使它所有的方法都被修飾成同步,也不意味著呼叫它的時候永遠都不再需要同步手段了。如下程式碼:

public class VectorTest {
    private static Vector<Integer> vector = new Vector<>();

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

            Thread removeThread = new Thread(()-> {
                // 讓執行緒稍微停頓一會
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int i = 0; i <vector.size() ; i++) {
                    vector.remove(i);
                }
            });

            Thread printThread = new Thread(()-> {
                for (int i = 0; i < vector.size(); i++) {
                    System.out.println(vector.get(i));
                }
            });

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

        }
    }
}

複製程式碼

結果如下:

image.png
如果另一個執行緒在錯誤的時間裡刪除了一個元素,導致序號i已經不再可用了,再用i訪問就會丟擲ArrayIndexOutOfBoundsException。因此,我們不得不需要在方法呼叫端做額外的同步操作,如:

	    Thread removeThread = new Thread(()-> {
                // 讓執行緒稍微停頓一會
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (vector) {
                    for (int i = 0; i <vector.size() ; i++) {
                        vector.remove(i);
                    }
                }

            });

            Thread printThread = new Thread(()-> {
                synchronized (vector) {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println(vector.get(i));
                    }
                }

            });

複製程式碼

相對執行緒安全

相對的執行緒安全就是我們通常意義上講的執行緒安全。它需要保證這個物件單獨的操作是執行緒安全的,我們在呼叫的時候不需要做額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性,如上例。 在Java中,大部分安全類都屬於這種型別,如Vector、HashTable、Collections的synchronizedCollection()方法包裝的集合等。

執行緒相容

執行緒相容是指物件本身並不是執行緒安全的,但是可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用,我們平常說一個類不是執行緒安全的,絕大多數時候指的是這一種情況。Java API中大部分的類都是屬於執行緒相容的,如集合類ArrayList和HashMap等。

執行緒對立

執行緒對立是指無論呼叫端是否採取了同步措施,都無法在多執行緒環境中併發使用的程式碼。

一個執行緒對立的例子是Thread類的suspend()和resume()方法,如果有兩個執行緒同時持有一個執行緒物件,一個嘗試去中斷執行緒,另一個嘗試去恢復執行緒,如果併發進行的話,無論呼叫時是否進行了同步,目標執行緒都是存在死鎖風險的,如果suspend()中斷的執行緒就是即將要執行resume()的那個執行緒,那就肯定要產生死鎖了。也正是由於這個原因,suspend()和resume()方法已經被JDK宣告廢棄(@Deprecated)了。常見的執行緒對立的操作還有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等。

執行緒安全的實現方法

互斥同步

互斥同步(Mutual Exclusion & Synchronization),同步時指在多個執行緒併發訪問共享資料時,保證共享資料在同一時刻只被一個(或者是一些,使用訊號量的時候)執行緒使用。而互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和訊號量(Semaphore)都是主要的互斥實現方式。互斥是因,同步是果;互斥是方法,同步是目的。

在Java中,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令,這兩個位元組碼都需要一個reference型別的引數來指明要鎖定和解鎖的物件。如果Java程式中的synchronized明確指定了物件引數,那就是這個物件的reference;如果沒有明確指定,那就根據synchronized修飾的是例項方法還是類方法,去取對應的物件例項或Class物件來作為鎖物件。

根據虛擬機器規範的要求,在執行monitorenter時,首先要嘗試獲取物件的鎖。如果這個物件沒有被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1,相應的。在執行monitoerexit指令時將會將鎖的計數器減1,當計數器為0時,鎖就被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到物件鎖被另一個執行緒釋放為止。

在虛擬機器規範對monitorenter和monitorexit的行為描述中,有兩點是需要特別注意的。首先,synchronized同步塊對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題。其次,同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入。Java的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來幫忙完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間。所以synchronized是Java語言中一個重量級(Heavyweight)的操作。虛擬機器本身也會進行一些優化,譬如在通知作業系統阻塞執行緒之前加入一段自旋等待過程,避免頻繁地切入到核心態之中。

除了synchronized之外,我們還可以使用java.util.concurrent(下文稱JUC)包中的重入鎖(ReentrantLock)來實現同步,他們都具備一樣的執行緒重入特性,只是程式碼寫法上有點區別,一個表現為API層面的互斥鎖(lock()和unlock()方法配合try/finally語句塊來完成),另一個表現為原生語法層面的互斥鎖。相比synchronized,ReentrantLock增加了一些高階功能,主要有以下3項:等待可中斷、可實現公平鎖,以及鎖可以繫結多個條件。

  1. 等待可中斷是指當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情。可中斷特性對處理執行時間非常長的同步塊很有幫助。
  2. 公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock預設情況下也是非公平的,但可以通過帶布林值的建構函式要求使用公平鎖。
  3. 鎖繫結多個條件是指一個ReentrantLock物件可以同時繫結多個Condition物件,而在synchronized中,鎖物件的wait()和notify()或notifyAll()方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外地新增一個鎖,而ReentrantLock則無須這樣做,只需要多次呼叫newCondition()方法即可。

非阻塞同步

互斥同步最主要的問題就是進行執行緒阻塞和喚醒所帶來的效能問題,因此這種同步也稱為阻塞同步(Blocking Synchronization)。從處理問題的方式上說,互斥同步屬於一種悲觀的併發策略,總是認為只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題,無論共享資料是否真的會出現競爭,它都要進行加鎖(這裡討論的是概念模型,實際上虛擬機器會優化掉很大一部分不必要的加鎖)、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要喚醒等操作。

隨著硬體指令集的發展,我們有了另外一個選擇:基於衝突檢測的樂觀併發策略,通俗地說,就是先進行操作,如果沒有其他執行緒爭用共享資料,那操作就成功了;如果共享資料有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止),這種樂觀的併發策略的許多實現都不需要把執行緒掛起,因此這種同步操作稱為非阻塞同步(Non-Blocking Synchronization)。

非阻塞同步一般通過硬體來保證一個從語義上看起來需要多次操作的行為只通過一條處理器指令就能完成,這類指令常用的有:

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

CAS指令需要有3個運算元,分別是記憶體位置(在Java中可以簡單理解為變數的記憶體地址,用V表示)、舊的預期值(用A表示)和新值(用B表示)。CAS指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會返回V的舊值,上述的處理過程是一個原子操作。

在JDK 1.5之後,Java程式中才可以使用CAS操作,該操作由sun.misc.Unsafe類裡面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供,虛擬機器在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器CAS指令,沒有方法呼叫的過程,或者可以認為是無條件內聯進去了。

由於Unsafe類不是提供給使用者程式呼叫的類(Unsafe.getUnsafe()的程式碼中限制了只有啟動類載入器(Bootstrap ClassLoader)載入的Class才能訪問它),因此,如果不採用反射手段,我們只能通過其他的Java API來間接使用它,如J.U.C包裡面的整數原子類,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe類的CAS操作。下面看看如何使用CAS來避免阻塞操作:

public class VolatileTest {
    public static volatile AtomicInteger race = new AtomicInteger(0);

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

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        //  等待所有累加執行緒都結束
        while (Thread.activeCount()>1) {
            Thread.yield();
        }
        System.out.println(race);
    }

}

複製程式碼

執行結果如下: 200000 這一切都要歸功於incrementAndGet()方法的原子性,其原始碼(JDK1.7)如下:

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}
複製程式碼

incrementAndGet()方法在一個無限迴圈中,不斷嘗試將一個比當前值大1的新值賦給自己。如果失敗了,那說明在執行“獲取-位置”的時候值已經有了修改,於是再次迴圈進行下一次操作,知道設定成功為止。

CAS這種操作無法涵蓋互斥同步的所有使用場景,並且CAS從語義上來說並不是完美的,存在這樣的一個邏輯漏洞:如果一個變數V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然為A值,那我們就能說它的值沒有被其他執行緒改變過了嗎?如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。這個漏洞稱為CAS操作的“ABA”問題。JUC包為了解決這個問題,提供了一個帶有標記的原子引用類AtomicStampedReference,它可以通過控制變數值的版本來保證CAS的正確性。大部分情況下ABA問題不會影響程式併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

無同步方案

要保證執行緒安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保證共享資料爭用時的正確性的手段,如果一個方法本來就不涉及共享資料,那它自然就無須任何同步措施去保證正確性,因此會有一些程式碼天生就是執行緒安全的,筆者簡單地介紹其中的兩類。

可重入程式碼(Reentrant Code)

這種程式碼也叫做純程式碼(Pure Code),可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼(包括遞迴呼叫它本身),而在控制權返回後,原來的程式不會出現任何錯誤。相對執行緒安全來說,可重入性是更基本的特性,它可以保證執行緒安全,即所有的可重入的程式碼都是執行緒安全的,但是並非所有的執行緒安全的程式碼都是可重入的。

可重入程式碼有一些共同的特徵,例如不依賴儲存在堆上的資料和公用的系統資源、用到的狀態量都由引數中傳入、不呼叫非可重入的方法等。我們可以“通過一個簡單的原則來判斷程式碼是否具備可重入性:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的資料,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是執行緒安全的。

執行緒本地儲存(Thread Local Storage)

如果一段程式碼中所需要的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行?如果能保證,我們就可以把共享資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。

Java語言中,如果一個變數要被多執行緒訪問,可以使用volatile關鍵字宣告它為“易變的”;如果一個變數要被某個執行緒獨享,可以通過java.lang.ThreadLocal類來實現執行緒本地儲存的功能。每一個執行緒的Thread物件中都有一個ThreadLocalMap物件,這個物件儲存了一組以ThreadLocal.threadLocalHashCode為鍵,以本地執行緒變數為值的鍵值對,ThreadLocal物件就是當前執行緒的ThreadLocalMap的訪問入口,每一個ThreadLocal物件都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以線上程鍵值值對中找回對應的本地執行緒變數。

鎖優化

為了線上程之間更高效地共享資料,以及解決競爭問題,從而提高程式的執行效率,HotSpot虛擬機器開發團隊實現了鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向鎖(Biased Locking)等。

自旋鎖於自適應鎖

互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的併發效能帶來了很大的壓力。在許多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖。

自旋鎖在JDK 1.4.2中就已經引入,只不過預設是關閉的,可以使用-XX:+UseSpinning引數來開啟,在JDK 1.6中就已經改為預設開啟了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時間很長,那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來效能上的浪費。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。自旋次數的預設值是10次,使用者可以使用引數-XX:PreBlockSpin來更改。

在JDK 1.6中引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。另外,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨著程式執行和效能監控資訊的不斷完善,虛擬機器對程式鎖的狀況預測就會越來越準確。

鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的資料支援,如果判斷在一段程式碼中,堆上的所有資料都不會逃逸出去從而被其他執行緒訪問到,那就可以把它們當做棧上資料對待,認為它們是執行緒私有的,同步加鎖自然就無須進行。

鎖粗化

原則上,我們在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的運算元量儘可能變小,如果存在鎖競爭,那等待鎖的執行緒也能儘快拿到鎖。

大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,那即使沒有執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

如果虛擬機器探測到一串零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部。

輕量級鎖

輕量級鎖是JDK 1.6之中加入的新型鎖機制,它名字中的“輕量級”是相對於使用作業系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就稱為“重量級”鎖。輕量級鎖用於在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

輕量級鎖能提升程式同步效能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗資料。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

偏向鎖

偏向鎖也是JDK 1.6中引入的一項鎖優化,它的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。

偏向鎖的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。

偏向鎖可以提高帶有同步但無競爭的程式效能。它同樣是一個帶有效益權衡(Trade Off)性質的優化,也就是說,它並不一定總是對程式執行有利,如果程式中大多數的鎖總是被多個不同的執行緒訪問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用引數-XX:-UseBiasedLocking來禁止偏向鎖優化反而可以提升效能。

相關文章