type
status
date
slug
summary
tags
category
password
1、单例模式是什么
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来访问该实例。
单例模式的适用场景:
- 需要频繁创建和获取的对象,例如 Spring 容器的各种 Bean。
- 创建对象耗时或资源消耗大,例如 Servlet 的
ServletContext
。
- 工具类对象,例如 Jackson 的
ObjectMapper
。
- 频繁访问数据库或文件的对象,例如 Druid 的
DruidDataSource
。
- 需要控制共享资源访问的场景,例如 Spring 容器的
ApplicationContext
。
2、单例模式的实现
2.1 懒汉式(非线程安全)
这种是最简单的单例写法,特点:
- 构造方法使用
private
关键字,确保外部只能通过getInstance()
获取单例。
- 实例对象使用
static
关键字,确保全局只有一个实例。
- 线程不安全:并发时可能出现多个单例。
其中最大的问题就是线程不安全,后面的多种方案都是为了解决线程不安全的问题。
2.2 饿汉式(线程安全)
相比起非线程安全的懒汉式写法,最大区别就是在类初始化的时候提前创建好实例。
特点:
- 线程安全:因为提前创建了,所以是天生的线程安全。
- 性能影响:在类加载的时候就会初始化,这对应用的启动会造成一定程度的影响。
2.3 懒汉式(使用synchronized)
相比起非线程安全的懒汉式写法,最大区别就是
getInstance()
方法使用 synchronized
修饰。特点:
- 线程安全:因为使用了
synchronized
修饰getIntance()
方法,可以保证获取实例一定是线程安全。
- 性能较差:每次获取实例之前都需要先获取锁,导致性能较差。
2.4 懒汉式(Double Check)
单例模式最经典的双重检查(Double Check)写法,相比起使用
synchronized
修饰 getInstance()
的懒汉模式,最大区别就是- 使用
volatile
修饰实例(为什么要用volatile
后面会解释)。
synchronized
不再用来修饰getInstance()
方法,而是用来修饰Singleton
类,并增加一段双重判断的逻辑。
特点:
- 线程安全。
- 性能较高:只在第一次创建实例的时候需要获取锁,后续在获取实例的时候可以直接返回,不需要获取锁。
2.5 静态内部类(推荐)
这种方式巧妙地解决了饿汉式(线程安全)的内存浪费问题和 synchronized 的性能问题。推荐使用这种方式。
- 避免内存浪费:内部类在方法被调用之前才会初始化,所以不会造成内存浪费和影响程序启动速度。
- 线程安全:内部类里面的实例其实就是饿汉式(线程安全)的写法,所以可以保证线程安全。
2.6 枚举实现(官方推荐)
《Effective Java》推荐枚举的方式,特点:
- 线程安全:枚举类型默认就是安全的
- 避免反序列化破坏单例
但是枚举类型会造成更多的内存消耗。枚举会比使用静态变量多消耗两倍的内存,如果是 Android 应用,尽量避免这种方式。
3、单例模式常见问题
3.1 为什么单例模式中的Double Check要加volatile
volatile
的作用是禁止指令重排序。在 java 里面 new 一个对象在 JVM 层面并不是一个原子操作,大概分为以下三条指令:- 申请一块内存,此时成员变量赋默认值。
- 调用类的构造方法,给成员变量赋初始值。
- 将这块内存区域赋值给对应的变量。
JIT 编译器在进行代码优化的时候,有可能会进行指令重排序,把第2,3步骤调换。从 JVM 的角度来说这种调换并不会影响最终实例的创建,但是在这段 Double Check 代码里面,在高并发的情况下有可能会发生问题:
- 线程 A 在调用
new Singleton()
的时候,刚执行第二步(把内存区域赋值给INSTANCE
变量,此时内存里面的成员变量都是默认值),还没到第三步(调用类的构造方法,给成员变量赋初始值)的时候,线程 B 也调用了getInstance
方法。
- 线程 B 判断
INSTANCE == null
,由于此时INSTANCE
不为空,所以直接返回了INSTANCE
对象,但是此时INSTANCE
才仅是半初始化状态(成员变量都是默认值),线程 B 拿到的其实是一个不完整的INSTANCE
对象,这种情况下是会出问题的。
使用
volatile
来修饰 INSTANCE
对象 ,就可以避免指令重排序的问题。但是在实际的生产中,上述的情况发生的其实很少,只有在极高的并发量的时候才会偶尔出现。如果程序并发量不高,不使用 volatile
关键字一般也不会出现问题。
3.2 为什么静态内部类是线程安全的
简单来说,JVM虚拟机会保证一个类的类构造器
<clinit>()
在多线程环境中被正确的加锁和同步,使得静态内部类在被加载和初始化时是绝对线程安全的。以下面这个类为例:
getInstance()
被调用时发生的初始化过程:- 首次调用
getInstance()
:当线程 A 第一次调用Singleton.getInstance()
时,JVM 会开始加载并初始化Holder
这个静态内部类。
- 触发初始化:根据 Java 规范,
Holder.INSTANCE
是一个静态字段,它的使用(return Holder.INSTANCE
)触发了Holder
类的初始化条件。
- 获取初始化锁:JVM 会为
Holder
类获取一个唯一的初始化锁。假设此时线程 B 也来调用getInstance()
,它同样会遇到return Holder.INSTANCE
,并试图初始化Holder
类。但由于线程 A 已经持有了Holder
类的初始化锁,线程 B 会被阻塞 (Block),进入等待状态。
- 执行初始化 (
<clinit>()
):在线程 A 持有锁期间,JVM 执行Holder
类的<clinit>()
方法。这个方法是由编译器自动生成的,包含了所有静态字段的赋值操作。在这里,就是执行private static final Singleton INSTANCE = new Singleton();
。
- 完成初始化并释放锁:当
INSTANCE
被成功创建并赋值后,Holder
类的初始化完成。线程 A 释放初始化锁。
- 其他线程继续:此时,线程 B(以及其他可能被阻塞的线程)获取到锁。它们会发现
Holder
类已经被初始化完毕,于是直接返回已经被初始化好的INSTANCE
实例,而不会再次执行初始化过程。
3.3 枚举类实现单例模式的原理
枚举实现单例模式的原理,核心在于 Java 枚举类型的本质和 JVM 的底层保障。
- 枚举是特殊的类
- 在 Java 中,使用
enum
关键字定义的枚举类型,在编译后其实是一个继承了java.lang.Enum
的final
类。 - 例如下面的
Singleton
枚举编译后大致相当于下面的类结构(这是一个概念上的类比,实际实现更复杂): - 从这个“等价代码”可以看出,枚举项 (
INSTANCE
) 本质上就是一个public static final
的类实例。
- JVM 对枚举的保障(关键!):JVM 在加载枚举类时,会像处理静态常量一样,在类的初始化阶段(
<clinit>
方法)创建所有的枚举实例。这个过程是线程安全的,因为 JVM 会保证一个类只被加载一次,并且类的初始化过程是同步的。这从根本上防止了通过反射创建多个实例的可能性(在类加载之后,实例已经创建好了)。
- 序列化与反序列化的安全:这是枚举单例相对于“双重检查锁”或“静态内部类”等方式最大的优势。
- 普通单例类需要实现
Serializable
接口,并且必须额外提供readResolve()
方法来防止反序列化时创建新的对象,破坏单例。 - Java 规范中明确规定,枚举类型的序列化和反序列化是有特殊处理的。序列化时只会保存枚举的
name
属性;反序列化时,会通过Enum.valueOf()
方法根据这个name
去查找已经存在的枚举实例,而不会新建一个对象。这保证了反序列化后得到的仍然是同一个实例。
- 防止反射攻击:
- 普通的单例模式(即使是饿汉式或静态内部类),其构造方法是私有的,但仍然可以通过反射机制
setAccessible(true)
来强制调用私有构造器,从而创建第二个实例。 - Java 的
Constructor
类中明确禁止使用反射来创建枚举实例。如果你尝试这样做,会直接抛出IllegalArgumentException
异常。这是 Java 语言层面的保障,使得枚举单例对反射攻击免疫。
- Author:mcbilla
- URL:http://mcbilla.com/article/1e285c7d-7c1d-8067-a34e-cee64e718428
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts