单例模式的N种实现

ywz888
发布于 2022-11-2 10:14
浏览
0收藏

前言

单例设计模式是GOF 23中设计模式中常见的设计模式之一,不论是在我们日常开发,还是一些第三方库中几乎都能见到单例模式。包括在面试时初中级的程序员基本都会被问到单例模式。

单例模式的目的主要是为了保证在多线程场景下实例唯一的一种解决方案,实现起来还是比较简单的,但是实现方式各式各样,五花八门,今天小黑带大家梳理下单例模式的7种实现方式,并比较各有什么优缺点。

饿汉式

饿汉式,顾名思义一上来就会创建实例对象,因为很饿嘛,马上就要创建出来。


/**
 * @author java_xiaohei 公众号:小黑说Java
 * @ClassName Singleton
 * @Description 单例设计模式:饿汉式
 * @date 2021/9/27
 **/
public final class Singleton {
    // 定义变量时直接初始化
    private static Singleton instance = new Singleton();
    // 构造方法私有,不允许外部new
    private Singleton() {
    }
    // 外部通过getIntstance获取实例
    public static Singleton getInstance() {
        return instance;
    }
}

饿汉式的关键在于定义instance时直接实例化。通过饿汉式完全可以保证实例对象的线程安全。

但是有一个问题,如果该实例对象被创建之后过了很久才会被访问,那么在访问之前这个对象数据会一直存放在堆内存当中,如果实际场景中单例对象的实例数据很大,将会占用比较多的资源,这种方式则不太合适。

懒汉式

懒汉式相对饿汉式而言,区别的地方主要在创建对象的时机有区别,不会在定义变量时初始化,而是在需要使用时才进行创建。

public final class Singleton {
    // 定义变量时不做初始化
    private static Singleton instance = null;
    // 构造方法私有,不允许外部new
    private Singleton() {
    }
    // 外部通过getIntstance获取实例
    public static Singleton getInstance() {
        if(instance==null){
            instance = new Singleton();
        }
        return instance;
    }
}

这种方式相对饿汉式而言,可以避免在使用对象之前创建对象造成的空间资源浪费。

上面代码在单线程场景下调用​​getInstance()​​没有问题,但是在多线程情况下,会有线程安全问题。

是因为​​getInstance()​​方法中的if判断和对instance的赋值动作不是原子操作。

如下图,有两个线程A和B先后调用​​getInstance()​​​,线程A判断​​instance==null​​​为​​true​​。

在线程A进行实例化对象之前,线程B拿到了CPU的执行权,这时线程B判断​​instance==null​​​也为​​true​​,此时线程B也会做实例化,这样无法保证实例的唯一性。

单例模式的N种实现-鸿蒙开发者社区

这种懒汉式的实现方式因为不能保证线程安全,所以实际开发中不可以使用。

懒汉式+同步方法

简单的懒汉式因为存在线程安全问题,那么我们可以通过加锁的方式来保证线程安全。

public final class Singleton {
    // 定义变量时不做初始化
    private static Singleton instance = null;
    // 构造方法私有,不允许外部new
    private Singleton() {
    }
    // 方法上加synchronized保证线程安全
    public static synchronized Singleton getInstance() {
        if(instance==null){
            instance = new Singleton();
        }
        return instance;
    }
}

通过在​​getInstance()​​方法上加锁,可以保证多线程情况下只能有一个线程进入此方法,也就保证了线程安全。

但是这种方法未免有点太偷懒,在初次创建实例对象之时加锁可以避免实例对象被创建多个,但是在实例被成功创建之后,每次获取实例时都需要获取锁,这会极大的降低性能。

DCL

DCL是Double Check Lock的简写,意思是双重检查锁。

