JVM學習筆記——線程安全與鎖優化

線程安全

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

java語言中的線程安全

討論線程安全有一個前提,即多個線程之間存在共享數據訪問。按照線程安全的“安全程度”由強至弱來排序,可以將Java語言中各種操作共享的數據分爲以下5類:不可變、 絕對線程安全、 相對線程安全、 線程兼容和線程對立。

不可變

JDK1.5之後,不可變的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要再採取任何的線程安全保障措施,Java語言中,如果共享數據是一個基本數據類型,那麼只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。
如果共享數據是一個對象,那就需要保證對象的行爲不會對其狀態產生任何影響纔行,java.lang.String類的對象就是一個典型的不可變對象,它的substring()、 replace()和concat()這些方法都不會影響它原來的值,只會返回一個新構造的字符串對象。
保證對象行爲不影響其狀態的最簡單的就是把對象中帶有狀態的變量都聲明爲final,這樣在構造函數結束之後,它就是不可變的,除了String對象,常用的還有枚舉類型,以及java.lang.Number的部分子類,如Long和Double等數值包裝類型,BigInteger和BigDecimal等大數據類型。

絕對線程安全

滿足絕對要求的東西,通常都要付出很大的代價,這個也不例外,所以java中的絕大多數標註自己是線程安全的類,都不是絕對的線程安全。舉個例子,vector類是線程安全的,它的set,get等方法都採用了synchronized同步,但是在某些情況下仍然不安全。

public class Lock {
    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-132"java.lang.ArrayIndexOutOfBoundsException:
Array index out of range:17
at java.util.Vector.remove(Vector.java777)
at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java21)
at java.lang.Thread.run(Thread.java662

儘管這裏使用到的Vector的get()、 remove()和size()方法都是同步的,但是在多線程的環境中,如果不在方法調用端做額外的同步措施的話,使用這段代碼仍然是不安全的,因爲如果另一個線程恰好在錯誤的時間裏刪除了一個元素,導致序號i已經不再可用的話,再用i訪問數組就會拋出一個ArrayIndexOutOfBoundsException。
解決方案如下:

public class Lock {
    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() {
                    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));
                    }
                }
            });
            removeThread.start();
            printThread.start();
            while (Thread.activeCount() > 20) ;
        }
    }

}

相對線程安全

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

線程兼容

線程兼容是指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在併發環境中可以安全地使用。

線程對立

線程對立是指無論調用端是否採取了同步措施,都無法在多線程環境中併發使用的代碼。 java中線程對立這種排斥多線程的代碼是很少出現的,而且通常都是有害的,應當儘量避免。
一個線程對立的例子是Thread類的suspend()和resume()方法,suspend方法並不會釋放鎖,假設線程A持有某個重要系統資源的鎖,然後A被suspend了。這時線程B嘗試先持有這個系統資源,然後再resum()A線程。但是很明顯無法獲取鎖,這兩個線程就死鎖了,也就是凍結線程。也正是由於這個原因,suspend()和resume()方法已經被JDK聲明廢棄。

線程安全的實現方法

互斥同步

實現互斥同步的方法是一些老生常談的東西,臨界區(Critical Section)、 互斥量(Mutex)和信號量(Semaphore)。這些不做介紹。
在Java中,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個字節碼指令,這兩個字節碼都需要一個reference類型的參數來指明要鎖定和解鎖的對象。 如果Java程序中的synchronized明確指定了對象參數,那就是這個對象的reference;如果沒有明確指定,那就根據synchronized修飾的是實例方法還是類方法,去取對應的對象實例或Class對象來作爲鎖對象。
根據虛擬機規範的要求,在執行monitorenter指令時,首先要嘗試獲取對象的鎖。 如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會將鎖計數器減1,當計數器爲0時,鎖就被釋放。 如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放爲止。
值得注意的兩點:

  • synchronized同步塊對同一條線程來說是可重入的,不會出現自己把自己鎖死的問題
  • 同步塊在已進入的線程執行完之前,會阻塞後面其他線程的進入

java中阻塞或喚醒一個線程,都需要操作系統從用戶態轉換到核心態中,狀態轉換需要耗費很多的處理器時間。對於代碼簡單的同步塊(如被synchronized修飾的getter()或setter()方法),狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。 所以synchronized是Java語言中一個重量級(Heavyweight)的操作,除了synchronized之外,我們還可以使用java.util.concurrent(下文稱J.U.C)包中的重入鎖(ReentrantLock)來實現同步。
關於這兩者的區別,可以參考這篇文章。

非阻塞同步

互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱爲阻塞同步(Blocking Synchronization)。 無論共享數據是否真的會出現競爭,它都要進行加鎖,這樣就有了另外一種方法:基於衝突檢測的樂觀併發策略,即先進行操作,如果沒有其他線程爭用共享數據,那操作就成功了;如果共享數據有爭用,產生了衝突,那就再採取其他的補償措施,比如不斷重試直到成功。這樣就不需要把線程掛起。
非阻塞同步依賴於硬件指令集的發展,因爲這樣可以保證一些操作的原子性而不需要進行同步操作,比如CAS(compare and swap)。
比如下列的場景:

public class Lock {
    public static AtomicInteger raceSYN = new AtomicInteger(0);
    public static int raceNotSyn = 0;

    public static void increaseSYN() {
        raceSYN.incrementAndGet();
    }
    public static void increase() {
        raceNotSyn++;
    }

    private static final int THREADS_COUNT = 5;

    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(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 100; i++) {
                        increaseSYN();
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while (Thread.activeCount() > 1)
            Thread.yield();
        System.out.println(raceSYN);
        System.out.println(raceNotSyn);
    }
}

結果:
raceSYN = 500
raceNotSyn = 499
incrementAndGet()方法在一個無限循環中,不斷嘗試將一個比當前值大1的新值賦給自己。 如果失敗了,那說明在執行“獲取-設置”操作的時候值已經有了修改,於是再次循環進行下一次操作,直到設置成功爲止。當時這種方法有侷限性,那就是萬一操作先加一然後再減一,無法判斷值是否被改變過。

無同步方案

如果方法本身就不涉及共享數據,那麼明顯是線程安全的。以下爲典型的兩類的介紹:
可重入代碼(Reentrant Code):這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤。可重入的代碼都是線程安全的,但線程安全的代碼並不一定是可重入的。
線程本地存儲(Thread Local Storage):如果共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。

鎖優化

自旋鎖與自適應自旋

在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得。 這時,可以讓後面請求鎖的那個線程執行一個忙循環(自旋),但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖,這項技術就是所謂的自旋鎖。
值得注意的是,自旋等待本身雖然避免了線程切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時間很長,那麼自旋的線程只會白白耗處理器資源,而不會做任何有用的工作,反而會帶來性能上的浪費。 因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起線程了。
自適應自旋的概念讓我想起局部性原理,即自旋鎖的事件不固定,而是由前一次的自旋時間以及鎖的擁有者的狀態決定的,如果上一次成功了,那麼這一次會允許較長的自旋時間,如果上一次失敗,則會直接跳過自旋狀態直接掛起。

鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。很多的同步措施並不是程序員自己加入的,而是JVM在運行期間轉換導致的。

鎖粗化

如果虛擬機探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的範圍擴展(粗化)到整個操作序列的外部,這樣只需要加鎖一次就可以了。

輕量級鎖與偏向鎖

量級鎖並不是用來代替重量級鎖(傳統的鎖機制)的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。
偏向鎖的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。 如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。

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