【重回基础】设计模式:你真的懂单例模式吗?

目录

一、前言

单例模式是我们大部分人接触的第一个设计模式,因此网上的分享泛滥,良莠不齐,甚至出现不少错误。因为比较简单,所以没有自行研究学习,只是从网上的分享中吸收。前不久与同事的交流中发现与自己的认知有些出入,因此回顾了一下,也便有了此文。

学习任何东西都离不开 what、why、how 的疑问:

什么是单例模式?

即确保某个类只创建一个实例,访问者只能通过该实例访问该类。

为什么需要单例模式?

个人理解主要有三点:

  1. 业务需要,比如,应用中的配置类,主窗口句柄等,为了保证全局只有一个实例,防止引起业务异常。
  2. 便于资源统一管理,比如连接池、日志输出等。
  3. 避免重复创建,节省重复性的内存开辟实例化的消耗。

如何实现单例模式?

单例的实现简单,整体上主要包括以下三个步骤:

  1. 私有化构造,防止外部创建。
  2. 在内部创建实例。
  3. 提供访问实例的静态公有方法。

具体的实现上,网上许多分享都提供了七八种的方式,分为线程安全和非安全版等。个人认为,实际上归根结底就只有四种:饿汉,懒汉,静态内部类,枚举。因为静态代码块和静态变量的实现方式都属于饿汉;DCL 和 同步方法都属于懒汉。知道各自原理、特点、注意点,再根据需求进行调整即可。

后文会对具体实现方式展开介绍,这里先总结各自特点:

序号实现方式懒加载天然防止反射破坏天然防止反序列化破坏
1饿汉
2懒汉
3静态内部类
4枚举类

为何第四、五列防止单例破坏要加上天然二字,这是因为经过第四章介绍的处理方法,各种单例的实现方式都能够做到防止反射/反序列化破坏。因此加上天然二字,反映第三章介绍的原始实现方式的防止破坏情况。

二、相关知识

1. 类加载流程

先回顾一下 JVM 类加载的流程,主要包括加载、连接、初始化三个阶段。非本篇重点,因此简单回顾一下:

在这里插入图片描述

加载阶段,主要就是将二进制字节流通过类加载器,加载进JVM,并按照所需格式存储。

验证流程,主要就是出于JVM安全考虑,检查二进制字节流是否符合JVM规范。

准备流程,主要就是为静态变量开辟内存,并赋予默认初始值,比如 private static Date d = new Date() ,在本流程里,只是开辟了引用 d ,并将其赋值为 null,而非执行构造方法。若是静态常量,则赋予初始化后的值。

解析流程,主要就是将常量池符号引用替换为直接应用。

初始化阶段,主要就是执行构造方法赋予初始值,需要注意的是仅对静态变量。

2. 序列化、序列化攻击

不再赘述,可见: 【重回基础】序列化、序列化攻击与序列化代理

三、实现方式

1. 饿汉

之所以名为饿汉,故名思义就是很“饥渴”,在类加载时,就急不可耐将引用的对象实例化,因此,后续无需担心对象开辟/多线程等问题,但不具备懒加载特点。

实现十分简单,如下,也可以通过静态块执行初始化。

public class HungrySingle {
  	// 注意点1:用final修饰
    private static final HungrySingle instance = new HungrySingle();
    private HungrySingle() {}
    public static HungrySingle getInstance() {
        return instance;
    }
}

需要注意的是引用要采用final修饰,为了防止指令重排影响安全发布。因为private static final HungrySingle instance = new HungrySingle();看着是一行代码,实际上分为三个步骤,且不具备原子性。步骤分为:

  1. 首先,为实例开辟内存空间。
  2. 其次,调用构造方法初始化对象,存储于步骤1开辟的内存。
  3. 最后,将 instance 引用指向该内存空间。

而虚拟机和CPU为了执行效率,在不同的维度上都对指令进行了重排,getInstance()可能发生于构造完成之前(即步骤2前),也可能发生于构造完成但步骤3未完成。而final修饰的变量具备可见性,并且防止指令重排影响安全发布,能够保证对于该变量的读写发生于以上三个步骤完成之后。

下面为反射及序列化测试,验证是否可以通过反射/序列化手段创建新的实例,破坏单例:

 public static void main(String[] args) throws Exception {
        LazySingle ori = getInstance();
        // 反射测试
        LazySingle refeInst = LazySingle.class.newInstance();
        System.out.println(refeInst);
        System.out.println(ori);
        System.out.println(refeInst == ori); // 结果为 false
        // 序列化测试
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(ori);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        LazySingle serializeInst = (LazySingle) objectInputStream.readObject();
        System.out.println(serializeInst);
        System.out.println(ori);
        System.out.println(serializeInst == ori); // 结果为 false        
 }

可以看到,饿汉的实现方式并不具备防止单例破坏的能力,但是我们可以做额外的处理,具体方式见后文。

2. 懒汉

之所以名为懒汉,相比饿汉,比较懒,有拖延症,在类加载时只是定义了一个空引用,并不开辟内存进行实例化,一直拖到调用时才进行实例化,因此具备懒加载特点。

实现如下:

public class LazySingle {
    private LazySingle() {}
    // 注意点1: 要用volatile修饰
    private static volatile LazySingle instance = null;
    public static LazySingle getInstance() {
        // 注意点2: DCL, double check lock
        if (instance == null) {
            synchronized (LazySingle.class) {
                if (instance == null) {
                    instance = new LazySingle();
                }
            }
        }
        return instance;
    }
}

