美文网首页
单例模式

单例模式

作者: 慌张而黑糖 | 来源:发表于2020-01-06 14:58 被阅读0次

    最近看了极客时间徐老师讲的单例模式,感觉这个设计模式确实涉及的知识点挺多的,所以想写一篇文章来加深自己的理解。
    首先,了解一下什么是单例模式

    单例模式,从字面意思也能大致了解它的意思。下面是我从geeksforgeeks找到的定义。
    The singleton pattern is a design pattern that restricts the instantiation of a class to one object.
    单例模式的限制就是一个类只能实例化一个对象。

    单例模式的五种实现方式

    一、饿汉式

    首先来看看饿汉式的实现代码

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

    代码很简单,现在来解释一下这段代码。
    为什么叫这种实现方式为饿汉式呢?因为它在类加载的时候,就实例化了,就像一个没吃饱饭的人一样,有东西就赶紧吃了。

    • 对于单例模式我们要考虑它是否真正的保证了只有一个实例。
      对于上面的实现方式我们是如何保证的呢?我们使用static让类在加载的时候就实例化一个对象,为了不让其他程序实例化该对象,我们将该类的构造函数声明为私有的。
    • 对于单例模式我们也要考虑在多线程中是不是也能保证它只有一个实例
      饿汉式很显然是一个线程安全的,因为它在类加载的时候就将对象实例化好了,所以在线程中直接调用就可以了。

    二、懒汉式

    对于饿汉式来说,它没有通过懒加载来实现对象的实例化的,也就是说不管我们将来用户用的到,在程序启动的时候它就帮我们实例化好了,这就可能造成内存的浪费。而懒汉式则解决了这一问题,让我们看看懒汉式的实现代码。

    public class LazySingleton {
        private static LazySingleton lazySingleton;
    
        private LazySingleton(){}
    
        public static LazySingleton getInstance(){
            if (lazySingleton == null){
                lazySingleton = new LazySingleton();
            }
            return lazySingleton;
        }
    }
    

    从这段代码可以看出,当我们调用getInstance方法时才会实例化对象,起到了懒加载的效果。
    该方法的缺点就是在单线程的时候是没有问题的,但是在多线程的时候就可能实例化的不是一个对象了。
    下面我们来分析一下是为什么会出现这种情况。
    假设我们有两个线程A和B在调用getInstance方法,它们两个调用该方法的时间间隔很短,A先进入if判断,但是在A还没有实例化单例对象的时候B开始进行if判断,结果它发现单例对象为空,也进入了if判断,这时,就会实例化两个对象,违背了单例模式的约束。下面用一张图加深理解。


    image.png

    当我们想到线程安全的时候,就会想起加锁。看下面的实现方式:

    public class LazySingleton {
        private static LazySingleton lazySingleton;
    
        private LazySingleton(){}
        //线程不安全
       //    public static LazySingleton getInstance(){
      //        if (lazySingleton == null){
     //            lazySingleton = new LazySingleton();
    //        }
    //        return lazySingleton;
    //    }
       //线程安全
        public static synchronized LazySingleton getInstance(){
            if (lazySingleton == null){
                lazySingleton = new LazySingleton();
            }
            return lazySingleton;
        }    
    }
    

    这里我们在getInstance方法上加了synchronized关键字,这里虽然是线程安全的了,但是getInstance()方法的执行效率却变得很低,我们的初衷是为了第一次实例化时保证只实例化一个对象,而之后再调用该方法都是直接返回该对象了,但是这种方法无论在什么时候都会加锁,其他线程先要调用该方法只能等待。所以我们需要改进该方法。

    public class LazySingleton {
        private static LazySingleton lazySingleton;
    
        private LazySingleton(){}
        public static LazySingleton getInstance(){
            if (lazySingleton == null){
                synchronized(LazySingleton.class){
                    lazySingleton = new LazySingleton();
                }
            }
            return lazySingleton;
        }
    }
    

    把锁加到实例化对象上,这样当lazySingleton不为空时直接返回该实例了,就不会在加锁了,提高了执行效率,但是这种方式却不是线程安全的。会出现我们一开始提到的问题,只不过这里是加了个锁。


    image.png

    这个图和上面的图的区别就是,线程B只有在A实例化完对象后,它才能实例化。
    所以,下面将引出我们的双重检查模式。在介绍这种模式之前我先介绍一个概念:

    竞态条件:由于不恰当的执行时序而出现不正确的结果。
    它的本质就是基于一种可能失效的观察结果来做出判断或者执行某个计算。
    上面出现的错误就是因为我们认为lazySingleton为null而引起的错误,这里简单提一下,详细解释在java并发编程实战有讲。

    三、双重检查式

    先看代码如何实现:

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

    这里,我们在对象声明上加了volatile关键字,在加锁的代码块中又加了一重if判断。
    在上文中我提到了线程B只有在线程A实例化对象之后才能进行实例化,现在,当线程B进入加锁的代码块后会再次进行判断对象为不为空,因为在线程A中已经创建好对象了,线程B就不会进入第二重if语句。那volatile的作用是什么呢?
    因为new一个对象不是原子操作,这里就会出现一些不可控的情况,实例化对象大致分为三步。
    1.为lazySingleton分配内存空间
    2.初始化lazySingleton
    3.将lazySingleton指向分配的内存空间(lazySingleton不为null)
    这是我们理想中的执行顺序,但是在实际执行的过程中它可能是按照1-3-2的顺序执行,这样就会出现一种情况:


    image.png

    一旦将lazySingleton指向分配的内存空间,它就不为null了,但是没有初始化,其他线程调用就会报错。
    所以加volatile关键字,防止重排序问题。

    四、静态内部类式

    这种比较好理解,直接上代码:

    public class StaticNestedClass {
        private StaticNestedClass(){}
        private static class SingletonInstance{
            private static final StaticNestedClass singleton = new StaticNestedClass();
        }
        public static StaticNestedClass getInstance(){
            return SingletonInstance.singleton;
        }
    }
    

    但是这里还是要说一点,因为静态内部类,只有当外部类调用它时,内部类才会被加载,所以这里也实现了懒加载,所以和上面的双重检查式的效果是一样的。

    最后一种我感觉是最难理解的,而且也是effective java的作者最推荐的方式

    枚举式

    先看代码如何实现:

    public enum EnumSingleton {
        INSTANCE;
        public void method(){
            System.out.println("调用单例的方法");
        }
    }
    

    可以看出来,枚举的代码很简单,而且它保证了只有一个实例。看下面的代码:

    public static void main(String[] args) {
            EnumSingleton singleton = EnumSingleton.INSTANCE;
            EnumSingleton singleton1 = EnumSingleton.INSTANCE;
            System.out.println(singleton==singleton1);
            singleton.method();
        }
    

    输出结果:

    true
    调用单例的方法

    所以通过枚举实例化的对象能够保证只有一个实例。
    但是它又是怎么保证是懒加载的呢?
    我通过jad将Enum类进行反编译,如下所示:

    import java.io.PrintStream;
    
    public final class EnumSingleton extends Enum
    {
    
        public static EnumSingleton[] values()
        {
            return (EnumSingleton[])$VALUES.clone();
        }
    
        public static EnumSingleton valueOf(String name)
        {
            return (EnumSingleton)Enum.valueOf(singleton/EnumSingleton, name);
        }
    
        private EnumSingleton(String s, int i)
        {
            super(s, i);
        }
    
        public void method()
        {
            System.out.println("\u8C03\u7528\u5355\u4F8B\u7684\u65B9\u6CD5");
        }
    
        public static final EnumSingleton INSTANCE;
        private static final EnumSingleton $VALUES[];
    
        static 
        {
            INSTANCE = new EnumSingleton("INSTANCE", 0);
            $VALUES = (new EnumSingleton[] {
                INSTANCE
            });
        }
    }
    

    转换成这样难道就可以实现懒加载了吗?
    我们可以做一个测试:

    public class A{
        public static void main(String[] args) {
            System.out.println("测试");
            B.INSTANCE.method();
            B.INSTANCE.method();
        }
    }
    class B{
        public static final B INSTANCE;
        static {
            INSTANCE = new B();
            System.out.println("initialize B");
        }
        public void method(){
            System.out.println("hello");
        }
    }
    

    这是一个精简版的Enum,但是模拟了它最核心的功能,通过它的输出结果可以看出,当我们调用的时候,该实例才初始化,并且只初始化一次。

    测试
    initialize B
    hello
    hello

    这里为什么说,枚举式的方法比较好呢?
    因为它能够防止通过反射和反序列化生成新的对象而破坏单例模式。

    • 反射检验
      对双重检查模式进行测试,结果发现两个实例是不同的,所以能够通过反射破坏单例。
     public static void main(String[] args) {
            LazySingleton lazySingleton1 = LazySingleton.getInstance();
            try {
                LazySingleton lazySingleton2 = LazySingleton.class.newInstance();
                System.out.println(lazySingleton1 == lazySingleton2);
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    

    对Enum进行测试:

    public static void main(String[] args) throws IllegalAccessException, InstantiationException {
            EnumSingleton singleton1 = EnumSingleton.INSTANCE;
            EnumSingleton singleton2 = EnumSingleton.class.newInstance();
            System.out.println(singleton1==singleton2);
        }
    

    该测试用例会报:

    Exception in thread "main" java.lang.InstantiationException: singleton.EnumSingleton
    at java.base/java.lang.Class.newInstance(Class.java:571)
    at singleton.SingletonDemo.main(SingletonDemo.java:8)
    Caused by: java.lang.NoSuchMethodException: singleton.EnumSingleton.<init>()
    at java.base/java.lang.Class.getConstructor0(Class.java:3349)
    at java.base/java.lang.Class.newInstance(Class.java:556)
    ... 1 more
    这个是newInstance()方法在实例化对象之前做的一个校验,所以有效防止了反射对单例的破坏。

    • 反序列化检验
      测试用例如下:
     public static void main(String[] args) throws IOException, ClassNotFoundException {
            LazySingleton lazySingleton1 = LazySingleton.getInstance();
            FileOutputStream fileOutputStream = new FileOutputStream("lazy_singleton.out");
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(lazySingleton1);
            FileInputStream fileInputStream = new FileInputStream("lazy_singleton.out");
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            LazySingleton lazySingleton2 = (LazySingleton) objectInputStream.readObject();
            System.out.println(lazySingleton1 == lazySingleton2);
        }
    

    这里需要注意,LazySingleton实现了Serializable接口,不然不能序列化。最终发现两个实例不是同一个,所以也破坏了单例模式。

    检测枚举式:

     public static void main(String[] args) throws IOException, ClassNotFoundException {
            EnumSingleton enumSingleton1 = EnumSingleton.INSTANCE;
            FileOutputStream fileOutputStream = new FileOutputStream("lazy_singleton.out");
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(enumSingleton1);
            FileInputStream fileInputStream = new FileInputStream("lazy_singleton.out");
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            EnumSingleton enumSingleton2 = (EnumSingleton) objectInputStream.readObject();
            System.out.println(enumSingleton1==enumSingleton2);
        }
    

    这里会打印一个true,所以仍然保持了单例。
    这是因为java对Enum进行特殊规定,Enum类在进行序列化的时候仅仅是将name属性输出到结果中,在反序列化的是通过Enum的valueOf()方法获取实例,所以保证了只有一个实例的限制。

    相关文章

      网友评论

          本文标题:单例模式

          本文链接:https://www.haomeiwen.com/subject/jtofactx.html