【单例模式】深入三大单例对象创建方式

1.简介

单例模式是一种创建型设计模式,它确保一个类在内存中只有一个实例,并提供全局访问点来共享该实例。在程序中,如果需要频繁使用同一个对象且它们的作用相同,单例模式可以避免频繁创建对象,减少内存开销。

单例模式主要有两种类型:

  • 懒汉式:在需要使用时才去创建该对象的单例
  • 饿汉式:在类加载时就已经创建好该单例对象,等待被程序使用

2.懒汉式

懒汉式创建对象的方法是在程序使用对象前,判断对象是否为空(是否被实例化),如果为空则创建该对象的实例,如果不为空直接返回该对象的实例即可。

非线程安全

使用 static 关键字修饰对象,表示该对象属于类本身,而不是类的某个实例。在应用程序的整个生命周期中,该类只会存在一个这样的实例。当多线程访问该类时,可以通过类获取这个静态对象,从而避免重复创建新对象。

public class Singleton {
    
    private static Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    
}

线程安全

上述方法在多线程创建对象的时候是有问题的。如果两个线程同时判断singleton为空,那么它们都会执行singleton = new Singleton();语句创建一个实例,这就变成双例了。

解决方法也很简单,给获取实例的方法getInstance()加锁即可。

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

但是这种方法也存在问题:每次去获取对象都需要获取互斥锁,在高并发场景下,可能会多线程阻塞的问题。

优化方案是:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例。

双重校验锁

在单例模式中,双重校验锁可以确保只有在第一次访问单例对象时才会进行实例化,并且在多线程环境下能够保证单例对象的唯一性。

public class Singleton {
    
