单例模式

时间:2021-6-12 作者:qvyue

单例模式是创建型模式的一种,他提供了创建对象的最佳模式。此模式涉及到一个单一的类,该类负责创建自己的实例,并确保只有单个对象被创建。此类提供了访问其唯一对象的方式,可以直接访问,可以不用实例化该类的对象。
注意:
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缓存。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。