DCL写单例模式是否需要Volatile?

459 阅读10分钟

Java中单例模式(Singleton)是一种广泛使用的设计模式。单例模式的主要作用是保证在Java程序中某一个类只有一个实例存在。

单例模式可以保证一个类仅创建一个实例,并且提供一个访问它实例的全局方法。

简单的介绍一下好处:

  • 能够避免实例对象的重复创建,可以减少每次创建对象的时间开销,还能够节约内存空间。
  • 能够避免由于操作多个实例导致的逻辑错误。
  • 如果一个实例对象可能贯穿整个应用程序,并且充当统一管理作用,那么可以考虑单例模式。

单例模式的写法有很多种,这里主要介绍双锁校验(Double Check Lock)

1.几种单例模式写法

1.1 饿汉模式

public class Singleton {
    private static Singleton INSTANCE = new Singleton();
​
    private Singleton() {
        
    }
​
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

如果我们希望延迟初始化这个单例对象,就不能够使用饿汉式实现,而是需要懒汉式实现:需要单例对象时才进行创建。

1.2 懒汉模式

public class Singleton {
    private static Singleton INSTANCE;
​
    private Singleton() {
​
    }
​
    public synchronized static Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
​
​
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(() -> System.out.println(Singleton.getInstance().hashCode())).start();
        }
    }
}

打印出创建实例的hashCode地址。

// 多线程下创建单例对象,打印单例对象hashCode
1470333138
1470333138
1470333138
1470333138
1470333138
1470333138
...
    这是最简单的单例模式的延迟初始化实例对象的方法,并且通过`synchronized`锁住Singleton类的字节码,保证了多线程下的安全。
从上述代码得到,这种情况的`粒度`太大了,同一时间只能够有一个线程可以拿到这个单例对象,可想而知在高并发情况下,对性能吞吐量限制较高。为了提升并发性能,可以采用DCL(double check lock)双锁校验,降低锁的粒度,增加并发性能。

1.3 双锁校验DCL(double check lock)

public class Singleton {
    private static Singleton INSTANCE;
​
    private Singleton() {
​
    }
​
    public static Singleton getInstance() {
        //第一次校验单例对象是否为空
        if (INSTANCE == null) {
            //同步代码块
            synchronized (Singleton.class) {
                //第二次校验单例对象是否为空
                 if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                 }
            }
        }
        return INSTANCE;
    }
​
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(() -> System.out.println(Singleton.getInstance().hashCode())).start();
        }
    }
}

DCL将同步方法修改成同步代码块,降低锁的粒度,提高并发性能。

  • 当单例对象已经被创建后,多线程情况下同时执行第一个if条件判断并且拿到单例对象。
  • 当单例对象未被创建时,同时只有一个线程能够进入同步代码块,进行第二次if语句判断,如果发现此时单例对象仍然未被其他线程创建,则创建单例对象。

这里可以看到,并没有对单例对象INSTANCE添加volatile关键字。那么此时已经满足了单例模式要素,同时也可以满足多线程情况下的高性能。那DCL双锁校验是否需要添加volatile关键字呢?

回答:需要添加volatile关键字,下面就介绍为什么需要。

2. DCL为什么要添加volatile关键字?

先介绍在上述单例模式中,实例化一个单例对象时,字节码层面进行了哪些操作。

{
  public static main.singleton.Singleton getInstance();
    descriptor: ()Lmain/singleton/Singleton;
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field INSTANCE:Lmain/singleton/Singleton;
         3: ifnonnull     16
         6: new           #3                  // class main/singleton/Singleton
         9: dup
        10: invokespecial #4                  // Method "<init>":()V
        13: putstatic     #2                  // Field INSTANCE:Lmain/singleton/Singleton;
        16: getstatic     #2                  // Field INSTANCE:Lmain/singleton/Singleton;
        19: areturn
      LineNumberTable:
        line 16: 0
        line 17: 6
        line 19: 16
      StackMapTable: number_of_entries = 1
        frame_type = 16 /* same */
}
 6: new           #3                  // class main/singleton/Singleton
 9: dup
10: invokespecial #4                  // Method "<init>":()V
13: putstatic     #2                  // Field INSTANCE:Lmain/singleton/Singleton;

先看看INSTANCE = new Singleton();这段代码对应的字节码逻辑

  • new 在堆内存空间中分配内存,将指向该区域的引用放入操作数栈
  • dup 在操作数栈中复制引用
  • invokespecial 调用Singleton的构造方法
  • putstatic 将该引用赋值给静态变量INSTANCE

if (INSTANCE == null)使用了一个半初始化状态的对象,导致重新创建了新的单例对象。 抛出一个问题,上述初始化一个INSTANCE实例化对象的指令会进行重排吗? 回答是肯定的。

image.png

指令重排包括:

  • 编译器优化的重排序。
  • 指令级并行的重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

