2.5W 字詳解線程與鎖了,面試隨便問!!

在 java 併發編程中,線程和鎖永遠是最重要的概念。語言規範雖然是規範描述,但是其中也有非常多的知識和最佳實踐是值得學習的,相信這篇文章還是可以給很多讀者提供學習參考的。

本文主要是翻譯 + 解釋 Oracle 《The Java Language Specification, Java SE 8 Edition》 的第17章 《Threads and Locks》 ,原文大概30頁pdf,我加入了很多自己的理解,希望能幫大家把規範看懂,並且從中得到很多你一直想要知道但是還不知道的知識。

注意,本文在說 Java 語言規範,不是 JVM 規範,JVM 的實現需要滿足語言規範中定義的內容,但是具體的實現細節由各 JVM 廠商自己來決定。所以,語言規範要儘可能嚴謹全面,但是也不能限制過多,不然會限制 JVM 廠商對很多細節進行性能優化。

我能力有限,雖然已經很用心了,但有些地方我真的不懂,我已經在文中標記出來了。

建議分 3 部分閱讀。

  • 將 17.1、17.2、17.3 一起閱讀,這裏關於線程中的 wait、notify、中斷有很多的知識;
  • 17.4 的內存模型比較長,重排序和 happens-before 關係是重點;
  • 剩下的 final、字分裂、double和long的非原子問題,這些都是相對獨立的 topic。

Chapter 17. Threads and Locks

前言

在 java 中,線程由 Thread 類表示,用戶創建線程的唯一方式是創建 Thread 類的一個實例,每一個線程都和這樣的一個實例關聯。在相應的 Thread 實例上調用 start() 方法將啓動一個線程。

如果沒有正確使用同步,線程表現出來的現象將會是令人疑惑的、違反直覺的。這個章節將描述多線程編程的語義問題,包括一系列的規則,這些規則定義了在多線程環境中線程對共享內存中值的修改是否對其他線程立即可見

java編程語言內存模型定義了統一的內存模型用於屏蔽不同的硬件架構,在沒有歧義的情況下,下面將用內存模型表示這個概念。

這些語義沒有規定多線程的程序在 JVM 的實現上應該怎麼執行,而是限定了一系列規則,由 JVM 廠商來滿足這些規則,即不管 JVM 的執行策略是什麼,表現出來的行爲必須是可被接受的。

操作系統有自己的內存模型,C/C++ 這些語言直接使用的就是操作系統的內存模型,而 Java 爲了屏蔽各個系統的差異,定義了自己的統一的內存模型。簡單說,Java 開發者不再關心每個 CPU 核心有自己的內存,然後共享主內存。而是把關注點轉移到:每個線程都有自己的工作內存,所有線程共享主內存。

17.1 同步(synchronization)

Java 提供了多種線程之間通信的機制,其中最基本的就是使用同步 (synchronization),其使用監視器 (monitor) 來實現。java中的每個對象都關聯了一個監視器,線程可以對其進行加鎖和解鎖操作。

在同一時間,只有一個線程可以拿到對象上的監視器鎖。如果其他線程在鎖被佔用期間試圖去獲取鎖,那麼將會被阻塞直到成功獲取到鎖。同時,監視器鎖可以重入,也就是說如果線程 t 拿到了鎖,那麼線程 t 可以在解鎖之前重複獲取鎖;每次解鎖操作會反轉一次加鎖產生的效果。

synchronized 有以下兩種使用方式:

  • synchronized 代碼塊。synchronized(object)在對某個對象上執行加鎖時,會嘗試在該對象的監視器上進行加鎖操作,只有成功獲取鎖之後,線程纔會繼續往下執行。線程獲取到了監視器鎖後,將繼續執行synchronized 代碼塊中的代碼,如果代碼塊執行完成,或者拋出了異常,線程將會自動對該對象上的監視器執行解鎖操作。
  • synchronized 作用於方法,稱爲同步方法。同步方法被調用時,會自動執行加鎖操作,只有加鎖成功,方法體纔會得到執行。如果被 synchronized 修飾的方法是實例方法,那麼這個實例的監視器會被鎖定。如果是 static 方法,線程會鎖住相應的 Class 對象的監視器。方法體執行完成或者異常退出後,會自動執行解鎖操作。

Java語言規範既不要求阻止死鎖的發生,也不要求檢測到死鎖的發生。如果線程要在多個對象上執行加鎖操作,那麼就應該使用傳統的方法來避免死鎖的發生,如果有必要的話,需要創建更高層次的不會產生死鎖的加鎖原語。

java 還提供了其他的一些同步機制,比如對 volatile 變量的讀寫、使用 java.util.concurrent 包中的同步工具類等。

同步這一節說了 Java 併發編程中最基礎的 synchronized 這個關鍵字,大家一定要理解 synchronize 的鎖是什麼,它的鎖是基於 Java 對象的監視器 monitor,所以任何對象都可以用來做鎖。有興趣的讀者可以去了解相關知識,包括偏向鎖、輕量級鎖、重量級鎖等。

小知識點:對 Class 對象加鎖、對對象加鎖,它們之間不構成同步。synchronized 作用於靜態方法時是對 Class 對象加鎖,作用於實例方法時是對實例加鎖。

面試中經常會問到一個類中的兩個 synchronized static 方法之間是否構成同步?構成同步。

17.2 等待集合 和 喚醒(Wait Sets and Notification)

每個 java 對象,都關聯了一個監視器,也關聯了一個等待集合。等待集合是一個線程集合。

當對象被創建出來時,它的等待集合是空的,對於向等待集合中添加或者移除線程的操作都是原子的,以下幾個操作可以操縱這個等待集合:Object.wait, Object.notify, Object.notifyAll。

等待集合也可能受到線程的中斷狀態的影響,也受到線程中處理中斷的方法的影響。另外,sleep 方法和 join 方法可以感知到線程的 wait 和 notify。

這裏概括得比較簡略,沒看懂的讀者沒關係,繼續往下看就是了。

這節要講Java線程的相關知識,主要包括:

  • Thread 中的 sleep、join、interrupt
  • 繼承自 Object 的 wait、notify、notifyAll
  • 還有 Java 的中斷,這個概念也很重要

17.2.1 等待 (Wait)

等待操作由以下幾個方法引發:wait(),wait(long millisecs),wait(long millisecs, int nanosecs)。在後面兩個重載方法中,如果參數爲 0,即 wait(0)、wait(0, 0) 和 wait() 是等效的。

如果調用 wait 方法時沒有拋出 InterruptedException 異常,則表示正常返回。

前方高能,請讀者保持高度精神集中。

我們在線程 t 中對對象 m 調用 m.wait() 方法,n 代表加鎖編號,同時還沒有相匹配的解鎖操作,則下面的其中之一會發生:

  • 如果 n 等於 0(如線程 t 沒有持有對象 m 的鎖),那麼會拋出 IllegalMonitorStateException 異常。

