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

本文深入探讨了DCL单例模式中的volatile关键字作用及其必要性,从C++和Java的角度分析了对象创建过程中的指令重排序问题,并提供了一个高性能的DCL单例模式实现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

代码展示

对于单例模式来说,我们为了保证一个类的实例在运行时只有一个,所以我们首先将构造器私有化,禁止在其他地方创建该类的对象,同时我们将单例对象保存在该类的静态变量中,当我们需要单例对象时,可以调用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/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;)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 "<init>":()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 <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) {
                    // 1
                    Singleton obj = new Singleton();
                    // 2 写屏障保证局部变量obj的写入顺序与全局变量的写入有序性
                    UNSAFE.storeFence();
                    // 3
                    Singleton.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语义描述图。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值