Java 单例模式的线程安全实现

1 篇文章 0 订阅
1 篇文章 0 订阅

单例模式概念

引用维基百科:

单例(Singleton)模式,也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。还有就是我们经常使用的servlet就是单例多线程的。使用单例能够节省很多内存。

可见单例模式是设计模式的一种,为了保证一个类仅有一个实例,并提供该实例的唯一全局访问点。

关于如何实现单例模式,维基百科也有解释:

实现单例模式的思路是:一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用 getInstance 这个名称);当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

简言之,单例模式中一定要遵循的规则无非三点:

  1. 获取单例的方法一定为静态方法,单例对象一定使用私有静态成员变量来声明。
  2. 可以不用在类加载阶段(准确地说,静态成员变量初始化阶段)就创建单例,但如果单例为 null 时需要新建一个单例。
  3. 构造函数一定定义为私有方法,防止通过构造函数来实例化对象。

单例模式代码

饿汉式

public class EagerSingleton {

    // jvm保证在任何线程访问uniqueInstance静态变量之前一定先创建了此实例
    private static EagerSingleton uniqueInstance = new EagerSingleton();

    // 私有的默认构造子,保证外界无法直接实例化
    private EagerSingleton() {
    }

    // 提供全局访问点获取唯一的实例
    public static EagerSingleton getInstance() {
        return uniqueInstance;
    }
    
}

之所以命名为饿汉式,因为该方法在类加载的初始化阶段就实例化了对象,此时尚未调用 getInstance() 方法。

优点

  • 实现简单
  • 线程安全

缺点

  • 在类加载阶段就实例化对象,对内存的开销较大。
  • 如果单例的初始化依赖于某些外部资源(比如数据库)时,那么就需要考虑延迟加载而不能考虑饿汉式了。

懒汉式

public class LazySingleton {

    private static LazySingleton uniqueInstance;

    private LazySingleton() {
    }

    public static synchronized LazySingleton getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new LazySingleton();
        }
        return uniqueInstance;
    }

}

针对饿汉式在内存开销上存在的缺点,懒汉式对其进行改进:

  • 仅仅声明了单例对象,没有初始化;
  • getInstance() 方法中,当且仅当单例对象为 null 时,才实例化一个新对象。

以上优化既可以避免单例对内存的早期开销,又能够保证单例的唯一性。

注意,getInstance() 方法之前需要加上 synchronized 关键字。如果不加 synchronized 关键字,可能存在线程安全问题:

  • 线程 A 调用 getInstance() 方法,uniqueInstancenull,此时尚未执行 uniqueInstance = new LazySingleton()
  • 线程B也开始调用 getInstance() 方法,uniqueInstance 也为 null,又准备执行一次 uniqueInstance = new LazySingleton()
  • 这样线程 A 和线程 B 都分别创建了一个 uniqueInstance,但并非同一实例。

但加入 synchronized 关键字使得每次调用 getInstance() 方法获取单例时都加锁,导致程序运行效率下降。事实上我们只想保证第一次获取单例时初始化成功而已,以后快速返回即可,如果在 getInstance() 频繁使用的地方就要考虑重新优化了。

双重检测锁

public class DoubleCheckedLockingSingleton {

    private static volatile DoubleCheckedLockingSingleton uniqueInstance;

    private DoubleCheckedLockingSingleton() {
    }

