设计模式之单例模式(饿汉、懒汉式)

274 阅读4分钟

1.单例模式

1.1 什么是单例模式

  单例模式是一种设计模式,用于确保一个类只能创建一个实例,并提供全局访问该实例的方法。

  单例模式的核心思想是将类的实例化操作封装在类的内部,并提供一个静态方法或静态变量来获取该实例。这个静态方法或变量可以确保在整个应用程序中只存在一个实例。

  单例模式有以下几个优点:

  • 节省内存,避免频繁创建和销毁对象。
  • 避免对共享资源的多重占用。
  • 可以全局控制。

1.2 单例模式(饿汉式)

public class Singleton {
    
    //static 关键字 保证只有一个实例
    private static Singleton singleton = new Singleton();

    //无法通过 new 的方式来创建实例
    private Singleton(){}

    //只能通过该方法获得实例
    public static Singleton getInstance(){
        return singleton;
    }
}
class Main{
    public static void main(String[] args) {
        Singleton singleton1 = Singleton.getInstance();
        Singleton singleton2 = Singleton.getInstance();
        System.out.println(singleton1 == singleton2);
    }
}
结果:true

  之所以叫“饿汉式”,是因为它在类加载时就立即创建实例。与字面上的意思一样,它可以理解为“一开始就饿着肚子(渴望被使用)”。

优点:实现简单直观,线程安全。由于实例在类加载时就被创建,所以不存在多线程并发访问创建实例的问题,不需要考虑线程同步。

缺点:类加载时就创建实例,无论是否使用,都会占用一定的内存空间。

1.3 单例模式(懒汉式)

class Singleton2 {
    
    private static Singleton2 singleton2 = null;

    private Singleton2(){};

    public static Singleton2 getInstance() {
        if(singleton2 == null){
            singleton2 = new Singleton2();
        }
        return singleton2;
    }
}

  懒汉式单例模式得名于它的延迟加载特性。与字面上的意思一样,懒汉式可以理解为“比较懒,需要的时候再去获取”。

优点:实现了延迟加载,即在第一次使用时才创建实例,避免了不必要的资源占用。

缺点:在多线程环境下,懒汉式单例模式需要考虑线程安全的问题。

  上面的代码存在一个问题:可能有多个线程同时进入 if 条件判断,这时候还没来得及创建第一个实例,从而使得这几个线程都进入了if语句里,最终导致前前后后创建了多次实例。虽然它们共享的一直是相同的实例(static关键字,多个线程同时进入 getInstance() 方法并创建多个实例,最终只会存在一个实例。),但是单例模式的目标是保证整个程序中只有一个实例存在,并且任何时候都只能获取到这一个实例

1.4 单例模式(线程安全的懒汉式)

  修改后(版本一):

class Singleton2 {
    private static Singleton2 singleton2 = null;

    private Singleton2(){};

    public static Singleton2 getInstance() {
        //加锁
        synchronized(Singleton2.class){
            if(singleton2 == null){
                singleton2 = new Singleton2();
            }
        }
        return singleton2;
    }
}

  上面的版本还是有一点问题,这里的加锁只是在new出来之前加上是有必要的。但是new完后,后续调用 get 就没有必要锁竞争了,因为singleton2一定是非空的。

  版本二:

class Singleton2 {
    private static Singleton2 singleton2 = null;

    private Singleton2(){};

    public static Singleton2 getInstance() {
       
        if(singleton2 == null){
            //加锁:
            synchronized(Singleton2.class){
                if(singleton2 == null){
                    singleton2 = new Singleton2();
                }
            }
        }
        return singleton2;
    }
}

  这里的前后两个if 的含义是不同的,前一个if用来判断是否要加锁,后一个if用来判断是否创建实例。

  那么这里的代码是否是正确的呢?还有问题!这里涉及到内存可见性问题和指令重排序问题。用volatile关键字可以解决这两个问题。

最终版:

class Singleton2 {
    private static volatile Singleton2 singleton2 = null;

    private Singleton2(){};

    public static Singleton2 getInstance() {
        if(singleton2 == null){
            //加锁:
            synchronized(Singleton2.class){
                if(singleton2 == null){
                    singleton2 = new Singleton2();
                }
            }
        }
        return singleton2;
    }
}

  这里对公共变量singleton2同时进行读、写操作,所以涉及到了内存可见性问题。对于什么是内存可见性问题,可以看我往期的文章:(Java中的线程安全 与 synchronized、volatile关键字 - 掘金 (juejin.cn)

  什么是指令从排序呢?指令重排序是指处理器为了提高程序性能,在不改变语义的情况下对指令进行重新排序。这可能导致某个线程在访问到 singleton2 时,它的引用不为 null,但实际上实例还未完成初始化。

  具体来说,new这个操作不是原子的,它大致可以拆分成三个步骤:

  1. 申请内存空间。
  2. 调用构造方法,把这个内存空间初始化成一个合理的对象。
  3. 把内存空间的地址赋值给变量。

  假如有两个线程t1t2t1是按照1 3 2顺序的步骤执行的。

image.png

  假如t1执行完“分配地址”这一步骤后,被切出CPUt2来执行。t2执行到最外层的if时候,发现此时的singleton2非空,最后直接返回了一个实例,而这个实例还没有被初始化t2可能会尝试去引用这个实例中的属性。这就会对程序造成未知的影响。