執行緒的快取何時重新整理?

mars_jun發表於2018-12-30

前言

曾經有遇到過這樣一個問題,有一個共享變數keepRunning=true,執行緒A中執行while (keepRunning);,執行緒B中執行keepRunning = false;,在main函式中同時開啟A,B執行緒,然後會發現程式會一直執行且不會退出。說白了這其實就是一個典型的可見性問題,A執行緒並不知道keepRunning已經被修改過了,故未將修改後的keepRunning變數的值從主記憶體中讀取到執行緒快取中來。


舉例

上面的問題等價於下面的程式碼段:

 1/**
2 * @author mars_jun
3 */

4public class NoVisibility_Demonstration extends Thread {
5    boolean keepRunning = true;
6
7    public static void main(String[] args) throws InterruptedException {
8        NoVisibility_Demonstration t = new NoVisibility_Demonstration();
9        t.start();
10        System.out.println("start: " + t.keepRunning);
11        Thread.sleep(1000);
12        t.keepRunning = false;
13        System.out.println("end: " +t.keepRunning);
14    }
15
16    public void run() {
17        int x = 1;
18        while (keepRunning) {
19            //System.out.println("如果你不註釋這一行,程式會正常停止!");
20            x++;
21
22        }
23        System.out.println("x:" + x);
24    }
25}
複製程式碼

按上述程式碼直接執行,你會發現在列印完end: false之後,程式並沒有正常的退出,而是在一直跑著while (keepRunning)這個死迴圈。但是我們嘗試著將其中註釋的程式碼System.out.println("如果你不註釋這一行,程式會正常停止!");給取消掉註釋,再執行一次上面的程式碼,就會發現程式會跑一段時間後正常退出。看到這裡大家也許會感到奇怪,在進行System.out.println這個IO操作後,執行緒t竟然讀到了主執行緒寫入的t.keepRunning = false這個值,然後導致while迴圈退出了。這裡就不得不去看下println這個方法的原始碼了。

1    public void println(String x) {
2        synchronized (this) {
3            print(x);
4            newLine();
5        }
6    }
複製程式碼

這裡我們會發現println方法是一個同步的方法。大家都知道用synchronized這個關鍵字修飾的方法或者程式碼塊能保證程式碼序列化的執行(同一時間只能有一個執行緒獲取執行許可權),在Doug Lea大神的Concurrent Programming in Java一書中有這樣一個片段來描述synchronized這個關鍵字:

In essence, releasing a lock forces a flush of all writes from working memory employed by the thread, and acquiring a lock forces a (re)load of the values of accessible fields. While lock actions provide exclusion only for the operations performed within a synchronized method or block, these memory effects are defined to cover all fields used by the thread performing the action.

簡單翻譯一下:從本質上來說,當執行緒釋放一個鎖時會強制性的將工作記憶體中之前所有的寫操作都重新整理到主記憶體中去,而獲取一個鎖則會強制性的載入可訪問到的值到執行緒工作記憶體中來。雖然鎖操作只對同步方法和同步程式碼塊這一塊起到作用,但是影響的卻是執行緒執行操作所使用的所有欄位。
這也就解釋了為什麼加上System.out.println("如果你不註釋這一行,程式會正常停止!");這句程式碼後,執行緒t能夠讀取到修改後的keepRunning的值了。對於這個問題上,有些人的說法是:列印是IO操作,而IO操作會引起執行緒的切換,執行緒切換會導致執行緒原本的快取失效,從而也會讀取到修改後的值。這裡我認為這種說法也是有道理的,我嘗試著將列印換成File file = new File("G://1.txt");這句程式碼,程式也能夠正常的結束。當然,在這裡大家也可以嘗試將將列印替換成synchronized(NoVisibility_Demonstration.class){ }這句空同步程式碼塊,發現程式也能夠正常結束。


結論

針對上述問題,最起碼可以得出一個結論:當進行IO操作或者執行緒內部呼叫synchronized修飾的方法或者同步程式碼塊時,執行緒的快取會進行重新整理,也就是會感知到共享變數的變化。當然這也只是針對非volatile修飾的變數而言,當變數被申明為volatile的時候,每次使用該變數都會從主記憶體中進行讀取。(這裡對volatile不太熟悉的可以去看我的相關文章淺析volatile原理及其使用


總結

只有在以下條件下,才能保證一個執行緒對欄位的更改對其他執行緒可見:

  1. 寫入執行緒釋放同步鎖,讀取執行緒隨後獲取相同的同步鎖。釋放鎖的時候會強制從執行緒使用的工作記憶體中重新整理所有寫入,並且在獲取鎖的時候會強制重新載入可訪問欄位的值。
  2. 如果一個欄位被宣告為volatile,則寫入執行緒會立即將修改後的值同步到主記憶體。讀取執行緒必須在每次訪問時重新載入volatile欄位的值。
  3. 執行緒第一次訪問一個物件的某個欄位時,它會看到欄位的初始值或來自某個其他執行緒寫入的值。
  4. 當一個執行緒終止時,所有寫入的變數都被重新整理到主記憶體。例如:現有執行緒A,B,在B執行緒中呼叫A.join(),那麼在B中可以保證看到A執行緒產生的影響。

END

相關文章