Java 单例模式学习

单例模式有哪几种实现?如何保证线程安全?

单例模式面向的问题

在系统中,某个类在逻辑上只能有一个实例,因此需要由程序来保证这一约束。

例如系统的全局配置管理类,整个系统在运行时只应该有一份生效的配置,如果存在多个实例,程序将无法确定应该使用哪一份。

单例模式的实现

在 Java 中,一个类的对象主要是通过 new 关键字调用类的构造方法创建的。

如果允许外部随意调用构造方法,就无法控制实例的数量,因此需要限制外部对构造方法的访问

单例模式的核心在于控制实例的创建过程

通过将构造方法私有化,阻止外部直接实例化对象。

在类的内部提供受控的实例获取方式,集中管理实例的创建与返回,从而确保该类在系统中始终只有一个实例。

在实现单例时,实例可以在类加载阶段直接创建,也可以在首次使用时再创建。

因此,单例模式的实现方式通常分为两类:

  • 立即加载(Eager Initialization)
  • 延迟加载(Lazy Initialization)

立即加载

一个简单的例子:

java
复制代码
public class Singleton { private static Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }

上述代码能够保证实例的唯一性,关键在于 JVM 的类加载机制

Java 中的类只有在首次使用时才会被加载到内存中并初始化,这个过程称为类加载。

类加载时,静态变量 instance 会在初始化阶段自动赋值,且由 JVM 负责处理,无需手动干预。

在程序运行期间,静态变量 instance 只有一个副本,所有对 instance 的访问都将返回同一个实例,这就保证了单例模式的实现。

延迟加载

一个简单的例子:

java
复制代码
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }

先考虑在单线程中,只有首次调用 getInstance 方法时才会实例化 Singleton 实例。后续再次调用 getInstance 则始终返回同一实例,这就保证了单例模式的实现。

然而,在多线程环境中,由于线程安全问题,当多个线程几乎同时调用 getInstance 方法时,可能会有多个线程同时执行到 instance = new Singleton(); 这一行代码。这样就可能导致 Singleton 的构造方法被多次调用,从而无法保证只有一个实例。

一种最简单的解决方案是将 getInstance 方法声明为同步方法,即:

java
复制代码
public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }

这种方式确保了在多线程环境下,Singleton 的构造方法不会被多个线程同时执行。然而,这种做法存在严重的性能问题

当前方案会导致即便 instance 已经创建,线程每次调用 getInstance 方法时仍然需要竞争锁。而锁的竞争会导致线程上下文切换,增加时间开销

为了解决这个性能问题,可以考虑以下两种优化方案:

方案一:双检查锁定(Double Check Lock)

该方案的核心思路是:避免每次都进行同步操作,只在第一次创建实例时加锁,之后便不再需要。

例如:

java
复制代码
public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); } } } return instance; }

与第一版方案相比,这种方法在 instance 创建后,后续调用 getInstance 方法时无需再进行同步,从而避免了每次访问时都竞争锁,提高了性能。

然而,这并非双重检查锁定的完美实现。

在多线程环境下,instance = new Singleton(); 这一行代码可能会遇到一个问题:JVM 的指令重排序

通常,instance = new Singleton(); 会依次执行以下三个步骤:

  1. 分配内存:为 Singleton 实例分配内存空间。
  2. 初始化实例:执行构造方法,初始化对象。
  3. 赋值给 instance:将实例的内存地址赋给 instance 引用。

然而,JVM 可能会调整这些操作的顺序,例如,先将 instance 引用赋值,再进行初始化,这种现象称为指令重排序

如果发生这种情况,其他线程可能会看到一个已赋值的 instance 地址,但此时对象尚未初始化完成,其他线程使用该对象时可能导致潜在的逻辑错误或运行时问题。

为了解决这个问题,Java 提供了 volatile 关键字。

volatile 用于修饰被多个线程共享的成员变量,确保其在多线程环境中的内存可见性执行顺序

使用 volatile 后,JVM 确保:

① 内存可见性

每个线程会有自己的工作内存,并缓存变量的值。volatile 禁止线程对被 volatile 修饰的变量在工作内存中的缓存,确保每个线程都从主内存读取该变量的最新值,而不是使缓存的过时数据。这样,一个线程对 volatile 变量的修改会立即对其他线程可见

② 禁止指令重排序

volatile 确保对变量的操作按正确顺序执行,避免 JVM 重排序操作,从而防止其他线程看到一个未完全初始化的实例。

因此,双重检查锁定的最终实现如下:

java
复制代码
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; } }

方案二:静态内部类

该方案的核心思想是:利用 JVM 的类加载机制,通过静态内部类来延迟 Singleton 的实例化,确保只在第一次访问 getInstance 时创建实例。

例如:

java
复制代码
public class Singleton { private Singleton() {} private static class Inner { static final Singleton instance = new Singleton(); } public static Singleton getInstance() { return Inner.instance; } }

这种方式利用静态内部类的特性,getInstance 被调用时,Inner 类才会被加载,从而确保实例只会在第一次调用时创建

反射攻击

在单例模式中,我们通常将构造方法设为 private,以确保外部不能直接创建多个实例。然而,Java 的反射机制允许我们通过 Constructor 类获取到私有构造方法,绕过访问控制,从而创建多个实例,破坏单例模式的唯一性,这种现象被称为“反射攻击”。

为了解决这个问题,可以考虑以下两种方法来防止反射重新创建实例:

① 枚举单例

使用枚举实现单例是防止反射攻击的最简单且最安全的方式。

枚举类型的实例在类加载时由 JVM 自动创建,且JVM 严格保证反射无法破坏它的唯一性

因此,使用枚举单例可以视为一种从语言层面提供的强力保证。

例如:

java
复制代码
enum Singleton { INSTANCE }

Singleton 是一个 枚举类型,只有一个常量 INSTANCE,表示唯一的单例实例。

② 在构造方法中检查

另一种方法是在单例类的构造方法中添加逻辑检查,确保反射不会破坏实例唯一性。

例如,在构造方法中,如果 instance 已经被创建,则抛出异常:

java
复制代码
private Singleton() { if (instance != null) { throw new RuntimeException("非法的实例化!"); } }
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
坤坤
下载 APP