youyichannel

志于道,据于德,依于仁,游于艺!

0%

手把手带你实现四种单例

简介

熟悉Spring的我们都知道,Spring替我们管理Bean,默认的类型就是 Singleton,也就是单例,即全局唯一,并且提供一个全局访问点。

那么,我们自己如何实现单例呢?这里需要把握住几个核心要点:

  1. 静态化实例对象
  2. 私有化构造方法
  3. 提供一个公共的访问入口

单例模式的应用场景

  1. 资源共享场景:避免由于资源操作时导致性能损耗
  2. 控制资源场景,便于资源之间互相通信

实现

单例模式的实现方式主要有四种:

  1. 饿汉式
  2. 懒汉式
  3. 登记式
  4. 枚举式

上述四种都可以实现单例,最推荐的方式还是枚举,因为其线程安全,而且可以防止反序列化和反射导致破坏单例。

饿汉式

见名知意,即在JVM加载的时候就创建相应的实例对象,这种方式最简单,也没有并发问题和效率问题,但是存在内存浪费问题,容易产生垃圾对象。

/**
* 单例模式实现 —— 饿汉式
*
* @author <a href="https://github.com/yoyocraft">youyi</a>
*/
public class Singleton1 {

private static final Singleton1 INSTANCE = new Singleton1();

// 构造器私有
private Singleton1() {
}

// 全局访问入口
public static Singleton1 getInstance() {
return INSTANCE;
}
}

测试:

@Test
public void testSingleton1() {
Singleton1 instance1 = Singleton1.getInstance();
Singleton1 instance2 = Singleton1.getInstance();
System.out.println(instance1 == instance2); // 比较内存地址,ture
}

懒汉式

即延迟创建,使用到时才创建。这种方式需要注意并发问题和指令重排序问题,一般通过双重检查锁内存屏障来解决。

/**
* 单例模式实现 —— 懒汉式
*
* @author <a href="https://github.com/yoyocraft">youyi</a>
*/
public class Singleton2 {

private static volatile Singleton2 INSTANCE;

// 构造器私有
private Singleton2() {
}

// 全局访问入口
public static Singleton2 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton2.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton2();
}
}
}
return INSTANCE;
}
}

也可以不使用volatile关键字,但是需要将创建实例的语句拆成两句,就像下面这样:

public static Singleton2 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton2.class) {
if (INSTANCE == null) {
Singleton2 tmp = new Singleton2();
INSTANCE = tmp;
}
}
}
return INSTANCE;
}

这样,不管针对创建实例的语句如何进行重排序,都可以保证INSTANCE=tmp语句拿到的是完整的实例对象。

测试:

@Test
public void testSingleton2() {
Singleton2 instance1 = Singleton2.getInstance();
Singleton2 instance2 = Singleton2.getInstance();
System.out.println(instance1 == instance2); // 比较内存地址,ture
}

登记式(静态内部类)

这种方式利用了JVM的类加载机制来保证只有一个实例。外部类加载时并不需要立即加载内部类,实现了Lazy Load 的效果。实现方式简单,推荐。

JVM 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

/**
* 单例模式实现 —— 静态内部类
*
* @author <a href="https://github.com/yoyocraft">youyi</a>
*/
public class Singleton3 {

// 构造器私有
private Singleton3() {
}

// 静态内部类
private static class LazyHolder {

// 只有在第一次显式调用 getInstance 方法时,JVM 才会装载 LazyHolder 类,从而实例化 INSTANCE
private static final Singleton3 INSTANCE = new Singleton3();
}

// 全局访问入口
public static Singleton3 getInstance() {
return LazyHolder.INSTANCE;
}
}

测试:

@Test
public void testSingleton3() {
Singleton3 instance1 = Singleton3.getInstance();
Singleton3 instance2 = Singleton3.getInstance();
System.out.println(instance1 == instance2); // 比较内存地址,ture
}

枚举式

枚举可以说是单例模式的最佳实践,代码非常简洁,保证线程安全自动支持序列化机制,而且可以防止反序列化

/**
* 单例模式实现 —— 枚举类
*
* @author <a href="https://github.com/yoyocraft">youyi</a>
*/
public enum Singleton4 {

INSTANCE;

// do something
}

测试:

