Java单例模式:为什么我强烈推荐你用枚举来实现单例模式 (qq.com)
一、什么时候使用单例模式?
单例模式可谓是23种设计模式中最简单、最常见的设计模式了,它可以保证一个类只有一个实例。我们平时网购时用的购物车,就是单例模式的一个例子。想一想,如果购物车不是单例的,会发生什么?
- 数据不一致:用户在不同页面看到的购物车内容可能不同。用户在一个页面加了商品,可能换到另一个页面就看不到了、或者看到的商品不对。这会让用户感到困惑和不满。
- 购物车状态丢失:用户在不同服务器上访问的购物车实例可能不同。用户在一个页面加了商品,如果下一个请求被转到另一个服务器,那么之前加的商品就没了。这可能导致用户重新选购,那实在是太麻烦了。
- 资源浪费:购物车需要加载和处理一些数据,假如用户每次访问页面都创建一个新的购物车实例,这样就会占用更多的资源,并且、频繁地创建和销毁购物车实例,也会增加系统的负担和响应时间。
所以,用单例模式来做购物车可以避免以上问题,并提供更好的用户体验。购物车作为一个共享的对象,把用户选的商品信息保存在一个唯一的实例中,可以在整个用户会话中访问和更新,这样可以保证购物车中的数据是正确、完整和一致的。这其实也和我们生活中,在超市里使用购物小推车或购物篮是一样的。
在 Java 开发中,有很多地方使用到了单例模式。比如 JDK、Spring。
- JDK:Runtime 封装了 Java 运行信息,可以获取有关运行时环境的信息,一个 JVM 只需要一个 Runtime 实例。Runtime 单例是饿汉单例,在类加载时就初始化实例。
Spring是Java开发中常用的框架,它里面也有很多单例模式的应用:
- ApplicationContext:Spring的核心类之一,负责管理和配置应用程序的Bean。ApplicationContext是单例模式的实例,保证整个应用程序中只有一个ApplicationContext。
- Bean对象:在Spring中,通过配置文件或注解方式定义的Bean对象通常也是单例的,默认情况下,Spring会把它们当作单例来管理。这意味着在应用程序中任何地方,通过Spring注入或获取Bean对象时,都是同一个实例。
- 缓存对象:在Spring中,可以使用缓存注解来实现方法级的缓存策略。这些缓存对象通常也是单例模式的实例,保证在多个方法调用中共享和管理缓存数据。
- 事务管理器:Spring的事务管理器通常也是单例模式的实例。事务管理器用于处理数据库事务,并保证整个应用程序中保持事务的一致性。
- AOP切面:Spring的AOP(面向切面编程)通常也使用单例模式来管理切面。切面用于实现横切关注点的模块化,并可以在多个对象和方法中应用。通过使用单例模式,Spring可以保证在整个应用程序中共享和管理切面对象。
单例模式是关于对象创建的设计模式,当我们需要某个类在整个系统运行期间有且只有一个实例,就可以考虑使用单例模式。
二、Java实现单例模式的几种方式
在Java中,如何实现单例模式呢?经典的单例模式有同样经典的2种实现方式:**“饿汉式”和“懒汉式”**。
经典饿汉式
public final class Hungry { // final 不允许被继承
// 在类初始化过程中收入<clinit>()方法中,该方法能100%保证同步;final 保证不被改变
private static final Hungry instance = new Hungry();
private Hungry() {
}
public static Hungry getInstance() {
return instance;
}
}
经典饿汉线程安全
“饿汉式”是一种最简单直接的实现方式,它的好处是在多线程环境下应用时是安全的,来验证下:
public static void main(String[] args) throws Exception {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " " + Hungry.getInstance());
}).start();
}
}
运行结果如下:
Thread-1 src.Hungry@22673956 Thread-8 src.Hungry@22673956 Thread-4 src.Hungry@22673956 Thread-0 src.Hungry@22673956 Thread-2 src.Hungry@22673956 Thread-5 src.Hungry@22673956 Thread-3 src.Hungry@22673956 Thread-10 src.Hungry@22673956 Thread-9 src.Hungry@22673956 Thread-7 src.Hungry@22673956 Thread-6 src.Hungry@22673956 Thread-16 src.Hungry@22673956 Thread-13 src.Hungry@22673956 Thread-11 src.Hungry@22673956 Thread-17 src.Hungry@22673956 Thread-18 src.Hungry@22673956 Thread-15 src.Hungry@22673956 Thread-14 src.Hungry@22673956 Thread-19 src.Hungry@22673956 Thread-12 src.Hungry@22673956
经典饿汉反射脱单
可见,不同线程得到的对象都是同一个,符合“单例”。但是,这个“单例”是否牢不可破呢?再来运行下面这段代码:
public static void main(String[] args) throws Exception {
Hungry instance1 = Hungry.getInstance();
Constructor<Hungry> constructor = Hungry.class.getDeclaredConstructor(null);
Hungry instance2 = constructor.newInstance();
Hungry instance3 = constructor.newInstance();
System.out.println("非反射:" + instance1.hashCode());
System.out.println("反射1:" + instance2.hashCode());
System.out.println("反射2:" + instance3.hashCode());
}
运行结果如下:
非反射:460141958 反射1:1163157884 反射2:1956725890
可以看到,“单例”不单、它被反射破坏了。
并且,“饿汉式”还有一个缺点是:当我们还没有使用它时,它就已经被实例化了,这就会造成资源浪费;
经典懒汉式
由此,产生了“懒汉式”实现方式,它在我们第1次使用时才进行实例化:
public final class Lazy { // final 不允许被继承
private static Lazy instance;
private Lazy() {
}
public static Lazy getInstance() {
if (instance == null) {
instance = new Lazy();
}
return instance;
}
}
但是,上面这样的“饿汉式”代码在多线程环境下是不安全的、并且同样也会被反射破坏。
经典懒汉线程不安全咋办
要将它改为线程安全的,有以下2种方法:
方法1,为 getInstance 方法加上 synchronized 关键字
public static synchronized Lazy getInstance() {
if (instance == null) {
instance = new Lazy();
}
return instance;
}
这样配置虽然保证了线程的安全性,但是效率低,只有在第一次调用初始化之后,才需要同步,初始化之后都不需要进行同步。锁的粒度太大,影响了程序的执行效率。
方法2,通过双重检查锁
双重检验首先判断实例是否为空,然后使用 synchronized (Lazy.class)
使用类锁,锁住整个类,执行完代码块的代码之后,新建了实例,其他代码都不走 if (instance== null)
里面,只会在最开始的时候效率变慢。而 synchronized
里面还需要判断是因为可能同时有多个线程都执行到 synchronized (Lazy.class)
,如果有一个线程线程新建实例,其他线程就能获取到 instance
不为空,就不会再创建实例了。
public static Lazy getInstance() {
if (instance == null) {
synchronized (Lazy.class) {
if (instance == null) {
instance = new Lazy();
}
}
}
return instance;
}
怎么解决反序列化破坏单例模式
为了防止反序列化破坏单例性,可以使用枚举或者重写readResolve()
方法。这两种方式可以保证反序列化后返回的对象仍然是单例的。
在枚举中,编译器会自动为我们生成
readResolve()
方法,它会返回该枚举实例。枚举我们最后说
import java.io.Serializable;
public class Singleton implements Serializable {
private static volatile Singleton instance;
// 私有构造方法
private Singleton() {
// 防止通过反射实例化对象
if (instance != null) {
throw new IllegalStateException("Already initialized.");
}
}
// 双重检查锁定获取单例对象
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
// 防止反序列化破坏单例性
protected Object readResolve() {
return getInstance();
}
}
需要注意的是,双重检查锁方式在多线程环境下可能会产生NPE,因为new Lazy()
并非原子操作,它将经历:
- 分配内存空间
- 执行构造函数创建对象
- 对象指向空间这几个步骤
而步骤2、3可能会被重排序从而引发NPE。
避免指令重排序 volatile
那么,是否有办法可以避免NPE呢?很简单,为 instance 加上 volatile 关键字即可:
private volatile static Lazy instance;
除了“饿汉式”和“懒汉式”,还有别的实现方式吗?
静态内部类
答案是肯定的,我们还可以通过静态内部类来实现单例模式:
外部类加载时,并不会加载内部类,也就不会执行 new SingletonHolder()
,这属于懒加载。只有第一次调用 getInstance()
方法时才会加载 SingletonHolder
类。而静态内部类是线程安全的。
public final class Holder {
private Holder() {
}
/**
* 调用getInstance实际上是获得InnerHolder的instance静态属性
*/
public static Holder getInstance() {
return InnerHolder.instance;
}
private static class InnerHolder {
private static Holder instance = new Holder();
}
}
静态内部类为什么是线程安全
静态内部类利用了类加载机制的初始化阶段 方法,静态内部类的静态变量赋值操作,实际就是一个方法,当执行getInstance()
方法时,虚拟机才会加载 SingletonHolder
静态内部类,
然后在加载静态内部类,该内部类有静态变量,JVM会改内部生成方法,然后在初始化执行方法 —— 即执行静态变量的赋值动作。
虚拟机会保证 方法在多线程环境下使用加锁同步,只会执行一次 方法。
这种方式不仅实现延迟加载,也保障线程安全。
静态内部类方式是线程安全的,但它仍然逃不过被反射和反序列化破坏的命运。
枚举
那么,有不会被反射破坏的实现方式吗?来看下列代码:
public enum EnumSingleton implements Serializable {
INSTANCE;
EnumSingleton() {
}
public static EnumSingleton getInstance() {
return INSTANCE;
}
}
使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,《Effective Java》 推荐尽可能地使用枚举来实现单例。
来测试一下:
public static void main(String[] args) throws Exception {
// 通过反编译工具看到确实没有无参构造函数,而是String,int的2个参数的构造函数
Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
// 将抛出 java.lang.IllegalArgumentException: Cannot reflectively create enum objects
EnumSingleton instance1 = constructor.newInstance();
System.out.println("反射1:" + instance1.hashCode());
}
当我们想要通过反射来得到实例时,将得到异常,这次这个破坏王终于被打败啦。遗憾的是,这种实现方式无法延迟加载。
最后,再来看看静态内部类+枚举类这种实现方式:
public final class HolderEnum {
private HolderEnum() {
}
public static HolderEnum getInstance() {
return Holder.INSTANCE.getInstance();
}
// 使用枚举类充当holder
private enum Holder {
INSTANCE;
private HolderEnum instance;
Holder() {
this.instance = new HolderEnum();
}
private HolderEnum getInstance() {
return instance;
}
}
}
经过测试,这种实现方式可以延迟加载、在多线程环境下安全、但却还是逃不过“反射”这个破坏王。
综上,Java实现单例模式的几种方法各有优缺点,以下是它们的对比小结: