原文链接:https://zhuanlan.zhihu.com/p/385271959

目录

  • 代码展示
  • DCL分析
  • DCL单例变量加volatile关键字的原因
  • Java对象创建过程
  • volatile修饰单例变量的原因
  • 不同角度下的对象创建原理
  • 从C++角度分析对象创建
  • 从Java角度分析对象创建
  • 小结
  • CPU模型与DCL
  • 完整的Java DCL实例

代码展示

对于单例模式来说,我们为了保证一个类的实例在运行时只有一个,所以我们首先将构造器私有化,禁止在其他地方创建该类的对象,同时我们将单例对象保存在该类的静态变量中,当我们需要单例对象时,可以调用getObj方法来获取对象,在该方法中我们首先判断obj是否为空,如果不为空直接返回,否则使用synchronized加锁后继续判断是否为空,若仍然不为空那么我们创建新的对象。详细代码如下所示,代码中笔者用数字标号将代码切割为5个部分。

public class Singleton {// 1public volatile static Singleton obj;private int a;private Singleton() {a=3;}public static Singleton getObj() {// 2if (obj == null) {// 3synchronized (Singleton.class) {// 4if (obj == null) {// 5obj = new Singleton();}}}return obj;}public static void main(String[] args) {getObj();}
}

DCL分析

对于标号为2的地方我们使用if判断是为了增加性能,因为我们并不是每次都需要上锁后判断,这会降低性能,因为创建对象只是在第一次访问时才会创建。在标号为3处我们使用synchronized对当前类对象上锁,保证了多线程并发安全,这将会只允许一个线程进入其中创建对象,其他线程则等待。在标号为4处我们再次判断对象是否为空,这是因为如果在外层标号为2处,同时有多个线程判断obj为空,那么将会有多个线程阻塞在标号为3处的synchronized锁处,虽然只有一个线程能进入,但是当进入创建对象的线程创建完对象后,会唤醒阻塞在标号3处的线程,这时线程进入,就需要再次判断单例对象是否已经被其他线程创建。在标号为5处我们创建了单例对象。DCL的很多博客,包括有朋友向笔者展示Doug Lea与其他人编写的《Java并发编程实战》一书,展示DCL必须要在标号为1处加上volatile,那这是为什么呢?我们来继续分析。

DCL单例变量加volatile关键字的原因

对于Volatile的解释,笔者在《从C语言聊聊JMM内存可见性》一文中已经详细讲解,这里不做过多解释,文章链接:https://www.bilibili.com/read/cv9518280。这里我们只是简单描述下volatile的语义,在java中该语义保证了可见性并保证了有序性,也即禁止指令重排,那么我们看到DCL的代码中使用了synchronized关键字,而该关键字底层通过moniter_enter和monitor_exit两个字节码来完成,该字节码自身已经完成的可见性,所以我们这里使用volatile肯定不是因为可见性而使用得,那么只有一个答案,那就是禁止指令重排。那么为何需要禁止指令重排呢?

Java对象创建过程

我们先来看一段代码,仅仅只是在main方法中创建了一个对象obj,并将其存入局部变量obj中,其中Demo对象定义了一个实例变量a,同时在构造器中初始化了a变量为3。详细代码如下。

public class Demo{private int a = 0;public Demo(){a=3;}public static void main(String[] args){Demo obj = new Demo();}
}

那么我们来看生成的对应字节码信息,我们看到首先通过new指令创建了class Demo对象,随后使用dup复制了一个对象引用,随后使用字节码指令invokespecial调用该对象的方法,该方法也即构造方法,随后调用astore_1指令,将剩余的一个引用保存至局部变量表为索引为1的slot中。

public static void main(java.lang.String[]);descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:stack=2, locals=2, args_size=10: new           #3                  // class Demo3: dup4: invokespecial #4                  // Method "<init>":()V7: astore_18: return

volatile修饰单例变量的原因