public final class Singleton {
    private static Singleton instance = null;
    String msg;
    private Singleton() {
        // 初始化msg
        this.msg = "对象描述信息";
    }
    public static Singleton getInstance() {
        // 第一层检查
        if (instance == null) {
            // 只能有一个线程获得Singleton.class锁
            synchronized (Singleton.class) {
                // 第二层检查
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

DCL方式在保证线程安全的做法上没有偷懒,并没有直接在方法上加锁。

第一层判断如果为true,则会执行加锁和创建实例的操作;如果第一层判断为false,表示对象已经创建完成了,那么直接返回实例就OK,避免每次请求都加锁,性能高。

在第一层判断结果为true,则需要加锁保证实例创建过程的安全性。

加锁成功后的第二层判空检查的目的是为了防止在进入第一层检查和加锁成功的过程中已经有其他线程完成实例的创建,避免重复创建。

DCL方式保证了线程安全的同时又没有损失性能,并且还是懒加载的,貌似是一种完美的解决方案。

但是这种方式在多线程情况下可能会出现空指针异常。如果你有细心看我的代码会发现上面的代码中,​​Singleton()​​构造方法中对属性msg进行了初始化赋值。

那么我们在getInstance()方法中的​​instance = new Singleton();​​可以分为三步操作。

1.new Singleton()在堆中创建一个对象;

2.msg赋值;

3.instance赋值;

但是根据JVM运行时Happens-before规则,这三步的顺序并没有前后依赖,很有可能实际运行的顺序是,3->1->2或者其他;那么就可能造成在getInstance()中​​instance == null​​的结果为true,但是实例中的msg为null;假设调用方拿到instance之后直接使用msg则会抛出空指针异常。

volatile+DCL

DCL方式虽然看起来很巧妙的实现了单例模式,但是因为JVM的运行时指令重排序导致单例对象使用过程中的异常。

解决这个问题则需要使用volatile关键字。

public final class Singleton {
    // volatile修饰
    private static volatile Singleton instance = null;
    String msg;
    private Singleton() {
        this.msg = "对象描述信息";
    }
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile关键字可以防止重排序的发生。如果你对volatile的底层原理感兴趣,可以看看我另一篇文章。Java内存模型

静态内部类

使用静态内部类实现单例模式的方式主要是借助于类加载机制完成。

public final class Singleton {
    private Singleton() {
    }
    public static Singleton getInstance() {
        // 实际是返回静态内部类中的实例
        return Holder.instance;
    }

    private static class Holder {
        // 在静态内部类中定义instance并实例化
        private static Singleton instance = new Singleton();
    }
}

这种方式将单例对象的定义放在静态内部类Holder中,如果你对静态内部类的加载机制比较了解那这里你一定很明白了。

静态内部类并不会随着外部类的加载一起加载,只有在使用时才会加载;

而类加载的过程则直接保证了线程安全性,保证实例对象的唯一。

这种方式又被称为IoDH(Initialization Demand Holder)技术,是目前使用比较广的方式之一,也算是最好的一种单例设计模式了。

枚举

最后一种实现单例的方式是枚举。

public enum Singleton {
    INSTANCE;
    String data = "实例数据";

    Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

使用枚举方式实现单例是《Effective Java》中推荐的一种方式。枚举不可以被继承,并且线程安全,只会实例化一次。

打破单例

以上说了这么多单例的实现方式,到底能不能保证实例只被创建一次呢?可不可以被突破呢?

我们学过一种叫做反射的技术,可以来尝试能不能搞搞事情,使用饿汉式这种方式举例。

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

class SingletonTest {
    public static void main(String[] args) throws Exception {
        Singleton instance = Singleton.getInstance();
        System.out.println(instance);
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton = constructor.newInstance();
        System.out.println(singleton);
    }
}

运行结果:

单例模式的N种实现-鸿蒙开发者社区

从结果上看,简单的将构造方法私有化并不能保证实例创建一个,如果使用方反射处理构造方法可以突破这个限制。

如果要防止使用方这样操作,我们可以在构造方法上做点手脚。

public final class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {
        if(Singleton.instance != null){
           throw new IllegalStateException("你不要搞事情");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }
}

如果instance已经被实例化,则抛出异常,可以避免被反射突破单例。如果是懒汉式并不能保证在调用​​getInstance()​​方法之前被创建。

这里我们会发现,不管是懒汉式还是饿汉式,通过私有化构造方法都不能保证构造方法不被外部执行,那么枚举能不能被突破唯一性呢?我们再来试试。

public enum Singleton {
    INSTANCE;
    String data = "实例数据";
    Singleton() {
    }
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

class SingletonTest {
    public static void main(String[] args) throws Exception {
        Singleton instance = Singleton.getInstance();
        System.out.println(instance);
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton = constructor.newInstance();
        System.out.println(singleton);
    }
}

运行结果:

单例模式的N种实现-鸿蒙开发者社区

从运行结果发现,枚举类型虽然从代码上有一个私有的构造方法,但是通过反射执行时并不能拿到。难道枚举没有构造方法?

单例模式的N种实现-鸿蒙开发者社区

小黑通过Debug获取所有构造方法发现,枚举类虽然没有无参的构造方法,但是有一个有参的方法,第一个参数为String,第二个参数为int。我猜这俩个参数应该是枚举对象的name和ordinal属性,我们再来搞个事情看看。

class SingletonTest {
    public static void main(String[] args) throws Exception {
        Singleton instance = Singleton.getInstance();
        System.out.println(instance);
        Constructor<?>[] declaredConstructors = Singleton.class.getDeclaredConstructors();
        Constructor<?> constructor = declaredConstructors[0];
        constructor.setAccessible(true);
        // 按照指定参数创建
        Object singleton = constructor.newInstance("INSTANCE", 1);
        System.out.println(singleton);
    }
}

执行结果:

单例模式的N种实现-鸿蒙开发者社区

我们发现,通过反射获取构造方法,然后按照我们Debug看到的参数进行创建对象时,抛出了异常。

异常描述为 ​​Cannot reflectively create enum objects​​。不能反射地创建枚举对象!!!

原来JDK早就预料到可能会有人这样搞事情来破坏枚举对象的唯一性,在构造方法中进行处理了,想必这应该是《Effective Java》中推荐的一部分原因吧。

总结

单例模式不管是在面试还是在实际的开发过程中会很高频的出现,看似简单,但是要实现一个线程安全,高性能,并且可以不被外部非法破坏,需要考虑的点还是挺多的,小黑通常会通过静态内部类实现IoDH方式或者枚举方式来设计单例。


以上就是本期分享,希望对大家有所帮助。我是小黑,一个在互联网“苟且”的农民工!



文章转载自公众号:小黑说java

分类
已于2022-11-2 10:14:09修改
收藏
回复
举报
回复
    相关推荐