1. 程式人生 > >JVM學習記錄-線程安全與鎖優化(一)

JVM學習記錄-線程安全與鎖優化(一)

多線程 image @param decimal 屬於 資源分配 try 可能 例如

前言

線程:程序流執行的最小單元。線程是比進程更輕量級的調度執行單位,線程的引入,可以把一個進程的資源分配和執行調度分開,各個線程既可以共享進程資源(內存地址、文件I/O等),又可以獨立調度(線程是CPU調度的基本單位)。

Java語言定義了5中線程狀態,在任意一個時間點,一個線程只能有且只有其中的一種狀態,5中狀態如下。

新建(New):創建後尚未啟動的線程處於這種狀態。

運行(Runnable):Runnable包括了操作系統線程狀態中的Running和Ready,也就是處於此狀態的線程可能正在執行,也可能正在等待著CPU為它分配執行時間。

無限期等待(Waiting):處於這種狀態的線程不會被分配CPU執行時間,它們要等待被其他線程顯示地喚醒。

讓線程進入無限等待的方法有如下幾個:

  • 沒有設置Timeout參數的Object.wait()方法。
  • 沒有設置Timeout參數的Thread.join()方法。
  • LockSupport.park()方法。

限期等待(Timed Waiting):處於這種狀態的線程也不會被分配CPU執行時間,不過無須等待被其他線程顯式地喚醒,在一定時間之後它們會由系統自動喚醒。

讓線程進入限期等待狀態的方法有如下幾個:

  • Thread.sleep()方法。
  • 設置了Timeout參數的Object.wait()方法。
  • 設置了Timeout參數的Thread.join()方法。
  • LockSupport.parkNanos()方法。
  • LockSupport.parkUntil()方法。

阻塞(Blocked):線程被阻塞了,“阻塞狀態”是在等待著獲取到一個排他鎖,這個事件將在另一個線程放棄這個鎖的時候發生;更通俗的解釋就是一個線程正在幹著一件事,沒資源幹其他的事,當來了其他的事時就只能阻塞的等著線程能騰出時間來處理。

結束(Terminated):已終止線程的線程狀態,線程已經結束執行。

這5種狀態在遇到特定的事件的時候會相互轉換。

技術分享圖片

線程安全

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

Java語言中的線程安全

研究線程安全,需要限定於多個線程之間存在共享數據訪問這個前提。Java語言中各種操作共享數據分為以下5類:

不可變

在JDK1.5以後,Java語言中不可變的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要再采取任何的線程安全保障措施。如果一個基本數據類在定義時使用final關鍵字修飾它,就可以保證它時不可變的。如果final修飾的是一個對象,需要保證對象的方法不會對其狀態產生影響才行。例如:String類的substring()、concat()這些方法不會影響原來的值,只會生成一個新的字符串。

保證對象方法不會對其狀態產生影響的實現方式有很多,最簡單是將對象中帶有狀態的屬性用final修飾。

例如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;
    }

Java中除了String類、Integer類,還有其他的Long、Double等包裝類,以及BigInteger和BigDecimal等大數據類型,都符合不可變要求的類型。

絕對線程安全

絕對線程安全,是指絕對的符合前面提到的線程安全的定義,多線程永遠調用對象時永遠都能獲得正確的結果。但是為了實現這個絕對要付出的代價是很大的,在Java中標註自己是線程安全的類,絕大多數都不是絕對線程安全的,例如Vector類,java.util.Vector是一個線程安全類,它的add()、get()、size()等都是被synchronized修飾的,但這並不能保證它是絕對安全的。

如下代碼:

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

    public static void main(String[] args){
        

        while (true){
            for(int index = 0;index < 10;index++){
                vector.add(index);
            }
            //移除元素的線程
            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()>5);
        }


    }
    

}

運行結果:

Exception in thread "Thread-229" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 15
    at java.util.Vector.get(Vector.java:748)
    at com.eurekaclient2.client2.shejimoshi.JVM.Test$2.run(Test.java:38)
    at java.lang.Thread.run(Thread.java:748)

盡管Vector的方法都是同步的,但是在多線程環境下,若不在調用方法端做額外的同步措施的話,仍然不是線程安全的,因為若另一線程恰好在錯誤的時間裏刪除了一個元素,導致序號i已經不再可用的話,再用i訪問數組就會拋出一個ArrayIndexOutOfBoundsException。

解決方法如下(將移除和打印都設置為同步):

public class Test {

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

    public static void main(String[] args){


        while (true){
            for(int index = 0;index < 10;index++){
                vector.add(index);
            }
            //移除元素的線程
            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()>5);
        }


    }


}

