单例中的双重判空

65 阅读4分钟

在Java中实现单例有饿汉式、懒汉式两种实现方式,针对懒汉式一般需要做双重判空,打算从字节码层面分析下 。synchronizedvolatile 的作用  

class Demo {

	private static volatile Demo sInstance;
	
  	public static Demo getInstance() {
        if (sInstance == null) {
            synchronized (Demo.class) {
                if (sInstance == null) {
                    sInstance = new Demo();
                }
            }
        }

        return sInstance;
    }
}

Java 内存模型:

JVM 虚拟机定义了 Java内存模型 相关概念,简单概述下就是把内存划分为主内存和工作内存,这个和垃圾回收机制中的内存划分不相关,不做过多介绍。

在Java内存模型中规定,每个线程都有自己的工作内存,所有的运算操作都在工作内存中完成。比如执行一个方法时,方法内的局部变量会直接在工作内存中分配,而全局变量、静态变量等则需要先拷贝到工作内存中在进行处理。static final 修饰的变量会在编译期间直接写入到方法中,不需要进行拷贝。这种场景下一般会经历内存的读取、运算和写入操作,在Java中这些操作不一定是原子的,所以就会带来缓存一致性问题,进而引发出一些并发问题,这里不展开讲。

为了保证程序在高并发环境下正常运行,需要考虑原子性、可见性和有序性。

  • 原子性:程序不可拆分,高并发的情况下程序可以完整的运行,进而保证了结果的可靠
  • 可见性:能保证工作内存在使用类似全局变量时能保证和主内存的数据一致,当有改动写入主内存时,工作内存能立即可见并刷新
  • 有序性:指令重排序,在Java层面和物理层面都有可能对程序机器码的执行顺序进行调整,能保证程序的运算结果

Java 中的 synchronized 关键词:

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,方法级的同步是隐式的,无须通过字节码指令来控制。

synchronized 修饰的方法在编译成字节码时,其方法会被标记为 ACC_SYNCHRONIZED,当方法被调用时,当前线程会被要求先持有锁,然后才能执行方法。

  • 如果 synchronized 修饰的是普通方法,那么它的锁是当前对象
  • 如果 synchronized 修饰的是静态方法,那么它的锁是当前类

在Java虚拟机中通过 monitorentermonitorexit 两条指令来支持 synchronized 关键词的语义。编译器会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须有其对应的 monitorexit 指令。比如上述代码,为了保证在方法异常完成时monitorentermonitorexit 指令任然以正确的方式配对执行,会自动产生一个异常处理程序,这个程序可以处理所有的异常代码,它的目的是为了执行 monitorexit 指令。

synchronized 修饰的代码块可以保证原子性和在monitorexit 前能把运算结果写入主内存,但是存在风险,比如:

如果上述 sInstance 变量没有使用 volatile 修饰,且有多个线程访问getInstance() 方法,那么在synchronized代码块前,所有线程都会考呗一下 sInstance 这个变量,此时都是 null, 当一个线程获取锁并执行完初始化逻辑后,会把新创建的变量写入到主内存中,给 sInstance 赋值,然后释放锁。这个时候下一个线程获取锁,因为没有刷新工作内存中的值,就会导致重新创建一个对象,同样的会写入到主内存中,导致对象被覆盖。

Java中的 volatile 关键词:

volatile 关键词有2个作用:

  • 可见性:当一个线程修改了volatile修饰的变量后,新值对于其他线程来说是立即可知的
  • 有序性:通过添加内存屏障可以防止代码的指令重排序

有上述2个特点,就可以解决单一使用 synchronized 修饰导致的单例实现问题