    public static DoubleCheckedLockingSingleton getInstance() {
        if (uniqueInstance == null) {
            synchronized (DoubleCheckedLockingSingleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return uniqueInstance;
    }

}

双重检测锁对懒汉式的 getInstance() 方法做了优化,不在方法上加锁,而是在更小的代码块内加锁。这个方法首先判断变量是否被初始化,没有被初始化,再去获取锁。获取锁之后,再次判断变量是否被初始化。第二次判断目的在于有可能其他线程获取过锁,已经初始化改变量。第二次检查还未通过,才会真正初始化变量。第一次判断主要针对已经经过了单例对象的初次(也是唯一一次)实例化后,以后调用 getInstance() 方法获取单例对象时不用反复进入判断体,到达同步代码块处。第二次判断主要针对单例对象的初次实例化时多线程存在的竞争问题,即不让多个线程去同时实例化对象。
这个方法检查判定两次,并使用锁,所以被形象地称为双重检查锁定模式。

volatile 和指令重排序

当然,我们不能忘记给 uniqueInstance 加上 volatile 修饰符。
首先明确 volatile 的功能主要有两点:

  1. 保证可见性。使用 volatile 定义的变量,将会保证对所有线程的可见性。
  2. 禁止指令重排序优化。

另外,假如 uniqueInstance 没有被 volatile 修饰,那么对于 uniqueInstance = new DoubleCheckedLockingSingleton() 这一实例化语句,会被编译器编译成以下三条 JVM 指令:

1、 分配对象的内存空间

memory = allocate();

2、初始化对象

ctorInstance(memory); 

3、设置 instance 指向刚分配的内存地址

instance = memory;

但是这些指令顺序并非一成不变,有可能会经过 JVM 和 CPU 的优化,指令重排成下面的顺序:

1、分配对象的内存空间

memory = allocate();

3、设置 instance 指向刚分配的内存地址

instance = memory;

2、初始化对象

ctorInstance(memory); 

当线程A执行完 1、3 时,uniqueInstance 对象还未完成初始化,但已经不再指向 null。此时如果线程B抢占到CPU资源,执行外层的 if (uniqueInstance == null) 的结果会是 false,从而返回一个没有初始化完成的uniqueInstance 对象。如下图所示:
在这里插入图片描述

而当 uniqueInstance (图中的 instance)被 volatile 修饰后,JVM 的指令就会严格遵照以下顺序执行:

1、 分配对象的内存空间

memory = allocate();

2、初始化对象

ctorInstance(memory); 

3、设置 instance 指向刚分配的内存地址

instance = memory;

如此在线程B看来,uniqueInstance 对象的引用要么指向 null,要么指向一个初始化完毕的 new DoubleCheckedLockingSingleton(),而不会出现某个中间态,保证了线程安全。
综上所述,双重检测锁是一种较为严谨、也是常用的创建单例对象的方式,原因总结起来有两点:

  1. 使用了双重检测锁机制保证了初次实例化时不会有多个线程同时创建对象,对象被实例化后不会反复进入同步代码块。
  2. 使用 volatile 修饰单例对象,从而禁止 JVM 指令重排序,保证没有获取到锁的线程不会在外层空判断时做出误判而直接返回一个尚未被获取到锁的线程初始化好的对象。

静态内部类

public class LazyInitHolderSingleton {

    private LazyInitHolderSingleton() {
    }

    private static class SingletonHolder {
        private static final LazyInitHolderSingleton INSTANCE = new LazyInitHolderSingleton();
    }

    public static LazyInitHolderSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
}

这种方法的优点:

  • 外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存。这意味着即使使用 class.forName() 加载 LazyInitHolderSingleton,也不会加载 SingletonHolder
  • 当第一次调用 LazyInitHolderSingleton.getInstance() 时,才会加载 SingletonHolder 并初始化 INSTANCE
    这样的特性延迟了单例的实例化。
  • 因为 INSTANCE 为常量,所以在这种情况下获取单例是线程安全的。

反射获取不同的单例

静态内部类的实现方式虽好,但也存在着单例模式共同的问题:无法防止利用反射来重复构建对象。

@Test
public void getDifferentInstanceByReflection() throws Exception {
    Constructor<LazyInitHolderSingleton> con = LazyInitHolderSingleton.class.getDeclaredConstructor();
    con.setAccessible(true);
    LazyInitHolderSingleton singleton1 = con.newInstance();
    LazyInitHolderSingleton singleton2 = con.newInstance();
    assertNotSame(singleton1, singleton2);
}

反射获取不同单例的代码可以简单归纳为三个步骤:

  1. 获得单例类的构造器。
  2. 把构造器设置为可访问。
  3. 使用 newInstance() 方法构造对象。

枚举

那么如何防止使用暴力反射来获取单例对象呢?枚举是一个 《Effective Java》 推荐的的优雅的实现单例的方法。

public enum SingletonEnum {

    INSTANCE;
    
    private EnumSingleton uniqueInstance;
    
    SingletonEnum() {
        uniqueInstance = new EnumSingleton();
    }
    
    public EnumSingleton getInstance() {
        return uniqueInstance;
    }
    
}

class EnumSingleton {
}

有了 enum 语法糖,JVM 会阻止反射获取枚举类的私有构造方法。
再试图使用反射来获取 SingletonEnum 实例:

@Test
public void getDifferentInstanceByReflection() throws Exception {
    Constructor<SingletonEnum> con = SingletonEnum.class.getDeclaredConstructor();
    con.setAccessible(true);
    SingletonEnum singleton1 = con.newInstance();
    SingletonEnum singleton2 = con.newInstance();
    assertNotSame(singleton1, singleton2);
}

运行后控制台报错:

java.lang.NoSuchMethodException: singleton.SingletonEnum.<init>()

	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at singleton.LazyInitHolderSingletonTest.getDifferentInstanceByReflection(LazyInitHolderSingletonTest.java:34)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
	at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
	at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)

使用枚举创建单例与我们平时书写代码的习惯有一定出入,比如枚举中的常量 INSTANCE 其实可以等价于类的一个实例,该实例在被调用时会默认调用到枚举的构造方法。为枚举单例加入打印语句,并观察单元测试的输出顺序。

public enum SingletonEnum {