    private static Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) { // 多个线程判断类是否实例化,如果已经被实例化,直接return即可
            synchronized(Singleton.class) { // 多线程争抢该锁进行实例化
                if (singleton == null) { // 保证后续的锁进来后,不需要再实例化
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    } 
}

详细解释下这段代码的执行流程:

  1. 多个线程执行getInstance()方法后,进入if (singleton == null)语句块,判断类是否已经被实例化了,如果已经被实例化,直接返回对象,无需继续创建对象。
  2. 如果 singleton 为 null,多个线程争抢该锁进行实例化,同一时刻只有一个线程可以进入同步代码块去创建对象。
  3. 再次判断该类是否被实例化,这一步确保了上一个线程在进入同步块后创建了对象,当前线程也不会重复创建实例。

防止指令重排

尽管我们使用双重校验锁的方案保证了线程安全并提升了性能,但由于JVM指令重排序的存在,在多线程创建单例时仍然可能存在问题。

当JVM执行new Singleton()创建一个对象时,会经过三个步骤:

  1. 为singleton分配内存空间。
  2. 初始化singleton对象,将对象的成员变量赋值。
  3. 将内存空间的引用赋值给 singleton 变量。

当发生指令重排序时,这个顺序可能会发生变化,导致未完成初始化(即对象的构造方法可能尚未执行完毕,某些属性可能未被赋值)的对象被其他线程访问,如果这些线程使用了该对象的成员变量,就会导致NPE。

具体场景:

线程A进入同步块并执行singleton= new Singleton()时,由于指令重排,创建对象的顺序变成:

  1. 为singleton分配内存空间。
  2. 将内存空间的引用赋值给 singleton 变量。
  3. 初始化singleton对象,将对象的成员变量赋值。

这时候线程B在第一次检查singleton == null时得到的结果是false,因为instance已经不再是null(已经指向了内存地址),于是直接返回了这个未初始化的半成品对象

由于singleton指向的对象尚完成初始化,当线程B在访问该对象尚未初始化的成员变量时,就会导致空指针异常NullPointerException

解决方案:

只需加上volatile关键字即可,使用volatile修饰变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换。

完整代码如下:

public class Singleton {
    private static volatile Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized(Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

3.饿汉式

饿汉式在类加载时就已经创建好单例对象,因此在程序调用时可以直接返回该对象。也就是说,我们在调用时直接获取单例对象即可,而不需要在调用时再去创建实例。

由于饿汉式在类加载过程中就由JVM创建了单例,因此不存在线程安全问题

public class Singleton{
    
    private static final Singleton singleton = new Singleton();
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        return singleton;
    }
}

4.破坏单例模式

无论是使用了双重校验锁+volatile关键字的懒汉式还是饿汉式,都无法防止反射和序列化破坏单例模式(创建多个对象),具体演示代码如下:

反射破坏单例模式

这个原理很简单,利用反射可以强制访问类的私有构造器,从而创建另一个对象

public static void main(String[] args) {
    // 获取类的显式构造器
    Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
    // 可访问私有构造器
    construct.setAccessible(true); 
    // 利用反射构造新对象
    Singleton obj1 = construct.newInstance(); 
    // 通过正常方式获取单例对象
    Singleton obj2 = Singleton.getInstance(); 
    System.out.println(obj1 == obj2); // false
}

序列化破坏单例模式

首先使用将单例对象序列化为文件流或其他形式,再使用反序列化的手段从流中读取对象。

当使用readObject()方法时,一定会创建一个新的对象。

public static void main(String[] args) {
    // 创建输出流
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
    // 将单例对象写到文件中
    oos.writeObject(Singleton.getInstance());
    // 从文件中读取单例对象
    File file = new File("Singleton.file");
    ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
    Singleton newInstance = (Singleton) ois.readObject();
    // 判断是否是同一个对象
    System.out.println(newInstance == Singleton.getInstance()); // false
}

5.枚举类

枚举类不仅能确保线程安全性,还可以防止反序列化破坏反射攻击

public enum Singleton {
    INSTANCE;  // 唯一的实例

    // 示例方法,可以在单例实例中添加其他方法
    public void doSomething() {
        System.out.println("Singleton instance is doing something.");
    }
}

public class TestSingleton {
    public static void main(String[] args) {
        // 获取单例实例
        Singleton singleton = Singleton.INSTANCE;

        // 调用单例实例的方法
        singleton.doSomething();

        // 验证多次获取的实例是相同的
        Singleton anotherInstance = Singleton.INSTANCE;
        System.out.println("Are both instances the same? " + (singleton == anotherInstance));
    }
}

枚举类的优点:

  1. 线程安全:枚举类的实例在 JVM 加载时由 JVM 保证,只会实例化一次,因此枚举的单例实现天然是线程安全的。
  2. 防止反序列化破坏:默认情况下,反序列化一个枚举类型时,会通过类加载器加载枚举类,从而保证每个枚举类型在 JVM 中仅存在一个实例。因此枚举类型可以防止反序列化导致的单例破坏。
  3. 防止反射攻击:反射攻击是指通过反射机制来调用私有构造器从而创建多个实例的情况,但在枚举类型中,这种操作会抛出 IllegalArgumentException,从而有效地防止了反射攻击。

6.对比

懒汉式

  • 实例化时机:在第一次调用getInstance()方法时,才会创建单例实例。这意味着如果程序一直没有调用这个方法,单例实例将永远不会被创建。
  • 优点:延迟加载(Lazy Loading),只有在需要时才创建实例,节省了系统资源。
  • 缺点:首次创建实例时可能会有性能开销。在多线程环境下,如果没有适当的同步机制,可能会导致线程安全问题,如重复创建实例。

饿汉式

  • 实例化时机:在类加载时就创建单例实例。无论是否调用getInstance()方法,类加载时都会创建实例。
  • 优点:实现简单,类加载时就完成了实例化,避免了多线程同步问题,因为JVM在类加载时确保了线程的安全性。
  • 缺点:即使单例实例从未被使用,也会在程序启动时创建,可能会浪费系统资源,尤其是在实例化过程较重或单例实例不常用的情况下。

枚举类

  • 实例化时机:在枚举类被加载时创建单例实例,与饿汉式类似,枚举类型的实例会在类加载时被创建,并且JVM会确保只有一个实例存在。
  • 优点:
    • 线程安全:枚举类型在Java中是天然线程安全的,JVM保证了枚举实例的唯一性,无需额外的同步控制。
    • 防止反序列化破坏单例:枚举类在反序列化时不会创建新的实例,保证了单例的唯一性。
    • 防止反射攻击:枚举类在Java中不允许通过反射创建实例,因此可以防止反射攻击破坏单例。
  • 缺点:如果单例类需要继承其他类或实现某些接口,枚举类不太适合。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值