注意,如果沒有獲取到監視器鎖,wait 方法是會拋異常的,而且注意這個異常是IllegalMonitorStateException異常。這是重要知識點,要考。

  • 如果線程 t 調用的是 m.wait(millisecs) 或m.wait(millisecs, nanosecs),形參millisecs 不能爲負數,nanosecs 取值應爲 [0, 999999],否則會拋出IllegalArgumentException 異常。
  • 如果線程 t 被中斷,此時中斷狀態爲 true,則 wait 方法將拋出 InterruptedException 異常,並將中斷狀態設置爲 false。

中斷,如果讀者不瞭解這個概念,可以參考我在 AQS(二) 中的介紹,這是非常重要的知識。

  • 否則,下面的操作會順序發生:

注意:到這裏的時候,wait 參數是正常的,同時 t 沒有被中斷,並且線程 t 已經拿到了 m 的監視器鎖。

1.線程 t 會加入到對象 m 的等待集合中,執行 加鎖編號 n 對應的解鎖操作

這裏也非常關鍵,前面說了,wait 方法的調用必須是線程獲取到了對象的監視器鎖,而到這裏會進行解鎖操作。切記切記。。。

 public Object object = new Object();
 void thread1() {
     synchronized (object) { // 獲取監視器鎖
         try {
             object.wait(); // 這裏會解鎖,這裏會解鎖,這裏會解鎖
             // 順便提一下,只是解了object上的監視器鎖,如果這個線程還持有其他對象的監視器鎖,這個時候是不會釋放的。
         } catch (InterruptedException e) {
             // do somethings
         }
     }
 }

2.線程 t 不會執行任何進一步的指令,直到它從 m 的等待集合中移出(也就是等待喚醒)。在發生以下操作的時候,線程 t 會從 m 的等待集合中移出,然後在之後的某個時間點恢復,並繼續執行之後的指令。

並不是說線程移出等待隊列就馬上往下執行,這個線程還需要重新獲取鎖纔行,這裏也很關鍵,請往後看17.2.4中我寫的兩個簡單的例子。

  • 在 m上執行了 notify 操作,而且線程 t 被選中從等待集合中移除。
  • 在 m 上執行了 notifyAll 操作,那麼線程 t 會從等待集合中移除。
  • 線程 t 發生了 interrupt 操作。
  • 如果線程 t 是調用 wait(millisecs) 或者 wait(millisecs, nanosecs) 方法進入等待集合的,那麼過了millisecs 毫秒或者 (millisecs*1000000+nanosecs) 納秒後,線程 t也會從等待集合中移出。
  • JVM 的“假喚醒”,雖然這是不鼓勵的,但是這種操作是被允許的,這樣 JVM 能實現將線程從等待集合中移出,而不必等待具體的移出指令。

注意,良好的 Java 編碼習慣是,只在循環中使用 wait 方法,這個循環等待某些條件來退出循環。

個人理解wait方法是這麼用的:

 synchronized(m) {
     while(!canExit) {
       m.wait(10); // 等待10ms; 當然中斷也是常用的
       canExit = something();  // 判斷是否可以退出循環
     }
 }
 // 2 個知識點:
 // 1. 必須先獲取到對象上的監視器鎖
 // 2. wait 有可能被假喚醒

每個線程在一系列 可能導致它從等待集合中移出的事件 中必須決定一個順序。這個順序不必要和其他順序一致,但是線程必須表現爲它是按照那個順序發生的。

例如,線程 t 現在在 m 的等待集合中,不管是線程 t 中斷還是 m 的 notify 方法被調用,這些操作事件肯定存在一個順序。如果線程 t 的中斷先發生,那麼 t 會因爲 InterruptedException 異常而從 wait 方法中返回,同時 m 的等待集合中的其他線程(如果有的話)會收到這個通知。如果 m 的 notify 先發生,那麼 t 會正常從 wait 方法返回,且不會改變中斷狀態。

我們考慮這個場景:

線程 1 和線程 2 此時都 wait 了,線程 3 調用了 :

synchronized (object) {
    thread1.interrupt(); //1
    object.notify();  //2
}

本來我以爲上面的情況 線程1 一定是拋出 InterruptedException,線程2 是正常返回的。感謝評論留言的 xupeng.zhang,我的這個想法是錯誤的,完全有可能線程1正常返回(即使其中斷狀態是true),線程2 一直 wait。

3.線程 t 執行編號爲 n 的加鎖操作

回去看 2 說了什麼,線程剛剛從等待集合中移出,然後這裏需要重新獲取監視器鎖才能繼續往下執行。

4.如果線程 t 在 2 的時候由於中斷而從 m 的等待集合中移出,那麼它的中斷狀態會重置爲 false,同時 wait 方法會拋出 InterruptedException 異常。

這一節主要在講線程進出等待集合的各種情況,同時,最好要知道中斷是怎麼用的,中斷的狀態重置發生於什麼時候。

這裏的 1,2,3,4 的發生順序非常關鍵,大家可以仔細再看看是不是完全理解了,之後的幾個小節還會更具體地闡述這個,參考代碼請看 17.2.4 小節我寫的簡單的例子。

17.2.2 通知(Notification)

通知操作發生於調用 notify 和 notifyAll 方法。

我們在線程 t 中對對象 m 調用 m.notify() 或 m.notifyAll() 方法,n 代表加鎖編號,同時對應的解鎖操作沒有執行,則下面的其中之一會發生:

  • 如果 n 等於 0,拋出 IllegalMonitorStateException 異常,因爲線程 t 還沒有獲取到對象 m 上的鎖。

這一點很關鍵,只有獲取到了對象上的監視器鎖的線程纔可以正常調用 notify,前面我們也說過,調用 wait 方法的時候也要先獲取鎖

  • 如果 n 大於 0,而且這是一個 notify 操作,如果 m 的等待集合不爲空,那麼等待集合中的線程 u 被選中從等待集合中移出。

對於哪個線程會被選中而被移出,虛擬機沒有提供任何保證,從等待集合中將線程 u 移出,可以讓線程 u 得以恢復。注意,恢復之後的線程 u 如果對 m 進行加鎖操作將不會成功,直到線程 t 完全釋放鎖之後。

因爲線程 t 這個時候還持有 m 的鎖。這個知識點在 17.2.4 節我還會重點說。這裏記住,被 notify 的線程在喚醒後是需要重新獲取監視器鎖的。

  • 如果 n 大於 0,而且這是一個 notifyAll 操作,那麼等待集合中的所有線程都將從等待集合中移出,然後恢復。

注意,這些線程恢復後,只有一個線程可以鎖住監視器。

本小節結束,通知操作相對來說還是很簡單的吧。

17.2.3 中斷(Interruptions)

中斷髮生於 Thread.interrupt 方法的調用。

令線程 t 調用線程 u 上的方法 u.interrupt(),其中 t 和 u 可以是同一個線程,這個操作會將 u 的中斷狀態設置爲 true。

順便說說中斷狀態吧,初學者肯定以爲 thread.interrupt() 方法是用來暫停線程的,主要是和它對應中文翻譯的“中斷”有關。中斷在併發中是常用的手段,請大家一定好好掌握。可以將中斷理解爲線程的狀態,它的特殊之處在於設置了中斷狀態爲 true 後,這幾個方法會感知到:

  • wait(), wait(long), wait(long, int), join(), join(long), join(long, int), sleep(long), sleep(long, int)這些方法都有一個共同之處,方法簽名上都有throws InterruptedException,這個就是用來響應中斷狀態修改的。
  • 如果線程阻塞在 InterruptibleChannel 類的 IO 操作中,那麼這個 channel 會被關閉。
  • 如果線程阻塞在一個 Selector 中,那麼 select 方法會立即返回。

