单例模式是一种创建型设计模式, 它的核心思想是保证一个类只有一个实例,并提供一个全局访问点来访问这个实例。
本章参考:单例模式详解
为什么要使用单例模式呢
- 全局控制
- 节省资源:也正是因为只有一个实例存在,就避免多次创建了相同的对象,从而节省了系统资源,而且多个模块还可以通过单例实例共享数据。
- 懒加载:单例模式可以实现懒加载,只有在需要时才进行实例化,这无疑会提高程序的性能。
实现单例模式的基本规则
- 私有的静态实例变量:保存该类的唯一实例
- 私有的构造函数:防止外部代码直接创建类的实例
- 公有的静态方法或枚举返回实例
- 确保反序列化时不会重新构建对象(序列化反序列化场景需要考虑)
(静态方法可以直接用Class.methods()
的方式来调用)
单例模式创建方式
1 懒汉式 – 需要使用时才创建实例
懒汉式指的是只有在请求实例时才会创建,如果在首次请求时还没有创建,就创建一个新的实例,如果已经创建,就返回已有的实例,意思就是需要使用了再创建,所以称为“懒汉”。
如果有多个线程同时访问 getInstance()
方法,并且在同一时刻检测到实例没有被创建,就可能会同时创建实例,从而导致多个实例被创建。因此懒汉式需要采用同步机制如互斥锁来确保在任何时刻只有一个线程能够执行实例的创建。
public class Singleton {
private static Singleton instance;
private Singleton() {
// 私有构造方法,防止外部实例化
}
// 使用了同步关键字来确保线程安全, 可能会影响性能
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
上面的模式中,不管有实例有没有创建过,每一次getInstance()都要加互斥锁。锁的机制虽然能保证线程安全,但在高并发情况下,会导致不必要的性能负担,尤其是当实例已经创建的情况下,锁的操作就变成了多余的。
2 双重锁懒汉式
可以添加双重检查锁DCL,通过这种方式,当单例实例已经创建后,后续的 getInstance() 调用就不会再进行加锁操作,大大减少了不必要的同步。
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
// 私有构造方法,防止外部实例化
}
public static Singleton getInstance() {
// 第一次检查,不加锁
if (instance == null) {
synchronized (Singleton.class) {
// 同步代码块
// 第二次检查,确保只有一个线程创建实例
if (instance == null) {
instance = new Singleton(); // 创建实例
}
}
}
return instance;
}
}
需要注意的是 在多线程环境下,instance加volatile修饰是必要的,因为instance = new Singleton();
这段代码在字节码中其实是分为三步执行:
- 为
instance
分配内存空间 - 初始化
instance
- 将
instance
指向分配的内存地址
JVM默认会使指令重排序,也就是可能执行顺序为 1, 3, 2。
- 在线程a执行完1 2 操作后,会创建出一个还没有初始化的实例(不是null)
- 此时线程b来执行
getInstance()
,
因为instance != null
,会直接返回这个未被初始化的单例对象,这显然是有问题的。
因此需要用到volatile
指令重排序的功能。
3 饿汉式 – 类加载时完成实例创建
在类加载时就已经完成了实例的创建,不管后面创建的实例有没有使用,先创建再说,所以叫做 “饿汉”。
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {
// 如果不声明,会自动生成公有、无参数的构造方法
// 私有构造方法,防止外部实例化
}
public static Singleton getInstance() {
return instance;
}
}
4 完美方案——利用静态内部类
原理:
- 在外部类被加载时,其静态内部类不会被加载,而是在用到的时候被加载了,从而解决了饿汉式单例实例创建早的问题。
- 同时 JVM 保证了每个类的静态内部类是单例的
public class Singleton {
private Singleton() {
}
private static class SingletonHolder {
private final static Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
5 完美且优雅——枚举单例
传统单例模式的序列化问题:
在传统的单例模式中,经过反序列化后会创建一个新的实例,并且不能挡住反射构造(Class.forName())。了解决这个问题,通常需要在单例类中提供 readResolve() 方法,确保反序列化时返回同一个实例。
而 JVM 为 enum 类型提供了特殊的反序列化机制,每次反序列化时都会返回同一个枚举实例。
代码模版:比如一个单例的配置管理器
// Enum singleton
public enum ConfigManager {
INSTANCE;
private String config;
// 构造器自动私有,不需要显式声明
// 模拟读取配置的方法
public String getConfig() {
return config;
}
// 模拟设置配置的方法
public void setConfig(String config) {
this.config = config;
}
public void printConfig() {
System.out.println("Current Config: " + config);
}
}
enum 类型在 Java 中编译后实际上是一个继承自 java.lang.Enum 的类。
枚举实例(如 INSTANCE)通过编译器自动生成唯一的私有构造器,确保只有枚举类定义的实例存在。
枚举类型的单例实现中,枚举的构造器默认是私有的,并且不能显式声明为 public 或 protected。这是因为枚举类型在 Java 中是被设计为一个有限的常量集,而 Java 编译器会自动为枚举实例(如 INSTANCE)生成唯一的私有构造器,保证枚举的实例只能是类中定义的那些。
枚举单例方式有以下优势:
- 实现简单:使用enum实现单例模式的代码非常简洁,不需要显式地处理同步(synchronized)或双重检查锁定等线程安全问题。枚举类型保证了线程安全性。
- 序列化机制:枚举类型的单例天生就是序列化安全的。通常,在单例实现中,如果不显式地提供readResolve()方法,反序列化后会产生新的实例,而枚举通过语言层面确保了不会发生这种情况,始终只有一个实例。
- 防止反射攻击:通过反射通常可以破坏传统的单例模式(比如使用private构造函数),但是枚举的单例实现不会受到反射攻击,因为Java语言对枚举类型的实例化做了特殊处理。
《Effective Java》 的作者推荐使用这种方法,可以详细阅读 该书的第3条。
使用场景
- 资源共享
多个模块共享某个资源的时候,可以使用单例模式,比如说应用程序需要一个全局的配置管理器来存储和管理配置信息、亦或是使用单例模式管理数据库连接池。 - 只有一个实例
当系统中某个类只需要一个实例来协调行为的时候,可以考虑使用单例模式, 比如说管理应用程序中的缓存,确保只有一个缓存实例,避免重复的缓存创建和管理,或者使用单例模式来创建和管理线程池。 - 懒加载
如果对象创建本身就比较消耗资源,而且可能在整个程序中都不一定会使用,可以使用单例模式实现懒加载。
实例:
- Java中的
Runtime
类就是一个经典的单例,表示程序的运行时环境。 -
Spring
框架中的应用上下文 (ApplicationContext
) 也被设计为单例,以提供对应用程序中所有 bean 的集中式访问点。 Redisson
中的序列化/反序列化器,也就是Jackson
库的JSON
编解码器,提供了单例实例的获取方式,可以使用全局相同的JsonJacksonCodec
减少资源浪费。
public class JsonJacksonCodec extends BaseCodec {
public static final JsonJacksonCodec INSTANCE = new JsonJacksonCodec();
// 设定编码器, JsonJacksonCodec的单例实例
config.setCodec(JsonJacksonCodec.INSTANCE);
example1 : Runtime类:
Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the getRuntime method.
An application cannot create its own instance of this class.
Since: JDK1.0
public class Runtime {
// 私有的静态实例变量 - 饿汉式创建
private static Runtime currentRuntime = new Runtime();
// 公有的静态方法获取实例
public static Runtime getRuntime() {
return currentRuntime;
}
// 私有的构造函数
private Runtime() {}
}
例题 – 小明的购物车
纠错记录
import java.util.Map;
import java.util.Scanner;
public class Main {
// 缺少String[] args
public static void main() {
ShoppingCart shoppingCart = ShoppingCart.getInstance();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String name = scanner.next();
Integer num = Integer.valueOf(scanner.nextInt());
shoppingCart.addToCart(name, num);
}
ShoppingCart.showCart();
}
}
// 内部类不需要public
class ShoppingCart {
// 私有静态实例变量
private static ShoppingCart instance = new ShoppingCart();
// 内部属性没加private
Map<String, Integer> cart;
// 私有构造方法
// Map 是接口,不能直接实例化,需要使用某个实现类,例如 HashMap。
private ShoppingCart() {
// 要保证顺序,应该用LinkedHashMap
this.cart = new Map<>();
};
// 静态方法可以直接类名.来调用
public static ShoppingCart getInstance() {
return shoppingCart;
}
private void addToCart(String thing, Integer num) {
// 未指定default值0
cart.put(thing, cart.getOrDefault(thing) + num);
}
private void showCart() {
// entrySet是方法,是Map.entrySet()
for(Map.Entry<String, Integer> entry : cart.entrySet) {
System.out.println(entry.getKey() + " " + entry.getValue());
}
}
}
⚠️要保证顺序,应该使用LinkedHashMap
而不是HashMap
- HashMap:不保证键值对的顺序,内部数据存储基于哈希值,插入顺序可能会改变。
- LinkedHashMap:是 HashMap 的子类,使用双向链表来维护键值对的插入顺序。因此,遍历 LinkedHashMap 时,键值对将以插入顺序返回。
面试题
1. 在DCL单例写法中,为什么要做两次检查?
(4.33 复制打开抖音,看看【Tom聊架构(Java架构师)的作品】【Java面试】学会这道题面试成功率飙升!在DCL... https://v.douyin.com/CeiJejDE/ SYM:/ 02/18 n@d.NJ
在单例模式中,我们要创建唯一的一个单例对象有两种方式,第一种方式是在创建类的时候就创建好这个静态对象,第二种方式呢,是在第一次getInstance的时候才创建这个对象,那么在第二种方式中如果直接给getInstance这个方法加上同步的标识,那么不管这个对象有没有创建,只要是调用getInstance都有可能产生阻塞,那这种情况不是我们想要的,因此就引入了两次检查锁这种改进方式,顾名思义就是两次检查一次加锁,第一次检查看这个对象是否已经创建,如果还没有创建就加锁并进行第二次检查,没有创建就创建这个对象,如果已经创建了,就直接返回这个对象,用这种方法可以确保在对象还没有创建的时候才进行同步操作,避免了无效的同步操作,有效地提高了系统的性能。
优化后的回答
单例模式的目的是确保一个类在应用程序中只有一个实例,且提供全局访问点。创建单例对象有两种常见方式:
- 饿汉式:在类加载时就创建好单例对象。
- 懒汉式:在第一次调用
getInstance()
方法时才创建对象。
在第二种懒汉式方式中,虽然能够延迟加载单例,但如果直接在 getInstance()
方法上加 synchronized
,那么无论实例是否已经存在,每次调用都会导致同步操作,这可能会带来性能问题,尤其是在高并发环境下,每次获取实例都会阻塞等待锁。
为了优化这种情况,引入了“双重检查锁”(DCL)机制。双重检查锁的关键在于“两次检查,一次加锁”:
- 第一次检查:在进入同步块之前,先检查实例是否已经被创建。如果已创建,直接返回实例,避免不必要的加锁。
- 第二次检查:如果第一次检查发现实例未被创建,线程进入同步块,此时再进行第二次检查,确保在多线程竞争的情况下,仍然只会有一个线程创建实例。即便有多个线程通过了第一次检查,只有第一个进入同步块的线程能够创建实例,其余线程在加锁后会发现实例已被创建,直接返回实例。
通过这种机制,只有在对象尚未创建时才进行同步操作,从而避免了不必要的同步开销,有效提高了系统性能。
2. 介绍一下关键字volatile :
首先,要提一下JVM的内存机制,Java将内存分为主内存和工作内存两部分。主内存是所有线程共享的,而工作内存是每个线程独享的。当一个线程要对变量进行操作时,通常先将变量从主内存拷贝到工作内存中,完成操作后再将变量写回主内存。在多线程环境下,这种机制容易导致主内存中的数据不一致。
为了避免这种问题,可以使用volatile关键字修饰变量。volatile保证了变量的可见性,强制所有线程直接在主内存中读写变量,从而确保一个线程对变量的修改能被其他线程立即看到。
此外,volatile还禁止指令重排序,这意味着操作将按照代码顺序执行,确保操作的顺序性和一致性。
然而,volatile仅能保证对单个变量的简单读写操作是线程安全的,对于像i++这种涉及多个步骤的操作,volatile并不能确保其原子性。在这种情况下,需要使用更强的同步机制,如synchronized,来确保操作的正确性。
总结来说,volatile是一个轻量级的同步工具,适合用于简单的变量同步,但在更复杂的场景下,还需要借助更强的同步机制来保证线程安全。
并发的三大特征是原子性可见性和有序性
volatile实现了可见性和有序性,而 synchronized 在此基础上实现了操作的原子性。