【面试手撕】单例模式详解

124 阅读7分钟

你知道单例模式吗?

  1. 饿汉,一开始就创建好。安全但是占空间。
  2. 懒汉,为了保证线程安全,可以加粗锁。然后进阶就是volatile+双检测锁。
    1. 双检测锁是为了后续获取锁的线程不再重复创建对象。
    2. volatile禁止什么指令重排?禁止的是new对象的时候,对象创建过程的指令重排(对象创建过程不是原子的)。
  1. 说了这两种之后,可以说基于java语言的特性,有 静态内部类的方式,顺便说静态内部类的特性(静态内部类只有使用的时候加载类,基于此特性JVM就可以线程安全地创建单例对象,因此不用加锁了
  2. 上面所有的单例模式,都可以通过反射 破坏私有构造,从而创建新对象 破坏单例模式,但用枚举类不会被破坏,因为反射的Constructor.newInstance()源码中规定了 不能通过反射创建枚举对象。

单例模式

1、懒汉单例(线程安全)

懒加载,线程安全但getInstance方法加了锁,效率低

public class Singleton_01 {
    //静态变量,因为要在静态方法中调用
    private static Singleton_01 instance;

    // 私有化,不允许外部调用
    // 构造方法属于类 级别
    private Singleton_01(){

    }
    //静态方法
    //当一个方法被声明为 synchronized 时,它被称为同步方法。同一时间只能有一个线程进入该方法,其他线程必须等待。该方法的锁是当前对象的实例
    public static synchronized Singleton_01 getInstance(){
        //如果不为空则直接返回
        if (null != instance) return instance;

        //否则创建实例返回
        instance = new Singleton_01();
        return instance;
    }

}

这样对获取单例实例方法加锁 能保证线程安全,但 效率低,原因是 无论单例对象是否创建,后续所有的获取单例的多线程方法都必须串行执行(理论上当第一次单例对象创建完成后,后续getInstance读方法允许多线程共享访问无需加锁),这样必然导致效率降低。

2、懒汉单例(线程安全)双检查锁和volatile

  1. 为了避免上述 保证线程安全时效率低 的问题,采用在方法内部加类锁 synchronized (Singleton_02.class) 而不是 对整个方法加锁
  2. 而在内部加类锁后,必须双重检测(在锁里面再次用 if 判断 instance 是否),
    1. 锁里面为什么还有 if 双重检测?
    2. 假如第一个创建单例的线程竞争到了锁,并在锁里面创建了单例对象,这个线程释放锁后第二个线程会进入获取锁这时需要再次判断,因为这时已经有了单例对象将不会进入if语句内重复创建对象
  1. 加了粒度更细的锁后,私有的单例变量instance 用volatile 修饰,防止指令重排。为什么?

这里涉及JVM的对象创建的几个步骤(面试八股), 首先 instance = new Singleton_02(); 本身不是原子操作,给引用创建并分配对象 这一步实际如下分为 3 步:

单线程环境下,这三步是允许指令重排且不会出现问题的,例如1、3、2:分配一个空间,instance指向这个分配的null空间,给分配的空间内初始化对象。

但是在多线程环境下,指令重排可能出现问题。例如,此时线程a执行new 操作1、3、2,执行3后就已经给引用变量指向了内存空间,但空间中未初始化对象(还未执行2)这时线程b进来 在锁外面判断 if (instance == null),instance不为null,然后返回了一个还未初始化完全的对象引用。

//TODO 必须加volatile,防止指令重排(狂神JUC课程)
private volatile static Singleton_02 instance;

// 私有化,不允许外部调用
// 构造方法属于类 级别
private Singleton_02(){

    synchronized (Singleton_02.class){
        if (instance != null){
            //抛出异常
            throw new RuntimeException("不要试图通过反射破坏单例异常");
        }
    }
}

//TODO 双重检测锁模式
public static Singleton_02 getInstance(){
    if (instance == null){
        //加锁
        synchronized (Singleton_02.class){
            //TODO 锁里面的if有什么用,非常有用
            // 假如第一个创建单例的线程竞争到了锁,并在锁里面创建了单例对象,这个线程释放锁后其他线程会进入获取锁,这时需要再次判断,因为这时已经有了单例对象将不会进入if语句内重写创建对象
            if (instance == null){
                instance = new Singleton_02();  //不是原子操作
                /**
                 * 1分配内存空间
                 * 2执行构造方法
                 * 3把对象指向内存空间
                 */
            }
        }
    }
    return instance;
}

3、懒汉单例(线程不安全)

//线程不安全
//instance == null时,多个线程同时调用getInstance可能会创建多个实例
public class Singleton_01 {
    //静态变量,因为要在静态方法中调用
    private static Singleton_01 instance;

    // 私有化,不允许外部调用
    // 构造方法属于类 级别
    private Singleton_01(){

    }
    //静态方法
    public static Singleton_01 getInstance(){
        //如果不为空则直接返回
        if (null != instance) return instance;

        //否则创建实例返回
        instance = new Singleton_01();
        return instance;
    }
}

4、饿汉单例

线程安全,但不是懒加载,资源浪费

Java 的类加载机制保证了静态成员instance的初始化只会发生一次

public class Singleton_02 {

    private static Singleton_02 instance = new Singleton_02();

    public static Singleton_02 getInstance(){
        return instance;
    }
    
    private Singleton_02(){

    }
}

5、使用类的静态内部类(线程安全),推荐,也是懒加载

通过定义一个私有的静态内部类 SingletonHolder,内部类持有一个静态的 Singleton_04 实例 instance

通过静态内部类的方式,可以延迟加载单例对象,并且在多线程环境下保证了线程安全。当 Singleton_04 类被加载时,静态内部类不会被初始化。只有当调用 getInstance() 方法时,才会触发静态内部类的初始化,并创建唯一的 Singleton_04 实例

public class Singleton_04 {
    private static class SingletonHolder {
        private static Singleton_04 instance = new Singleton_04();
    }
    
    private Singleton_04() {
        // 私有的构造函数,防止外部直接实例化该类
    }
    
    public static Singleton_04 getInstance() {
        return SingletonHolder.instance;
    }
}

懒加载:当 Singleton_04 类被加载时,静态内部类不会被初始化,只有当调用 getInstance() 方法时,才会触发静态内部类的初始化

线程安全:静态内部类的初始化是线程安全的(JVM保证类的构造方法线程安全),Java 的类加载机制保证了静态成员的初始化只会发生一次

高效的单例对象: 因为getInstance()方法没有加锁,所以实例创建后getInstance()单例对象的获取是高效多线程的。

为什么要用静态类,因为静态类不能被实例化且静态类及其中的成员只有一份。

问题

为什么静态内部类在类加载时不会初始化

在 Java 中,当一个类被加载时,它的静态成员(包括静态变量和静态代码块)会被初始化。然而,静态内部类并不在外部类加载的过程中被立即初始化

静态内部类的初始化是在首次使用该内部类的时候才会触发,而不是在外部类加载时。这是由于 Java 的类加载机制。当外部类被加载时,静态内部类并不会被加载和初始化,只有在使用内部类的时候才会加载和初始化内部类。

静态内部类在加载和初始化过程中是线程安全的。Java 的类加载机制保证了静态成员的初始化只会发生一次,避免了多线程环境下的竞争条件。

6、枚举类实现单例

上面所有的单例模式,都可以通过反射 破坏私有构造,从而创建新对象 破坏单例模式。

//反射破坏单例模式
public static void main(String[] args) throws Exception{
    Singleton_02 instance = Singleton_02.getInstance();
    //反射获取空参构造器
    Constructor<Singleton_02> declaredConstructor = Singleton_02.class.getDeclaredConstructor(null);
    //设置方法的可访问性
    declaredConstructor.setAccessible(true);
    Singleton_02 instance2 = declaredConstructor.newInstance();

    System.out.println(instance==instance2);
}

但用枚举类不会被破坏,因为反射的Constructor.newInstance()源码中规定了 不能通过反射创建枚举对象

//枚举本身也是一个Class类
public enum EnumSingle {

    INSTANCE;

    public EnumSingle getInstance(){
        return INSTANCE;
    }

}