相對線程安全

我們通常所講的線程安全就是指的相對線程安全,需要保證對象單獨的操作時線程安全的,不需要做額外的保障措施。但若是對於一些特定屬性的連續調用,就可能會需要在調用端添加額外的同步措施。Java語言中,大部分的線程安全類都是相對線程安全的,例如Vector、HashTable以及Collections的synchronizedCollection()方法包裝的集合等。

線程兼容

線程兼容是指對象本身不是線程安全的,但是可以通過在調用端使用同步措施來保證對象在並發環境中可以安全的使用。Java中大部分類都是線程兼容的,如ArrayList、HashMap等等。

線程對立

線程對立指無論調用端是否采用了同步措施,都無法在多線程環境中並發使用代碼。這種代碼是有害的,應盡量避免。常見的線程對立操作有System.setIn()、System.setOut()和System.runFinalizersOnExit()等。

線程安全的實現方法

線程安全的實現主要有以下幾個方法:

互斥同步

通過互斥來實現同步,臨界區、互斥量、信號量都是主要的互斥實現方法。在Java中最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字通過編譯後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個字節碼指令,這兩個字節碼都需要一個reference類型的慘呼是來指明要鎖定和解鎖的對象。若在程序中為synchronized指明了對象參數,那就是這個對象的reference,若沒有指明,則根據synchronized修飾的是實例方法或類方法,來獲取對應的對象或Class對象來作為鎖對象。

在虛擬機規範中要求,在執行monitorernter指令時,首先要嘗試獲取對象的鎖。如若此對象沒被鎖定或當期線程已經擁有了此對象的鎖,則把鎖的計數器加1,響應的在執行monitorexit指令時會將鎖計數器減1,當計數器為0時,鎖就會被釋放。若獲取鎖失敗,那麽當前線程就要進入阻塞狀態,直到對象鎖被另外一個線程釋放為止。

有兩點需要註意的是:

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

除了synchronized之外,還可以使用java.util.concurrent包中的重入鎖(ReentrantLock)來實現同步。用法很相似,只是代碼寫法上有區別,ReentrantLock表現為API層面的互斥鎖(lock()和unlock()方法配合try/finally()語句塊來完成),synchronized表現為原生語法層面的互斥鎖。不過ReentrantLock比synchronized增加了一些高級功能,主要有以下3項:等待可中斷、可實現公平鎖,以及鎖可以綁定多個條件。

  • 等待可中斷是指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。
  • 公平鎖是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;非公平鎖,在釋放時任何一個等待線程都有機會獲得鎖。synchronized是非公平鎖,ReentrantLock默認情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。
  • 鎖綁定多個條件是指一個ReentrantLock對象可以同時綁定多個Condition對象,而synchronized中,鎖對象的wait()和notify()或notifyAll()方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外的添加一個鎖,而ReentrantLock則無須這樣做,只需要多次調用newCondition()方法即可。

非阻塞同步

互斥同步最主要的問題就是進行現場阻塞和喚醒鎖帶來的性能問題,因此這種同步也稱為阻塞同步(Block Synchronization)。從處理問題的方式上來說,互斥同步屬於一種悲觀的並發策略,那麽相對而言的就有了另一種基於沖突檢測的樂觀並發策略,通俗的解釋就是先執行操作,如果沒有其他線程爭用共享數據,那操作就成功了;如果有線程爭用共享數據,那就再采取其他補償措施(常見的補償措施就是不斷重試,直到成功為止),這種樂觀的並發策略不需要把線程掛起,因此也被稱為非阻塞同步(Non-Block Synchronization)

在進行操作和沖突檢測時,需要保證這兩個步驟的原子性,這個時候如果靠同步互斥,那就也成悲觀並發了,所以只能靠硬件來完成這個保證,硬件保證一個從語義上開起來需要多次操作的行為只通過一條處理器指令就能完成,此類指令常用的有:

  • 測試並設置(Test-and-Set)。
  • 獲取並增加(Fetch-and-Increment)。
  • 交換(Swap)。
  • 比較並交換(Compare-and-Swap)CAS
  • 加載鏈接/條件存儲(Load-Linked/Store-COnditional)。

無同步方案

可重入代碼

如果一個方法,它的返回結果是可以預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是線程安全的。這個方法就是可重入代碼,在這段代碼可以在執行的任何時刻中斷它,轉而去執行另外一段代碼,而在控制權返回後,原來的程序不會出現任何錯誤。

線程本地存儲

如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。例如大部分的消息隊列的架構模式(生產者-消費者)都符合這個特點。

JVM學習記錄-線程安全與鎖優化(一)