美文网首页Java学习笔记
Java线程可见性——加一句System.out.println

Java线程可见性——加一句System.out.println

作者: zerouwar | 来源:发表于2017-09-01 10:47 被阅读214次

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

水平有限,若有错误地方望指出

相关文章

  • Java线程可见性——加一句System.out.println

    今天突然想起一个以前有人提到过的问题,大概就是A线程持有一个引用类型b变量(不加valotile或者final),...

  • volatile的原理和使用

    1.对线程的可见性 Java的volatile关键字声明使变量对不同线程具有可见性。程序在多线程操作non-vol...

  • volatile关键字

    Java 内存模型中的可见性、原子性和有序性。 可见性:可见性,是指线程之间的可见性,一个线程修改的状态对另一个线...

  • 身为JAVA工作者必须了解的实战知识(二)

    一、可见性 什么是可见性? Java线程安全需要防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且需要...

  • Java内存模型

    Java内存的可见性 Java内存模型(Java Memory Model)描述线程之间如何通过内存(memory...

  • java并发中volatile的使用

    java中volatile声明变量,有两个作用 保证变量对所有线程的可见性 禁止指令重排 保证可见性 多线程访问共...

  • 多线程 | Volatile到底有什么用?

    Volatile的作用: 保持内存可见性.内存可见性:多个线程操作同一个变量,可确保写线程更新变量,其他读线程可以...

  • 单例模式加volatile作用

    单例模式加volatile作用 1 保证内存可见性 1.1 基本概念 * ”可见性“ 是指线程之间的可见性,一个线...

  • Volatile关键字详解

    基本概念 Java 内存模型中的可见性、原子性和有序性。 可见性:是指线程之间的可见性,一个线程修改的状态对另一个...

  • java内存结构和java内存模型

    java内存模型:与多线程JMM,就是线程可见性有关java内存结构:JVM虚拟机存储空间 class文件被类加载...

网友评论

    本文标题:Java线程可见性——加一句System.out.println

    本文链接:https://www.haomeiwen.com/subject/wsnqjxtx.html