【设计模式】创建型——单例模式

112 阅读12分钟

一、定义

单例模式是一种创建型设计模式,有以下特点:   

  1. 单例类只能有一个实例。
  2. 单例类必须自己创建自己的唯一实例。   
  3. 单例类必须给所有其他对象提供这一实例。

二、实现方式

(一)饿汉式

饿汉式在类创建的同时就已经创建好一个静态的对象供系统使用,以后不再改变,所以天生是线程安全的。

这种方式比较常用,但容易产生垃圾对象。

优点: 没有加锁,执行效率会提高。

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

//饿汉式单例类.在类初始化时,已经自行实例化 
public class Singleton {
    //私有化构造函数
    private Singleton() {}
    private static final Singleton1 single = new Singleton();
    //静态工厂方法 
    public static Singleton1 getInstance() {
        return single;
    }
}

(二)懒汉式

顾名思义就是实例顾名思义就是实例在用到的时候才去创建

public class Singleton {
​
    private static Singleton instance;
​
    // 构造器私有,其他类就无法通过new Singleton() 来创建对象实例了
    private Singleton() { }
    // 获取实例的方法
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
​
}

Singleton通过将构造方法限定为private避免了类在外部被实例化,在同一个虚拟机范围内,Singleton的唯一实例只能通过getInstance()方法访问。(可以通过反射机制获取,暂时忽略这个问题)

但是以上懒汉式单例的实现没有考虑线程安全问题,它是线程不安全的,并发环境下很可能出现多个Singleton实例,要实现线程安全,有以下三种方式,都是对getInstance这个方法改造,保证了懒汉式单例的线程安全

1. 加同步锁

public class Singleton {
​
    private static Singleton instance;
​
    private Singleton() {
    }
    //加入synchronized
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
​
}

这种方式具备很好的 lazy loading,能够在多线程中很好的工作,但是,效率很低,99% 情况下不需要同步。

  • 优点:第一次调用才初始化,避免内存浪费。
  • 缺点:必须加锁 synchronized 才能保证单例,但加锁会影响效率。getInstance() 的性能对应用程序不是很关键(该方法使用不太频繁)。

2.双重检查锁定

