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(); 会依次执行以下三个步骤:
- 分配内存:为
Singleton实例分配内存空间。 - 初始化实例:执行构造方法,初始化对象。
- 赋值给
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("非法的实例化!"); } }
