单例模式
概要
单例模式定义
英文原话
单例模式的英文原话是:Ensure a class has only one instance,and provide a globalpoint of access to it.
直译
- 确保一个类只有一个实例,并为其提供一个全局的访问点
分析下如何做到?
- 确保一个类只有一个实例,那就不要将构造方法暴露出来(参考:私有化构造函数),这样外部就不能实例化该类;
- 通过类加载,执行静态初始化语句进行实例化(饿汉式)
- 或者,通过首次获取该实例时对其进行实例化(懒汉式)
- 提供一个全局的访问点:这个比较容易,并对外提供唯一的获取该实例的方法即可
效果
- 达到的效果就是:一个类只有一个实例,自行实例化,并对外提供一个且仅一个获取该实例的方法。
延伸
-
延伸一点:在一个JVM进程内,多线程场景下,要考虑线程安全问题,
- 由于类一般只加载一次,静态初始化语句只会执行一次,因此只会执行一次实例化(参考:静态初始化语句执行时刻与执行次数),所以饿汉式单例是线程安全的,并且,通过私有化构造函数,禁止外部实例化该类,从而保证该类只有一个实例;
- 然而,懒汉式单例模式,由于实例化过程是在首次获取该类实例的时候,而实例化对象的过程不是原子操作,因此需要考虑线程同步,同时,实例化的过程分多个步骤,该过程不应该发生指令重排否则会出问题
- 👉️以上划线部分参考:补充知识点
单例模式的形式
饿汉式单例
饿汉式单例类:类加载时,就进行对象实例化(通过执行静态初始化语句);
public class Singleton {
//静态初始化语句确保只实例化一次,保证实例唯一性
private static Singleton singleton = new Singleton();
//构造方法私有,保证外界无法直接实例化
private Singleton() {
}
//通过该方法获得实例对象
public static Singleton getInstance() {
return singleton;
}
}
singleton属性私有化:单例类中的实例属性外部不能修改,封装在单例类内,保证只有本类内部实例化,确保实例的唯一性。对外只提供get方法。
单例类中一个重要特点:类的构造函数时私有的,从而避免外界利用构造函数直接创造出任意多的实例。
需要注意:由于构造函数私有化,因此此类不能够被继承(参考:私有化构造函数不能被继承)。
懒汉式单例
基础版
懒汉式单例类:第一次引用类时,才进行对象实例化。
与饿汉式不同的是,懒汉式单例类在加载时不会将自己实例化,而是在第一次被调用时将自己实例化
public class Singleton {
private static Singleton instance = null;
//构造函数私有化,保证外界无法直接实例化。且能保证此类不能被继承。
private Singleton(){}
//线程同步: 这里每次获取实例,都会加锁,性能差!
synchronized public static Singleton getInstance(){
if(instance==null){
instance=new Singleton();
}
return instance;
}
}
懒汉式单例类中对静态方法 getInstance()进行同步,以确保多线程环境下只创建一个实例,
例如,如果getInstance()方法未被同步,
并且线程A和线程B同时调用此方法,则执行if (instance == null)语句时都为真,
那么线程A和线程B都会创建一个对象,在内存中就会出现两个对象,这样就违反了单例模式;
但使用synchronized关键字进行同步后,则不会出现此种情况。
最佳实践:双重检查锁定(double-checked locking)
在Java多线程场景下,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。
public class Singleton {
//【重点】禁止指令重排【***参考下面补充知识点5.***】
private static volatile Singleton singleton = null;
private Singleton(){}
public static Singleton getInstance(){
// 如果已经创建,则直接获取到
if(singleton==null){
//线程同步
synchronized (Singleton.class) {
// 只允许一个线程创建,创建好其他线程直接可以拿到
if (singleton==null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
知识点
singleton属性私有化:
单例类中的实例属性外部不能修改,封装在单例类内,保证只有本类内部实例化,确保实例的唯一性。对外只提供get方法。
volatile禁止指令重排
懒汉式单例类指令重排问题,参考:指令重排问题
避免提前暴露未构造完成的对象,被其他线程获取到”半成品“对象而产生问题。
构造对象的过程不是原子操作
参考:对象实例化过程不是原子操作,多线程场景需要进行线程同步
多线程场景存在线程安全问题,需要进行线程同步,这里使用
synchronized
关键字。
双重检查锁定(double-checked locking)
在同步块之前和内部都进行了
singleton == null
的检查。这是为了确保只有当singleton为null时才进入同步块进行初始化(还未实例化前只有一个线程能够进入同步代码块进行实例化,此时同时获取实例的其他线程阻塞在同步代码块外,等待进入同步代码块,之前进入同步代码块的那一个线程完成实例化,并exit退出同步代码块,此时阻塞在同步代码块外的线程竞争进入同步代码块,有一个成功进入,其他继续阻塞,然后发现已经存在实例,直接返回该实例,外面阻塞的线程会继续这个过程,最终都返回的该实例;而当实例化完成,新来的线程调用getInstance()方法,可以直接获取到该实例,完全不需要进入同步代码块)。这可以减少不必要的同步开销,因为一旦singleton被初始化,就不再需要进入同步块。
单例模式的优点
- 由于单例模式在内存中只有一个实例,减少了内存的开支,
特别是一个对象需要频繁地创建、销毁,而且创建或销毁的性能
又无法优化时,单例模式的优势就非常明显。 - 由于单例模式只生成一个实例,所以减少了系统的性能开销,
当一个对象的产生需要比较多资源时,如读取配置、产生其他依赖
对象时,则可以通过在启用时直接产生一个单例对象,然后用永久
驻留内存的方式来解决。 - 单例模式可以避免对资源的多重占用,例如,一个写文件动作,
由于只有一个实例存在于内存中,避免了对同一个资源文件的同时操作。 - 单例模式可以在系统设置全局的访问点,优化和共享资源访问,
例如,可以设计一个单例类,负责所有数据表的映射处理。
单例模式的缺点
- 单例模式无法创建子类,扩展困难,若要扩展,除了修改代码基本上
没有第二种途径可以实现。 - 单例模式对测试不利。在并行开发环境中,如果采用单例模式的类没有完成,
是不能进行测试的;单例模式的类通常不会实现接口,这也妨碍了使用mock的方式虚拟一个对象。 - 单例模式与单一职责原则有冲突。一个类应该只实现一个逻辑,而不关心它是否是单例的,
是不是要用单例模式取决于环境,单例模式把“要单例”和业务逻辑融合在一起。
单例模式的使用场景
如果要求一个类有且仅有一个实例,当出现多个实例时就会造成不良反应,
则此时可以采用单例模式。
- 要求生成唯一序列号的环境
- 在整个项目中需要一个共享访问点或共享数据,例如,一个Web页面上的计数器,
可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的。(**注意:**这是在单体架构场景下的,如果分布式环境下,如果还是用这种方式,每个JVM进程都会记录自己进程的计数,就会存在数据不一致问题。分布式场景下计数器有别的方案,比如使用Redis,基于Redis的INCR命令是一个原子操作) - 创建一个对象需要消耗的资源过多,如访问IO和数据库等资源。
- 需要定义大量的静态常量和静态方法(如工具类)的环境,
可以采用单例模式(也可以采用直接声明为static的方式)。 - [注]:单例模式是23个模式中比较简单的模式,应用也非常广泛,如在Spring框架中,
每个Bean 默认就是单例的;Java 基础类库中的java.lang.Runtime 类也采用了单例模式,
其getRuntime()方法返回了唯一的实例。
使用单例模式的注意事项
根据功能,单例类可以分为有状态单例类和无状态单例类。
- 有状态单例类:一个有状态单例类的对象一般是可变的,
通常当做状态库使用。例如,给系统提供一个唯一的序列号。 - 无状态单例类:无状态单例类是不变的,通常用来提供
工具性的功能方法。例如, IO或数据库访问等。
因为单例类具有状态,所以在使用时应注意以下两点。
- 单例类仅局限于一个JVM,因此当多个JVM的分布式系统时,
这个单例类就会在多个JVM中被实例化,造成多个单例对象的
出现。如果是无状态的单例类,则没有问题,因为这些单例
对象是没有区别的。如果是有状态的单例类,则会出现问题,
例如,给系统提供一个唯一的序列号,此时序列号不唯一,
可能出现多次。因此,在任何使用EJB、RMI和JINI技术的分
布式系统中,应当避免使用有状态的单例类。 - 同一个JVM中会有多个类加载器,当两个类加载器同时加载同
一个类时,会出现两个实例,此时也应尽量避免使用有状态的单例类。
另外,使用单例模式时,需要注意序列化和克隆对实例唯一性的影响。
如果一个单例的类实现了Serializable或Cloneable接口,则有可能被
反序列化或克隆出一个新的实例来,从而破坏了“唯一实例”的要求,
因此,通常单例类不需要实现Serializable和Cloneable接口。
单例模式的代码示例
本代码示例,使用单例模式记录访问次数。
- 本案例使用单例类(记录访问次数)采用饿汉式,在加载时就创建了单例类实例
- 单例类的属性-访问次数,由于全局访问且有状态(可以修改),要保证线程同步,获取此变量的方法需要synchronized修饰
单例类GlobalNum
/**
* 饿汉式单例类GlobalNum,其中getNum()方法用于返回访问次数,
* 并且使用synchronized对该方法进行线程同步(由于++num不是原子操作)
*/
public class GlobalNum {
/**
* 私有化构造函数,仅仅允许通过提供的getInstance()静态方法获取当前类的实例
* 而不允许手动创建该类的实例,从而避免外界利用构造函数直接创造出任意多的实例
*/
private GlobalNum(){}
// 饿汉式单例模式:在类加载时即创建单例对象,并将其赋值给静态变量gn
// JVM在加载GlobalNum类时,会执行此初始化语句,实例化GlobalNum类并赋值给gn变量
// 该类被加载一次(不考虑特殊情况如重新加载等),所以gn也只会被初始化一次
private static GlobalNum gn = new GlobalNum();
private int num = 0;
/**
* @return 获取当前类的实例的唯一方法,就是通过该静态方法
*/
public static GlobalNum getInstance() {
return gn;
}
/**
* @return 运行时,单个JVM进程内保证获取到一个唯一数字
* 由于++num并非原子操作,因此加了synchronized进行线程同步
*/
public synchronized int getNum() {
return ++num;
}
}
测试类SingleDemo
public class SingleDemo {
//测试单例模式
public static void main(String[] args) {
//创建线程A
NumThread threadA = new NumThread("线程A");
//创建线程B
NumThread threadB = new NumThread("线程B");
//启动线程
threadA.start();
threadB.start();
}
}
//线程类
class NumThread extends Thread {
private String threadName;
public NumThread(String name) {
this.threadName = name;
}
//重写线程的run方法(线程任务)
@Override
public void run() {
GlobalNum globalNum = GlobalNum.getInstance();
//循环访问,输出访问次数
for (int i = 0; i < 5; i++) {
System.out.println(threadName + ", Num:" + globalNum.getNum() + ".");
try {
Thread.sleep(5000);//线程休眠5000ms(5s)
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
补充知识点
1. 私有化构造函数
private
修饰符保证只能在当前类中访问,因此避免了其他外部类对它的访问,从而保证构造方法的执行只能由当前类内部执行。
私有化构造函数(Private Constructor)
- 当一个类的构造函数被声明为
private
时,意味着这个类不能被外部代码实例化(即创建对象)。这样的类通常用作工具类(utility class),它包含静态方法,但不需要创建对象来调用这些方法。例如,Java中的Math
类就使用了私有构造函数。
public final class Math {
/**
* Don't let anyone instantiate this class.
*/
private Math() {}
... ...
}
- 另一个常见的用途是作为单例模式的实现基础。通过私有化构造函数,并结合一个静态方法来返回类的唯一实例,可以确保一个类在全局范围内只有一个实例。
2. 静态初始化语句执行时刻与执行次数
静态初始化语句(包括静态变量的初始化和静态初始化块)在Java中只会执行一次,且发生在类被加载到JVM(Java虚拟机)时。这个加载过程是由类加载器(ClassLoader)触发的,通常是在第一次主动使用该类(例如创建类的实例、访问类的静态变量或静态方法、反射调用等)时。
一旦类被加载到JVM中,它的静态初始化块和静态变量的初始化就会被执行,并且只执行一次。这些初始化操作是在类的构造方法之前完成的,并且它们的执行顺序是按照在源代码中声明的顺序进行的。
需要注意的是,虽然静态初始化语句只执行一次,但是静态变量可以在类的静态方法中被修改。这些修改会影响到所有使用这个静态变量的地方,因为它们都是引用同一个内存地址。然而,静态初始化块中的初始化操作本身是不可重复执行的。
3. 对象实例化过程不是原子操作,多线程场景需要进行线程同步
使用synchronized同步块来确保线程安全地创建单例对象
这是因为new创建对象的过程不是原子操作,它包括了多个步骤:
-
分配内存空间给对象
-
将分配的内存空间都初始化为零值(对于对象的成员变量)
-
设置对象的对象头信息(例如,哈希码、GC分代年龄等)
-
执行对象的初始化语句和构造方法
如果不使用synchronized,可能会遇到以下问题:
-
线程A看到singleton为null,开始创建对象,但尚未完成构造(即步骤4)
-
此时,线程A的CPU时间片用完,切换到线程B
-
线程B看到singleton仍为null(因为A的写入对B尚未可见),也开始创建对象
-
结果是两个线程都可能创建出singleton的实例,违反了单例模式的初衷
synchronized同步块保证了在任一时刻只有一个线程可以执行此块中的代码
这确保了单例对象只会被创建一次,并避免了“半成品”对象的问题
4. 私有化构造函数不能被继承
在Java中,如果一个类的构造函数是私有的,那么该类就无法被其他类所继承。
这是因为子类在继承父类时,需要调用父类的构造函数来完成初始化操作,
但如果父类的构造函数是私有的,子类无法直接访问父类的构造函数,因此无法进行继承。
理解这一点的关键在于理解继承的过程。
当一个类继承另一个类时,子类会隐式地调用父类的构造函数来完成父类部分的初始化。
但是,如果父类的构造函数是私有的,子类无法直接调用它,
这就意味着子类无法完成对父类的初始化,因此也就无法继承父类。
class Parent {
// 私有构造函数
private Parent() {}
public static Parent createInstance() {
return new Parent();
}
}
class Child extends Parent {
// 这里无法编译通过,因为无法访问父类的构造函数
}
在这个示例中,父类 Parent 的构造函数是私有的,因此子类 Child 无法继承 Parent 类。
如果你尝试编译这段代码,会得到一个编译错误,提示子类无法访问父类的构造函数。
5. 指令重排问题
通过下面实例化过程分析,可以发现不是原子操作,因此加锁是必要的。
5.1 对象实例化完整过程(伪码说明)
在Java中,当你执行singleton = new Singleton()
来实例化一个对象,
以下是对象实例化的一个简化和通用的伪代码说明:
5.1.1 加载类:
执行静态初始化语句(包括静态属性赋值和静态代码块的执行,按照代码前后顺序依次执行)。这一步在类首次被主动使用时发生。
// 假设 Singleton 类还没有被加载
if (!Singleton.class.isLoaded()) {
// 加载 Singleton 类
load_class(Singleton.class)
// 执行静态初始化块
for each static_block in Singleton.class.getStaticInitializationBlocks():
execute(static_block)
// 执行静态属性赋值
for each static_field in Singleton.class.getStaticFields():
if static_field has initializer:
static_field.value = static_field.initializer.evaluate()
}
5.1.2 分配内存:
JVM首先为Singleton
类的新实例分配内存。这个内存是从Java堆中分配的。
memory = allocate_memory_from_heap_for_Singleton_instance()
5.1.3 初始化实例字段(赋默认零值):
在内存分配后,JVM将实例字段(不包括静态字段)初始化为默认值(如int为0,引用类型为null等)。
for each field f in Singleton.class.getDeclaredFields():
if f is an instance field:
f.value = default_value_for_type(f.type)
5.1.4 执行初始化语句(属性赋值、初始化块)和构造函数:
在内存分配和字段初始化(赋默认零值)之后,JVM会执行初始化语句和调用Singleton
类的构造函数来初始化实例的状态。
注意,对于单例模式,构造函数通常是私有的,但在这一步,JVM仍然会调用它(如果是通过new
关键字创建的)。
// 执行实例属性赋值
for each non_static_field in Singleton.class.getNonStaticFields():
if non_static_field has initializer:
non_static_field.value = non_static_field.initializer.evaluate()
// 执行实例初始化块
for each initialization_block in Singleton.class.getInstanceInitializationBlocks():
execute(initialization_block, memory) // 使用分配的内存
// 调用构造函数
constructor = Singleton.class.getDeclaredConstructor() // 假设构造函数是默认的,没有参数
call_constructor(constructor, memory)
// 构造函数内部可能会执行更多的初始化逻辑,如设置字段值等
在构造函数的实际执行中,可能会执行更多的初始化逻辑,如设置字段值、启动线程等。
注意:在单例模式中,构造函数通常是私有的,但这里的伪代码描述的是通用的对象实例化过程,不考虑单例的特殊性。
5.1.5 对象引用赋值:
将新创建的对象实例的内存地址赋值给变量singleton
。
外部代码通过调用getInstance()
方法(在单例模式中)或其他方式来获取对象的引用。
singleton = memory // 这里实际上是指向新分配的内存的引用
5.2 异常情况分析:指令重排
这里涉及到并发的三大特性:原子性、可见性、有序性,中的有序性问题。
即程序执行的顺序按照代码的先后顺序执行。为了提升性能,编译器和处理器常常会对指令做重排序,所以存在**有序性问题**。
这里插入一个题外话,启发以下思考:
为充分发挥多核处理器强大能力,往往会考虑任务可行的并行性。
前提是任务间最好没有关联依赖关系,这样就可以并行化执行,极大提升执行效率;
但是有顺序先后关系,就不能简单的并行处理。
回到指令重排问题:
前边分析对象的创建完整过程,但是编译器和处理器有指令重排优化操作,
多线程环境下,
有一个线程进入同步代码块去实例化单例类实例,如果5.1.2 分配内存 后紧接着 发生了 5.1.5 对象引用赋值
那么此时其他线程可以读取到该实例不为null,因此直接去用这个"半成品"的实例,就出问题了呀,因为对象还没构造 完成呢!
构造(创建)对象的整个过程的每一个步骤它是有明确的先后顺序,相互依赖的,不能够并行执行,因此,要禁止掉指令重排优化。
5.3 volatile关键字
而Java中volatile
就可以保证禁止指令重排优化
而volatile关键字的作用:
5.3.1 保证可见性
Java中的volatile
关键字主要用于确保多线程之间的变量可见性。当一个变量被声明为volatile
时,它意味着这个变量在多线程环境中是“直接共享”的,并且每次对该变量的读写都会直接从主内存(或称为共享内存)中读取或写入,而不是从线程的本地缓存中。
以下是volatile
如何保证可见性的关键点:
- 立即同步到主内存:当一个线程修改了
volatile
变量的值,这个值会立即被同步到主内存,而不仅仅是写入到线程的本地缓存中。 - 每次读取都从主内存:当一个线程读取
volatile
变量的值时,它总是从主内存中读取,而不是从线程的本地缓存中读取。这确保了线程总是看到其他线程对volatile
变量所做的最新修改。
5.3.2 禁止指令重排
volatile
还有一个额外的效果,即禁止JVM对包含volatile
变量的指令进行重排序。这有助于确保在多线程环境中代码的执行顺序与程序员的预期一致。