如果線程阻塞在以上3種情況中,那麼當線程感知到中斷狀態後(此線程的 interrupt() 方法被調用),會將中斷狀態重新設置爲 false,然後執行相應的操作(通常就是跳到 catch 異常處)。

如果不是以上3種情況,那麼,線程的 interrupt() 方法被調用,會將線程的中斷狀態設置爲 true。

當然,除了這幾個方法,我知道的是 LockSupport 中的 park 方法也能自動感知到線程被中斷,當然,它不會重置中斷狀態爲 false。我們說了,只有上面的幾種情況會在感知到中斷後先重置中斷狀態爲 false,然後再繼續執行。

另外,如果有一個對象 m,而且線程 u 此時在 m 的等待集合中,那麼 u 將會從 m 的等待集合中移出。這會讓 u 從 wait 操作中恢復過來,u 此時需要獲取 m 的監視器鎖,獲取完鎖以後,發現線程 u 處於中斷狀態,此時會拋出 InterruptedException 異常。

這裏的流程:t 設置 u 的中斷狀態 => u 線程恢復 => u 獲取 m 的監視器鎖 => 獲取鎖以後,拋出 InterruptedException 異常。

這個流程在前面 wait 的小節已經講過了,這也是很多人都不瞭解的知識點。如果還不懂,可以看下一小節的結束,我的兩個簡單的例子。

一個小細節:u 被中斷,wait 方法返回,並不會立即拋出 InterruptedException 異常,而是在重新獲取監視器鎖之後纔會拋出異常。

實例方法 thread.isInterrupted() 可以知道線程的中斷狀態。

調用靜態方法 Thread.interrupted() 可以返回當前線程的中斷狀態,同時將中斷狀態設置爲false。

所以說,如果是這個方法調用兩次,那麼第二次一定會返回 false,因爲第一次會重置狀態。當然了,前提是兩次調用的中間沒有發生設置線程中斷狀態的其他語句。

17.2.4 等待、通知和中斷的交互(Interactions of Waits, Notification, and Interruption)

以上的一系列規範能讓我們確定 在等待、通知、中斷的交互中 有關的幾個屬性。

如果一個線程在等待期間,同時發生了通知和中斷,它將發生:

  • 從 wait 方法中正常返回,同時不改變中斷狀態(也就是說,調用 Thread.interrupted 方法將會返回 true)
  • 由於拋出了 InterruptedException 異常而從 wait 方法中返回,中斷狀態設置爲 false

線程可能沒有重置它的中斷狀態,同時從 wait 方法中正常返回,即第一種情況。

也就是說,線程是從 notify 被喚醒的,由於發生了中斷,所以中斷狀態爲 true

同樣的,通知也不能由於中斷而丟失。

這個要說的是,線程其實是從中斷喚醒的,那麼線程醒過來,同時中斷狀態會被重置爲 false。

假設 m 的等待集合爲 線程集合 s,並且在另一個線程中調用了 m.notify(), 那麼將發生:

  • 至少有集合 s 中的一個線程正常從 wait 方法返回,或者
  • 集合 s 中的所有線程由拋出 InterruptedException 異常而返回。

考慮是否有這個場景:x 被設置了中斷狀態,notify 選中了集合中的線程 x,那麼這次 notify 將喚醒線程 x,其他線程(我們假設還有其他線程在等待)不會有變化。

答案:存在這種場景。因爲這種場景是滿足上述條件的,而且此時 x 的中斷狀態是 true。

注意,如果一個線程同時被中斷和通知喚醒,同時這個線程通過拋出 InterruptedException 異常從 wait 中返回,那麼等待集合中的某個其他線程一定會被通知。

下面我們通過 3 個例子簡單分析下 wait、notify、中斷 它們的組合使用。

第一個例子展示了 wait 和 notify 操作過程中的監視器鎖的 持有、釋放 的問題。考慮以下操作:

public class WaitNotify {

    public static void main(String[] args) {

        Object object = new Object();

        new Thread(new Runnable() {
            @Override
            public void run() {

                synchronized (object) {
                    System.out.println("線程1 獲取到監視器鎖");
                    try {
                        object.wait();
                        System.out.println("線程1 恢復啦。我爲什麼這麼久才恢復,因爲notify方法雖然早就發生了,可是我還要獲取鎖才能繼續執行。");
                    } catch (InterruptedException e) {
                        System.out.println("線程1 wait方法拋出了InterruptedException異常");
                    }
                }
            }
        }, "線程1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("線程2 拿到了監視器鎖。爲什麼呢,因爲線程1 在 wait 方法的時候會自動釋放鎖");
                    System.out.println("線程2 執行 notify 操作");
                    object.notify();
                    System.out.println("線程2 執行完了 notify,先休息3秒再說。");
                    try {
                        Thread.sleep(3000);
                        System.out.println("線程2 休息完啦。注意了,調sleep方法和wait方法不一樣,不會釋放監視器鎖");
                    } catch (InterruptedException e) {

                    }
                    System.out.println("線程2 休息夠了,結束操作");
                }
            }
        }, "線程2").start();
    }
}

output:
線程1 獲取到監視器鎖
線程2 拿到了監視器鎖。爲什麼呢,因爲線程1 在 wait 方法的時候會自動釋放鎖
線程2 執行 notify 操作
線程2 執行完了 notify,先休息3秒再說。
線程2 休息完啦。注意了,調sleep方法和wait方法不一樣,不會釋放監視器鎖
線程2 休息夠了,結束操作
線程1 恢復啦。我爲什麼這麼久才恢復,因爲notify方法雖然早就發生了,可是我還要獲取鎖才能繼續執行。

上面的例子展示了,wait 方法返回後,需要重新獲取監視器鎖,纔可以繼續往下執行。

同理,我們稍微修改下以上的程序,看下中斷和 wait 之間的交互:

public class WaitNotify {

    public static void main(String[] args) {

        Object object = new Object();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {

                synchronized (object) {
                    System.out.println("線程1 獲取到監視器鎖");
                    try {
                        object.wait();
                        System.out.println("線程1 恢復啦。我爲什麼這麼久才恢復,因爲notify方法雖然早就發生了,可是我還要獲取鎖才能繼續執行。");
                    } catch (InterruptedException e) {
                        System.out.println("線程1 wait方法拋出了InterruptedException異常,即使是異常,我也是要獲取到監視器鎖了纔會拋出");
                    }
                }
            }
        }, "線程1");
        thread1.start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("線程2 拿到了監視器鎖。爲什麼呢,因爲線程1 在 wait 方法的時候會自動釋放鎖");
                    System.out.println("線程2 設置線程1 中斷");
                    thread1.interrupt();
                    System.out.println("線程2 執行完了 中斷,先休息3秒再說。");
                    try {
                        Thread.sleep(3000);
                        System.out.println("線程2 休息完啦。注意了,調sleep方法和wait方法不一樣,不會釋放監視器鎖");
                    } catch (InterruptedException e) {

                    }
                    System.out.println("線程2 休息夠了,結束操作");
                }
            }
        }, "線程2").start();
    }
}
output:
線程1 獲取到監視器鎖
線程2 拿到了監視器鎖。爲什麼呢,因爲線程1 在 wait 方法的時候會自動釋放鎖
線程2 設置線程1 中斷
線程2 執行完了 中斷,先休息3秒再說。
線程2 休息完啦。注意了,調sleep方法和wait方法不一樣,不會釋放監視器鎖
線程2 休息夠了,結束操作
線程1 wait方法拋出了InterruptedException異常,即使是異常,我也是要獲取到監視器鎖了纔會拋出