那么问题就出现在如下字节顺序中,我们看到创建的对象需要分为两步,创建对象实例,调用实例构造函数,假如我们不加上volatile,那么将会调用astore_1指令重排序到invokespecial之前,从而导致外部线程虽然拿到了单例对象,但是该对象是不完整的,因为其构造函数还未调用,那么这时它的成员变量应该是0,而不是3。

new
dup
invokespecial #4
astore_1

不同角度下的对象创建原理

那么我们此时仅仅只是站在字节码指令的角度去看待该问题,我们知道字节码是交给虚拟机执行的,如果没有底层的汇编指令支撑,那么我们没法了解到确切的真相:astore_1真的会和invokespecial指令重排吗?甚者很多博客和书籍说编译器会导致指令重排。那么我们现在就来通过C++和Java汇编的角度来看看,是否编译器会导致重排序。

从C++角度分析对象创建

我们从C++层面,通过调整编译器为最大优化级别,看看是否编译器会导致创建对象过程和调用对象构造函数的过程重排序,C++和Java毕竟创建对象都是这么做的,但是C++可以将new运算符重载,Java不行。我们来看代码,同样我们创建一个类为Singleton,同时也声明了成员变量a,在构造器中将其初始化为3,为了保证生成的汇编代码简单,笔者这里把mutex上锁的代码去了,毕竟C++可不知道什么synchronized关键字,不过这并不影响我们研究问题的本质。代码实现如下。

using namespace std;
class Singleton
{  public:int a;static Singleton* getObj()  {  if ( obj == NULL )    obj = new Singleton();  return obj;  }  private:  Singleton(){a=3;};  static Singleton * obj;
};
​
int main(){Singleton *p=Singleton::getObj();return 1;
}

接下来我们用gcc -S -O4 -mno-sse demo.cpp -lstdc++命令,开启最高级别优化编译该代码,随后我们来看生成的汇编指令,我们看到在main方法中代码被编译器优化为直接取类Singleton的静态变量地址直接判断是否为null,如果不为null直接返回,否则调用.L6处代码继续执行。我们看到call _Znwm用于创建对象内存地址,而movl $3, (%rax)则是构造器中的赋值操作,将3放入rax所指的内存地址空间中,随后调用movq %rax, _ZN9Singleton3objE(%rip)将该对象地址放入静态变量obj中。那么我们看到,在最高级别的优化下,编译器并不会将构造器的调用和放置对象地址的操作重排序。

main:cmpq    $0, _ZN9Singleton3objE(%rip) ; 看看静态变量obj是否为null(C++非零即真)je  .L6  ; 如果为0,那么跳转到.L6处执行movl    $1, %eax ; 直接返回1ret
.L6:pushq   %rax   ; 保存rax信息到栈上movl    $4, %edicall    _Znwm    ; 调用函数,开辟对象内存,也即new操作符movl    $3, (%rax) ; 当call  _Znwm 返回后,rax寄存中保存值为开辟的内存地址,此时将3放入该地址中movq    %rax, _ZN9Singleton3objE(%rip) ; 将创建的对象内存地址放入静态变量obj的地址中movl    $1, %eax ; 将返回值放入eax中popq    %rdx  ; 弹出rdxret ; 返回

从Java角度分析对象创建

我们来看Java代码,同样为了保证生成的汇编代码简单,笔者这里去掉了加锁的操作,毕竟我们只是看看编译器是否会导致指令重排,因为加了synchronized关键字只是保证了互斥性和可见性,但是synchronized关键字内的互斥代码并不能保证有序性。

public class Singleton {public static Singleton obj;int a;private Singleton() {a = 3;}public static Singleton getObj() {if (obj == null) {obj = new Singleton();}return obj;}public static void main(String[] args) {getObj();}
}

我们来看汇编代码,这里我们使用-XX:TieredStopAtLevel=4指定编译层级为4最高等级优化,

0x0000000003556a2f: jae    0x0000000003556a9f ; 调用new操作创建对象
0x0000000003556a5c: mov    %rax,%rbp          ;*new  ; - org.com.msb.dcl.Singleton::getObj@6 (line 19) 保存创建的对象地址放入rbp中

