美文网首页
单例模式

单例模式

作者: 云芈山人 | 来源:发表于2021-06-09 16:12 被阅读0次

    单例模式是创建型模式的一种,他提供了创建对象的最佳模式。此模式涉及到一个单一的类,该类负责创建自己的实例,并确保只有单个对象被创建。此类提供了访问其唯一对象的方式,可以直接访问,可以不用实例化该类的对象。
    注意:
    1. 单例类只有一个实例;
    2. 单例类必须自己创建自己的实例类;
    3. 单例类必须给所有其他对象开放(即提供该实例类)。

    如何保证一个类在内存中只能有一个实例呢?
    1. 构造私有;
    2. 使用私有静态成员变量初始化本身对象;
    3. 对外提供静态公共方法获取本身对象。

    介绍

    • 有何目的:保证一个类只有一个实例,并提供一个访问他的全局访问点。
    • 解决什么:一个全局使用的类频繁的创建与销毁。
    • 如何解决:判断系统是否有这个实例,有则返回,无则创建。
    • 何时使用:想控制创建实例数目,节省系统资源。
    • 关键代码:私有的构造函数。
    • 实际应用:例如要求生成唯一序列号,网站访问人数等。
    • 优点:减少内存开销及资源的占用。
    • 缺点:没有接口,不能继承,与单一职责原则冲突(一个类应只关心内部逻辑,不管外部如何实例化)。

    实现

    提供了两种实现模式:饿汉式与懒汉式(延迟加载)。

    一、饿汉式实现

    例如:

    public class Student {
    
        // 2:成员变量初始化本身对象
        //static修饰的变量在new对象时,不存在多线程问题
        private static Student student = new Student();
    
        // 构造私有
        private Student() {}
    
        // 3:对外提供公共方法获取对象
        public static Student getSingletonInstance() {
            return student;
        }
    
        public void sayHello(String name) {
            System.out.println("hello," + name);
        }
    }
    

    饿汉式单例模式是线程安全的。

    static修饰的变量在new对象时,不存在多线程问题。JVM通过类加载器去加载一个类的时候,默认针对该流程是加锁的,也就是线程安全的。类加载的时候,会初始化类的静态成员,其实就是调用clinit()方法。

    如何判断存在线程安全问题?

    1. 是否存在共享数据(存储数据的成员变量);
    2. 是否存在多线程;
    3. 是否是非原子性操作。

    二、懒汉式实现(延迟加载)

    疑问:饿汉式既然是线程安全的,为什么还要用懒汉式实现方式?

    如果存在很多对象,需要单例模式去管理。而有的对象不需要创建,如果都使用饿汉式去创建,就会造成资源的浪费。

    懒汉式有三种方式:

    1. 双重检查锁方式
    2. 静态内部类方式
    3. 枚举方式

    懒汉式实现的思想:需要对象的时候,再去创建对象。

    懒汉式实现的步骤:

    1. 构造私有;
    2. 定义私有静态成员变量,但先不初始化;
    3. 定义公开静态方法,获取本身对象(有对象就返回已有对象,没有对象,再去创建)。
    2.1 静态内部类方式:
    public class Student {
    
        private Student() {}
    
        /*
         * 此处使用一个内部类来维护单例 JVM在类加载的时候,是互斥的,所以可以由此保证线程安全问题
         */
        private static class SingletonFactory {
            private static Student student = new Student();
        }
    
        /* 获取实例 */
        public static Student getSingletonInstance() {
            return SingletonFactory.student;
        }
    
    }
    
    2.2 双重检查锁方式演变
    public class Student {
    
        //1:构造私有
        private Student(){}
        
        //2:定义私有静态成员变量,先不初始化
        private static Student student = null;
        
        //3:定义公开静态方法,获取本身对象
        public static Student getSingletonInstance(){
            //没有对象,再去创建
            if (student == null) {
                student = new Student();
            }
            //有对象就返回已有对象
            return student;
        }
        
    }
    

    这种方式基本满足懒汉式基本要求,但却存在线程安全的问题。如何解决线程安全的问题 ?

    首先想到对getSingletonInstance方法加synchronized关键字。

    如下:

    public class Student {
    
        private Student(){}
        
        private static Student student = null;
        
        // 此处考验对synchronized知识点的掌握情况
        public static synchronized Student getSingletonInstance(){
            if (student == null) {
                student = new Student();
            }
            return student;
        }
        
    }
    

    synchronized关键字作用

    • 确保线程互斥地访问同步代码
    • 保证共享变量的修改能够及时可见
    • 有效解决重排序问题

    synchronized关键字锁住的是这个对象,这样的用法,在性能上会有所下降。因为每次调用getSingletonInstance()方法,都要对对象上锁。事实上,只有在第一次创建对象的时候加锁,之后就不需要了。所以需要在此基础上做出优化。

    将synchronized关键字加在方法体内部
    如下:

    public class Student {
    
        private static Student student = null;
        private Student() {}
        public static Student getSingletonInstance() {
            if (student == null) {
                // 采用这种方式,对于对象的选择会有问题
                // JVM优化机制:先分配内存空间,再初始化
                synchronized (Student.class) {
                    if (student == null) {
                        student = new Student();
                        //student.setName("ss")
                        //new ---- 开辟JVM中堆空间---产生堆内存地址保存到栈内存的student引用中---创建对象
                        // 存在的问题:指令重排
                    }
                }
            }
            return student;
        }
        //student.getName();
    }
    

    这样做似乎解决了之前性能的问题,将synchronized关键字加在了内部,也就是说当调用的时候是不需要加锁的,只有在instance为null,并创建对象的时候才需要加锁,性能有一定的提升。

    但是如果发生以下情况,就会出现问题。

    1. 在Java指令中创建对象和赋值操作是分开进行的,也就是说instance = new Singleton();语句是分两步执行的。

      JVM中的对象创建过程是什么流程?

      Student student = new Student();// 高级语言
      a)new关键字会触发Student类的类加载(如果已加载,则此步骤作废)
      b)根据Class对象中的信息,去开辟相应大小的内存空间
      c)初始化Student对象,就是完成成员变量的初始化操作(到这一步,我们才能说该对象是可用的)
      d)将开辟出来的内存空间地址,赋值给栈空间的变量student

      以上步骤,其实都是通过字节码指令去完成的(物理机器直接操作的都是CPU指令(原子性其实是相对我们CPU指令来说的))

      指令重排序(JIT即时编译器优化)
      有序性:代码执行时有序的。
      如果两行代码交换不会影响最终的执行结果,那么JIT即时编译器就会根据情况去进行指令重排序。

      可见如果程序之间没有依赖性,指令就可能发生重排(happend-before先行发生原则(六大原则))。

    2. JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的Singleton实例分配空间,然后直接赋值给instance成员,然后再去初始化这个Singleton实例(分析创建对象的步骤中,c和d这两个步骤,有没有依赖性呢?答案是它们两者之间没有依赖性,那么就有可能发生指令重排序。也就是说有可能先执行d再执行c)。

    3. 这样就可能出错了,我们以A、B两个线程为例:

      a)A、B线程同时进入了第一个if判断;
      b)A首先进入synchronized块,由于instance为null,所以它执行instance = new Singleton();
      c)由于JVM内部的优化机制(指令重排序),JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员(注意此时JVM没有开始初始化这个实例),然后A离开了synchronized块。
      d)B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序。
      e)此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。

    所以程序还是有可能发生错误,其实程序在运行过程是很复杂的,从这点我们就可以看出,尤其是在写多线程环境下的程序更有难度,有挑战性。我们对该程序做进一步优化。

    双重检查加锁
    如下:

    public class Student{
    
        private volatile static Student student;
    
        private Student() {
        }
    
        public static Student getSingletonInstance() {
            if (student == null) {
                synchronized (Student.class) {
                    if (student == null) {
                        student = new Student();
                    }
                }
            }
            return student;
        }
    
    }
    

    volatile关键字的作用:

    • 禁止指令重排序
    • 禁止使用CPU缓存

    可见性

    • 在CPU单核时代,线程1和线程2使用的是同一个CPU缓存,所以线程之间的数据是可见的。
    • 在CPU多核时代,线程1在A核,线程2在B核,每个核都有自己的CPU缓存空间,如果线程1产生的数据缓存没有同步到线程2对应的CPU缓存,则会出现可见性问题。

    CPU、内存、磁盘的处理速度不同。为了提供CPU使用率,硬件提供了在内存与CPU之间的高速缓存,即CPU缓存。

    相关文章

      网友评论

          本文标题:单例模式

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