飞道的博客

由DCL引发的一次思考

353人阅读  评论(0)

上次群里面试的小伙伴在看完对逃逸分析的说明后,下功夫好好学了HotSpot即时编译的相关知识,信心十足的又去面试了,结果又让回家等消息。

看了看他分享的面试题,又在各种高大上的题目里发现了一道有意思的题:请看一个DCL单例模式,并简单说明一下是否正确。

这题目过于简单,不像是这种级别的面试题,所以这道题大有深意,很有意思。

先上代码:


  
  1. /**
  2. * @author liuyan
  3. * @date 19:44 2020/4/4
  4. * @description
  5. */
  6. public class DCLDemo {
  7. private int num;
  8. private static DCLDemo demo;
  9. private DCLDemo() {
  10. this.num = 10; //(1)
  11. }
  12. public static DCLDemo getInstance() {
  13. if (demo == null) { //(2)
  14. synchronized (DCLDemo.class) { //(3)
  15. if (demo == null) { //(4)
  16. demo = new DCLDemo(); //(5)
  17. }
  18. }
  19. }
  20. return demo; //(6)
  21. }
  22. public int getNum() {
  23. return num; //(7)
  24. }
  25. }

很标准的错误示范,上学认真听讲的可能能指出来:

private static DCLDemo demo;

需要改成:

private volatile static DCLDemo demo;

我们简单分析一下,这就要从JVM内存模型说起:

在JVM中每个线程都会有一份本地内存,包括写缓冲、寄存器等等,其中保存了共享变量的副本。这就涉及到了内存可见性,线程A对共享变量的修改,线程B不一定会立即看到。为了描述内存可见性,Java内存模型引入了happens-before语义:如果一个操作的结果对另一个操作可见,那么两个操作之间一定要满足happens-before关系。这里很值得注意:满足happens-before的规则,前一个操作的代码一定会在后一个操作之前执行吗?当然不是,因为happens-before仅仅是要求前一个操作结果对后一个操作可见,如果两个操作没有数据依赖,则不一定会按顺序执行。这是因为为了提高性能,即时编译器和处理器都会对操作进行重排序。重排序包括以下三种:

  1. 即时编译器优化重排序。
  2. 处理器指令级并行重排序。
  3. 内存系统重排序。

我们简单描述一下会产生重排序的场景。

即时编译器重排序,在即时编译时,如果即时编译发现操作顺序可以优化,那可能会产生重排序,如下代码:


  
  1. private void test() {
  2. int i = 0;
  3. int a, b;
  4. while (i++ < 100) {
  5. a = 100;
  6. b = i + 1;
  7. }
  8. }

在循环中,a的赋值与循环无关,所以可以把a的赋值移除到循环外,这就会产生即时编译重排序。

处理器指令集并行重排序,也就是我们计算机系统结构里说的:当指令之间不存在相关时,它们在流水线中是可以重叠起来并行执行的。如下代码:


  
  1. private void test() {
  2. int[] array = new int[ 10];
  3. for ( int i = 0; i < 10; i++) {
  4. array[i] = i;
  5. }
  6. }

我们可以对其进行循环展开,变成不相关的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 of volatile 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做了两个关键的事情:

  1. volatile在即时编译时,禁止做指令的重排序
  2. 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
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场