我们看到以上代码为创建对象过程,由于其中创建对象需要获取到元数据信息metadata,然后将对象放入操作数栈等等步骤,所以其中包含较多汇编代码,笔者这里去掉了不需要的汇编,只保留这两句。我们只需要关注这一句jae 0x0000000003556a9f,我们继续看该地址的操作。

0x0000000003556a9f: movabs $0x7c0060828,%rdx  ;   {metadata('org/com/msb/dcl/Singleton')}
0x0000000003556aa9: xchg   %ax,%ax
0x0000000003556aab: callq  0x00000000035512e0  ; OopMap{off=208}
;*new  ; - org.com.msb.dcl.Singleton::getObj@6 (line 19)
;   {runtime_call}  这里就是调用创建对象的方法地址
0x0000000003556ab0: jmp    0x0000000003556a5c  ;*new
; - org.com.msb.dcl.Singleton::getObj@6 (line 19) // 创建完毕后跳转到该地址

接下来我们继续看0x0000000003556a5c之后的代码,

0x0000000003556a5c: mov    %rax,%rbp          ;*new  ; - org.com.msb.dcl.Singleton::getObj@6 (line 19)
0x0000000003556a5f: mov    %rbp,%rdx
0x0000000003556a62: nop
0x0000000003556a63: callq  0x00000000031d61a0  ; OopMap{rbp=Oop off=136}
;*invokespecial <init>
; - org.com.msb.dcl.Singleton::getObj@10 (line 19)
;   {optimized virtual_call}  调用<init>方法,该方法也即对象的构造器
0x0000000003556a68: mov    %rbp,%r10
0x0000000003556a6b: shr    $0x3,%r10
0x0000000003556a6f: movabs $0x66b6acc08,%r11  ;   {oop(a 'java/lang/Class' = 'org/com/msb/dcl/Singleton')}
0x0000000003556a79: mov    %r10d,0x68(%r11)   ;   将对象的地址放入到类静态变量obj中,0x68为obj偏移量
0x0000000003556a7d: movabs $0x66b6acc08,%r10  ;   {oop(a 'java/lang/Class' = 'org/com/msb/dcl/Singleton')}
0x0000000003556a87: shr    $0x9,%r10
0x0000000003556a8b: mov    $0x10741000,%r11d
0x0000000003556a91: mov    %r12b,(%r11,%r10,1)
0x0000000003556a95: lock addl $0x0,(%rsp)     ;*putstatic obj
; - org.com.msb.dcl.Singleton::getObj@13 (line 19)  synchronized的monitor_exit保证可见性的操作
​
0x0000000003556a9a: jmpq   0x00000000035569ff

小结

由此我们从C++的角度,Java的角度分析,得到结论:编译器将不会导致指令重排序。这也就是为什么在C++的单例模式中没有对单例对象加上volatile关键字的原因,我们在《从C语言聊聊JMM内存可见性》一文中知道,volatile对于C类语言来说只是禁止编译器重排序的手段,既然编译器不会干扰对于new操作符分配内存、调用构造器、赋值这三步的步骤,那么我们并不需要使用它。

CPU模型与DCL

接下来我们来看看,既然编译器不会导致该指令重排,那么还有另外一种原因:CPU模型导致的重排现象。我们来看C++ DCL的这段汇编代码,我们知道movl $3, (%rax)是构造器中的操作,那么如果CPU在执行过程中,将指令重排执行将movq %rax, _ZN9Singleton3objE(%rip),也即对象写入到了内存中,这时就会导致半对象的产生。

call    _Znwm    ; 调用函数,开辟对象内存,也即new操作符
movl    $3, (%rax) ; 当call  _Znwm 返回后,rax寄存中保存值为开辟的内存地址,此时将3放入该地址中
movq    %rax, _ZN9Singleton3objE(%rip) ; 将创建的对象内存地址放入静态变量obj的地址中

