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实例化对象的指令会进行重排吗? 回答是肯定的。
指令重排包括:
- 编译器优化的重排序。
- 指令级并行的重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
其次我们需要明确这样一个概念:
- 乱序对于单线程可以提高效率
- 乱序对于多线程可能出现意想不到现象。
那么此时在多线程情况下如果出现了指令重排,乱序会导致什么情况呢?首先由于变量可见性,那么INSTANCE对于其他线程均是可见的。 如果putstatic和invokespecial进行指令重排:
-
当前线程A创建
INSTANCE对象时,执行指令invokespecial进行构造方法 -
执行
putstatic指令时,将引用赋值给静态变量INSTANCE由于单线程情况下,JVM在执行字节码时,会出现指令重排情况:在执行完`dup`指令之后,跳过构造方法的指令(`invokespecial`) ,直接执行`putstatic`指令,然后再将操作数栈上剩下的引用来执行`invokespecial`。单线程情况下JVM任何打乱`invokespecial`和`putstatic`执行顺序并不会影响程序执行的正确性。但是,在多线程情况下,如果上述两指令发生重排,执行完
new、dup、putstatic后,INSTANCE对象已经不为null,此时第二个线程执行进行getInstance会执行到if (INSTANCE == null),就会拿到一个尚未初始化完成的对象,从而发生对象逃逸。 这种情况在单例对象构造时耗时较大时更加频繁。 那volatile关键字就是在告诉JVM在执行被其修饰过的变量时,禁止使用指令重排。volatile主要作用是来保证可见性和有序性,此处正是使用其有序性来保证执行时禁止指令重排。
3. 内存屏障(memory barrier)
内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在memory barrier 之前的指令和memory barrier之后的指令不会由于系统优化等原因而导致乱序。 大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。 语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
单线程时执行乱序可以提高效率,多线程情况下可能会导致意想不到的效果。为了避免多线程情况下的副作用,那么就需要采用内存屏障,对敏感指令的执行前后插入屏障,保证其顺序执行。 那么JVM是如何实现内存屏障的呢?
这里先看一张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
关于上面四种内存屏障的详细解释可以直接查看 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
}
}
参考文档: