看到一篇文章增加了我对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 中的store
和write
操作,导致变量i
所在的缓存行失效,每次都去内存中获取变量,由于flag与变量i在同一缓存行中,所以也会重新从内存中获取
案例五:Integer
不知道,目前猜测是由于 GC引起的