1. 简介
单例模式是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
所谓类的单例设计模式,就是采取一定的方法保证在整个的软件系统中,对某个类只能存在一个对象实例,并且该类只提供一个取得其对象实例的方法(静态方法)。
单例模式具备典型的3个特点:
- 单例类只能有一个实例(构造器私有)
- 它必须自行创建这个实例(自己编写实例化逻辑)
- 它必须自行向整个系统提供这个实例(对外提供实例化方法)
2. 优缺点
优点:
- 在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例
- 避免对资源的多重占用
缺点:
- 单例模式没有抽象层,扩展困难
- 单例类违背了单一职责原则,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化
3. 使用场景
- 需要频繁实例化与销毁的对象
- 有状态的工具类对象
- 创建对象时耗时过多或者耗资源过多,但又经常用到的对象,比如I/O与数据库的连接等
- 唯一序列号,计数器等,不用每次刷新都在数据库里加一次,用单例先缓存起来
- 一些设备管理器,比如有两台打印机,不能打印同一个文件,可以设计为单例模式
- 如果需要更加严格地控制全局变量, 可以设计为单例模式
4. 分类
单例模式有八种方式:
- 饿汉式(静态常量)
- 饿汉式(静态代码块)
- 懒汉式(线程不安全)
- 懒汉式(线程安全,同步方法)
- 懒汉式(线程安全,同步代码块)
- 双重检查
- 静态内部类
- 枚举
饿汉式:类加载就会导致该单实例对象被创建 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建
5. 饿汉式(静态常量)
5.1 代码
步骤如下:
- 构造器私有化 (防止 new )
- 类的内部创建对象
- 向外暴露一个静态的公共方法。
public class Singleton {
// 1、构造器私有化
private Singleton() {
}
// 2、类的内部创建对象
private static final Singleton instance = new Singleton();
// 3、向外暴露一个静态的公共方法
public static Singleton getInstance() {
return instance;
}
}
5.2 优缺点
优点:这种写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题
缺点:在类装载的时候就完成实例化,没有达到 Lazy Loading 的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费
这种方式基于 classloder 机制避免了多线程的同步问题。不过,instance 在类装载时就实例化,在单例模式中大多数都是调用getlnstance 方法,但是导致类装载的原因有很多种,因此不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 就没有达到 Lazy loading 的效果。
6. 饿汉式(静态代码块)
6.1 代码
步骤如下:
- 构造器私有化
- 类的内部声明对象
- 在静态代码块中创建对象
- 向外暴露一个静态的公共方法
public class Singleton {
// 1、构造器私有化
private Singleton() {
}
// 2、类的内部声明对象
private static Singleton instance;
// 3、在静态代码块中创建对象
static {
instance = new Singleton();
}
// 4、向外暴露一个静态的公共方法
public static Singleton getInstance() {
return instance;
}
}
这种方式和上面的方式其实类似,只不过将类实例化的过程放在了静态代码块中,也是在类装载的时候,就执行静态代码块中的代码,初始化类的实例。
6.2 优缺点
优缺点和上面是一样
7. 懒汉式(线程不安全)
7.1 代码
步骤如下:
- 构造器私有化
- 类的内部创建对象
- 向外暴露一个静态的公共方法,当使用到该方法时,才去创建 instance
// 1、构造器私有化
private Singleton() {
}
// 2、类的内部声明对象
private static Singleton instance;
// 3、向外暴露一个静态的公共方法,当使用到该方法时,才去创建 instance
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
7.2 优缺点
- 起到了 Lazy Loading 的效果,但是只能在单线程下使用
- 如果在多线程下,一个线程进入了判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例
在实际开发中,不要使用这种方式
8. 懒汉式(线程安全,同步方法)
8.1 代码
步骤如下:
- 构造器私有化
- 类的内部创建对象
- 向外暴露一个静态的公共方法,加入同步处理的代码,解决线程安全问题
public class Singleton {
// 1、构造器私有化
private Singleton() {
}
// 2、类的内部声明对象
private static Singleton instance;
// 3、向外暴露一个静态的公共方法,加入同步处理的代码,解决线程安全问题
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
8.2 优缺点
- 解决了线程不安全问题
- 效率低,每个线程在想获得类的实例时候,执行getlnstance()方法都要进行同步。而其实这个方法只执行一次实例化代码就够了,后面的想获得该类实例,直接return就行了。方法进行同步效率太低
在实际开发中,不推荐使用这种方式
9. 懒汉式(线程不安全,同步代码块)
9.1 代码
步骤如下:
- 构造器私有化
- 类的内部创建对象
- 向外暴露一个静态的公共方法,加入同步处理的代码块
public class Singleton {
// 1、构造器私有化
private Singleton() {
}
// 2、类的内部声明对象
private static Singleton instance;
// 3、向外暴露一个静态的公共方法,加入同步处理的代码,解决线程安全问题
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
}
9.2 优缺点
- 这种方式,本意是想对上一种实现方式的改进,因为前面同步方法效率太低,改为同步产生实例化的的代码块
- 但是这种同步并不能起到线程同步的作用。跟第7节实现方式遇到的情形一致,假如一个线程进入了判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例
在实际开发中,不能使用这种方式
10. 双重检查
10.1 线程不安全
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (instance) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
我们看到有两个判空的逻辑,第二个判断逻辑就是为了解决上面写法,在等待synchronized锁的过程中有可能上一个线程已经创建了实例,从而导致创建多个实例的问题。
但是此种写法依然不是线程安全的,因为创建对象不是一个原子性操作。JVM创建新的对象instance = new Singleton()时,主要要经过三步(省略其他非主要步骤):
- 分配内存
- 初始化构造器
- 将对象指向分配的内存的地址
而实际情况是,JVM会对以上三个指令进行调优,其中有一项就是调整指令的执行顺序,该操作由JIT编译器来完成。所以,在指令被重排序的情况下可能会出现问题。
假如2和3的步骤是相反的,先将分配好的内存地址指给instance,然后再进行初始化构造器,这时候后面的线程去请求getInstance方法时,会认为instance对象已经实例化了,直接返回一个引用。如果这时还没进行构造器初始化并且这个线程使用了instance的话,则会出现线程会指向一个未初始化构造器的对象现象,从而发生错误。
我们以A、B两个线程为例:
- A、B线程同时进入了第一个if判断
- A首先进入synchronized块,由于instance为null,所以它执行instance = new Singleton(),由于JVM内部的优化机制,JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员,注意此时JVM没有开始初始化这个实例,然后A离开了synchronized块
- B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序
- 此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了
针对这种情况,我们可以使用volatile关键字,禁止指令重排来实现线程安全。所以有了下面这种线程安全的双重检验锁模式。
10.2 volatile修饰的双重检验锁模式
步骤如下:
- 构造器私有化
- 类的内部创建对象,同时用volatile关键字修饰
- 向外暴露一个静态的公共方法,加入同步处理的代码块,并进行双重判断,解决线程安全问题
public class Singleton {
// 1、构造器私有化
private Singleton() {
}
// 2、类的内部声明对象,同时用`volatile`关键字修饰修饰
private static volatile Singleton instance;
// 3、向外暴露一个静态的公共方法,加入同步处理的代码块,并进行双重判断,解决线程安全问题
public static Singleton getInstance() {
//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实际
if (instance == null) {
synchronized (Singleton.class) {
//抢到锁之后再次判断是否为空
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
添加 volatile 关键字之后的双重检查锁模式是一种比较好的单例实现模式,能够保证在多线程的情况下线程安全也不会有性能问题。
需要注意,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。所以,在jdk1.5版本前,双重检测锁形式的单例模式是无法保证线程安全的,即使将变量声明为volatile也无法完全避免重排序所导致的问题。
10.3 优缺点
- Double-Check 概念是多线程开发中常使用到的,我们进行了两次检查,这样就可以保证线程安全了
- 这样实例化代码只用执行一次,后面再次访问时直接 return 实例化对象,也避免的反复进行方法同步
- 线程安全;延迟加载;效率较高
在实际开发中,推荐使用这种单例设计模式
11. 静态内部类
11.1 代码
步骤如下:
- 构造器私有化
- 定义一个静态内部类,内部定义当前类的静态属性
- 向外暴露一个静态的公共方法
public class Singleton {
// 1、构造器私有化
private Singleton() {
}
// 2、定义一个静态内部类,内部定义当前类的静态属性
private static class SingletonInstance {
private static final Singleton instance = new Singleton();
}
// 3、向外暴露一个静态的公共方法
public static Singleton getInstance() {
return SingletonInstance.instance;
}
}
因为一个类的静态属性只会在第一次加载类时初始化,这是JVM帮我们保证的,所以我们无需担心并发访问的问题。另外由于静态变量只初始化一次,所以singleton仍然是单例的。
11.2 优缺点
- 这种方式采用了类装载的机制,来保证初始化实例时只有一个线程
- 静态内部类方式在 Singleton 类被装载时并不会立即实例化,而是在需要实例化时,调用getlnstance方法,才会装载Singletonlnstance 类,从而完成 Singleton 的实例化
- 类的静态属性只会在第一次加载类的时候初始化,JVM帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的
- 优点:避免了线程不安全,利用静态内部类特点实现延迟加载,效率高
推荐使用
11.3 与双重检测锁模式比较
这种方式能达到双检锁方式双重检测锁模式一样的功能,但实现更简单。对静态域使用延迟初始化,应使用静态内部类模式。而对实例域需要延迟初始化时,使用双重检测锁模式。
11.4 与饿汉式比较
这种方式同样利用了classloader机制来保证初始化instance时只有一个线程。饿汉模式只要 Singleton类被装载了,那么instance就会被实例化,没有达到延迟加载的目的,而这种方式Singleton类被装载了,instance不一定被初始化。只有SingletonFactory通过显式调用 getInstance方法时,才会显式装载SingletonFactory类,从而实例化 instance。
静态内部类模式看起来已经非常完美了,但是它存在两个缺点:反射攻击和序列化攻击。
12. 枚举
12.1 代码
public enum Singleton {
INSTANCE;
public void sayHello() {
System.out.println("Hello World");
}
}
枚举的这种写法更简洁,自动支持序列化机制,并且是无法通过反射来生成新的实例,绝对防止多次实例化,因为枚举没有public构造方法。但是这种⽅式在存在继承场景下是不可⽤的。
一般情况下,不建议使用懒汉模式,建议使用饿汉模式,在有明确实现延迟加载时,建议使用静态内部类模式,如果涉及到反序列化创建对象时,可以使用枚举单例模式,如果有其他特殊的需求,可以考虑使用双重检测锁模式。
12.2 优缺点
- 这借助 JDK1.5 中添加的枚举来实现单例模式。不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象
- 这种方式是 Effective Java 作者 Josh Bloch 提倡的方式
推荐使用
13. CAS模式(线程安全)
java并发库提供了很多原⼦类来⽀持并发访问的数据安全性,所以,使用并发库实现单例模式也是可以的。如下AtomicReference可以封装引⽤⼀个单例实例,⽀持并发访问。
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE
= new AtomicReference<Singleton>();
private static Singleton instance;
private Singleton() {
}
public static final Singleton getInstance() {
for (; ;) {
Singleton instance = INSTANCE.get();
if (null != instance) return instance;
INSTANCE.compareAndSet(null, new Singleton());
return INSTANCE.get();
}
}
使⽤CAS的好处就是不需要使⽤传统的加锁⽅式保证线程安全,⽽是依赖于CAS的忙等算法,依赖于底层硬件的实现,来保证线程安全。相对于其他锁的实现没有线程的切换和阻塞也就没有了额外的开销,并且可以⽀持较⼤的并发性。
CAS也有⼀个缺点就是忙等,如果⼀直没有获取到将会处于死循环中。
14. JDK 源码分析
我们JDK中,java.lang.Runtime就是经典的单例模式(饿汉式)
单例模式注意事项和细节说明
- 单例模式保证了 系统内存中该类只存在一个对象,节省了系统资源,对于一些需要频繁创建销毁的对象,使用单例模式可以提高系统性能
- 当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用new
- 单例模式使用的场景:需要频繁的进行创建和销毁的对象、创建对象时耗时过多或耗费资源过多(即:重量级对象),但又经常用到的对象、工具类对象、频繁访问数据库或文件的对象(比如数据源、session工厂等)
15. 单例模式如何防止反射攻击和反序列化攻击
枚举单例天生解决了危害单例模式安全性的两个问题:反射攻击和反序列化攻击,接下来,我们来看看其他单例模式是如何防止这两个问题的。
15.1 反射攻击
public class ReflectTest {
public static void main(String[] args) throws Exception {
Singleton instance = Singleton.getInstance();
// 获取无参构造函数
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
// 使用构造函数创建对象
constructor.setAccessible(true);
Singleton reflectInstance = constructor.newInstance();
log.info("result:{}", instance == reflectInstance);
}
// result:false
}
15.2 防止反射攻击
实现单例模式时增加一个标志变量,在构造函数中检查是否已被调用过,若已被调用过,则抛出异常,保证构造函数只被调用一次:
public class Singleton {
private static Singleton instance = null;
private static boolean isInstance = false;
private Singleton() {
synchronized(Singleton.class) {
if (!isInstance) {
isInstance = true;
} else {
throw new RuntimeException("单例模式受到反射攻击!");
}
}
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
15.3 反序列化攻击
public class DeserializeTest {
public static void main(String[] args) throws Exception {
Singleton instance = Singleton.getInstance();
byte[] bytes = serialize(instance);
Object deserializeInstance = deserialize(bytes);
log.info("result:{}", instance == deserializeInstance);
}
// result:false
private static byte[] serialize(Object object) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(object);
return baos.toByteArray();
}
private static Object deserialize(byte[] bytes) throws Exception {
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais);
return ois.readObject();
}
}
15.4 防止反序列化攻击
增加一个readResolve方法并返回instance对象。在反序列化时,如果对象存在readResolve方法,则会调用该方法返回对象:
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
/* 如果该对象被用于序列化,可以保证对象在序列化前后保持一致 */
public Object readResolve() {
return instance;
}
}
由于jdk的设计,为枚举防止了反射和反序列化攻击,所以我们可以直接使用枚举类型,不用担心枚举单例的安全性。其他模式,也可以根据上面提到的两种方式,解决单例模式的安全性问题。
暂无评论内容