println、sleep、Integer与线程安全的一些故事

2020年3月23日 | 作者 Siran | 3000字 | 阅读大约需要6分钟
归档于 并发编程 | 标签 #并发理论

看到一篇文章增加了我对JMM的认知,故做此记录。

设计一个程序定义一个 boolean 型的 flag 并设置为 false。主线程一直循环,直到 flag 变为 true。

子线程休眠 100ms 后,把 flag 修改为 true。

来判断这个程序会不会结束

下面是内存不可见的一些变种案例:


案例1:

public class VolatileExample {

    private static boolean flag = false;
    private static int i = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被修改成 true");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        while (!flag) {
            i++;
        }
        System.out.println("程序结束,i=" + i);
    }
}

这个程序是一个死循环。导致死循环的原因是 flag 变量不是被 volatile 修饰的,所以子线程对 flag 的修改不一定能被主线程看到。

而这个地方,如果是在 HotSpot jvm 中用 Server 模式跑的程序,是一定不会被主线程看到,原因后面会讲。


案例2:添加输出语句:

public class VolatileExample {

    private static boolean flag = false;
    private static int i = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被修改成 true");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        while (!flag) {
            i++;
            //<update> 加入了一个输出语句,用于输出每次循环时打印 flag 的值。其他地方没有任何变化。
            System.out.println("flag标识= " + flag);
        }
        System.out.println("程序结束,i=" + i);
    }
}

<update> 加入了一个输出语句,用于输出每次循环时打印 flag 的值。其他地方没有任何变化。

这个程序是会正常结束的,具体原因是输出语句里面有 synchronized 关键字。


案例3:添加sleep:

public class VolatileExample {

        private static boolean flag = false;
        private static int i = 0;
        public static void main(String[] args) {
            new Thread(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                    flag = true;
                    System.out.println("flag 被修改成 true");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
            while (!flag) {
                i++;
                //<update>  100ms 的睡眠。
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("程序结束,i=" + i);
        }
    }

这次的变动点是在<update>处 加了一个 100ms 的睡眠

这个程序能正常结束,具体原因在下面分析。


案例4:添加volatile:

public class VolatileExample {

    private static boolean flag = false;
    //<update>  添加volatile变量
    private static volatile int i = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被修改成 true");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        while (!flag) {
            i++;
        }
        System.out.println("程序结束,i=" + i);
    }
}

这次改动在<update> 用 volatile 修饰了变量 i。注意啊,flag 变量还是没有用 volatile 修饰的。

这个程序能正常结束,具体原因在下面分析。


案例5:换成Integer:

public class VolatileExample {

    private static boolean flag = false;
    //<update>  把int 换成 Integer
    private static Integer i = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被修改成 true");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        while (!flag) {
            i++;
        }
        System.out.println("程序结束,i=" + i);
    }
}

这次改动在<update> 用 包装类替换成基本类型int

这个程序能正常结束,具体原因在下面分析。


解答

案例一

《Effective Java》第 66 条(同步访问共享的可变数据)这一小节中,有这么一个程序:

书里面说

  • 也许你可能期望这个程序运行大概一秒钟左右,之后主线程将 stopRequested 设置为 true,致使后台线程的循环停止。但是在我的机器上,这个程序永远不会终止:因为后台线程永远在循环!

  • 问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对 stopRequested 的值所做的改变。

  • 没有同步,所以虚拟机会将这个代码变成下面这个样子:

书里面是这样说的

书里提到了一个活性失败的概念:多线性并发时,如果 A 线程修改了共享变量,此时 B 线程感知不到此共享变量的变化,叫做活性失败。

如何解决活性失败呢

  • 让两个线程之间对共享变量有 happens-before 关系,最常用的操作就是volatile 或 加锁

  • 书里说:这是可以接受的,这种优化称作提升(hoisting)

在《深入理解Java虚拟机》描述即时编译(Just In Time,JIT)的一些东西

而这个提升是 JIT 帮我们做的。

我们还能怎么验证一下这个结论呢?