那么我们知道这是两步写入操作,在TSO模型下并不会产生问题,因为CPU MOB(内存顺序缓冲区访问模型)为TSO模型下,只有storeload重排序现象,但是如果我们在其他模型,比如:PSO、RMO下,那么将会导致storestore乱序。那么这时我们就需要指令屏障来保证指令结果写入顺序,而Java的volatile语义恰好满足了这一条件,同理我们在Java生成的汇编代码也满足这种现象。所以我们在前面使用volatile就是使用了它屏蔽底层模型,保证了完整的顺序,但是这样真的好吗?附上一个JMM模型与CPU MMO模型的关系图。

完整的Java DCL实例

我们来看去掉了volatile的单例模式,读者可以看看上面的图中,我们看到TSO模型下会导致storestore乱序,那么我们只需要一点小小的改动,就能完成保证了高性能,同时也能保证写入顺序的操作。代码如下。

public class Singleton {public static Singleton obj;public static final Unsafe UNSAFE = MyUtils.getUnsafe();int a;private Singleton() {a = 3;}public static Singleton getObj() {if (obj == null) {synchronized (Singleton.class) {if (obj == null) {// 1Singleton obj = new Singleton();// 2 写屏障保证局部变量obj的写入顺序与全局变量的写入有序性UNSAFE.storeFence();// 3Singleton.obj = obj;}}}return obj;}public static void main(String[] args) {getObj();}
}

我们知道,只需要保证写入顺序即可,这时我们将volatile修饰符去掉,同时我们在标号为1处首先将创建的单例对象保存到局部变量中,随后加上storeFence屏障,保证局部变量和全局变量的写顺序,这时就避免了会导致storestore内存顺序的CPU上写写的顺序性。那么为何去掉volatile,用unsafe的内存屏障呢?考虑下volatile的语义:volatile变量读后面加上loadload、loadstore屏障,写之前加上storestore屏障,写之后加上storeload屏障。那么我们在对象创建完毕后,需要这些屏障吗?答案肯定是否定的。所以我们不需要使用volatile关键字,通过unsafe的屏障就能完成同样的工作。这种现象在Linux内核中非常常见,不允许使用volatile,因为它禁止了编译器在使用这些变量时的优化,而对于内核来说,它必须要满足高性能,这时就要求:不能使用volatile关键字,当需要指令顺序时,采用编译器屏障(:::“memory”)或者指令屏障(lfence,sfence,mfence,lock前缀)。所以我们这里使用storefence避免了storestore的重排序现象,不同的CPU下的MOB模型,也即内存顺序缓冲区访问模型的不同,将会导致不同程度下的loadload、loadstore、storeload、storestore现象,当然我们现在最常见的就是TSO模型,比如x86等等。那么我们这里使用storeFence保证了局部变量的写入和全局变量的写入顺序性,即可完善单例模型下的高性能操作,因为我们在读单例变量时实在不需要读屏障,同时在TSO模型下由于不存在storestore的乱序,所以storeFence就等同于空操作,更进一步的提升性能。这里附上Java Volatile语义描述图。

Java DCL 单例模式真的需要对变量加 Volatile 吗?相关推荐

  1. 面试突击51:为什么单例一定要加 volatile?

    . 作者 | 磊哥 来源 | Java面试真题解析(ID:aimianshi666) 转载请联系授权(微信ID:GG_Stone) 单例模式的实现方法有很多种,如饿汉模式.懒汉模式.静态内部类和枚举等 ...

  2. 懵了,Java枚举单例模式比DCL和静态单例要好???

    点击关注公众号,实用技术文章及时了解 来源:liuchenyang0515.blog.csdn.net/article/ details/121049426 文章目录 双重校验锁单例(DCL) 为什么 ...

  3. java并发编程(二十六)——单例模式的双重检查锁模式为什么必须加 volatile?

    前言 本文我们从一个问题出发来进行探究关于volatile的应用. 问题:单例模式的双重检查锁模式为什么必须加 volatile? 什么是单例模式 单例模式指的是,保证一个类只有一个实例,并且提供一个 ...