@Test
public void testSingleton4() {
Singleton4 instance1 = Singleton4.INSTANCE;
Singleton4 instance2 = Singleton4.INSTANCE;
System.out.println(instance1 == instance2); // 比较内存地址,ture
}

单例?

思考下,上述方式就一定能够保证单例吗?其实除了枚举可以,其他方式都是不行的,我们可以使用反序列化对象或者反射的方式来破坏上述的单例方式。

反射破坏单例

以 懒汉式 单例为例,其余同理。

@Test
public void destroySingleton()
throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Singleton2 instance1 = Singleton2.getInstance();

// 反射方式
Constructor<Singleton2> constructor = Singleton2.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton2 instance2 = constructor.newInstance();

System.out.println(instance1 == instance2); // false
}

通过获取到构造器来实例化新对象,破坏了单例。

解决方案:通过在构造方法中设置标识,来标识是否调用过构造函数。

private static boolean isInit = false;
// 全局访问入口
public static Singleton2 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton2.class) {
if (isInit) {
throw new IllegalStateException("单例模式已经初始化过了");
}
isInit = true;
if (INSTANCE == null) {
INSTANCE = new Singleton2();
}
}
}
return INSTANCE;
}

序列化破坏单例

需要让 Singleton2 类实现 序列化接口 Serializable

@Test
public void destroySingleton2() throws IOException, ClassNotFoundException {
Singleton2 instance1 = Singleton2.getInstance();

// 序列化方式
FileOutputStream fos = new FileOutputStream("Singleton.obj");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(instance1);
oos.flush();
oos.close();

FileInputStream fis = new FileInputStream("Singleton.obj");
ObjectInputStream ois = new ObjectInputStream(fis);
Singleton2 instance2 = (Singleton2) ois.readObject();

System.out.println(instance1 == instance2); // false
}

解决方案:在被序列化的类中添加readResolve方法。

参考:https://docs.oracle.com/javase/7/docs/platform/serialization/spec/input.html

public class Singleton2 implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

private static volatile Singleton2 INSTANCE;

// 构造器私有
private Singleton2() {
}

// 全局访问入口
public static Singleton2 getInstance() {
// ...
}

@Serial
private Object readResolve() {
return INSTANCE;
}
}

枚举实现原理

枚举是 JDK5 中提供的一种语法糖。

语法糖是在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是但是更方便开发者使用。只是在编译器上做了手脚,却没有提供对应的指令集来处理它。

通过对 Singleton4 这个类进行反编译,我们可以得到:

❯ javap Singleton4.class
Compiled from "Singleton4.java"
public final class com.youyi.singleton.Singleton4 extends java.lang.Enum<com.youyi.singleton.Singleton4> {
public static final com.youyi.singleton.Singleton4 INSTANCE;
public static com.youyi.singleton.Singleton4[] values();
public static com.youyi.singleton.Singleton4 valueOf(java.lang.String);
static {};
}

由反编译后的代码可知,INSTANCE 被声明为 static 的,虚拟机会保证一个类的 <clinit>()方法在多线程环境中被正确的加锁、同步。所以,枚举实现在实例化时是线程安全。

除此之外,Java 规范中规定,每一个枚举类型及其定义的枚举变量在 JVM 中都是唯一的,因此在枚举类型的序列化和反序列化上,Java 做了特殊的规定:在序列化的时候 Java 仅仅是将枚举对象的 name 属性输出到结果中,反序列化的时候则是通过 java.lang.EnumvalueOf() 方法来根据名字查找枚举对象,因此反序列化后的实例和之前被序列化的对象实例相同。

总结

  1. 饿汉式简单实用,且线程安全。不要求懒加载的情况下,推荐使用
  2. 静态内部类的方式实现了懒加载,比饿汉式更节省内存空间
  3. 懒汉式如果不加锁就无法保证线程安全,如果加锁就会影响效率,较为复杂,不推荐使用
  4. 枚举式比较简洁,线程安全,且防止反序列化,设计上最为完美推荐使用

  • https://www.cnblogs.com/shoufeng/p/10820964.html
  • https://zhuanlan.zhihu.com/p/150004430
  • https://juejin.cn/post/7140266949962891277
  • https://java.jverson.com/design/1-singleton.html