上次群里面试的小伙伴在看完对逃逸分析的说明后,下功夫好好学了HotSpot即时编译的相关知识,信心十足的又去面试了,结果又让回家等消息。
看了看他分享的面试题,又在各种高大上的题目里发现了一道有意思的题:请看一个DCL单例模式,并简单说明一下是否正确。
这题目过于简单,不像是这种级别的面试题,所以这道题大有深意,很有意思。
先上代码:
-
/**
-
* @author liuyan
-
* @date 19:44 2020/4/4
-
* @description
-
*/
-
public
class DCLDemo {
-
private
int num;
-
private
static DCLDemo demo;
-
private DCLDemo() {
-
this.num =
10;
//(1)
-
}
-
public static DCLDemo getInstance() {
-
if (demo ==
null) {
//(2)
-
synchronized (DCLDemo.class) {
//(3)
-
if (demo ==
null) {
//(4)
-
demo =
new DCLDemo();
//(5)
-
}
-
}
-
}
-
return demo;
//(6)
-
}
-
public int getNum() {
-
return num;
//(7)
-
}
-
}
很标准的错误示范,上学认真听讲的可能能指出来:
private static DCLDemo demo;
需要改成:
private volatile static DCLDemo demo;
我们简单分析一下,这就要从JVM内存模型说起:
在JVM中每个线程都会有一份本地内存,包括写缓冲、寄存器等等,其中保存了共享变量的副本。这就涉及到了内存可见性,线程A对共享变量的修改,线程B不一定会立即看到。为了描述内存可见性,Java内存模型引入了happens-before语义:如果一个操作的结果对另一个操作可见,那么两个操作之间一定要满足happens-before关系。这里很值得注意:满足happens-before的规则,前一个操作的代码一定会在后一个操作之前执行吗?当然不是,因为happens-before仅仅是要求前一个操作结果对后一个操作可见,如果两个操作没有数据依赖,则不一定会按顺序执行。这是因为为了提高性能,即时编译器和处理器都会对操作进行重排序。重排序包括以下三种:
- 即时编译器优化重排序。
- 处理器指令级并行重排序。
- 内存系统重排序。
我们简单描述一下会产生重排序的场景。
即时编译器重排序,在即时编译时,如果即时编译发现操作顺序可以优化,那可能会产生重排序,如下代码:
-
private void test() {
-
int i =
0;
-
int a, b;
-
while (i++ <
100) {
-
a =
100;
-
b = i +
1;
-
}
-
}
在循环中,a的赋值与循环无关,所以可以把a的赋值移除到循环外,这就会产生即时编译重排序。
处理器指令集并行重排序,也就是我们计算机系统结构里说的:当指令之间不存在相关时,它们在流水线中是可以重叠起来并行执行的。如下代码:
-
private void test() {
-
int[] array =
new
int[
10];
-
for (
int i =
0; i <
10; i++) {
-
array[i] = i;
-
}
-
}
我们可以对其进行循环展开,变成不相关的10个赋值语句,那么就可以在流水线中并行执行。其实在即时编译中,HotSpot对循环做了相当多的优化,比如循环无关外提、循环展开、分支预测等。(其实学这部内容时,一直在感叹计算机的知识点真的是一张网,从计算机系统结构的设计到上层高级语言的设计,环环相扣,让人心中敬畏。)
话说回来,我们看下JUC包中描述的常见的happends-before规则:
- Each action in a thread happens-before every action in that thread that comes later in the program's order.
- An unlock (
synchronized
block or method exit) of a monitor happens-before every subsequent lock (synchronized
block or method entry) of that same monitor. And because the happens-before relation is transitive, all actions of a thread prior to unlocking happen-before all actions subsequent to any thread locking that monitor.- A write to a
volatile
field happens-before every subsequent read of that same field. Writes and reads ofvolatile
fields have similar memory consistency effects as entering and exiting monitors, but do not entail mutual exclusion locking.- A call to
start
on a thread happens-before any action in the started thread.- All actions in a thread happen-before any other thread successfully returns from a
join
on that thread.
那么基于以上的规则,我们分析一下错误示范代码中的DCL单例为什么错误。也就是在多线程中,代码(1)是否会happens-before代码(7)。
当两个线程都执行到了语句2时,此时有两种场景,即第一种场景线程1看到了线程2对实例的初始化,也就是不为null。第二种场景线程1此时看到的instance==null。
我们先看线程1看到instance==null的场景,也就是线程1与2都会执行同步代码块(3),又因为上述happends-before规则第二条描述的,synchronized的unlock操作一定会happens-before于synchronized的lock操作。也就是说线程2的(6)happends-before线程1的(7),又因为线程2的(1)happens-before线程2的(6),因为happens-before的传递性,那么线程2的(1)happend-before线程1的(7),所以此时DCL正确。
再看线程1看到instance!=null的场景,线程1会执行(6)、(7),这两个操作与线程2的所有操作没有满足上述happends-before的任意一条,所以我们说线程2的(1)不具备happends-before线程1的(7),所以此时DCL是错误的。
那么为什么加上关键字volatile就可以保证DCL的正确呢?
我们看happens-before规则的第三条,任何对volatile的写操作都会happends-before其后的对于volatile的读操作。因此,加上volatile关键字之后,线程2对instance的写就会happends-before线程1对instance的读,所以DCL可以保证正确。
那么volatile关键字到底做了什么来保证内存可见性?
volatile做了两个关键的事情:
- volatile在即时编译时,禁止做指令的重排序
- volatile通过增加读写屏障,保证了内存可见性
第一个很好解释,对于volatile的变量禁止进行指令重排序,保证了指令执行顺序与代码时序相同。
第二个,volatile如何保证内存可见性。前文说过Java的内存模型,每个线程都有本地内存,其中就包括了写缓冲。一般处理器为了提高性能,对于变量的写,会先更新到写缓冲区,批量合并更新到内存。那就导致了,某个线程对共享变量的修改,其他线程不一定会立即看到。volatile做的事情就是,在对volatile的变量写时,会直接刷新到内存,对volatile的变量读时,会直接从内存中读取。这又涉及到了CPU的缓存一致性协议MESI。
MESI代表了缓存行的四种状态:
- Modified修改状态:此时缓存行有效,与内存不一致,并且该缓存行只存在于本cache中。
- Exclusive独占状态:此时缓存行有效,与内存一致,并且被该cache独占。
- Shared共享状态:此时缓存行有效,与内存一致,被很多cache共享。
- Invalid无效状态:此时缓存行无效。
缓存行状态变更是一个非常复杂的过程,我们简单介绍几种:
当CPU-A读取某数据时,该缓存行在CPU-A的cache为E状态,其他CPU为I状态。
当CPU-B也要读取该数据时,会先告知CPU-A修改为S状态,CPU-B此时也为S状态。
当CPU-A修改数据时,设置为M状态,并告知CPU-B设置为I状态。
当CPU-B再读取数据时,CPU-A会先同步数据到内存,然后设置为E状态,再同步CPU-B数据,CPU-A与CPU-B设置为S状态。
我们可以看到,这么一个简单的多核读取数据、修改数据、再读取数据就如此的复杂,如果CPU真这么设计,那执行会得多慢。所以前文所说的写缓冲就是在这里用到的,CPU对数据的读/写都是会先读/写到写缓冲,再批量刷新。并且还提供了写屏障与读屏障,来保证内存可见性。
读屏障:在执行读屏障之后的指令之前,要先执行所有的失效缓存行操作。
写屏障:在执行完写屏障之前的所有指令后,要先执行所有的刷新内存操作。
那么实际上,volatile只不过是在写之后加了一个写读屏障,也就是volatile写之后,先执行所有的刷新内存操作,就保证了内存中volatile数据的正确性,接着执行所有失效缓存行操作,保证了其他cache中关于该volatile数据全部失效。这样在其他cpu读取该volatile数据时,会发现本地cache失效,从内存中读取。
如此就保证了,volatile的内存可见性。
以上,就分析完毕DCL的正确性。
深有感触,计算机的知识真的是环环相扣,终于也明白前辈们谆谆教诲我们基础的重要性。没有基础,很难把这一环环的知识编织成网。
感谢前辈们的总结:
https://www.cnblogs.com/z00377750/p/9180644.html
《深入理解Java内存模型》
《深入拆解Java虚拟机》
《java并发编程实战》
转载:https://blog.csdn.net/ly262173911/article/details/105317251