上面的這個例子也很清楚,如果線程調用 wait 方法,當此線程被中斷的時候,wait 方法會返回,然後重新獲取監視器鎖,然後拋出InterruptedException 異常。

我們再來考慮下,之前說的 notify 和中斷:

package com.javadoop.learning;

/**
 * Created by hongjie on 2017/7/7.
 */
public class WaitNotify {

    volatile int a = 0;

    public static void main(String[] args) {

        Object object = new Object();

        WaitNotify waitNotify = new WaitNotify();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {

                synchronized (object) {
                    System.out.println("線程1 獲取到監視器鎖");
                    try {
                        object.wait();
                        System.out.println("線程1 正常恢復啦。");
                    } catch (InterruptedException e) {
                        System.out.println("線程1 wait方法拋出了InterruptedException異常");
                    }
                }
            }
        }, "線程1");
        thread1.start();

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {

                synchronized (object) {
                    System.out.println("線程2 獲取到監視器鎖");
                    try {
                        object.wait();
                        System.out.println("線程2 正常恢復啦。");
                    } catch (InterruptedException e) {
                        System.out.println("線程2 wait方法拋出了InterruptedException異常");
                    }
                }
            }
        }, "線程2");
        thread2.start();

         // 這裏讓 thread1 和 thread2 先起來,然後再起後面的 thread3
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("線程3 拿到了監視器鎖。");
                    System.out.println("線程3 設置線程1中斷");
                    thread1.interrupt(); // 1
                    waitNotify.a = 1; // 這行是爲了禁止上下的兩行中斷和notify代碼重排序
                    System.out.println("線程3 調用notify");
                    object.notify(); //2
                    System.out.println("線程3 調用完notify後,休息一會");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                    }
                    System.out.println("線程3 休息夠了,結束同步代碼塊");
                }
            }
        }, "線程3").start();
    }
}

// 最常見的output:
線程1 獲取到監視器鎖
線程2 獲取到監視器鎖
線程3 拿到了監視器鎖。
線程3 設置線程1中斷
線程3 調用notify
線程3 調用完notify後,休息一會
線程3 休息夠了,結束同步代碼塊
線程2 正常恢復啦。
線程1 wait方法拋出了InterruptedException異常

上述輸出不是絕對的,有可能發生 線程1 是正常恢復的,雖然發生了中斷,它的中斷狀態也確實是 true,但是它沒有拋出 InterruptedException,而是正常返回。此時,thread2 將得不到喚醒,一直 wait。

17.3. 休眠和禮讓(Sleep and Yield)

Thread.sleep(millisecs) 使當前正在執行的線程休眠指定的一段時間(暫時停止執行任何指令),時間取決於參數值,精度受制於系統的定時器。休眠期間,線程不會釋放任何的監視器鎖。線程的恢復取決於定時器和處理器的可用性,即有可用的處理器來喚醒線程。

需要注意的是,Thread.sleep 和 Thread.yield 都不具有同步的語義。在 Thread.sleep 和 Thread.yield 方法調用之前,不要求虛擬機將寄存器中的緩存刷出到共享內存中,同時也不要求虛擬機在這兩個方法調用之後,重新從共享內存中讀取數據到緩存。

例如,我們有如下代碼塊,this.done 定義爲一個 non-volatile 的屬性,初始值爲 false。

while (!this.done)
    Thread.sleep(1000);

編譯器可以只讀取一次 this.done 到緩存中,然後一直使用緩存中的值,也就是說,這個循環可能永遠不會結束,即使是有其他線程將 this.done 的值修改爲 true。

yield 是告訴操作系統的調度器:我的cpu可以先讓給其他線程。注意,調度器可以不理會這個信息。

這個方法太雞肋,幾乎沒用。

17.4 內存模型(Memory Model)

內存模型這一節比較長,請耐心閱讀

內存模型描述的是程序在 JVM 的執行過程中對數據的讀寫是否是按照程序的規則正確執行的。Java 內存模型定義了一系列規則,這些規則定義了對共享內存的寫操作對於讀操作的可見性。

簡單地說,定義內存模型,主要就是爲了規範多線程程序中修改或者訪問同一個值的時候的行爲。對於那些本身就是線程安全的問題,這裏不做討論。

內存模型描述了程序執行時的可能的表現行爲。只要執行的結果是滿足 java 內存模型的所有規則,那麼虛擬機對於具體的實現可以自由發揮。

從側面說,不管虛擬機的實現是怎麼樣的,多線程程序的執行結果都應該是可預測的。

虛擬機實現者可以自由地執行大量的代碼轉換,包括重排序操作和刪除一些不必要的同步。

這裏我畫了一條線,從這條線到下一條線之間是兩個重排序的例子,如果你沒接觸過,可以看一下,如果你已經熟悉了或者在其他地方看過了,請直接往下滑。

示例 17.4-1 不正確的同步可能導致奇怪的結果

java語言允許 compilers 和 CPU 對執行指令進行重排序,導致我們會經常看到似是而非的現象。

這裏沒有翻譯 compiler 爲編譯器,因爲它不僅僅代表編譯器,後續它會代表所有會導致指令重排序的機制。

如表 17.4-A 中所示,A 和 B 是共享屬性,r1 和 r2 是局部變量。初始時,令 A == B == 0。

表17.4-A. 重排序導致奇怪的結果 - 原始代碼

按照我們的直覺來說,r2 == 2 同時 r1 == 1 應該是不可能的。直觀地說,指令 1 和 3 應該是最先執行的。如果指令 1 最先執行,那麼它應該不會看到指令 4 對 A 的寫入操作。如果指令 3 最先執行,那麼它應該不會看到執行 2 對 B 的寫入操作。

如果真的表現出了 r22 和 r11,那麼我們應該知道,指令 4 先於指令 1 執行了。

如果在執行過程出表現出這種行爲( r22 和r11),那麼我們可以推斷出以下指令依次執行:指令 4 => 指令 1=> 指令 2 => 指令 3。看上去,這種順序是荒謬的。

但是,Java 是允許 compilers 對指令進行重排序的,只要保證在單線程的情況下,能保證程序是按照我們想要的結果進行執行,即 compilers 可以對單線程內不產生數據依賴的語句之間進行重排序。如果指令 1 和指令 2 發生了重排序,如按照表17.4-B 所示的順序進行執行,那麼我們就很容易看到,r22 和 r11 是可能發生的。

