Java線程可見性——加一句System.out.println后運行結果不一樣?

今天突然想起一個以前有人提到過的問題,大概就是A線程持有一個引用類型b變量(不加valotile或者final),A通過檢查b的狀態來控制A線程的循環退出,然后主線程通過引用修改了b的值,按理說因為A線程的b變量(真正的b實際上還在堆里面)被拷貝到線程內存里面,無法察覺到主線程對b的修改,運行結果的確是這樣,只要主線程不結束(阻塞住),A線程就會一直阻塞住。然后問題來了,如果在A線程的循環里面加一個System.out.print/println,隨便輸出什么都好,A居然可以察覺到主線程對b的修改了!

測試代碼如下:

@Test
public void test() throws InterruptedException {
    A a = new A();
    new Thread(a).start();
    Thread.sleep(3000);
    a.b = 2;
    //阻塞住主線程
    while (true){}
}

private class A implements Runnable{

    public Integer b = 1;

    @Override
    public void run() {
        while (true){
//                System.out.println(b);
            if(b.equals(2))
                break;
        }

        System.out.println("A is finished!");
    }
}

如果把注釋掉的那行System.out.println應用上,就會發現A可以結束。

我一開始以為是因為輸出b,控制臺有特別的操作(例如會去主內存看一下)?后來再換個變量輸出,發現輸出什么A依然可以結束,無奈之下去stackoverflow提問一下,結果被人標注問題重復了(゜▽゜*),然后給了我那個問題的鏈接

Boann回答得很詳細,原因是System.out.print里面有加鎖!而jvm對于這個加鎖操作,會做一件事,不緩存線程變量!這樣一切都說得通了,不拷貝就不存在可見性問題了。

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

根據這個說明,修改一下原來的代碼,把輸出語句換成對當前對象加鎖

while (true){
            synchronized (this){
                if(b.equals(2))
                    break;
            }
}

果然A可以結束了(察覺到b的修改)。


到這里其實已經挺不錯了,但是好奇心重,又試了下其他操作,結果一發不可收拾.....

不獲取線程對象的鎖,加個c,獲取c的鎖

