深刻Java虛擬機(六)線程同步

能夠在語言級支持多線程是Java語言的一大優點,這種支持主要集中在同步上,或調節多個線程間的活動和共享數據。Java所使用的同步是監視器java

監視器Monitor

Java中的監視器支持兩種線程:互斥和協做bash

  • 虛擬機經過對象鎖來實現互斥,容許多個線程在同一個共享數據上獨立而不干擾地工做
  • 協做則是經過Object類的wait方法和notify方法來實現,容許多個線程爲了同一個目標而共同工做

咱們能夠把監視器比做一個建築,它有一個很特別的房間,房間裏有一些數據,並且在同一時間只能被一個線程佔據。一個線程從進入這個房間到它離開以前,它能夠獨佔地訪問房間中的所有數據。多線程

咱們用一些術語來定義這一系列動做:ide

  • 進入建築叫作進入監視器
  • 進入建築中的那個特別的房間叫作得到監視器
  • 佔據房間叫作持有監視器
  • 離開房間叫作釋放監視器
  • 離開建築叫作退出監視器

除了與一些數據關聯外,監視器仍是關聯到一些或更多的代碼,這樣的代碼稱做監視區域,對於一個監視器來講,監視區域是最小的、不可分割的代碼塊。而監視器會保證在監視區域上同一時間只會執行一個線程。一個線程想要進入監視器的惟一途徑就是到達該監視器所關聯的一個監視區域的開始處,而線程想要繼續執行監視區域的惟一途徑就是得到該監視器ui

監視器下的互斥

當一個線程到達了一個監視區域的開始處,它就會被放置到該監視器的入口區。若是沒有其餘線程在入口區等待,也沒有線程持有該監視器,則這個線程就能夠得到監視器,並繼續執行監視區域中的代碼。當這個線程執行完監視區域後,它就會退出(並釋放)該監視器。this

若是一個線程到達了一個一個監視區域的開始處,犯這個監視區域已經有線程持有該監視器了,則這個剛剛到達的線程必須在入口區等待。當監視器的持有者退出監視器後,新到達的線程必須與其它已經在入口區等待的線程進行一次比賽,最終只會有一個線程得到監視器。spa

監視器下的協做

當一個線程須要一些特別狀態的數據,而由另外一個線程負責改變這些數據的狀態時,同步就顯得特別重要。.net

舉例:一個讀線程會從緩衝區讀取數據,而另外一個寫線程會向緩衝區填充數據。讀線程須要緩衝區處於一個非空的狀態,這樣才能夠從中讀取數據,若是讀線程發現緩衝區是空的,它就必須等待。寫線程負責向緩衝區寫數據,只有寫線程寫入完成,讀線程才能相應的讀取。線程

Java虛擬機使用的這種監視器被稱做等待-喚醒監視器。在這種監視器中,在一個線程(方便區分,叫線程A)持有監視器的狀況下,能夠經過執行一個等待命令,暫停自身的執行。
線程A執行了等待命令後,它就會釋放監視器,並進入一個等待區,這個線程A會一直持續暫停狀態,直到一段時間後,這個監視器中的其餘線程執行了喚醒命令
當一個線程(線程B)執行了喚醒命令後,它會繼續持有監視器,直到他主動釋放監視器(執行完監視區域或執行一個等待命令)。當執行喚醒的線程(線程B)釋放了監視器後,等待線程(線程A)會甦醒,並從新得到監視器。翻譯

等待-喚醒監視器有時也被稱做發信號並繼續(這個翻譯沒誰了。。。。)監視器,究其緣由,就是在一個線程執行喚醒操做後,它還會繼續持有監視器並繼續執行監視區域,過了段時間以後,喚醒線程釋放監視器,等待線程纔會甦醒。

