今天突然想起一个以前有人提到过的问题,大概就是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为什么会分别处理,这就不知道了- -
水平有限,若有错误地方望指出