private String c = "123";

    @Override
    public void run() {
        while (true){
            synchronized (c){
                if(b.equals(2))
                    break;
            }
}

這樣也可以,再換個操作

    @Override
    public void run() {
        synchronized (this) {
            while (true) {
                if (b.equals(2))
                    break;
            }
        }
        System.out.println("A is finished!");
    }

結果出人意料的是, 這樣就不行了~

如果單看源代碼,上面這種和一開始我們修改的沒什么區別,前者和后者的唯一區別就是while的獲取鎖的順序不一樣,也看不出有什么不同的地方。回顧一下,加System.out.println(加鎖)使jvm不cache局部變量,那先加鎖再while肯定是cache變量b了。這里我們從字節碼上分析下執行過程,因為源代碼和編譯后的字節碼差距是很大的,這里通過javap命令查看兩個文件的字節碼的區別(對虛擬機不太了解的同學可以去看一下深入理解JVM了)。

首先是先while再獲取鎖的字節碼,也就是A可以結束,即b對于A可見(只關注run方法)

public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: getfield      #3                  // Field b:Ljava/lang/Integer;
         8: iconst_2
         9: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        12: invokevirtual #4                  // Method java/lang/Integer.equals:(Ljava/lang/Object;)Z
        15: ifeq          23
        18: aload_1
        19: monitorexit
        20: goto          36
        23: aload_1
        24: monitorexit
        25: goto          33
        28: astore_2
        29: aload_1
        30: monitorexit
        31: aload_2
        32: athrow
        33: goto          0
        36: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        39: ldc           #6                  // String A is finished!
        41: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        44: return
      Exception table:
         from    to  target type
             4    20    28   any
            23    25    28   any
            28    31    28   any
      LineNumberTable:
        line 17: 0
        line 18: 4
        line 19: 18
        line 20: 23
        line 22: 36
        line 23: 44

接著是先獲取鎖再while的字節碼

 public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: getfield      #3                  // Field b:Ljava/lang/Integer;
         8: iconst_2
         9: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        12: invokevirtual #4                  // Method java/lang/Integer.equals:(Ljava/lang/Object;)Z
        15: ifeq          4
        18: goto          21
        21: aload_1
        22: monitorexit
        23: goto          31
        26: astore_2
        27: aload_1
        28: monitorexit
        29: aload_2
        30: athrow
        31: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        34: ldc           #6                  // String A is finished!
        36: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        39: return
      Exception table:
         from    to  target type
             4    23    26   any
            26    29    26   any
      LineNumberTable:
        line 16: 0
        line 18: 4
        line 19: 18
        line 21: 21
        line 22: 31
    line 23: 39

注意這兩個的Code部分15字節碼ifeq,就算不了解這些字節碼指令是什么意思也大概能猜到。照顧一下沒看過JVM的同學,簡單說明一下,對于每個方法來說,一個方法的執行對應著都是一個方法棧的入棧出棧,例如i++就是把i壓入棧,i出棧加1后再壓入棧,最后i出棧賦給原來的i。因此基本所有的操作都是基于棧來進行。

這里先說一下我們關注的幾個指令

  • aload_n:把索引為n的變量從主內存中并放入工作內存的變量的副本中(cache),索引為0的是this,所以aload_0是把this壓入棧
  • ifeq:彈出棧頂元素并判斷是否等于0,如果等于0跳到后面指定的指令
  • goto:知道c語言和java的goto的話,這個指令意思一樣,跳到后面指定的指令

JVM指令

Oracle的JVM指令說明

關于aload

為了說明方便,我們定義先while后加鎖的是Code1,先加鎖后while的是Code2,指令序號n對應的指令是Pn

先看Code1。在P8,9,12執行Integer.equals方法后,把比對結果(java底層true和false也是用1和0表示)壓入棧,P15ifeq判斷棧頂元素是否為0(if條件運算符判斷結果為false,即b!=2),Code1中ifeq后跳P23aload_1,P23從本地變量b(對于JVM來說,b是一個引用)壓入棧中(不cache b),下一條指令P19釋放鎖后,P20goto跳到P33,P33又跳回了P0,重新執行while。相對應的Code2也按上面的步驟看,總結一下Code1和Code2

Code1指令 Code1 Code2指令 Code2
P8,P9,P12 執行equals方法 P8,P9,P12 執行equals方法
ifeq 只關注false的情況 ifeq 只關注false的情況
aload_1 從局部變量獲取引用b后壓入棧 P4 aload_0 把this壓入棧
monitorexit 釋放鎖 P5 getfield 獲取this.b的值后壓入棧頂 (cached b)
P25,P33 goto 最后跳到P0 P8,P9,P12 while循環繼續
P2,P3,P4,P5,P8,P9... astore_1后獲取鎖繼續while循環

對比一下,Code1和Code2在if判斷失敗后繼續循環前,Code1多了一個aload_1,這里就是重新檢肅了b引用,甚至在循環尾部都還astore_1一次,所以Code1并沒有cache b,而Code2始終都沒有重新檢索b,所以Code1能看到b的變化,Code2就不能。至于JVM為什么會分別處理,這就不知道了- -

水平有限,若有錯誤地方望指出

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,368評論 11 349
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,766評論 18 399
  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,731評論 0 11
  • 相關概念 面向對象的三個特征 封裝,繼承,多態.這個應該是人人皆知.有時候也會加上抽象. 多態的好處 允許不同類對...
    東經315度閱讀 1,992評論 0 8
  • 壹| 在路上的那些人 據說你只要通過六個人就能認識全世界的任意一個人 我喜歡在路上 因為我要與你們相遇、相識......
    海螺里的海姑娘閱讀 840評論 0 1