    INSTANCE;

    private EnumSingleton uniqueInstance;

    SingletonEnum() {
        System.out.println("constructor of enum SingletonEnum");
        uniqueInstance = new EnumSingleton();
    }

    public EnumSingleton getInstance() {
        System.out.println("get the only instance of class EnumSingleton");
        return uniqueInstance;
    }

}

class EnumSingleton {
}
@Test
public void instantiateEnum() {
    assertNotNull(SingletonEnum.INSTANCE);
}

运行以上单元测试方法,非空断言能够通过,且控制台输出:

constructor of enum SingletonEnum

可见在执行 SingletonEnum.INSTANCE 时,调用了 SingletonEnum 的构造方法,所以说调用枚举中的常量类似于为类创建了一个实例。另外,我们也可得知在执行 SingletonEnum.INSTANCE 时, EnumSingleton 对象就已经通过 uniqueInstance = new EnumSingleton() 创建。
于是,有了 INSTANCE 这一枚举“实例”,就可以调用枚举中定义的 getInstance() 方法获取 EnumSingleton 对象了。
另外,大家可能还有另一个疑问,枚举单例的代码如此简单,可以保证每次调用 getInstance() 方法返回的都是同一实例吗?这一点我们同样可以通过单元测试来验证。

@Test
public void getInstance() {
    assertSame(SingletonEnum.INSTANCE.getInstance(), SingletonEnum.INSTANCE.getInstance());
}

执行此单元测试,==断言能够通过,且控制台输出:

constructor of enum SingletonEnum
get the only instance of class EnumSingleton
get the only instance of class EnumSingleton

有输出可知,虽然执行了两次 SingletonEnum.INSTANCE.getInstance() 语句,但 SingletonEnum 的构造方法只被执行了一次,所以 uniqueInstance = new EnumSingleton() 只会被执行一次,以后返回的都是第一次实例化的对象,这就是枚举能够产生单例的原因。
由此,我们可以得出结论:

  • 在首次执行 SingletonEnum.INSTANCE 时,才会调用 SingletonEnum 的构造方法, EnumSingleton 单例于此时初始化完成,以后程序运行期间再也不会调用 SingletonEnum 的构造方法创建新的 EnumSingleton 对象 。
  • 通过 SingletonEnum.INSTANCE.getInstance() 获取 EnumSingleton 单例,且每一次获取的都是首次初始化好的单例对象。

而且枚举是线程安全的,其具体原因可以参考:深度分析Java的枚举类型—-枚举的线程安全性及序列化问题
另外,枚举还能防止通过反序列化的方式来实例化对象,具体原因参考:为什么要用枚举实现单例模式(避免反射、序列化问题)深度分析Java的枚举类型—-枚举的线程安全性及序列化问题

综上所述,使用枚举来实现单例具有以下优点:

  • 实现简单
  • 线程安全
  • 避免被反序列化获取单例
  • 避免被反射获取单例

参考博客

  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值