【设计模式深度剖析】【1】【创建型】【单例模式】|并发编程|线程安全|线程同步|双重检查锁定|double-checked locking|volatile|synchronized|私有化构造函数

👉️下一篇:工厂方法模式

单例模式

单例模式代码示例

概要

单例模式定义

英文原话

单例模式的英文原话是: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被初始化,就不再需要进入同步块。

单例模式的优点

  1. 由于单例模式在内存中只有一个实例,减少了内存的开支
    特别是一个对象需要频繁地创建、销毁,而且创建或销毁的性能
    又无法优化时,单例模式的优势就非常明显。
  2. 由于单例模式只生成一个实例,所以减少了系统的性能开销
    当一个对象的产生需要比较多资源时,如读取配置、产生其他依赖
    对象时,则可以通过在启用时直接产生一个单例对象,然后用永久
    驻留内存的方式来解决。
  3. 单例模式可以避免对资源的多重占用,例如,一个写文件动作,
    由于只有一个实例存在于内存中,避免了对同一个资源文件的同时操作。
  4. 单例模式可以在系统设置全局的访问点,优化和共享资源访问
    例如,可以设计一个单例类,负责所有数据表的映射处理。

单例模式的缺点

  1. 单例模式无法创建子类扩展困难,若要扩展,除了修改代码基本上
    没有第二种途径可以实现。
  2. 单例模式对测试不利。在并行开发环境中,如果采用单例模式的类没有完成,
    是不能进行测试的;单例模式的类通常不会实现接口,这也妨碍了使用mock的方式虚拟一个对象。
  3. 单例模式与单一职责原则有冲突。一个类应该只实现一个逻辑,而不关心它是否是单例的,
    是不是要用单例模式取决于环境,单例模式把“要单例”和业务逻辑融合在一起。

单例模式的使用场景

如果要求一个类有且仅有一个实例,当出现多个实例时就会造成不良反应,
则此时可以采用单例模式。

  • 要求生成唯一序列号的环境
  • 整个项目中需要一个共享访问点或共享数据例如,一个Web页面上的计数器
    可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的。(**注意:**这是在单体架构场景下的,如果分布式环境下,如果还是用这种方式,每个JVM进程都会记录自己进程的计数,就会存在数据不一致问题。分布式场景下计数器有别的方案,比如使用Redis,基于Redis的INCR命令是一个原子操作)
  • 创建一个对象需要消耗的资源过多,如访问IO和数据库等资源
  • 需要定义大量的静态常量和静态方法(如工具类)的环境,
    可以采用单例模式(也可以采用直接声明为static的方式)。
  • [注]:单例模式是23个模式中比较简单的模式,应用也非常广泛,如在Spring框架中,
    每个Bean 默认就是单例的
    ;Java 基础类库中的java.lang.Runtime 类也采用了单例模式,
    其getRuntime()方法返回了唯一的实例。

使用单例模式的注意事项

根据功能,单例类可以分为有状态单例类和无状态单例类。

  1. 有状态单例类:一个有状态单例类的对象一般是可变的,
    通常当做状态库使用。例如,给系统提供一个唯一的序列号。
  2. 无状态单例类:无状态单例类是不变的,通常用来提供
    工具性的功能方法。例如, IO或数据库访问等。

因为单例类具有状态,所以在使用时应注意以下两点。

  1. 单例类仅局限于一个JVM,因此当多个JVM的分布式系统时,
    这个单例类就会在多个JVM中被实例化,造成多个单例对象的
    出现。如果是无状态的单例类,则没有问题,因为这些单例
    对象是没有区别的。如果是有状态的单例类,则会出现问题
    例如,给系统提供一个唯一的序列号,此时序列号不唯一,
    可能出现多次。因此,在任何使用EJB、RMI和JINI技术的分
    布式系统中,应当避免使用有状态的单例类。
  2. 同一个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创建对象的过程不是原子操作,它包括了多个步骤:

  1. 分配内存空间给对象

  2. 将分配的内存空间都初始化为零值(对于对象的成员变量)

  3. 设置对象的对象头信息(例如,哈希码、GC分代年龄等)

  4. 执行对象的初始化语句和构造方法

如果不使用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如何保证可见性的关键点:

  1. 立即同步到主内存:当一个线程修改了volatile变量的值,这个值会立即被同步到主内存,而不仅仅是写入到线程的本地缓存中。
  2. 每次读取都从主内存:当一个线程读取volatile变量的值时,它总是从主内存中读取,而不是从线程的本地缓存中读取。这确保了线程总是看到其他线程对volatile变量所做的最新修改。
5.3.2 禁止指令重排

volatile还有一个额外的效果,即禁止JVM对包含volatile变量的指令进行重排序。这有助于确保在多线程环境中代码的执行顺序与程序员的预期一致。

返回↩️

👉️下一篇:工厂方法模式

  • 36
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值