运行的时候配置参数-Djava.compiler=NONE ,其含义是禁止 JIT 编译器的加载:会发现程序可以退出了。

总结一下

一个没有被 volatile 修饰的变量 stopRequested ,在子线程和主线程中都有用到的时候,Java 内存模型只是不能保证后台线程何时“看到”主线程对 stopRequested 的值所做的改变,而不是永远看不见

  • 加了 volatile,jvm 一定会保证 stopRequested 的可见性。
  • 不加 volatile,jvm 会尽量保证 stopRequested 的可见性。

也许你会问了,从左边到右边的提升到底是怎么回事,能细致一点,底层一点吗?

可以深入到汇编语言去。具体怎么操作,你看R大的这两个链接,

所以:根据不同的机器、不同的JVM、不同的CPU可能会产生不一样的效果。

但是由于我们绝大部分同学都使用的是 HotSpot 的 Server 模式,所以,运行结果都一样。

案例一中:

由于变量 flag 没有被 volatile 修饰,而且在子线程休眠的 100ms 中, while 循环的 flag 一直为 false,循环到一定次数后,触发了 jvm 的即时编译功能,进行循环表达式外提(Loop Expression Hoisting),导致形成死循环。而如果加了 volatile 去修饰 flag 变量,保证了 flag 的可见性,则不会进行提升。

比如下面的程序,注释了 14 行和 16 行,while 循环,循环了3359次(该次数视机器情况而定)后,就读到了 flag 为 true,还没有触发即时编译,所以程序正常结束。


案例二:println

首先,我们知道了,在<update>处加入输出语句后,这个程序是会正常结束的。

经过我们上面的分析,我们也可以推导出。加了输出语句后 JVM 并没有做 JIT

点进 println 方法,可以看到该方法内部是调用了 synchronized 的。

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

在 stack overflow 中的回答

  • 同步方法可以防止在循环期间缓存 pizzaArrived(就是我们的stop)。
  • 严格的说,为了保证变量的可见性,两个线程必须在同一个对象上进行同步。如果某个对象上只有一个线程同步操作,通过 JIT 技术,JVM 可以忽略它(逃逸分析、锁消除)。
  • 但是,JVM 不够聪明,它无法证明其他线程在设置 pizzaArrived 之后不会调用 println,因此它只能假定其他线程可能会调用 println。(所以有同步操作)
  • 因此,如果使用 System.out.println, JVM 将无法在循环期间缓存变量。
  • 这就是为什么,当有 print 语句时,循环可以正常结束,尽管这不是一个正确的操作。

Concurrent Programming in Java(TM) 中的回答

  • 从本质上来说,线程释放锁的操作,会强制性的将工作内存中涉及的,在释放锁之前的,所有写操作都刷新到主内存中去
  • 而获取锁的操作,则会强制新的重新加载可访问的值到该线程的工作内存中去。

案例三:sleep语句

在 stack overflow 中的回答

  • Thread.sleep 没有任何同步语义(Thread.yield也是)。编译器不必在调用 Thread.sleep 之前将缓存在寄存器中的写刷新到共享内存,也不必在调用 Thread.sleep 之后重新加载缓存在寄存器中的值。
  • 编译器可以自由(free)读取 done 这个字段仅一次
  • free,意味着编译器可以选择只读取一次,也可以选择每次都去读取,这才是自由的含义。这是编译器自己的选择。

案例四:volatile

个人的理解

  • 缓存行有关,一般的缓存行存放64位,那么当i被声明成volatile之后为了性能的考虑会把 flag 也一共加载到同一个缓存行之中。
  • 所以每次对被volatile 修饰的变量i 都会导致触发JMM 中的 storewrite 操作,导致变量i所在的缓存行失效,每次都去内存中获取变量,由于flag与变量i在同一缓存行中,所以也会重新从内存中获取

案例五:Integer

不知道,目前猜测是由于 GC引起的


转载于:https://mp.weixin.qq.com/s/qYKBJPrwliXiKfX7A1wQPg