其次我们需要明确这样一个概念:

  • 乱序对于单线程可以提高效率
  • 乱序对于多线程可能出现意想不到现象。

那么此时在多线程情况下如果出现了指令重排,乱序会导致什么情况呢?首先由于变量可见性,那么INSTANCE对于其他线程均是可见的。 如果putstaticinvokespecial进行指令重排:

  • 当前线程A创建INSTANCE对象时,执行指令invokespecial 进行构造方法

  • 执行putstatic指令时,将引用赋值给静态变量INSTANCE

    由于单线程情况下,JVM在执行字节码时,会出现指令重排情况:在执行完`dup`指令之后,跳过构造方法的指令(`invokespecial`) ,直接执行`putstatic`指令,然后再将操作数栈上剩下的引用来执行`invokespecial`。单线程情况下JVM任何打乱`invokespecial``putstatic`执行顺序并不会影响程序执行的正确性。
    

    但是,在多线程情况下,如果上述两指令发生重排,执行完newdupputstatic后,INSTANCE对象已经不为null,此时第二个线程执行进行getInstance会执行到if (INSTANCE == null),就会拿到一个尚未初始化完成的对象,从而发生对象逃逸。 这种情况在单例对象构造时耗时较大时更加频繁。 那volatile关键字就是在告诉JVM在执行被其修饰过的变量时,禁止使用指令重排。 volatile主要作用是来保证可见性和有序性,此处正是使用其有序性来保证执行时禁止指令重排。

3. 内存屏障(memory barrier)

内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在memory barrier 之前的指令和memory barrier之后的指令不会由于系统优化等原因而导致乱序。 大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。 语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

单线程时执行乱序可以提高效率,多线程情况下可能会导致意想不到的效果。为了避免多线程情况下的副作用,那么就需要采用内存屏障,对敏感指令的执行前后插入屏障,保证其顺序执行。 那么JVM是如何实现内存屏障的呢?

image.png

这里先看一张JMM(Java Memory Model)介绍图。

3.1 JMM中的happen before原则

SR-1337制定了Java内存模型(Java Memory Model, JMM)中happen before原则:

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

JMM对heppen before原则还进行了两个扩展:

  • 对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。
  • 对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(前提是没有this引用溢出)

内存屏障可以分为四种: Load:读操作,Store写操作。先确定这个概念,再看下面的几种内存屏障插入方式:

  • LoadLoad屏障

    • 操作序列 Load1, LoadLoad, Load2
    • 用于保证访问 Load2 的读取操作一定不能重排到 Load1 之前。
  • LoadStore屏障

    • 操作序列 Load1, LoadStore, Store2
    • 用于保证 Store2 及其之后写出的数据被其它 CPU 看到之前,Load1 读取的数据一定先读入缓存。甚至可能 Store2 的操作依赖于 Load1 的当前值。
  • StoreLoad屏障

    • 操作序列 Store1, StoreLoad, Load2
    • 用于保证 Store1 写出的数据被其它 CPU 看到后才能读取 Load2 的数据到缓存。
    • 如果 Store1 和 Load2 操作的是同一个地址,StoreLoad Barrier 需要保证 Load2 不能读 Store Buffer 内的数据,得是从内存上拉取到的某个别的 CPU 修改过的值。StoreLoad 一般会认为是最重的 Barrier 也是能实现其它所有 Barrier 功能的 Barrier。
  • StoreStore屏障

    • 操作序列 Store1, StoreStore, Store2
    • 用于保证 Store1 及其之后写出的数据一定先于 Store2 写出,即别的 CPU 一定先看到 Store1 的数据,再看到 Store2 的数据。可能会有一次 Store Buffer 的刷写,也可能通过所有写操作都放入 Store Buffer 排序来保证。
    • 上面volatile 关键字防止指令重排,便是使用该屏障,确保一定是 先请求构造函数invokespecial写入数据,再执行引用地址赋值 putstatic

image.png

关于上面四种内存屏障的详细解释可以直接查看 openjdk-MemoryBarriers.java

3.2 volatile关键字如何使用内存屏障

  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障
  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障

3.3 final字段内存

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

    • JMM禁止编译器把final域的写重排序到构造函数之外
    • 编译器会在final域的写之后,构造函数return之前,插入一个storestore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外。
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

4.JVM底层如何实现内存屏障

hotspot虚拟机实现可以查看[bytecodeinterpreter.cpp](http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/tip/src/share/vm/interpreter/bytecodeInterpreter.cpp) 进行字节码拦截,判断是否有volatile关键字修饰

        if (cache->is_volatile()) {
            if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
              OrderAccess::fence();
            }
            //省略其他逻辑
        }
      

OrderAccess::fence();逻辑在orderAccess_linux_x86.inline.hpp 可以看到底层是通过lock来实现。

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

参考文档: