zoukankan      html  css  js  c++  java
  • 单例模式,真不简单

    一、前言

    单例模式无论在我们面试,还是日常工作中,都会面对的问题。但很多单例模式的细节,值得我们深入探索一下。
    这篇文章透过单例模式,串联了多方面基础知识,非常值得一读。

    1、什么是单例模式?

    单例模式是一种非常常用的软件设计模式,它定义是 单例对象的类只能允许一个实例存在

    该类负责创建自己的对象,同时确保只有一个对象被创建。一般常用在工具类的实现或创建对象需要消耗资源的业务场景。

    单例模式的特点:

    • 类构造器私有
    • 持有自己类的引用
    • 对外提供获取实例的静态方法

    我们先用一个简单示例了解一下单例模式的用法。

    public class SimpleSingleton {
        //持有自己类的引用
        private static final SimpleSingleton INSTANCE = new SimpleSingleton();
    
        //私有的构造方法
        private SimpleSingleton() {
        }
        //对外提供获取实例的静态方法
        public static SimpleSingleton getInstance() {
            return INSTANCE;
        }
        
        public static void main(String[] args) {
            System.out.println(SimpleSingleton.getInstance().hashCode());
            System.out.println(SimpleSingleton.getInstance().hashCode());
        }
    }
    

    打印结果:

    1639705018
    1639705018
    

    我们看到两次获取SimpleSingleton实例的hashCode是一样的,说明两次调用获取到的是同一个对象。

    可能很多朋友平时工作当中都是这么用的,但我要说这段代码是有问题的,你会相信吗?

    不信,我们一起往下看。

    二、饿汉和懒汉模式

    在介绍单例模式的时候,必须要先介绍它的两种非常著名的实现方式:饿汉模式懒汉模式

    1、饿汉模式

    实例在初始化的时候就已经建好了,不管你有没有用到,先建好了再说。具体代码如下:

    public class SimpleSingleton {
        //持有自己类的引用
        private static final SimpleSingleton INSTANCE = new SimpleSingleton();
    
        //私有的构造方法
        private SimpleSingleton() {
        }
        //对外提供获取实例的静态方法
        public static SimpleSingleton getInstance() {
            return INSTANCE;
        }
    }
    

    饿汉模式,其实还有一个变种:

    public class SimpleSingleton {
        //持有自己类的引用
        private static final SimpleSingleton INSTANCE;
        static {
           INSTANCE = new SimpleSingleton();
        }
    
        //私有的构造方法
        private SimpleSingleton() {
        }
        //对外提供获取实例的静态方法
        public static SimpleSingleton getInstance() {
            return INSTANCE;
        }
    }
    

    使用静态代码块的方式实例化INSTANCE对象。

    使用饿汉模式的好处是:没有线程安全的问题,但带来的坏处也很明显。

    一开始就实例化对象了,如果实例化过程非常耗时,并且最后这个对象没有被使用,不是白白造成资源浪费吗?

    这个时候你也许会想到,不用提前实例化对象,在真正使用的时候再实例化不就可以了?

    这就是我接下来要介绍的:懒汉模式

    2、懒汉模式

    顾名思义就是实例在用到的时候才去创建,“比较懒”,用的时候才去检查有没有实例,如果有则返回,没有则新建。具体代码如下:

    public class SimpleSingleton2 {
    
        private static SimpleSingleton2 INSTANCE;
    
        private SimpleSingleton2() {
        }
    
        public static SimpleSingleton2 getInstance() {
            if (INSTANCE == null) {
                INSTANCE = new SimpleSingleton2();
            }
            return INSTANCE;
        }
    }
    

    示例中的INSTANCE对象一开始是空的,在调用getInstance方法才会真正实例化。
    嗯,不错不错。但这段代码还是有问题。

    3、synchronized关键字

    上面的代码有什么问题?

    答:假如有多个线程中都调用了getInstance方法,那么都走到 if (INSTANCE == null) 判断时,可能同时成立,因为INSTANCE初始化时默认值是null。这样会导致多个线程中同时创建INSTANCE对象,即INSTANCE对象被创建了多次,违背了只创建一个INSTANCE对象的初衷。

    那么,要如何改进呢?

    答:最简单的办法就是使用synchronized关键字。

    改进后的代码如下:

    public class SimpleSingleton3 {
        private static SimpleSingleton3 INSTANCE;
    
        private SimpleSingleton3() {
        }
    
        public synchronized static SimpleSingleton3 getInstance() {
            if (INSTANCE == null) {
                INSTANCE = new SimpleSingleton3();
            }
            return INSTANCE;
        }
        public static void main(String[] args) {
            System.out.println(SimpleSingleton3.getInstance().hashCode());
            System.out.println(SimpleSingleton3.getInstance().hashCode());
        }
    }
    

    在getInstance方法上加synchronized关键字,保证在并发的情况下,只有一个线程能创建INSTANCE对象的实例。这样总可以了吧?

    答:不好意思,还是有问题。

    有什么问题?

    答:使用synchronized关键字会消耗getInstance方法的性能,我们应该判断当INSTANCE为空时才加锁,如果不为空不应该加锁,需要直接返回。
    这就需要使用下面要说的双重检查锁了。

    4、饿汉和懒汉模式的区别

    but,在介绍双重检查锁之前,先插播一个朋友们可能比较关心的话题:饿汉模式 和 懒汉模式 各有什么优缺点?

    饿汉模式:优点是没有线程安全的问题,缺点是浪费内存空间。
    懒汉模式:优点是没有内存空间浪费的问题,缺点是如果控制不好,实际上不是单例的。
    

    好了,下面可以安心的看看双重检查锁,是如何保证性能的,同时又保证单例的。


    三、双重检查锁

    双重检查锁顾名思义会检查两次:在加锁之前检查一次是否为空,加锁之后再检查一次是否为空。

    那么,它是如何实现单例的呢?

    1、如何实现单例?

    具体代码如下:

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

    在加锁之前判断是否为空,可以确保INSTANCE不为空的情况下,不用加锁,可以直接返回。为什么在加锁之后,还需要判断INSTANCE是否为空呢?

    答:是为了防止在多线程并发的情况下,只会实例化一个对象。

    比如:线程a和线程b同时调用getInstance方法,假如同时判断INSTANCE都为空,这时会同时进行抢锁。
    假如线程a先抢到锁,开始执行synchronized关键字包含的代码,此时线程b处于等待状态。
    线程a创建完新实例了,释放锁了,此时线程b拿到锁,进入synchronized关键字包含的代码,如果没有再判断一次INSTANCE是否为空,则可能会重复创建实例。
    所以需要在synchronized前后两次判断。

    不要以为这样就完了,还有问题呢?

    2、volatile关键字

    上面的代码还有啥问题?

    public static SimpleSingleton4 getInstance() {
          if (INSTANCE == null) {//1
              synchronized (SimpleSingleton4.class) {//2
                  if (INSTANCE == null) {//3
                      INSTANCE = new SimpleSingleton4();//4
                  }
              }
          }
          return INSTANCE;//5
      }
    

    getInstance方法的这段代码,我是按1、2、3、4、5这种顺序写的,希望也按这个顺序执行。

    但是java虚拟机实际上会做一些优化,对一些代码指令进行重排。重排之后的顺序可能就变成了:1、3、2、4、5,这样在多线程的情况下同样会创建多次实例。重排之后的代码可能如下:

    public static SimpleSingleton4 getInstance() {
        if (INSTANCE == null) {//1
           if (INSTANCE == null) {//3
               synchronized (SimpleSingleton4.class) {//2
                    INSTANCE = new SimpleSingleton4();//4
                }
            }
        }
        return INSTANCE;//5
    }
    

    原来如此,那有什么办法可以解决呢?

    答:可以在定义INSTANCE是加上volatile关键字。具体代码如下:

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

    volatile关键字可以保证多个线程的可见性,但是不能保证原子性。同时它也能禁止指令重排。

    双重检查锁的机制既保证了线程安全,又比直接上锁提高了执行效率,还节省了内存空间。

    除了上面的单例模式之外,还有没有其他的单例模式?


    四、静态内部类

    静态内部类顾名思义是通过静态的内部类来实现单例模式的。那么,它是如何实现单例的呢?

    1、如何实现单例模式?

    如何实现单例模式?

    public class SimpleSingleton5 {
    
        private SimpleSingleton5() {
        }
    
        public static SimpleSingleton5 getInstance() {
            return Inner.INSTANCE;
        }
    
        private static class Inner {
            private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
        }
    }
    

    我们看到在SimpleSingleton5类中定义了一个静态的内部类Inner。在SimpleSingleton5类的getInstance方法中,返回的是内部类Inner的实例INSTANCE对象。

    只有在程序第一次调用getInstance方法时,虚拟机才加载Inner并实例化INSTANCE对象。

    java内部机制保证了,只有一个线程可以获得对象锁,其他的线程必须等待,保证对象的唯一性。

    2、反射漏洞

    上面的代码看似完美,但还是有漏洞。如果其他人使用反射,依然能够通过类的无参构造方式创建对象。例如:

    Class<SimpleSingleton5> simpleSingleton5Class = SimpleSingleton5.class;
    try {
        SimpleSingleton5 newInstance = simpleSingleton5Class.newInstance();
        System.out.println(newInstance == SimpleSingleton5.getInstance());
    } catch (InstantiationException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    

    上面代码打印结果是false。

    由此看出,通过反射创建的对象,跟通过getInstance方法获取的对象,并非同一个对象,也就是说,这个漏洞会导致SimpleSingleton5非单例。

    那么,要如何防止这个漏洞呢?

    答:这就需要在无参构造方式中判断,如果非空,则抛出异常了。

    改造后的代码如下:

    public class SimpleSingleton5 {
    
        private SimpleSingleton5() {
            if(Inner.INSTANCE != null) {
               throw new RuntimeException("不能支持重复实例化");
           }
        }
    
        public static SimpleSingleton5 getInstance() {
            return Inner.INSTANCE;
        }
    
        private static class Inner {
            private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
            }
        }
    
    }
    

    如果此时,你认为这种静态内部类,实现单例模式的方法,已经完美了。

    那么,我要告诉你的是,你错了,还有漏洞。。。

    3、反序列化漏洞

    众所周知,java中的类通过实现Serializable接口,可以实现序列化。

    我们可以把类的对象先保存到内存,或者某个文件当中。后面在某个时刻,再恢复成原始对象。

    具体代码如下:

    public class SimpleSingleton5 implements Serializable {
    
        private SimpleSingleton5() {
            if (Inner.INSTANCE != null) {
                throw new RuntimeException("不能支持重复实例化");
            }
        }
    
        public static SimpleSingleton5 getInstance() {
            return Inner.INSTANCE;
        }
    
        private static class Inner {
            private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
        }
    
        private static void writeFile() {
            FileOutputStream fos = null;
            ObjectOutputStream oos = null;
            try {
                SimpleSingleton5 simpleSingleton5 = SimpleSingleton5.getInstance();
                fos = new FileOutputStream(new File("test.txt"));
                oos = new ObjectOutputStream(fos);
                oos.writeObject(simpleSingleton5);
                System.out.println(simpleSingleton5.hashCode());
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (oos != null) {
                    try {
                        oos.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (fos != null) {
                    try {
                        fos.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
    
            }
        }
    
        private static void readFile() {
            FileInputStream fis = null;
            ObjectInputStream ois = null;
            try {
                fis = new FileInputStream(new File("test.txt"));
                ois = new ObjectInputStream(fis);
                SimpleSingleton5 myObject = (SimpleSingleton5) ois.readObject();
    
                System.out.println(myObject.hashCode());
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            } finally {
                if (ois != null) {
                    try {
                        ois.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (fis != null) {
                    try {
                        fis.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            writeFile();
            readFile();
        }
    }
    

    运行之后,发现序列化和反序列化后对象的hashCode不一样:

    189568618
    793589513
    

    说明,反序列化时创建了一个新对象,打破了单例模式对象唯一性的要求。那么,如何解决这个问题呢?

    答:重新readResolve方法。

    在上面的实例中,增加如下代码:

    private Object readResolve() throws ObjectStreamException {
        return Inner.INSTANCE;
    }
    

    运行结果如下:

    290658609
    290658609
    

    我们看到序列化和反序列化实例对象的hashCode相同了。

    做法很简单,只需要在readResolve方法中,每次都返回唯一的Inner.INSTANCE对象即可。程序在反序列化获取对象时,会去寻找readResolve()方法。
    如果该方法不存在,则直接返回新对象。如果该方法存在,则按该方法的内容返回对象。如果我们之前没有实例化单例对象,则会返回null。

    好了,到这来终于把坑都踩完了。
    还是费了不少劲。
    不过,我偷偷告诉你一句,其实还有更简单的方法,哈哈哈。

    纳尼。。。

    五、枚举

    其实在java中枚举就是天然的单例,每一个实例只有一个对象,这是java底层内部机制保证的。

    简单的用法:

    public enum  SimpleSingleton7 {
        INSTANCE;
        
        public void doSamething() {
            System.out.println("doSamething");
        }
    } 
    

    在调用的地方:

    public class SimpleSingleton7Test {
    
        public static void main(String[] args) {
            SimpleSingleton7.INSTANCE.doSamething();
        }
    }
    

    在枚举中实例对象INSTANCE是唯一的,所以它是天然的单例模式。

    当然,在枚举对象唯一性的这个特性,还能创建其他的单例对象,例如:

    public enum  SimpleSingleton7 {
        INSTANCE;
        
        private Student instance;
        
        SimpleSingleton7() {
           instance = new Student();
        }
        
        public Student getInstance() {
           return instance;
        }
    }
    
    class Student {
    }
    

    jvm保证了枚举是天然的单例,并且不存在线程安全问题,此外,还支持序列化。

    在java大神Joshua Bloch的经典书籍《Effective Java》中说过:

    单元素的枚举类型已经成为实现Singleton的最佳方法。
    

    参考

    1、公众号 苏三说技术 的一篇文章 非常感谢。


  • 相关阅读:
    jquery 序列化form表单
    nginx for windows 安装
    nodejs idea 创建项目 (一)
    spring 配置 shiro rememberMe
    idea 2018 解决 双击shift 弹出 search everywhere 搜索框的方法
    redis 在windows 集群
    spring IOC控制反转和DI依赖注入
    redis 的安装
    shiro 通过jdbc连接数据库
    handlebars的用法
  • 原文地址:https://www.cnblogs.com/qdhxhz/p/15501300.html
Copyright © 2011-2022 走看看