因此一次喚醒每每會被等待線程看做是一次提醒,告訴它「數據已是你想要的狀態了」。當等待線程甦醒後,它須要再次檢查狀態,以確認是否能夠繼續完成工做,若是數據不是它所須要的狀態,等待線程可能會再次執行等待命令或者放棄等待退出監視器

仍是上面的例子:一個讀線程、一個緩衝區、一個寫線程。假定緩衝區是由某個監視器所保護的,當讀線程進入這個監視器時,它會檢查緩衝區是否爲空:

  • 若是不爲空,讀線程會從中取出一些數據,而後退出監視器。
  • 若是是空的,讀線程會執行一個等待命令,同時它會暫停執行並進入等待區

這樣讀線程釋放了監視器,讓其餘線程有機會能夠進入。稍後,寫線程進入了監視器,向緩衝區寫入了一些數據,而後執行喚醒,並退出監視器。當寫線程執行了喚醒指令後,讀線程被標誌爲可能甦醒,當寫線程退出監視器後,讀線程被喚醒併成爲監視器的持有者。

監視器模型

Java虛擬機中的監視器模型分紅了三個區域。以下圖:

image
虛擬機將監視器分爲三個區域:

  • 中間大的監視區域只容許一個單獨的線程,是監視器的持有者;
  • 左邊是入口區
  • 右邊是等待區

等待線程和活動線程使用紅色和藍色區分。

模型中也規定了線程和監視器交互所必須經過的幾道門:

  • 當一個線程到達監視區域的開始處時,它會從最左邊1號箭頭進入入口區,當進入入口區
    • 若是沒有任何線程持有監視器,也沒有任何等待的線程,這個線程就能夠經過2號箭頭,並持有監視器。做爲監視器的持有者,它能夠繼續執行監視區域中的代碼。
    • 若是已經有另外一個線程正在持有監視器,這個新到達的線程必須在入口區等待,極可能已經有線程已經在等待了,而且這個新線程會被阻塞,不能執行監視區域中的代碼。
  • 上圖中有三個線程在等待區中,這些線程會一直在那裏,直到監視區域中的活動線程釋放監視器
  • 活動線程會經過兩條途徑釋放監視器:
    • 若是活動線程執行完了監視區域的代碼,它會從5號箭頭退出監視器。
    • 若是活動線程執行了等待命令,它會經過3號箭頭進入等待區,並釋放監視器
  • 若是活動線程在釋放監視器前沒有執行喚醒命令(同時在此以前沒有任何等待區的線程被喚醒並等待甦醒),那麼位於入口區的線程們將會競爭得到監視器。
  • 若是活動線程在釋放監視器前執行了喚醒命令,入口區的線程就不得不和等待區的線程一塊兒來競爭:
    • 若是入口區的線程獲勝,它就會經過2號箭頭進入監視區域,並得到監視器
    • 若是等待區的線程獲勝,它會經過4號箭頭退出等待區並從新得到監視器。

請注意,==一個線程只有經過3號箭頭4號箭頭才能進入或退出等待區。而且一個線程只有在它持有監視器的時候才能執行等待命令,並且它只能經過再次成爲監視器的持有者才能離開等待區。==

線程喚醒的一些細節

在Java虛擬機中,線程在執行等待命令時能夠隨意指定一個暫停之間。在暫停時間到了以後,即便沒有來自其餘線程的明確的喚醒命令,它也會自動甦醒。看下面這段代碼:

