设计模式系列:经典的单例模式 (qq.com)

Java单例模式:为什么我强烈推荐你用枚举来实现单例模式 (qq.com)

五种方式实现 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()并非原子操作,它将经历:

  1. 分配内存空间
  2. 执行构造函数创建对象
  3. 对象指向空间这几个步骤

而步骤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实现单例模式的几种方法各有优缺点,以下是它们的对比小结:

图片