表 17.4-B. 重排序導致奇怪的結果 - 允許的編譯器轉換

B = 1; => r1 = B; => A = 2; => r2 = A;

對於很多程序員來說,這個結果看上去是 broken 的,但是這段代碼是沒有正確的同步導致的:

  • 其中有一個線程執行了寫操作
  • 另一個線程對同一個屬性執行了讀操作
  • 同時,讀操作和寫操作沒有使用同步來確定它們之間的執行順序

簡單地說,之後要講的一大堆東西主要就是爲了確定共享內存讀寫的執行順序,不正確或者說非法的代碼就是因爲讀寫同一內存地址沒有使用同步(這裏不僅僅只是說synchronized),從而導致執行的結果具有不確定性。

這個是 數據競爭(data race) 的一個例子。當代碼包含數據競爭時,經常會發生違反我們直覺的結果。

有幾個機制會導致表 17.4-B 中的指令重排序。java 的 JIT 編譯器實現可能會重排序代碼,或者處理器也會做重排序操作。此外,java 虛擬機實現中的內存層次結構也會使代碼像重排序一樣。在本章中,我們將所有這些會導致代碼重排序的東西統稱爲 compiler。

所以,後續我們不要再簡單地將 compiler 翻譯爲編譯器,不要狹隘地理解爲 Java 編譯器。而是代表了所有可能會製造重排序的機制,包括 JVM 優化、CPU 優化等。

另一個可能產生奇怪的結果的示例如表 17.4-C,初始時 p == q 同時 p.x == 0。這個代碼也是沒有正確使用同步的;在這些寫入共享內存的寫操作中,沒有進行強制的先後排序。

Table 17.4-C

一個簡單的編譯器優化操作是會複用 r2 的結果給 r5,因爲它們都是讀取 r1.x,而且在單線程語義中,r2 到 r5之間沒有其他的相關的寫入操作,這種情況如表 17.4-D 所示。

Table 17.4-D

現在,我們來考慮一種情況,在線程1第一次讀取 r1.x 和 r3.x 之間,線程 2 執行 r6=p; r6.x=3; 編譯器進行了 r5複用 r2 結果的優化操作,那麼 r2r50,r4 == 3,從程序員的角度來看,p.x 的值由 0 變爲 3,然後又變爲 0。

我簡單整理了一下:

例子結束,回到正題

Java 內存模型定義了在程序的每一步,哪些值是內存可見的。對於隔離的每個線程來說,其操作是由我們線程中的語義來決定的,但是線程中讀取到的值是由內存模型來控制的。

當我們提到這點時,我們說程序遵守線程內語義,線程內語義說的是單線程內的語義,它允許我們基於線程內讀操作看到的值完全預測線程的行爲。如果我們要確定線程 t 中的操作是否是合法的,我們只要評估當線程 t 在單線程環境中運行時是否是合法的就可以,該規範的其餘部分也在定義這個問題。

這段話不太好理解,首先記住“線程內語義”這個概念,之後還會用到。我對這段話的理解是,在單線程中,我們是可以通過一行一行看代碼來預測執行結果的,只不過,代碼中使用到的讀取內存的值我們是不能確定的,這取決於在內存模型這個大框架下,我們的程序會讀到的值。也許是最新的值,也許是過時的值。

此節描述除了 final 關鍵字外的java內存模型的規範,final將在之後的17.5節介紹。

這裏描述的內存模型並不是基於 Java 編程語言的面向對象。爲了簡潔起見,我們經常展示沒有類或方法定義的代碼片段。大多數示例包含兩個或多個線程,其中包含局部變量,共享全局變量或對象的實例字段的語句。我們通常使用諸如 r1 或 r2 之類的變量名來表示方法或線程本地的變量。其他線程無法訪問此類變量。

17.4.1. 共享變量(Shared Variables)

所有線程都可以訪問到的內存稱爲共享內存堆內存

所有的實例屬性,靜態屬性,還有數組的元素都存儲在堆內存中。在本章中,我們用術語變量來表示這些元素。

局部變量、方法參數、異常對象,它們不會在線程間共享,也不會受到內存模型定義的任何影響。

兩個線程對同一個變量同時進行讀-寫操作寫-寫操作,我們稱之爲“衝突”。

好,這一節都是廢話,愉快地進入到下一節

17.4.2. 操作(Actions)

這一節主要是講解理論,主要就是嚴謹地定義操作。

線程間操作是指由一個線程執行的動作,可以被另一個線程檢測到或直接影響到。以下是幾種可能發生的線程間操作:

  • 讀 (普通變量,非 volatile)。讀一個變量。

  • 寫 (普通變量,非 volatile)。寫一個變量。

  • 同步操作,如下:

    • volatile 讀。讀一個 volatile 變量
    • volatile 寫。寫入一個 volatile 變量
    • 加鎖。對一個對象的監視器加鎖。
    • 解鎖。解除對某個對象的監視器鎖。
    • 線程的第一個和最後一個操作。
    • 開啓線程操作,或檢測一個線程是否已經結束。
  • 外部操作。一個外部操作指的是可能被觀察到的在外部執行的操作,同時它的執行結果受外部環境控制。

簡單說,外部操作的外部指的是在 JVM 之外,如 native 操作。

  • 線程分歧操作(§17.4.9)。此操作只由處於無限循環的線程執行,在該循環中不執行任何內存操作、同步操作、或外部操作。如果一個線程執行了分歧操作,那麼其後將跟着無數的線程分歧操作。

分歧操作的引入是爲了用來說明,線程可能會導致其他所有線程停頓而不能繼續執行。

此規範僅關心線程間操作,我們不關心線程內部的操作(比如將兩個局部變量的值相加存到第三個局部變量中)。如前文所說,所有的線程都需要遵守線程內語義。對於線程間操作,我們經常會簡單地稱爲操作。

我們用元祖< t, k, v, u >來描述一個操作:

  • t - 執行操作的線程

  • k - 操作的類型。

  • v - 操作涉及的變量或監視器

    • 對於加鎖操作,v 是被鎖住的監視器;對於解鎖操作,v 是被解鎖的監視器。
    • 如果是一個讀操作( volatile 讀或非 volatile 讀),v 是讀操作對應的變量
    • 如果是一個寫操作( volatile 寫或非 volatile 寫),v 是寫操作對應的變量
  • u - 唯一的標識符標識此操作

外部動作元組還包含一個附加組件,其中包含由執行操作的線程感知的外部操作的結果。這可能是關於操作的成敗的信息,以及操作中所讀的任何值。

外部操作的參數(如哪些字節寫入哪個 socket)不是外部操作元祖的一部分。這些參數是通過線程中的其他操作進行設置的,並可以通過檢查線程內語義進行確定。它們在內存模型中沒有被明確討論。

在非終結執行中,不是所有的外部操作都是可觀察的。17.4.9小節討論非終結執行和可觀察操作。

大家看完這節最懵逼的應該是外部操作和線程分歧操作,我簡單解釋下。

外部操作大家可以理解爲 Java 調用了一個 native 的方法,Java 可以得到這個 native 方法的返回值,但是對於具體的執行其實不感知的,意味着 Java 其實不能對這種語句進行重排序,因爲 Java 無法知道方法體會執行哪些指令。