  4. Java学习-----单例模式

    一.问题引入 偶然想想到的如果把Java的构造方法弄成private,那里面的成员属性是不是只有通过static来访问呢:如果构造方法是private的话,那么有什么好处呢:如果构造方法是privat ...

  5. Java 进阶——单例模式

    一.单例模式概念及特点         Java中单例模式是一种常见的设计模式,单例模式分三种:懒汉式单例.饿汉式单例.登记式单例三种. 单例模式有一下特点: 1.单例类只能有一个实例. 2.单例类必 ...

  6. Java 设计模式 - 单例模式

    Java 设计模式 - 单例模式 作者: 霍英俊 [huo920@live.com] 文章目录 Java 设计模式 - 单例模式 单例设计模式介绍 单例设计模式八种方式 饿汉式 - 静态常量 饿汉式( ...

  7. 美团面试题:DCL单例模式需不需要volatile?

    最近有粉丝收到美团的面试,去试了一试,结果没有过.然后在群里分享面试经历,其中有一个面试题<DCL 单例模式到底需不需要 volatile?>引起了大家的争议,今天我们一起来讨论讨论这个面 ...

  8. java float 加法_Java-杂项:Float 加减精度问题

    java float 加减精度问题 在取这个字段的时候转换成BigDecimal就可以了 同时,BigDecimal是可以设置精度的. float m = 12.22F; float c = 1.22 ...

  9. Java设计模式 - 单例模式详解(下)

    单例模式引发相关整理 关联线程安全 在多线程下,懒汉式会有一定修改.当两个线程在if(null == instance)语句阻塞的时候,可能由两个线程进入创建实例,从而返回了两个对象.对此,我们可以加 ...

最新文章

  1. 分块的单点修改查询区间和_树状数组的区间修改与单点查询与区间查询
  2. 老鸟运维该何去何从?
  3. redis服务器防止入侵,加ip,密码限制
  4. 北妈每日一学:ES6 之 模块化-重要!
  5. 快速排序的两种实现方法(js)
  6. win7查看tomcat端口_想研究Tomcat性能调优,看这篇就够了
  7. 定制C# combobox的下拉框
  8. 网上照片之博客照片与网店照片拍摄心得
  9. java ajax传值到后台_java ajax发送数据到后台,中文乱码
  10. 写给自己,关于对纯技术的追求,以及为了金钱与前途的技术追求
  11. 【人民币识别】基于matlab GUI形态学钞票面额识别与统计【含Matlab源码 906期】
  12. shell命令的退出状态码(exit status)
  13. docker-compose文件详解
  14. Python 使用OpenCV计算机视觉(一篇文章从零毕业)【附带OCR文字识别项目、停车场车位智能识别项目】
  15. Day9 Four French Words Pronounced Differently in English
  16. 加州大学戴维斯计算机博士生,加州大学戴维斯分校计算机排名及研究生申请条件是什么...
  17. 计算机群等级,腾讯客服-群成员活跃等级规则
  18. 如何修改request的parameter的几种方式
  19. 苹果手机软件闪退怎么解决_LOL手游卡顿闪退怎么办-卡顿闪退解决方法解析
  20. 瑞萨e2studio(1)----瑞萨芯片之搭建FSP环境

热门文章

  1. python鸢尾花数据集_Python实现鸢尾花数据集分类问题——使用LogisticRegression分类器...
  2. python 依据某几列累加求和_关于Python数组求和的四个问题及详解,让你更加爱Python!...
  3. 0006-ZigZag Conversion(Z 字形变换)
  4. js src 变量_Js基础学习笔记(一)
  5. L1-038. 新世界
  6. BZOJ3209(n的二进制表示中1的个数的乘积)
  7. 3_10 MediaMode 中介者模式
  8. 全面解析 Netflix 的微服务架构设计
  9. SQL 性能优化梳理
  10. 史上最难10道Java面试题!