美文网首页
设计模式 - 单例模式

设计模式 - 单例模式

作者: 41uLove | 来源:发表于2019-02-15 15:58 被阅读0次

    在项目开发时有一些对象其实我们只需要一个,比如:线程池、缓存、日志对象等等。这类对象只能有一个实例,如果制造出多个实例,就会导致许多问题产生,例如:程序的行为异常,资源使用过量,或者是不一致的结果。

    虽然程序员之间的约定以及全局变量也可以办得到,但是单例模式确实是经得起时间考验的更好的做法。单例模式和全局变量一样方便的给我们提供了一个全局的访问点,但是也解决了全局变量必须在程序一开始就要创建好对象的缺点。单例模式可以灵活的决定对象什么时候创建。

    结构定义

    单例模式

    单例模式: 保证一个类仅有一个实例,并且提供一个访问它的全局访问点。

    通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。一个最好的办法就是让类自身负责保存它的唯一实例。这个类可以保证没有其它实例可以被创建,并且它可以提供一个访问该实例的方法。[DP]

    单例模式的写法(7种)

    单例模式的思路

    1. 利用一个静态变量INSTANCE来记录类的唯一实例
    2. 把构造器声明为私有的,只有在类本身才能调用构造器
    3. getInstance()方法实例化对象,并返回这个类的实例

    分析:
    利用静态变量来保存类的实例确保该实例为类的唯一实例,如果实例为空,则表示还没有创建实例,而如果不存在我们就利用私有的构造器产生一个该类实例并把它赋值到静态变量中,如果我们不需要这个实例,它就永远不会产生。这个就是延迟实例化

    • 懒汉模式(线程不安全)
    public class Singleton {
        private static Singleton INSTANCE;
    
        private Singleton() {
        }
    
        public static Singleton getInstance() {
            if (INSTANCE == null) {
                INSTANCE = new Singleton();
            }
            return INSTANCE;
        }
    }
    

    这段代码简单明了,而且使用了懒加载模式,但是却存在致命的问题。当有多个线程并行调用 getInstance() 的时候,就会创建多个实例。也就是说在多线程下不能正常工作。

    • 懒汉模式(线程安全)
      解决懒汉模式线程安全问题,最简单的方法是将整个 getInstance() 方法设为同步synchronized
    public class Singleton {
        private static Singleton INSTANCE;
    
        private Singleton() {
        }
    
        public static synchronized Singleton getInstance() {
            if (INSTANCE == null) {
                INSTANCE = new Singleton();
            }
            return INSTANCE;
        }
    }
    

    虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效。因为在任何时候只能有一个线程调用 getInstance()方法。但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。这就引出了双重检验锁。

    • 双重校验锁 *
      双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。
     public static Singleton getInstance() {
            if (INSTANCE == null) {  // 一重
                synchronized (Singleton.class) {
                    if (INSTANCE == null) { // 二重
                        INSTANCE = new Singleton();
                    }
                }
            }
            return INSTANCE;
        }
    

    这段代码会有一个隐藏问题,主要是在INSTANCE = new Singleton()这句涉及到了JVM编译器的指令重排,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

    1. 给 instance 分配内存
    2. 调用 Singleton 的构造函数来初始化成员变量
    3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
      但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
      我们只需要将 instance 变量声明成 volatile 就可以了。
    public class Singleton {
        private static volatile Singleton INSTANCE;
    
        private Singleton() {
        }
        /**
         * 双重校验锁
         */
        public static Singleton getInstance() {
            if (INSTANCE == null) {
                synchronized (Singleton.class) {
                    if (INSTANCE == null) {
                        INSTANCE = new Singleton();
                    }
                }
            }
            return INSTANCE;
        }
    }
    

    关于volatile修饰符用最简单的方式理解就是阻止了变量访问前后的指令重排,保证了指令执行顺序。

    • 饿汉模式
    public class Singleton {  
        private static final Singleton instance = new Singleton();  
        private Singleton (){}  
        public static Singleton getInstance() {  
            return instance;  
        }  
    }  
    

    这种方法非常简单,因为单例的实例被声明成 staticfinal 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。

    这种方式基于classloder机制避免了多线程的同步问题,不过,instance在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用getInstance方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化instance显然没有达到lazy loading的效果。

    • 饿汉模式(变种)
    private static Singleton instance;
    
        static {
            instance = new Singleton();
        }
    
        public static Singleton getInstance() {
            return instance;
        }
    

    这种写法本质上和上一种写法没什么区别。

    • 静态内部类
     private Singleton() {
        }
    
        private static class SingletonHolder {
            private static final Singleton INSTANCE = new Singleton();
        }
    
        public static Singleton getInstance() {
            return SingletonHolder.INSTANCE;
        }
    

    注意:

    1. 从外部无法访问静态内部类SingletonHolder,只有当调用Singleton.getInstance方法的时候,才能得到单例对象INSTANCE。
    2. INSTANCE对象初始化的时机并不是在单例类Singleton被加载的时候,而是在调用getInstance方法,使得静态内部类SingletonHolder被加载的时候。因此这种实现方式是利用classloader的加载机制来实现懒加载,并保证构建单例的线程安全。
    3. 无法防止利用反射重复构建对象
    • 枚举高效写法
      在《Effective Java》最后推荐了这样一个写法,简直有点颠覆,不仅超级简单,而且保证了线程安全。这里引用一下,此方法无偿提供了序列化机制,绝对防止多次实例化,及时面对复杂的序列化或者反射攻击。单元素枚举类型已经成为实现Singleton的最佳方法。
    public enum Singleton {
        /**
         *
         */
        INSTANCE;
    
        /**
         *
         */
        public void hello() {
            System.out.println("Hello World");
        }
    }
    

    对于一个标准的enum单例模式,最优秀的写法还是实现接口的形式:

    public enum Singleton implements MySingleton {
        /**
         *
         */
        INSTANCE {
            @Override
            public void hello() {
                System.out.println("Hello world");
            }
        }
    }
    
    interface MySingleton {
        /**
         * xx
         */
        void hello();
    }
    

    使用枚举实现单例模式不仅防止了反射构建对象也保证了线程安全,但是同时它并不是懒加载,在枚举类加载的同时,其单例对象就已经被初始化。

    总结

    单例模式写法总结起来可以分为五种懒汉恶汉双重校验锁枚举静态内部类,上述所说都是线程安全的实现,第一种应该说是不正确的实现。
    对于这几种的比较

    单例模式 是否线程安全 是否懒加载 是否防止反射构建
    双重校验锁
    枚举
    静态内部类

    补充

    1. volatile关键字不但可以防止指令重排,也可以保证线程访问的变量值是主内存中的最新值。有关volatile的详细原理,我在以后的漫画中会专门讲解。
    2. 使用枚举实现的单例模式,不但可以防止利用反射强行构建单例对象,而且可以在枚举类对象被反序列化的时候,保证反序列的返回结果是同一对象。
      对于其他方式实现的单例模式,如果既想要做到可序列化,又想要反序列化为同一对象,则必须实现readResolve方法。
    3. 应该在任何情况下都应实现线程安全的写法。

    相关文章

      网友评论

          本文标题:设计模式 - 单例模式

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