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

Java DCL 单例模式真的需要对变量加 Volatile 吗?原文链接 https zhuanlan zhihu com p 目录代码展示 DCL 分析 DCL 单例变量加 volatile 关键字的原因 Java 对象创建过程 volatile 修饰单例变量的原因不同角度下的对象创建原理从 C 角度分析对象创建从 Java 角度分析对象创建小结 CPU 模型与 DCL 完整的 JavaDCL 实例代码展示对于单例模式来说 我们为了保证一个类的实例在运行时只有一个 所以我们首先将构造器私有化 禁止在其他地方创建该类的对象 同时我们将单例对象保存在该类的静态变量中 当我们需要

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

代码展示

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

public class Singleton { 
    // 1 public volatile static Singleton obj; private int a; private Singleton() { 
    a=3; } public static Singleton getObj() { 
    // 2 if (obj == null) { 
    // 3 synchronized (Singleton.class) { 
    // 4 if (obj == null) { 
    // 5 obj = 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/cv。这里我们只是简单描述下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;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #3 // class Demo 3: dup 4: invokespecial #4 // Method " 
   
     ":()V 
    7: astore_1 8: 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 ; 直接返回1 ret .L6: pushq %rax ; 保存rax信息到栈上 movl $4, %edi call _Znwm ; 调用函数,开辟对象内存,也即new操作符 movl $3, (%rax) ; 当call _Znwm 返回后,rax寄存中保存值为开辟的内存地址,此时将3放入该地址中 movq %rax, _ZN9Singleton3objE(%rip) ; 将创建的对象内存地址放入静态变量obj的地址中 movl $1, %eax ; 将返回值放入eax中 popq %rdx ; 弹出rdx ret ; 返回 

从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 
  
    ; - org.com.msb.dcl.Singleton::getObj@10 (line 19) ; {optimized virtual_call} 调用 
   
     方法,该方法也即对象的构造器 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 $0x,%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的地址中 

完整的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) { 
    // 1 Singleton obj = new Singleton(); // 2 写屏障保证局部变量obj的写入顺序与全局变量的写入有序性 UNSAFE.storeFence(); // 3 Singleton.obj = obj; } } } return obj; } public static void main(String[] args) { 
    getObj(); } } 
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。

发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/211575.html原文链接:https://javaforall.net

(0)
上一篇 2026年3月18日 下午10:06
下一篇 2026年3月18日 下午10:07


相关推荐

  • oracle删除用户及表空间

    oracle删除用户及表空间1 以 sysdba 用户 最高权限 登录 查找需要删除的用户 普通用户没有删除权限 select fromdba users 2 查询需要删除用户对应的表空间 SELECT FROMUser Tablespaces droptablespa MKDB TMPincluding select from

    2025年11月26日
    4
  • oracle db于,一个特定的数据字典pct miss其计算公式

    oracle db于,一个特定的数据字典pct miss其计算公式

    2022年1月13日
    297
  • easyui window refresh 刷新两次解决办法

    easyui window refresh 刷新两次解决办法这样写刷新两次$("#changeMwsAccountWin").window(‘refresh’,"adsfasdf.php"’);$("#changeMwsAccountWin").window(‘open’); 这样写刷新一次$("#changeMwsAccountWin").window(‘open’);$("#changeMwsAccountWin").wi…

    2022年7月18日
    19
  • 一小伙使用 python爬虫来算命?

    一小伙使用 python爬虫来算命?文章目录前言1.网站分析2.获取内容3.代码4.实操5.代码整合前言相信在日常生活中,平常大家聚在一起总会聊聊天,特别是女生(有冒犯的doge)非常喜欢聊星座,这个男生什么星座呀,那个男生什么星座呀…今天我就来满足各位的需求,通过爬虫来知晓上天的安排:开搞!1.网站分析第一步呢,咋们先打开这个网站:https://www.horoscope.com/us/index.aspx大家就能看到这个页面了我们今天呢,就先做一个通过星座来得知三天的运势的小玩意,这里有十二个星座,我点了第一个和第二个

    2022年7月25日
    7
  • 【Redis】五种数据类型「建议收藏」

    【Redis】五种数据类型「建议收藏」【Redis】五种数据类型

    2022年4月25日
    46
  • STM32移植uIP

    STM32移植uIP文件说明 这是 uIP1 0 源码文件 apps 是作者写好的应用程序 demo doc 是一些文档 lib 里面只有一个文件是内存申请与释放函数的接口 uip 是 tcp ip 的协议栈了 unix 是与外部的接口 我们移植也主要去改这些文件 说一下一眼看不出作用的文件 lc h lc addrlabels h lc switch h psock c ps

    2026年3月26日
    2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注全栈程序员社区公众号