Java虛擬機09——線程安全與鎖優化

線程安全

線程安全:當多個線程訪問一個對象時,若是不用考慮這些線程在運行時環境下的調度和交替執行,也不須要進行額外的同步,或者在調用方進行任何其餘的協調操做,調用這個對象的行爲均可以得到正確的結果,那這個對象是線程安全的。java

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等。優化

線程對立

線程對立是指不管調用端是否採起了同步措施,都沒法在多線程環境中併發使用的代碼。this

一個線程對立的例子是Thread類的suspend()和resume()方法,若是有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,另外一個嘗試去恢復線程,若是併發進行的話,不管調用時是否進行了同步,目標線程都是存在死鎖風險的,若是suspend()中斷的線程就是即將要執行resume()的那個線程,那就確定要產生死鎖了。也正是因爲這個緣由,suspend()和resume()方法已經被JDK聲明廢棄(@Deprecated)了。常見的線程對立的操做還有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等。spa

線程安全的實現方法

互斥同步

互斥同步(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來禁止偏向鎖優化反而能夠提高性能。

相關文章
相關標籤/搜索