上篇我们讲解了什么多线程中wait¬ify相关的知识点,本篇来讲解多线程中的单例模式~
1. 什么是单例模式?
单例模式就是一种创建设计模式,确保一个类只有一个实例,并提供一个全局访问点。它在很多场景中非常有用,例如配置管理器、线程池、数据库连接池等等。
核心要素:
- 私有构造函数,防止外部直接实例化。
- 静态私有成员变量,保存类的唯一实例。
- 静态公有方法,提供获取该实例的全局访问点。
2. 单例模式的几种实现方式
单例模式具体的实现方式有很多,最常见的就是'饿汉'和'懒汉两种'~
2.1 饿汉模式
饿汉模式的特点是在类加载的时候创建实例。
java
/**
* 单例模式示例类
* 使用饿汉模式实现单例模式,确保整个应用程序中只有一个实例
*/
class Singleton{
//通过饿汉模式构造单例模式
private static Singleton instance = new Singleton();
public static Singleton getInstatice(){
return instance;
}
private Singleton(){
}
public Singleton(int n){
}
}
public class demo15 {
public static void main(String[] args) {
Singleton t1 = Singleton.getInstatice();
Singleton t2 = Singleton.getInstatice();
System.out.println(t1==t2);
}
}
-
private static final Singleton instance = new Singleton();:在类加载时,静态变量instance被初始化,创建了单例对象。final修饰确保引用不可变。 -
private Singleton():私有构造器,阻止外部通过new创建实例。 -
提供的
public Singleton(int n)构造器是错误的,它会暴露公共构造方法,允许外部创建新实例,破坏单例。在实际编写时,不应提供任何公共构造器。 -
getInstance()方法直接返回已创建的实例,无需任何判断
通过这个代码案例我们可以看到,一开始就直接创建了一个实例。既然叫饿汉式肯定是有原因的,就像一天没吃饭的人,一启动(类加载)就立刻创建实例(吃东西),不管后面是否真的需要这个实例。
饿汉模式的优点:
- 简单直观:实现简单,没有线程安全问题, 因为实例在类加载的时候就已经创建,JVM保证了线程安全(类加载过程是线程互斥的)
- 访问速度块:调用 getInstance() 时直接返回,无需判断或者同步。
饿汉模式的缺点:
- 可能导致资源浪费:如果程序自始至终没有使用这个创建的实例,那么他就会一直占用这个内存空间。
- 无法延迟加载:实例在类加载时就被创建,不能按需创建,如果初始化很耗时,会影响程序启动速度。
类加载的机制是:当类被主动引用时(比如调用静态方法,访问静态变量等) 会出发类加载。饿汉式在类加载的时候创建实例,因此 getInstance() 调用时实例已经存在。
如何避免资源浪费:如果实例创建开销大并且可能不被使用,就不应该使用饿汉式。
也就是说,饿汉式最好使用在单利占用资源少,并且一定会被使用的场景之下。
2.2 懒汉式
2.2.1 线程不安全写法
懒汉式在类加载的时候不创建实例,第一次使用的时候才创建实例。
java
class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 线程不安全点
}
return instance;
}
}
-
private static Singleton instance;:声明静态变量,但不立即初始化。 -
getInstance()方法首先判断instance是否为null,如果是,则创建一个新实例并赋值;否则直接返回已有实例。
其实在这个代码中存在一个线程安全的点:
在多线程环境下,当多个线程同时首次调用 getInstance() 时,可能会同时键入 if(instance == null)这个判断,并且都发现 instance 为null,然后各自执行 instance = new Singleton,导致创建多个实例,违反单例原则。
具体场景:
-
线程 A 进入
if判断,发现instance == null,准备创建实例。 -
此时 CPU 调度切换到线程 B,线程 B 也进入
if判断,同样发现instance == null(因为 A 还没来得及赋值),于是线程 B 创建一个实例。 -
线程 A 继续执行,又创建一个实例。最终得到两个不同的实例
优点:
- 延迟加载:实例在第一次使用时才创建,避免了饿汉式的资源浪费。
- 实现简单,代码直观
缺点:
- 线程不安全:不能用于多线程环境
- 即使在单线程环境下,如果后续引入多线程代码,也可能出现问题,因此不推荐使用。
这个写法最好是不用!!!
2.2.2 线程安全写法
上面的那种写法可能会导致多个线程进入方法中,所以我们需要做的是想办法让同一时刻只有一个线程进入该方法,于是我们可以借出锁的特性加入一个 synchronized 关键字,使该方法成为同步方法,让同一时刻只进入一个线程,从而保证线程安全。
java
class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
-
在
getInstance()方法上添加synchronized关键字,使该方法成为同步方法。 -
同一时刻只有一个线程能进入该方法,从而保证了线程安全。
优点:
- 线程安全:通过同步锁解决了多线程并发创建实例的问题
- 延迟加载:实例在第一次调用时才创建。
缺点:
- 性能差:每次调用 getInstance() 都需要获取锁,及时实例已经创建,后续调用仍然要同步。同步会造成线程阻塞和上下文切换,高并发环境下会严重影响性能。
- 实际上,只有在第一次创建时需要同步,后续的读操作根本不需要同步。
使用场景:
- 单例对象使用频率不高,且对性能要求不苛刻的场景中,但在实际开发时,我们通常希望更高效的方式。
性能差的原因:锁的获取和释放需要时间,且可能引起线程阻塞。
3. 双重检查锁(DCL)
3.1 代码示例
java
class Singleton {
private static volatile Singleton instance; // volatile 禁止指令重排
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的同步
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,保证只创建一个实例
instance = new Singleton();
}
}
}
return instance;
}
}
代码详解:
-
第一次检查 :
if (instance == null)放在同步块外,这样当实例已创建时,直接返回,无需进入同步块,避免了性能损失。 -
同步块 :
synchronized (Singleton.class)锁住类对象,保证同一时刻只有一个线程进入创建代码。 -
第二次检查 :在同步块内再次检查
instance == null,这是因为可能有多个线程同时通过第一次检查,其中一个线程进入同步块创建实例后,其他线程进入同步块时需避免再次创建。 -
volatile关键字:这是 DCL 的关键,用于禁止指令重排序,防止返回"半初始化"的对象。
3.2 为什么需要 volatile?
instance = new Singleton(); 这一行代码在 JVM 中实际上分解为三步:
-
分配内存空间
-
调用构造函数初始化对象
-
将引用指向分配的内存地址
但 JVM 可能会进行指令重排序 ,将第 2 步和第 3 步的顺序交换(步骤 2 和 3 没有数据依赖,允许重排)。如果发生重排序,可能先执行引用赋值(此时对象还未初始化),然后另一个线程在第一次检查时发现 instance 不为 null,直接返回这个对象。但该对象尚未初始化(可能某些字段还是默认值),程序在使用时就会出错。
volatile 的作用:
-
禁止指令重排序:保证初始化完成后再将引用赋值。
-
保证可见性 :一个线程修改
instance后,其他线程立即可见。
优点
-
延迟加载,只在第一次创建时同步,后续调用无同步开销,性能好。
-
线程安全,兼顾了性能和安全。
缺点
-
代码复杂 ,容易出错(如忘记
volatile)。 -
在 Java 1.5 之前,
volatile的语义较弱,不能完全保证 DCL 的正确性;从 Java 1.5 开始,volatile具备了完整的禁止重排序语义,DCL 才可靠。
适用场景
-
需要延迟加载,且对性能要求较高的场景。
-
如果不需要延迟加载,饿汉式或静态内部类更简单。
4. 静态内部类
4.1 代码示例
java
class Singleton {
private Singleton() {}
// 静态内部类
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
代码详解:
-
Singleton类中定义了一个静态内部类Holder。 -
Holder中有一个静态常量INSTANCE,在Holder类加载时初始化。 -
getInstance()方法直接返回Holder.INSTANCE。
4.2 实现原理
-
延迟加载 :外部类
Singleton加载时,内部类Holder并不会被加载。只有当第一次调用getInstance()时,虚拟机会加载Holder类,并初始化其静态变量INSTANCE。因此实现了延迟加载。 -
线程安全 :类加载过程由 JVM 保证线程安全(一个类只会被加载一次,且加载过程中其他线程无法访问)。所以
INSTANCE的创建是线程安全的。 -
无同步开销 :不需要
synchronized,性能与饿汉式一样好。
优点
-
简洁高效:代码简单,没有同步开销。
-
延迟加载:实例在第一次使用时创建,避免了资源浪费。
-
线程安全:由类加载机制保证,无需额外同步。
缺点
-
无法在创建时传递参数 :因为实例是通过静态初始化创建的,无法在运行时传入参数。但单例通常不需要参数,如果需要,可以在
getInstance()中通过某种配置机制获取。 -
相比枚举,无法防止反射和序列化破坏(但可以加防护)。
适用场景
- 需要延迟加载且对性能要求高的场景。这是实际开发中最常用的懒加载单例实现。
静态内部类何时被加载?------ 当内部类被主动使用时才会加载,主动使用包括访问其静态成员。
为什么线程安全?------ JVM 类加载机制保证。
与 DCL 对比:静态内部类更简单、更可靠,没有 volatile 的复杂性。
以上就是本篇的全部内容啦~下篇再见~