public class MonitorTest {
    public static void main(String[] args) {
        byte[] buffer = new byte[4];
        MonitorObj monitorObj = new MonitorObj();

        Thread read00 = new Thread() {
            @Override
            public void run() {
                System.out.println("read00 準備獲取鎖");
                synchronized (monitorObj) {
                    System.out.println("read00 = " + buffer[3]);
                    try {
                        Thread.sleep(1000);

                        monitorObj.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("read00 = " + buffer[3]);
                }
                System.out.println("read00 釋放鎖");
            }
        };
        Thread read01 = new Thread() {
            @Override
            public void run() {
                System.out.println("read01 準備獲取鎖");
                synchronized (monitorObj) {
                    System.out.println("read01 = " + buffer[3]);
                    try {
                        Thread.sleep(1000);

                        monitorObj.wait(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("read01 = " + buffer[3]);
                }
                System.out.println("read01 釋放鎖");
            }
        };

        Thread write = new Thread() {
            @Override
            public void run() {
                System.out.println("write 準備獲取鎖");
                synchronized (monitorObj) {
                    try {
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    buffer[3] = 99;
                    //monitorObj.notifyAll();
                    try {
                        Thread.sleep(3000);
                        System.out.println("write thread finish");
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        read00.start();
        read01.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        write.start();

    }
}
class MonitorObj {
}
複製代碼

請注意,read01線程使用的是wait(2000)方法;read00線程使用的是wait()方法。

而後,咱們把write線程的monitorObj.notifyAll()喚醒方法註釋掉,輸出以下:

read00 準備獲取鎖
read01 準備獲取鎖
read00 = 0
read01 = 0
write 準備獲取鎖
write thread finish
read01 = 99
read01 釋放鎖
複製代碼

由於wait(2000)加了暫停時間的緣由,read01仍是自動喚醒了。而對於read00仍然而且會一直處於等待,除非調用喚醒指令notify()notifyAll()

而對於notify()notifyAll()的使用,請注意==只有當絕對確認只會有一個線程在等待區中掛起的時候,纔可使用notifynotifyAll也能夠);只要同時存在有多個線程在等待區中被掛起,就應該使用notifyAll==

對象鎖

前面講過,Java虛擬機的一些運行時數據區會被全部線程共享,像方法區。因此,Java程序須要爲這兩種多線程下的數據訪問進行協調:

  • 保存在堆中的實例變量
  • 保存在方法區中的類變量

程序不須要考慮Java棧中的局部變量,由於是線程私有的。

在Java虛擬機中,每一個對象和類在邏輯上都有一個監控器與之相關聯的。

  • 對於對象來講,相關聯的監視器保護對象的實例變量。
  • 對於類來講,監視器保護它的類變量。

若是一個對象沒有實例變量,或者一個類沒有類變量,相關聯的監視器就什麼都不監視。

爲了實現監視器的排他性監視能力,Java虛擬機爲每個對象和類都關聯了一個鎖(有時候被稱爲互斥體mutex)。一個鎖就像就像一種任什麼時候候只容許一個線程擁有的特權。

  • 正常狀況下,線程訪問實例變量或者類變量不須要獲取鎖。
  • 可是若是線程獲取了鎖,那麼在它釋放這個鎖以前,就沒有其餘線程能夠獲取這個鎖了。

鎖住一個對象,其實就是獲取對象相關聯的監視器。而類鎖實際上也是用對象鎖來實現的。咱們前面說過,當虛擬機裝載一個class文件時,它會建立一個java.lang.Class類的實例。當鎖住一個類時,實際上鎖住的的就是那個類的Class對象。

一個線程能夠容許屢次對同一個對象上鎖(可重入)。對於每個對象來講,Java虛擬機維護了一個計數器,記錄對象被加了多少次鎖:

  • 沒有被鎖的對象的計數器是0
  • 線程每加鎖一次,計數就加1(只有已經擁有了這個對象鎖的線程才能對該對象再次加鎖)
  • 線程每釋放一次鎖,計數器減1
  • 當計數器爲0時,鎖就被徹底釋放了。此時其餘線程纔可使用它。

對象鎖和監視器

==監視器可以實現攔截線程,保證監視區域只有一個線程在工做。靠的就是對象鎖==

在Java虛擬機中,每個監視區域都和一個對象引用相關聯。因此整個流程差很少是這樣子的:

  • Java虛擬機中的一個線程進入監視器入口區
  • 線程根據監視區域的對象引用,找到對應的數據
    • 若是數據顯示計數器數值爲0,表示監視區域沒有活動線程,能夠(多個線程的話須要競爭)加鎖並經過2號箭頭進入監視區域,執行後續代碼。
    • 若是數值不爲0,那麼表示監視區域正在被佔用,線程就要在入口區等待,等待鎖的數值變爲0,和其餘線程(若是有的話)競爭進入
    • 當線程離開監視區域後,不論是如何離開的,它都會釋放相關對象上的鎖。

虛擬機對於監視區域的處理

==怎麼定義上面提到的監視區域呢?==

Java中的關鍵字synchronized就是用來定義監視區域的關鍵。synchronized能夠用來定義同步語句同步方法

同步語句

synchronized包裹起來的代碼塊就是同步語句,像下面這樣:

public class SynchronizeTest {
    private int[] array = new int[]{1, 2, 3, 4};

    public void expandArray() {
        synchronized (this) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array[i] * 10;
            }
            System.out.println(Arrays.toString(array));
        }
    }
}
複製代碼

對於上面的同步代碼塊來講,虛擬機要保證無論線程以什麼樣的形式退出,必需要及時釋放鎖。

==怎麼保證呢?==

假如上面的代碼array[i] = array[i] * 10;不當心寫成了array[i] = array[i]/0;,當執行到這一步的時候就要報java.lang.ArithmeticException異常了。
對於可能拋出的異常來講,咱們會使用try-catch進行捕獲,編譯器的作法也是同樣的。

咱們看下javap -p SynchronizeTest.classexpandArray()部分輸出:

public void expandArray();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=4, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: iconst_0
         5: istore_2
         6: iload_2
         7: aload_0
         8: getfield      #2 // Field array:[I
        11: arraylength
        12: if_icmpge     35
        15: aload_0
        16: getfield      #2 // Field array:[I
        19: iload_2
        20: aload_0
        21: getfield      #2 // Field array:[I
        24: iload_2
        25: iaload
        26: iconst_0
        27: idiv
        28: iastore
        29: iinc          2, 1
        32: goto          6
        35: getstatic     #3 // Field java/lang/System.out:Ljava/io/PrintStream;
        38: aload_0
        39: getfield      #2 // Field array:[I
        42: invokestatic  #4 // Method java/util/Arrays.toString:([I)Ljava/lang/String;
        45: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        48: aload_1
        49: monitorexit
        50: goto          58
        53: astore_3
        54: aload_1
        55: monitorexit
        56: aload_3
        57: athrow
        58: return
      Exception table:
         from    to  target type
             4    50    53   any
            53    56    53   any
複製代碼

請注意Exception table這個異常表,這個就是編譯器細心爲咱們加上的。它會監遵從方法的第4條指令第50條指令執行過程當中的any異常,出現異常就跳到第53條指令

咱們能夠看到53日後還有一個monitorexit在等待執行(這個any說明啥異常也阻止不了釋放鎖的決心啊)。

==是否是感受編譯器真滴很貼心哇,贊!==

若是以爲不真實的話咱們把synchronized代碼塊去掉,再編譯一次看下字節碼信息,你會發現Exception table也被清除了。

同步方法

仍是上面的類SynchronizeTest,此次咱們把方法改爲這樣:

public synchronized void expandArray() {
        for (int i = 0; i < array.length; i++) {
            array[i] = array[i] / 0;
        }
        System.out.println(Arrays.toString(array));
    }
複製代碼

咱們再來看下相關的字節碼:

public synchronized void expandArray();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=4, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iload_1
         3: aload_0
         4: getfield      #2 // Field array:[I
         7: arraylength
         8: if_icmpge     31
        11: aload_0
        12: getfield      #2 // Field array:[I
        15: iload_1
        16: aload_0
        17: getfield      #2 // Field array:[I
        20: iload_1
        21: iaload
        22: iconst_0
        23: idiv
        24: iastore
        25: iinc          1, 1
        28: goto          2
        31: getstatic     #3 // Field java/lang/System.out:Ljava/io/PrintStream;
        34: aload_0
        35: getfield      #2 // Field array:[I
        38: invokestatic  #4 // Method java/util/Arrays.toString:([I)Ljava/lang/String;
        41: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        44: return
複製代碼

咱們看下區別(本人就找到了3條):

  • monitorentermonitorexit都不見了。
  • 多了一個ACC_SYNCHRONIZEDflag
  • 異常表也不見了(真滴不是故意沒粘貼哈)

因爲編譯器在同步語句裏表現那麼好,我來揣測一下它這麼作的理由哈?

  • 核心是ACC_SYNCHRONIZED的標記
  • 當虛擬機解析到對這個方法的符號引用時,它會判斷這個方法是不是同步的(根據ACC_SYNCHRONIZED標記)
  • 若是是同步的,虛擬機就在調用方法前獲取一個鎖。
    • 對於實例對象來講虛擬機獲取的是與當前對象相關聯的鎖
    • 對於類方法來講,虛擬機獲取的是類Class實例相關聯的鎖
  • 當方法執行完畢時(不論是異常終止仍是正常退出),虛擬機都會釋放這個鎖

簡單比較下同步方法和同步語句

從字節碼指令上來看:

  • 同步方法沒有monitorentermotorexit等指令
  • 同步方法沒有異常表
  • 同步方法少了一些變量保存的指令(用來記錄對象鎖的)

同步方法字節碼更簡潔,看上去更高效一些。

==但真的是這樣嗎?==

Amdahl 定律瞭解一下

speed=\frac{1}{F+\frac{1-F}{N}}
複製代碼

N 表示處理器,F 表示必須串行的部分

當N趨近於無窮大時,

speed = \frac{1}{F}
複製代碼

你懂得。。

Object的協調支持

Object一些方法咱們前面已經用過了,統一整理一下。下次讓你介紹Object中定義的方法就能夠把下面這幾個說一下了:

方法 描述
void wait() 進入監視器的等待區,直到被其餘線程喚醒
void wait(long timeout) 進入監視器的等待區,直到被其餘線程喚醒。或者通過timeout指定的毫秒後,自動甦醒
void wait(long timeout,int nanos) 進入監視器的等待區,直到被其餘線程喚醒。或者通過timeout指定的毫秒加上nanos指定的納秒後,自動甦醒
void notify() 喚醒監視器的等待區中的一個等待線程(若是等待區中沒有線程,那就什麼也不敢)
void notifyAll() 喚醒監視器的等待區中的全部等待線程(若是等待區中沒有線程,那就什麼也不敢)

==上面的5個方法,請在同步語句或同步方法中使用==,否則會報錯喲!
就是這種java.lang.IllegalMonitorStateException

附上Object類的代碼:

public class Object {

    private static native void registerNatives();
    static {
        registerNatives();
    }
    
    public final native Class<?> getClass();

    public native int hashCode();

    public boolean equals(Object obj) {
        return (this == obj);
    }

    protected native Object clone() throws CloneNotSupportedException;

    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

    public final native void notify();

    public final native void notifyAll();

    public final native void wait(long timeout) throws InterruptedException;

    public final void wait(long timeout, int nanos) throws InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos > 0) {
            timeout++;
        }

        wait(timeout);
    }

    public final void wait() throws InterruptedException {
        wait(0);
    }

    protected void finalize() throws Throwable { }
}

複製代碼

很簡潔有沒有。。。。。。

==有個疑問哈,這麼多native方法,咋沒看到在哪裏加載的lib呢?==
真滴是個疑問,權當挖個坑,他日必來回復。。。。。

結語

好滴,到這裏虛擬機的線程同步就結束啦,深刻Java虛擬機也到了尾聲,收穫不少。下一篇好好總結一下吧。

相關文章
相關標籤/搜索