今天突然想起一個以前有人提到過的問題,大概就是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的話,這個指令意思一樣,跳到后面指定的指令
為了說明方便,我們定義先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為什么會分別處理,這就不知道了- -
水平有限,若有錯誤地方望指出