引用 stackoverflow 中的一個例子:

// method()方法中jni()是外部操作,不會和 "foo = 42;" 這條語句進行重排序。
class Externalization {
  int foo = 0;
  void method() {
    jni(); // 外部操作
    foo = 42;
  }
  native void jni(); /* {
    assert foo == 0; //我們假設外部操作執行的是這個。
  } */
}

在上面這個例子中,顯然,jni() 與 foo = 42 之間不能進行重排序。

再來個線程分歧操作的例子:

// 線程分歧操作阻止了重排序,所以 "foo = 42;" 這條語句不會先執行
class ThreadDivergence {
  int foo = 0;
  void thread1() {
    while (true){} // 線程分歧操作
    foo = 42;
  }

  void thread2() {
    assert foo == 0; // 這裏永遠不會失敗
  }
}

17.4.3. 程序和程序順序(Programs and Program Order)

在每個線程 t 執行的所有線程間動作中,t 的程序順序是反映 根據 t 的線程內語義執行這些動作的順序 的總順序。

如果所有操作的執行順序 和 代碼中的順序一致,那麼一組操作就是連續一致的,並且,對變量 v 的每個讀操作 r 會看到寫操作 w 寫入的值,也就是:

  • 寫操作 w 先於 讀操作 r 完成,並且
  • 沒有其他的寫操作 w' 使得 w' 在 w 之後 r 之前發生。

連續一致性對於可見性和程序執行順序是一個非常強的保證。在這種場景下,所有的單個操作(比如讀和寫)構成一個統一的執行順序,這個執行順序和代碼出現的順序是一致的,同時每個單個操作都是原子的,且對所有線程來說立即可見。

如果程序沒有任何的數據競爭,那麼程序的所有執行操作將表現爲連續一致。連續一致性 和/或 數據競爭的自由仍然允許錯誤從一組操作中產生。

完全不知道這句話是什麼意思

如果我們用連續一致性作爲我們的內存模型,那我們討論的許多關於編譯器優化和處理器優化就是非法的。比如在17.4-C中,一旦執行 p.x=3,那麼後續對於該位置的讀操作應該是立即可以讀到最新值的。

連續一致性的核心在於每一步的操作都是原子的,同時對於所有線程都是可見的,而且不存在重排序。所以,Java 語言定義的內存模型肯定不會採用這種策略,因爲它直接限制了編譯器和 JVM 的各種優化措施。

注意:很多地方所說的順序一致性就是這裏的連續一致性,英文是 Sequential consistency

17.4.4. 同步順序(Synchronization Order)

每個執行都有一個同步順序。同步順序是由執行過程中的每個同步操作組成的順序。對於每個線程 t,同步操作組成的同步順序是和線程 t 中的代碼順序一致的。

雖然拗口,但畢竟說的是同步,我們都不陌生。同步操作包括瞭如下同步關係:

  • 對於監視器 m 的解鎖與所有後續操作對於 m 的加鎖同步
  • 對 volatile 變量 v 的寫入,與所有其他線程後續對 v 的讀同步
  • 啓動線程的操作與線程中的第一個操作同步。
  • 對於每個屬性寫入默認值(0, false,null)與每個線程對其進行的操作同步。
  • 儘管在創建對象完成之前對對象屬性寫入默認值有點奇怪,但從概念上來說,每個對象都是在程序啓動時用默認值初始化來創建的。
  • 線程 T1 的最後操作與線程 T2 發現線程 T1 已經結束同步。
  • 線程 T2 可以通過 T1.isAlive() 或 T1.join() 方法來判斷 T1 是否已經終結。
  • 如果線程 T1 中斷了 T2,那麼線程 T1 的中斷操作與其他所有線程發現 T2 被中斷了同步(通過拋出 InterruptedException 異常,或者調用 Thread.interrupted 或 Thread.isInterrupted )

以上同步順序可以理解爲對於某資源的釋放先於其他操作對同一資源的獲取。

好,這節相對 easy,說的就是關於 A synchronizes-with B 的一系列規則。

17.4.5. Happens-before順序(Happens-before Order)

Happens-before 是非常重要的知識,有些地方我沒有很理解,我儘量將原文直譯過來。想要了解更深的東西,你可能還需要查詢更多的其他資料。

兩個操作可以用 happens-before 來確定它們的執行順序,如果一個操作 happens-before 於另一個操作,那麼我們說第一個操作對於第二個操作是可見的。

注意:happens-before 強調的是可見性問題

如果我們分別有操作 x 和操作 y,我們寫成 hb(x, y) 來表示 x happens-before y。

  • 如果操作 x 和操作 y 是同一個線程的兩個操作,並且在代碼上操作 x 先於操作 y 出現,那麼有 hb(x, y)。請注意,這裏不代表不可以重排序,只要沒有數據依賴關係,重排序就是可能的。
  • 對象構造方法的最後一行指令 happens-before 於 finalize() 方法的第一行指令。
  • 如果操作 x 與隨後的操作 y 構成同步,那麼 hb(x, y)。
  • hb(x, y) 和 hb(y, z),那麼可以推斷出 hb(x, z)

對象的 wait 方法關聯了加鎖和解鎖的操作,它們的 happens-before 關係即是加鎖 happens-before 解鎖。

我們應該注意到,兩個操作之間的 happens-before 的關係並不一定表示它們在 JVM 的具體實現上必須是這個順序,如果重排序後的操作結果和合法的執行結果是一致的,那麼這種實現就不是非法的。

比如說,在線程中對對象的每個屬性寫入初始默認值並不需要先於線程的開始,只要這個事實沒有被讀到就可以了。

我們可以發現,happens-before 規則主要還是上一節 同步順序 中的規則,加上額外的幾條

更具體地說,如果兩個操作是 happens-before 的關係,但是在代碼中它們並沒有這種順序,那麼就沒有必要表現出 happens-before 關係。如線程 1 對變量進行寫入,線程 2 隨後對變量進行讀操作,那麼這兩個操作是沒有 happens-before 關係的。

happens-before 關係用於定義當發生數據競爭的時候。將上面所有的規則簡化成以下列表:

  • 對一個監視器的解鎖操作 happens-before 於後續的對這個監視器的加鎖操作。
  • 對 volatile 屬性的寫操作先於後續對這個屬性的讀操作。也就是一旦寫操作完成,那麼後續的讀操作一定能讀到最新的值
  • 線程的 start() 先於任何在線程中定義的語句。
  • 如果 A 線程中調用了 B.join(),那麼 B 線程中的操作先於 A 線程 join() 返回之後的任何語句。因爲 join() 本身就是讓其他線程先執行完的意思。
  • 對象的默認初始值 happens-before 於程序中對它的其他操作。也就是說不管我們要對這個對象幹什麼,這個對象即使沒有創建完成,它的各個屬性也一定有初始零值。

當程序出現兩個沒有 happens-before 關係的操作對同一數據進行訪問時,我們稱之爲程序中有數據競爭。

除了線程間操作,數據競爭不直接影響其他操作的語義,如讀取數組的長度、檢查轉換的執行、虛擬方法的調用。

