教你手写单例模式

790 阅读5分钟

单例模式(Singleton pattern):确保一个类只有一个实例,并提供该实例的全局访问点。

单例模式使用一个私有构造函数(不能 new) 、一个私有静态变量(唯一实例) 以及一个公有静态函数(获取唯一实例) 来实现。

私有构造函数保证了不能通过构造函数来创建对象实例,只能通过公有静态函数返回唯一的私有静态变量。

image.png

实现方式一:饿汉式

速记:类加载阶段实例化、线程安全。缺点是占用资源。

饿汉式在类加载的时候就进行实例化,这样做的好处是线程安全;但缺点也是有的,首先在加载的时候就进行实例化,万一这个类占用的资源很大,就会非常浪费资源,毕竟它不一定在什么时候被使用,但内存是一开始就被占用了。

public class HungryManSingleton {
    private static HungryManSingleton hungryManSingleton = new HungryManSingleton();
​
    private HungryManSingleton() { }
  
    public static HungryManSingleton getInstance() {
        return hungryManSingleton;
    }
} 

调用代码:

//调用代码
HungryManSingleton instance1 = HungryManSingleton.getInstance();
HungryManSingleton instance2 = HungryManSingleton.getInstance();
System.out.println("两个引用是否为同一个实例?:" + instance1.equals(instance2));

结果:

两个引用是否为同一个实例?:true

实现方式二:懒汉式

速记:需要的时候再实例化,节约资源,但是线程不安全。

在懒汉式的实现中,默认不会进行实例化,什么时候用 到了,什么时候 New,从而节约资源。

public class LazySingleton {
    private static LazySingleton lazySingleton;
  
    private LazySingleton() { 
        // 实例化的时候打印线程名称,用于验证线程不安全
        System.out.println(Thread.currentThread().getName());
    }
​
    public static LazySingleton getInstance() {
        if (lazySingleton == null) lazySingleton = new LazySingleton();
        return lazySingleton;
    }
}

但是这个实现在多线程的环境下是不安全的,试想以下,当 lazySingleton 为空时,试想一下,当lazySingleton 为空时,有多个线程同时通过了if (lazySingleton == null) 的判断,这样就会导致 new 被执行了多次,使用代码复现一下:

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> LazySingleton.getInstance()).start();
    }
}

输出:

Thread-0
Thread-2
Thread-1

可以看到,实例化代码被执行了三次,为了解决线程安全的问题有两个方法:

  1. 在 getInstance() 方法的层级上加关键字 synchronized
  2. 引入双重校验锁

实现方式三:双重校验锁

速记:解决懒汉式线程不安全问题的方案,比 synchronized 关键字的效率要高。

为了解决懒汉式线程不安全的问题,可以引入双重校验锁的机制,双重检验锁也是一种延迟加载,并且较好的解决了在确保线程安全的时候效率低下的问题。

public class DCLSingleton {
    //volatile 可以禁止指令重排
    private volatile static DCLSingleton dclSingleton;
​
    private DCLSingleton() {
    }
​
    public static DCLSingleton getInstance() {
        if (dclSingleton == null) {
            synchronized (DCLSingleton.class) {
                //如果有多个线程进入第一个if,第二个if可以防止多个线程进行实例化
                if (dclSingleton == null) dclSingleton = new DCLSingleton();
            }
        }
        return dclSingleton;
    }
}
​

在这个实现中,对比一下懒汉式在方法上加锁,那么每次调用那个方法都要获得锁,释放锁,等待等待……而双重校验锁锁住了部分的代码。进入方法如果检查为空才进入同步代码块,这样很明显效率高了很多

对于 new 操作来说,它不是一个原子性操作,他在底层大概发生了以下三件事:

  • 在堆中分配内存空间
  • 执行它的构造方法,初始化对象
  • 在栈中定义引用,再把这个对象指给堆中的实际对象

我们期望它是按顺序发生的,但是由于Java的指令重排机制,可能在没有初始化对象时,就把栈中定义的引用指给堆中的空间,当第二个线程再进来的时候,第一次判定是否为空,他认为不为空,于是将还没有进行初始化的对象返回了;这就是为什么要加上关键字volatile的原因

实现方式四:静态内部类

InnerClassSingleton类加载时,静态内部类 InnerClass没有被加载进内存。只有当调用 getInstance() 方法从而触发 InnerClass.INSTANCEInnerClass才会被加载,初始化实例 INSTANCE。

这种方式不仅具有延迟初始化的好处,而且由虚拟机提供了对线程安全的支持。

public class InnerClassSingleton {
    private InnerClassSingleton() { }
​
    public static InnerClassSingleton getInstance() {
        return InnerClass.INSTANCE;
    }
​
    static class InnerClass {
        private static final InnerClassSingleton 
                INSTANCE = new InnerClassSingleton();
    }
}

实现方式五:枚举

这是单例模式的最佳实践,它实现简单,并且在面对复杂的序列化或者反射攻击的时候,能够防止实例化多次

由于 Enum 实现了 Serializable 接口,所以不用考虑序列化的问题(其实序列化反序列化也能导致单例失败的,但是我们这里不过多研究),并且加载的时候 JVM 能确保只加载一个实例,所以它是线程安全的,而且反射无法破解这种单例模式的实现

public enum Singleton {
    INSTANCE;
}

扩展:使用反射破坏单例模式

事实上,使用反射后,无论是饿汉式、懒汉式、升级的双重校验锁机制、静态内部类机制,都可以用反射去破坏。

很简单,我们只需要使用反射获取构造方法,再将构造方法的私有性破坏,然后用这个构造方法创建一个实例。

以饿汉式为例:

Class<HungryManSingleton> singletonClass = HungryManSingleton.class;
Constructor<HungryManSingleton> declaredConstructor = singletonClass.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
​
HungryManSingleton instance1 = HungryManSingleton.getInstance();
HungryManSingleton instance2 = declaredConstructor.newInstance();

这样得到的 instance1 和 instance2 是不相等的。