public class Singleton {
    /*
        volatile 修饰,
        singleton = new Singleton() 可以拆解为3步:
        1、分配对象内存(给singleton分配内存)
        2、调用构造器方法,执行初始化(调用 Singleton 的构造函数来初始化成员变量)。
        3、将对象引用赋值给变量(执行完这步 singleton 就为非 null 了)。
        若发生重排序,假设 A 线程执行了 1 和 3 ,还没有执行 2,B 线程来到判断 NULL,B 线程就会直接返回还没初始化的 instance 了。
​
        volatile 可以避免重排序。
     */
    private volatile static Singleton singleton;
​
    private Singleton() {
    }
​
    public static Singleton getSingleton() {
        //减少性能开销。
        if (singleton == null) {
            synchronized (Singleton.class) {
                //避免生成多个对象实例
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
​
}

这种方式采用双锁机制,安全且在多线程情况下能保持高性能。getInstance() 的性能对应用程序很关键。

3.静态内部类

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

静态内部类的方式效果类似双检锁,但实现更简单。但这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。

这个实现思路中最主要的一点就是利用类中静态变量的唯一性

(三)枚举

这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法。

它更简洁,自动支持序列化机制,绝对防止多次实例化。

这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。不过,由于 JDK1.5 之后才加入 enum 特性,用这种方式写不免让人感觉生疏,在实际工作中,也很少用。

public enum  Singleton {
​
    INSTANCE;
​
    public void whateverMethod() {
        System.out.println("哈哈");
    }
​
}

这种方式的原理是什么呢?趁这个机会在这里好好梳理一下枚举的概念。

枚举是 JDK5 中提供的一种语法糖,所谓语法糖就是在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是但是更方便程序员使用。只是在编译器上做了手脚,却没有提供对应的指令集来处理它。

其实 Enum 就是一个普通的类,它继承自 java.lang.Enum 类,这个可以通过反编译枚举类的字节码来理解。

使用 javac Singleton.java 得到字节码文件 Singleton.class 使用 javap Singleton.class 反解析字节码文件可以得到下面的内容:

public final class Singleton extends java.lang.Enum<Singleton> {

    public static final Singleton INSTANCE;

    public static Singleton[] values();

    public static Singleton valueOf(java.lang.String);

    public void whateverMethod();

    static {};

}

image.png

枚举其实底层是依赖Enum类实现的,这个类的成员变量都是 static 类型的,并且在静态代码块中实例化的,和饿汉有点像, 所以他天然是线程安全的。

—— zhuanlan.zhihu.com/p/140479178

javap 是 jdk 自带的反解析工具。它的作用就是根据 class 字节码文件,反解析出当前类对应的 code 区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。

由反编译后的代码可知,INSTANCE 被声明为 static 的,虚拟机会保证一个类的 <clinit>()方法在多线程环境中被正确的加锁、同步。所以,枚举实现在实例化时是线程安全。

另外 Java 规范中规定,每一个枚举类型及其定义的枚举变量在 JVM 中都是唯一的,因此在枚举类型的序列化和反序列化上,Java 做了特殊的规定。在序列化的时候 Java 仅仅是将枚举对象的 name 属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象,因此反序列化后的实例也会和之前被序列化的对象实例相同。

三、问题

问题1: 为什么构造函数要使用 private

构造器私有,其他类就无法通过 new Singleton() 来创建对象实例

问题2:双重校验锁—为什么使用 volatile 和两次判空校验

  • 为什么要进行两次非空校验?

    • 第一个 if 判断是为了减少性能开销。
    • 第二个 if 判断是为了避免生成多个对象实例。
  • 为什么要用 volatile 关键字?

    • 为了禁止 JVM 的指令重排,指令重排会导致对象未初始化的情况,造成报错。

问题3:单例模式中唯一实例为什么要用静态?

因为 getInstance() 是静态方法,而静态方法不能访问非静态成员变量,所以 instance 必须是静态成员变量

为什么 getInstance() 是静态方法?

因为构造器是私有的,程序调用类中方法只有两种方式, ① 创建类的一个对象,用该对象去调用类中方法; ② 使用类名直接调用类中方法,格式“类名.方法名()”;

Singleton instance = Singleton.getInstance();

构造函数私有化后第一种情况就不能用,只能使用第二种方法。

为什么要私有化构造器呢?

目的是禁止其他程序创建该类的对象。如果构造函数不是私有的,每个人都可以通过 new Singleton() 创建类的实例,因此不再是单例。根据定义,对于一个单例,只能存在一个实例。

问题4:单例模式中成员变量为什么一定要是私有的private

image.png

运行结果为null;上面可以看做是一个单例模式,下面是调用该类并将单例的成员变量改成null。

万一有程序员这么做了,后面的程序员再用这个类时就是空,所以为了安全不要这么写

问题5:为什么静态内部类写法中,静态类里面获取单例对象要用 final 修饰

用 final 更多的意义在于提供语法约束。毕竟你是单例,就只有这一个实例,不可能再指向另一个实例。instance有了 final 的约束,后面再有人不小心编写了修改其指向的代码就会报语法错误。

这就好比 @Override 注解,你能保证写对方法名和参数,那不写注解也没问题,但是有了注解的约束,编译器就会帮你检查,还能防止别人乱改—— 公众号《Java课代表》作者

问题6:单例饿汉式为什么没有线程安全性问题?

在 getInstance() 获取实例的方法中,没有对资源进行非原子性操作,instance 在类加载过程中就实例化了

我们知道,出现线程的安全性问题要满足下面三个条件:

  • 多线程环境下
  • 多个线程共享一个资源
  • 对资源进行非原子性操作

而对于单例饿汉式不满足第三个条件

类加载过程的线程安全性保证

饿汉、静态内部类、枚举均是通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。

这其实是利用了ClassLoader 的线程安全机制。ClassLoaderloadClass 方法在加载类的时候使用了synchronized 关键字。

所以, 除非被重写,这个方法默认在整个装载过程中都是线程安全的。所以在类加载过程中对象的创建也是线程安全的。

枚举其实底层是依赖 Enum 类实现的,这个类的成员变量都是 static 类型的,并且在静态代码块中实例化的,和饿汉有点像, 所以他天然是线程安全的,所以,枚举其实也是借助了synchronized的

问题7:怎么不使用 synchronized 和 lock 实现一个线程安全的单例吗?

以上实现主要用到了两点来保证单例,一是JVM的类加载机制,另一个就是加锁了。那么有没有不加锁的线程安全的单例实现吗?

答:CAS实现单例

什么是 CAS?

CAS 是一项乐观锁技术,当多个线程尝试使用 CAS 同时更新一个变量时,只有其中一个线程能更新成功,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

代码实现

CAS 实现单例:

public class Singleton {
​
    // AtomicReference 提供了可以原子的读写对象引用的一种机制
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
​
    // 私有化构造器
    private Singleton() {
    }
​
    // 获取实例的 getInstance() 方法
    public static Singleton getInstance() {
        for(;;) {
            // 从 INSTANCE中 获取实例
            Singleton singleton = INSTANCE.get();
            // 如果实例不为空就返回
            if (null != singleton) {
                return singleton;
            }
            // 实例为空就创建实例
            singleton = new Singleton();
            // compareAndSet() 主要的作用是通过比对两个对象,然后更新为新的对象
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
​
}
使用 CAS 实现的单例有没有什么优缺点呀?

优点:

用 CAS 的好处在于不需要使用传统的锁机制来保证线程安全,CAS 是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

缺点:

CAS的一个重要缺点在于如果忙等待一直执行不成功(一直在死循环中),会对 CPU 造成较大的执行开销。

另外,代码中,如果 N 个线程同时执行到 singleton = new Singleton(); 的时候,会有大量对象被创建,可能导致内存溢出。

问题8:静态内部类与双重校验锁的区别?

静态内部类使用静态关键字去保证我们实例是单例的。

而我们的双重校验锁采用 lock 锁保证安全的。

问题9:为什么静态内部类写法中,静态类里面获取单例对象要用 final 修饰

用 final 更多的意义在于提供语法约束。毕竟你是单例,就只有这一个实例,不可能再指向另一个实例。instance有了 final 的约束,后面再有人不小心编写了修改其指向的代码就会报语法错误。

这就好比 @Override 注解,你能保证写对方法名和参数,那不写注解也没问题,但是有了注解的约束,编译器就会帮你检查,还能防止别人乱改—— 公众号《Java课代表》作者

四、破坏单例模式的方式

yuque_diagram

(二)防止反射破坏

public class Singleton {

    // 静态内部类
    private static class SingletonHolder {
        private static final  Singleton INSTANCE = new Singleton();
    }

    // 私有的构造方法
    private Singleton() {
        // 防止反射创建多个对象
        if(SingletonHolder.INSTANCE != null){
            throw new RuntimeException("不允许创建多个实例");
        }
    }

    // 公有的获取实例方法
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

}

(四)防止序列化破坏单例模式

public class Singleton implements Serializable {

    private static final long serialVersionUID = -4264591697494981165L;

    // 静态内部类
    private static class SingletonHolder {
        private static final  Singleton INSTANCE = new Singleton();
    }

    // 私有的构造方法
    private Singleton() {
        // 防止反射创建多个对象
        if(SingletonHolder.INSTANCE != null){
            throw new RuntimeException("不允许创建多个实例");
        }
    }

    // 公有的获取实例方法
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

    // 防止序列化创建多个对象,这个方法是关键
    private Object readResolve(){
        return SingletonHolder.INSTANCE;
    }

}

应用

java.lang.Runtime就是经典的单例模式(饿汉式)

总结

我们来总结下

  • 一般情况下,懒汉式(包含线程安全和线程不安全梁总方式)都比较少用;
  • 饿汉式和双检锁都可以使用,可根据具体情况自主选择;
  • 在要明确实现 lazy loading 效果时,可以考虑静态内部类的实现方式;
  • 若涉及到反序列化创建对象时,大家也可以尝试使用枚举方式。