因此,數據競爭不會導致錯誤的行爲,例如爲數組返回錯誤的長度。當且僅當所有連續一致的操作都沒有數據爭用時,程序就是正確同步的。

如果一個程序是正確同步的,那麼程序中的所有操作就會表現出連續一致性。

這是一個對於程序員來說強有力的保證,程序員不需要知道重排序的原因,就可以確定他們的代碼是否包含數據爭用。因此,他們不需要知道重排序的原因,來確定他們的代碼是否是正確同步的。一旦確定了代碼是正確同步的,程序員也就不需要擔心重排序對於代碼的影響。

其實就是正確同步的代碼不存在數據競爭問題,這個時候程序員不需要關心重排序是否會影響我們的代碼,我們的代碼執行一定會表現出連續一致。

程序必須正確同步,以避免當出現重排序時,會出現一系列的奇怪的行爲。正確同步的使用,不能保證程序的全部行爲都是正確的。

但是,它的使用可以讓程序員以很簡單的方式就能知道可能發生的行爲。正確同步的程序表現出來的行爲更不會依賴於可能的重排序。沒有使用正確同步,非常奇怪、令人疑惑、違反直覺的任何行爲都是可能的。

我們說,對變量 v 的讀操作 r 能看到對 v 的寫操作 w,如果:

讀操作 r 不是先於 w 發生(比如不是 hb(r, w) ),同時沒有寫操作 w' 穿插在 w 和 r 中間(如不存在 hb(w, w') 和 hb(w', r))。非正式地,如果沒有 happens-before 關係阻止讀操作 r,那麼讀操作 r 就能看到寫操作 w 的結果。

17.5. final 屬性的語義(final Field Semantics)

我們經常使用 final,關於它最基礎的知識是:用 final 修飾的類不可以被繼承,用 final 修飾的方法不可以被覆寫,用 final 修飾的屬性一旦初始化以後不可以被修改。

當然,這節說的不是這些,這裏將闡述 final 關鍵字的深層次含義。

用 final 聲明的屬性正常情況下初始化一次後,就不會被改變。final 屬性的語義與普通屬性的語義有一些不一樣。尤其是,對於 final 屬性的讀操作,compilers 可以自由地去除不必要的同步。相應地,compilers 可以將 final 屬性的值緩存在寄存器中,而不用像普通屬性一樣從內存中重新讀取。

final 屬性同時也允許程序員不需要使用同步就可以實現線程安全的不可變對象。一個線程安全的不可變對象對於所有線程來說都是不可變的,即使傳遞這個對象的引用存在數據競爭。

這可以提供安全的保證,即使是錯誤的或者惡意的對於這個不可變對象的使用。如果需要保證對象不可變,需要正確地使用 final 屬性域。

對象只有在構造方法結束了才被認爲完全初始化了。如果一個對象完全初始化以後,一個線程持有該對象的引用,那麼這個線程一定可以看到正確初始化的 final 屬性的值。

這個隱含了,如果屬性值不是 final 的,那就不能保證一定可以看到正確初始化的值,可能看到初始零值。

final 屬性的使用是非常簡單的:在對象的構造方法中設置 final 屬性;同時在對象初始化完成前,不要將此對象的引用寫入到其他線程可以訪問到的地方。如果這個條件滿足,當其他線程看到這個對象的時候,那個線程始終可以看到正確初始化後的對象的 final 屬性。

這裏面說到了一個正確初始化的問題,看過《Java併發編程實戰》的可能對這個會有印象,不要在構造方法中將 this 發佈出去。

這段代碼把final屬性和普通屬性進行對比。

class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3;
        y = 4;
    }

    static void writer() {
        f = new FinalFieldExample();
    }

    static void reader() {
        if (f != null) {
            int i = f.x;  // 程序一定能得到 3
            int j = f.y;  // 也許會看到 0
        }
    }
}

這個類FinalFieldExample有一個 final 屬性 x 和一個普通屬性 y。我們假定有一個線程執行 writer() 方法,另一個線程再執行 reader() 方法。

因爲 writer() 方法在對象完全構造後將引用寫入 f,那麼 reader() 方法將一定可以看到初始化後的 f.x : 將讀到一個 int 值 3。然而, f.y 不是 final 的,所以程序不能保證可以看到 4,可能會得到 0。

final 屬性被設計成用來保障很多操作的安全性。考慮以下代碼,線程 1 執行:

Global.s = "/tmp/usr".substring(4);

同時,線程 2 執行:

String myS = Global.s;
if (myS.equals("/tmp")) System.out.println(myS);

String 對象是不可變對象,同時 String 操作不需要使用同步。雖然 String 的實現沒有任何的數據競爭,但是其他使用到 String 對象的代碼可能是存在數據競爭的,內存模型沒有對存在數據競爭的代碼提供安全性保證。

特別是,如果 String 類中的屬性不是 final 的,那麼有可能(雖然不太可能)線程 2 會看到這個 string 對象的 offset 爲初始值 0,那麼就會出現 myS.equals("/tmp")。

之後的一個操作可能會看到這個 String 對象的正確的 offset 值 4,那麼會得到 “/usr”。Java 中的許多安全特性都依賴於 String 對象的不可變性,即使是惡意代碼在數據競爭的環境中在線程之間傳遞 String 對象的引用。