其中需要注意的是:

前文有言,引用对象的实例化实际上分为三个步骤,并且不具备原子性:

  1. 首先,为实例开辟内存空间。
  2. 其次,调用构造方法初始化对象,存储于步骤1开辟的内存。
  3. 最后,将 instance 引用指向该内存空间。

虚拟机和CPU为了执行效率,在不同的维度上都对指令进行了重排,所以步骤2、3的执行顺序具有不确定性:可能先将引用指向内存空间再调用构造方法;也可能先完成构造后,再将引用指向该内存空间。

因此,需要采用volatitle修饰变量,在该变量读写时插入相应的内存屏障,防止指令重排影响读写顺序。而这里volatitlefianl的主要区别在于引用指向的修改。

在同步快2外面增加空判断对效率起到很大作用,因为,只有在对象未实例化时才需要进入同步块执行初始化,之后该同步块更多时候十分影响效率,因此,外面的空判断可以防止进入无用的同步等待,提高效率。

下面为反射及序列化测试,验证是否可以通过反射/序列化手段创建新的实例,破坏单例:

    public static void main(String[] args) throws Exception {
        HungrySingle ori = getInstance();
        // 反射测试
        HungrySingle refeInst = HungrySingle.class.newInstance();
        System.out.println(refeInst);
        System.out.println(ori);
        System.out.println(refeInst == ori); // 结果为 false
        // 序列化测试
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(ori);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        HungrySingle serializeInst = (HungrySingle) objectInputStream.readObject();
        System.out.println(serializeInst);
        System.out.println(ori);
        System.out.println(serializeInst == ori); // 结果为 false
 }

可见,懒汉的实现方式同样并不具备防止单例破坏的能力,需要我们做额外的处理,具体方式见后文。

3. 静态内部类

静态内部类的单例就是在类的内部提供了一个静态内部类,内部类采用饿汉的方式定义声明实例。由于JVM对于内部类不会立即加载,而是等到使用时才进行加载,因此静态内部类的单例实现方式具备懒加载的特点。

实现如下:

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

下面为反射及序列化测试,验证是否可以通过反射/序列化手段创建新的实例,破坏单例:

public static void main(String[] args) throws Exception {
        InnerSingle ori = getInstance();
        // 反射测试
        InnerSingle refeInst = InnerSingle.class.newInstance();
        System.out.println(ori);
        System.out.println(refeInst);
        System.out.println(refeInst == ori); // 结果为 false
        // 序列化测试
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(ori);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        InnerSingle serializeInst = (InnerSingle) objectInputStream.readObject();
        System.out.println(serializeInst);
        System.out.println(ori);
        System.out.println(serializeInst == ori); // 结果为 false
 }

可见,静态内部类同样无法防止单例破坏,需要做额外的处理,具体方式见后文。

4. 枚举

从JDK1.5起,新增了枚举类型,它其实就是通过公有的静态final域为每个枚举常量导出实例的类,没有任何可以访问的构造方法,因此当它加载进JVM,它就是一个真正的final。当然,也正因此其不具备懒加载的特点。

实现如下,看着异常简单,与C++没有什么区别,其实不然,Java的枚举类型十分强大且安全,可以根据枚举的特点做很多事,这也是为何《Effective Java》一直推荐优先用枚举实现的原因。

public enum EnumSingle {
    INSTANCE;
    EnumSingle() { }
    // 事实上枚举很强大,这里可以做很多事情
}

下面为反射及序列化测试,验证是否可以通过反射/序列化手段创建新的实例,破坏单例:

public static void main(String[] args) throws Exception {
        EnumSingle ori = EnumSingle.INSTANCE;
        // 反射测试
        EnumSingle refeInst = EnumSingle.class.newInstance(); // 这里会抛出InstantiationException异常
        System.out.println(ori);
        System.out.println(refeInst);
        System.out.println(refeInst == ori);
        // 序列化测试
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(ori);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        EnumSingle serializeInst = (EnumSingle) objectInputStream.readObject();
        System.out.println(serializeInst);
        System.out.println(ori);
        System.out.println(serializeInst == ori); // 结果为 true
 }

从以上实验可以看出,枚举无法通过反射访问构造方法,而反序列化出来的实例与原来同一个实例,因此,天然具备防止通过反射和序列化破坏单例的机制。

四、防止单例破坏处理方法

1. 反射

我们都知道,通过反射可以访问到私有的构造方法,进而创建出新的实例。因此,要防止通过反射机制破坏单例,我们只需要在构造方法检测是否有实例,若有实例则抛出异常,禁止创建新的实例。

// 构造方法
private InnerSingle() {
		Assert.check(Inner.inst == null);
}

2. 反序列化

之前笔者写了篇相关文章【重回基础】序列化、序列化攻击与序列化代理 。其中有说到,反序列化机制就像是一个隐形的构造器,因此,可以通过这个隐形的构造器创建出新的实例,进而破坏单例。我们可以通过提供 readResolve 方法,用原有的实例代替反序列化出来的实例返回。

// 提供 readResolve 方法,并用原有实例代替反序列化出来的实例返回
public Object readResolve() {
		return Inner.inst;
}

参考

  1. 《Effective Java》 Joshua Bloch
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值