大家看這段的時候,如果要看代碼,請注意,這裏說的是 JDK6 及以前的 String 類:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence
{
    /** The value is used for character storage. */
    private final char value[];

    /** The offset is the first index of the storage that is used. */
    private final int offset;

    /** The count is the number of characters in the String. */
    private final int count;

    /** Cache the hash code for the string */
    private int hash; // Default to 0

因爲到 JDK7 和 JDK8 的時候,代碼已經變爲:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

17.5.1. final屬性的語義(Semantics of final Fields)

令 o 爲一個對象,c 爲 o 的構造方法,構造方法中對 final 的屬性 f 進行寫入值。當構造方法 c 退出的時候,會在final 屬性 f 上執行一個 freeze 操作。

注意,如果一個構造方法調用了另一個構造方法,在被調用的構造方法中設置 final 屬性,那麼對於 final 屬性的 freeze 操作發生於被調用的構造方法結束的時候。

對於每一個執行,讀操作的行爲被其他的兩個偏序影響,解引用鏈 dereferences() 和內存鏈 mc(),它們被認爲是執行的一部分。這些偏序必須滿足下面的約束:

17.5.2. 在構造期間讀 final 屬性(Reading final Fields During Construction)

在構造對象的線程中,對該對象的 final 屬性的讀操作,遵守正常的 happens-before 規則。如果在構造方法內,讀某個 final 屬性晚於對這個屬性的寫操作,那麼這個讀操作可以看到這個 final 屬性已經被定義的值,否則就會看到默認值。

17.5.3. final 屬性的修改(Subsequent Modification of final Fields)

在許多場景下,如反序列化,系統需要在對象構造之後改變 final 屬性的值。final 屬性可以通過反射和其他方法來改變。

唯一的具有合理語義的模式是:對象被構造出來,然後對象中的 final 屬性被更新。在這個對象的所有 final 屬性更新操作完成之前,此對象不應該對其他線程可見,也不應該對 final 屬性進行讀操作。

對於 final 屬性的 freeze 操作發生於構造方法的結束,這個時候 final 屬性已經被設值,還有通過反射或其他方式對於 final 屬性的更新之後。

即使是這樣,依然存在幾個難點。如果一個 final 屬性在屬性聲明的時候初始化爲一個常量表達式,對於這個 final 屬性值的變化過程也許是不可見的,因爲對於這個 final 屬性的使用是在編譯時用常量表達式來替換的。

另一個問題是,該規範允許 JVM 實現對 final 屬性進行強制優化。在一個線程內,允許對於 final 屬性的讀操作與構造方法之外的對於這個 final 屬性的修改進行重排序。

對於 final 屬性的強制優化(Aggressive Optimization of final Fields)

class A {
    final int x;
    A() {
        x = 1;
    }

    int f() {
        return d(this,this);
    }

    int d(A a1, A a2) {
        int i = a1.x;
        g(a1);
        int j = a2.x;
        return j - i;
    }

    static void g(A a) {
        // 利用反射將 a.x 的值修改爲 2
        // uses reflection to change a.x to 2
    }
}

在方法 d 中,編譯器允許對 x 的讀操作和方法 g 進行重排序,這樣的話,new A().f()可能會返回 -1, 0, 或 1。

我在我的 MBP 上試了好多辦法,真的沒法重現出來,不過併發問題就是這樣,我們不能重現不代表不存在。StackOverflow 上有網友說在 Sparc 上運行,可惜我沒有 Sparc 機器。

下文將說到一個比較少見的 final-field-safe context

JVM 實現可以提供一種方式在 final 屬性安全上下文(final-field-safe context)中執行代碼塊。如果一個對象是在 final 屬性安全上下文中構造出來的,那麼在這個 final 屬性安全上下文 中對於 final 屬性的讀操作不會和相應的對於 final 屬性的修改進行重排序。

final 屬性安全上下文還提供了額外的保障。如果一個線程已經看到一個不正確發佈的一個對象的引用,那麼此線程可以看到了 final 屬性的默認值,然後,在 final 屬性安全上下文中讀取該對象的正確發佈的引用,這可以保證看到正確的 final 屬性的值。在形式上,在final 屬性安全上下文中執行的代碼被認爲是一個獨立的線程(僅用於滿足 final 屬性的語義)。

在實現中,compiler 不應該將對 final 屬性的訪問移入或移出final 屬性安全上下文(儘管它可以在這個執行上下文的周邊移動,只要這個對象沒有在這個上下文中進行構造)。

對於 final 屬性安全上下文的使用,一個恰當的地方是執行器或者線程池。在每個獨立的 final 屬性安全上下文中執行每一個 Runnable,執行器可以保證在一個 Runnable 中對對象 o 的不正確的訪問不會影響同一執行器內的其他 Runnable 中的 final 帶來的安全保障。

17.5.4. 寫保護屬性(Write-Protected Fields)

通常,如果一個屬性是 final 的和 static 的,那麼這個屬性是不會被改變的。但是, System.in, System.out, 和 System.err 是 static final 的,出於遺留的歷史原因,它們必須允許被 System.setIn, System.setOut, 和 System.setErr 這幾個方法改變。我們稱這些屬性是寫保護的,用以區分普通的 final 屬性。

  public final static InputStream in = null;
    public final static PrintStream out = null;
    public final static PrintStream err = null;

編譯器需要將這些屬性與 final 屬性區別對待。例如,普通 final 屬性的讀操作對於同步是“免疫的”:鎖或 volatile 讀操作中的內存屏障並不會影響到對於 final 屬性的讀操作讀到的值。因爲寫保護屬性的值是可以被改變的,所以同步事件應該對它們有影響。因此,語義規定這些屬性被當做普通屬性,不能被用戶的代碼改變,除非是 System類中的代碼。

17.6. 字分裂(Word Tearing)

實現 Java 虛擬機需要考慮的一件事情是,每個對象屬性以及數組元素之間是獨立的,更新一個屬性或元素不能影響其他屬性或元素的讀取與更新。尤其是,兩個線程在分別更新 byte 數組相鄰的元素時,不能互相影響與干擾,且不需要同步來保證連續一致性。

一些處理器不提供寫入單個字節的能力。通過簡單地讀取整個字,更新相應的字節,然後將整個字寫入內存,用這種方式在這種處理器上實現字節數組更新是非法的。這個問題有時被稱爲字分裂(word tearing),在這種不能單獨更新單個字節的處理器上,將需要尋求其他的方法。

請注意,對於大部分處理器來說,都沒有這個問題

Example 17.6-1. Detection of Word Tearing

以下程序用於測試是否存在字分裂:

public class WordTearing extends Thread {
    static final int LENGTH = 8;
    static final int ITERS = 1000000;
    static byte[] counts = new byte[LENGTH];
    static Thread[] threads = new Thread[LENGTH];

    final int id;

    WordTearing(int i) {
        id = i;
    }

    public void run() {
        byte v = 0;
        for (int i = 0; i < ITERS; i++) {
            byte v2 = counts[id];
            if (v != v2) {
                System.err.println("Word-Tearing found: " +
                        "counts[" + id + "] = " + v2 +
                        ", should be " + v);
                return;
            }
            v++;
            counts[id] = v;
        }
        System.out.println("done");
    }

    public static void main(String[] args) {
        for (int i = 0; i < LENGTH; ++i)
            (threads[i] = new WordTearing(i)).start();
    }
}

這表明寫入字節時不得覆寫相鄰的字節。

17.7. double 和 long 的非原子處理 (Non-Atomic Treatment of double and long)

在Java內存模型中,對於 non-volatile 的 long 或 double 值的寫入是通過兩個單獨的寫操作完成的:long 和 double 是 64 位的,被分爲兩個 32 位來進行寫入。那麼可能就會導致一個線程看到了某個操作的低 32 位的寫入和另一個操作的高 32 位的寫入。

寫入或者讀取 volatile 的 long 和 double 值是原子的。

寫入和讀取對象引用一定是原子的,不管具體實現是32位還是64位。

將一個 64 位的 long 或 double 值的寫入分爲相鄰的兩個 32 位的寫入對於 JVM 的實現來說是很方便的。爲了性能上的考慮,JVM 的實現是可以決定採用原子寫入還是分爲兩個部分寫入的。

如果可能的話,我們鼓勵 JVM 的實現避開將 64 位值的寫入分拆成兩個操作。我們也希望程序員將共享的 64 位值操作設置爲 volatile 或者使用正確的同步,這樣可以提供更好的兼容性。

目前來看,64 位虛擬機對於 long 和 double 的寫入都是原子的,沒必要加 volatile 來保證原子性。

來源:https://javadoop.com/post/Threads-And-Locks-md\

參考:
https://docs.oracle.com/javase/specs/
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
http://gee.cs.oswego.edu/dl/jmm/cookbook.html

近期熱文推薦:

1.1,000+ 道 Java面試題及答案整理(2022最新版)

2.勁爆!Java 協程要來了。。。

3.Spring Boot 2.x 教程,太全了!

4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這纔是優雅的方式!!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!

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