单例模式设计

单例模式

单例模式是什么

核心思想:保证在一个进程中,某个类有且仅有一个实例,并提供一个全局访问点来获取这个实例。

由于这个类只有一个实例,所以不能让调用方使用new xxx()来创建实例。所以,单例的构造方法必须是private,这样就防止了调用方自己创建实例。

它们都遵循以下基本原则:

  1. 私有化构造函数:

    • 为了避免外部直接使用new关键字创建类的实例,单例模式的构造函数需要被声明为私有
    • 这样才能将类的创建权控制在类的内部,从而使得类的外部不能创建类的实例。
  2. 私有静态实例:

    • 单例类内部通常会有一个私有的静态变量来存放唯一的实例。
    • 这个实例是类级别的,不依赖于具体的对象实例,因此是全局性的。
  3. 公共静态方法:

    • 单例模式提供一个公共的静态方法(通常命名为getInstance()),用于全局访问这个唯一的实例。使得外部使用者可以访问类的唯一实例。

单例模式的这些实现确保了全局只有一个实例存在,并且这个实例可以在应用的任何部分被访问,从而方便地管理共享资源或全局状态。

实现单例类时,还需要考虑三个问题:

  • 创建单例对象时,是否线程安全。
  • 单例对象的创建,是否延时加载。
  • 获取单例对象时,是否需要加锁。

常用单例模式

—》单例模式介绍,关于懒汉模式,饿汉模式,双检索模式,内部类模式,以及枚举实现单例!!_懒汉模式为什么用双-CSDN博客

单例模式的本质区别在于 实例创建的时机JVM加载方式,也可以说是getInstance()方法的实现不同

getInstance() 方法是单例模式中用于获取单例类实例的公共静态方法。确保全局只有一个类实例,并提供一个访问点供外部获取这个实例。

下面将对比单例模式实现getInstance 的不同方式

饿汉模式

JVM在类的初始化阶段,会执行类的静态方法。

在执行类的初始化期间,JVM会去获取Class对象的锁。这个锁可以同步多个线程对同一个类的初始化

饿汉模式只在类加载的时候创建一次实例,没有多线程同步的问题。单例没有用到也会被创建,而且在类加载之后就被创建,内存就被浪费了。

class Single {
    private Single() {} // 私有化构造函数。

    private static Single s = new Single(); // 创建私有并静态的本类对象。

    public static Single getInstance() { // 定义公有并静态的方法,返回该对象。
        return s;
    }
}

INSTANCE 是在类加载时创建的(保证线程安全),因此getInstance() 方法只需返回这个预创建的实例。

优点:

  • 单例对象的创建是线程安全的;
  • 获取单例对象时不需要加锁。

缺点:单例对象的创建,不是延时加载

懒汉式

与饿汉式思想不同,懒汉式支持延时加载,将对象的创建延迟到了获取对象的时候。

不过为了线程安全,在获取对象的操作需要加锁,这就导致了低性能。

public class SingletonLazy {  
    private static volatile SingletonLazy INSTANCE;  
  
    private SingletonLazy() {}  
  
    public static synchronized SingletonLazy getInstance() {   //每次获取实例都要进入同步方法  
        if (INSTANCE == null) {  
            INSTANCE = new SingletonLazy();  
        }  
        return INSTANCE;  
    }  
}

instance 初始时是null,只有在首次调用getInstance() 时才创建实例。
需要使用synchronized 关键字确保线程安全,但可能会导致性能开销

上述代码加的锁只有在第一次创建对象时有用,而之后每次获取对象,其实是不需要加锁的(双重检查锁定优化了这个问题)。

优点:

  • 对象的创建是线程安全的。
  • 支持延时加载。

缺点:

  • 获取对象的操作被加上了锁,影响了并发性能。

双重检查锁定

双重检查锁定将懒汉式中的 synchronized 方法改成了 synchronized 代码块

使用volatile和synchronized来满足双重检查锁机制

volatile 关键字:确保了多线程环境下的可见性,保证了线程之间对变量的正确访问。

instance使用static修饰的原因:

getInstance为静态方法,因为静态方法的内部不能直接使用非静态变量,只有静态成员才能在没有创建对象时进行初始化,所以返回的这个实例必须是静态的。

代码如下:

public class SingletonDoubleCheck {
    private static volatile SingletonDoubleCheck INSTANCE;

    private SingletonDoubleCheck() {
        // 私有化构造函数防止外部实例化
    }

    public static SingletonDoubleCheck getInstance() {
        if (INSTANCE == null) {
            synchronized (SingletonDoubleCheck.class) {
                if (INSTANCE == null) {
                    INSTANCE = new SingletonDoubleCheck();
                }
            }
        }
        return INSTANCE;
    }
}


它是在懒汉式上的优化,减少懒汉式中同步的开销,只在实例未被创建时同步

双重校验锁先判断 instance 是否已经被实例化,如果没有被实例化,那么才对实例化语句进行加锁。

如何减少同步开销 —两次检查
避免了每次访问getInstance()方法时都进行同步操作

  1. 首次检查(无锁):

    • 第一次检查实例是否为null是在同步块之外进行的。
    • 如果实例已经被创建(即在首次调用之后),就直接返回实例,避免进入同步块。这个步骤减少了大多数调用的同步开销,因为同步只发生在实例初次创建时。
  2. 同步块:

    • 如果第一次检查发现实例为null,则进入同步块。
    • 这里的同步是必要的,以确保在多线程环境中只有一个线程可以创建实例。
  3. 第二次检查(有锁):

    • 在同步块内部,再次检查实例是否为null。这一步是必要的,因为在当前线程进入同步块之前,可能已经有另一个线程创建了实例。如果不进行第二次检查,实例可能会被创建多次。
    • 这第二次检查确保了即使多个线程同时通过了第一次检查并等待进入同步块,它们也不会重复创建实例。

首先检查instance 是否已经创建,然后在synchronized 块内再次检查。
只有在实例真正需要被创建时,才会进行同步操作

一旦实例被创建,后续的访问就不需要同步了。

为什么两次判断instance == null:

TimeThread AThread B
T1检查到instance
为空
T2检查到instance
为空
T3初始化对象A
T4返回对象A
T5初始化对象B
T6返回对象B

new Singleton()会执行三个动作

  • 分配内存空间
  • 初始化对象
  • 对象引用指向内存地址。
memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
instance = memory;   // 3:设置instance指向刚分配的内存地址

由于指令重排优化的存在,导致初始化对象和将对象引用指向内存地址的顺序是不确定的。

在某个线程创建单例对象时,会为该对象分配了内存空间并将对象的字段设置为默认值。 此时就可以将分配的内存地址赋值给instance字段了,然而该对象可能还没有初始化。

若紧接着另外一个线程来调用getInstance,取到的是未初始化的对象,程序就会出错。

volatile 可以禁止指令重排序,保证了先初始化对象再赋值给instance变量。

TimeThread AThread B
T1检查到instance
为空
T2获取锁
T3再次检查到instance
为空
T4为instance
分配内存空间
T5将instance
指向内存空间
T6检查到instance
不为空
T7访问instance
(此时对象还未完成初始化)
T8初始化instance

优点:

  • 对象的创建是线程安全的。
  • 支持延时加载。
  • 获取对象时不需要加锁。

静态内部类

它与饿汉模式一样,也是利用了类初始化机制,因此不存在多线程并发的问题。不一样的是,它是在内部类里面去创建对象实例。

这样的话,只要应用中不使用内部类,JVM就不会去加载这个单例类,也就不会创建单例对象,从而实现懒汉式的延迟加载。也就是说这种方式可以同时保证延迟加载和线程安全。

基于类初始化的方案的实现代码更简洁。

public class Instance {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    private Instance() {}
    public static Instance getInstance() {
        return InstanceHolder.instance ;  // 这里将导致InstanceHolder类被初始化
    }
}

如上述代码,InstanceHolder 是一个静态内部类,当外部类 Instance 被加载的时候,并不会创建 InstanceHolder 实例对象。

只有当调用 getInstance() 方法时,InstanceHolder 才会被加载,这个时候才会创建 Instance。Instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。

优点:

  • 对象的创建是线程安全的。
  • 支持延时加载。
  • 获取对象时不需要加锁。

枚举

用枚举来实现单例,是最简单的方式。

这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。

public enum Singleton {
INSTANCE; // 该对象全局唯一
}

总结

  • 饿汉式

    • getInstance()方法直接返回预先创建的实例。
    • 这种方式的特点是实现简单,但可能在类加载时就占用资源,无论实例是否需要。
  • 懒汉式

    • getInstance()在首次被调用时实例化单例对象,而不是在类加载时
    • 这种延迟加载的方式节省了资源,但需要额外的同步处理来确保线程安全,可能影响性能。(例如使用synchronized关键字)
  • 双重检查锁定(Double-Checked Locking)

    • 两次判空
      • 第一层判断为了避免不必要的同步
      • 第二层判断则是为了在null的情况下创建实例代码会检查两次单例类是否有已存在的实例
    • 这种方法需要正确处理volatile变量以避免多线程环境下的问题。
  • 枚举式(Enum Singleton):

    • 枚举式单例模式是实现单例的最佳方法之一。它自然地支持序列化机制,并由JVM保证只有一个单例实例。
    • 枚举单例不仅可以解决多线程同步问题,而且还可以防止反序列化重新创建新的对象。
    • 在这种方式中,单例实现为一个枚举的一个元素,这利用了枚举类型的语言特性来实现单例。

从JVM的角度来解析

饿汉单例模式比较特殊,从字面意思也能看出它较后几种好像吃相更厉害,它是在jvm内部的类加载初始化阶段就创建了实例,而其他的好像不是那么饿,所以都是需要的时候才开始创建实例。

—》下面是四种模式在JVM加载时机的比较

  • 饿汉式:立即创建实例,在JVM加载类并进行初始化阶段时就创建了单例实例,不考虑是否会被使用 (线程安全)
  • 懒汉式:延迟创建实例,实例是在首次调用getInstance()方法时创建的,只有当实例真正需要时才会创建。
  • 双重检查锁定:优化了懒汉式的性能,它在getInstance()方法被调用时,首先检查实例是否已经创建,如果尚未创建,才进入同步块。
    • 这种方法确保了只有第一次调用getInstance()方法时才会执行同步,从而减少了同步的开销。
  • 枚举式:利用Java枚举类型的特性,不仅简化了实现,还提供了序列化机制和保证单例的功能。

饿汉式->懒汉式->双重检查锁

懒汉式和双重检查锁定式都是在实例首次被请求时才创建,相较于饿汉式在JVM加载类的时候立即初始化实例,这样可以减少内存的使用,并提高应用的启动速度

JVM创建单例实例流程

类加载阶段

  1. 加载(Loading):

    • JVM通过类加载器读取类的二进制数据,并将其转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class对象。
    • 在这一阶段,类的静态变量还没有被初始化。
  2. 验证(Verification):

    • 检查加载的类或接口的二进制数据是否符合JVM规范,包括格式检查、元数据检查、字节码验证和符号引用验证。
  3. 准备(Preparation):

    • JVM为类变量(静态变量)分配内存,并将其初始化为默认值,这些变量所使用的内存都在方法区中分配。
    • 注意静态变量只是被分配内存并赋予默认值,并没有进行显式的初始化(即赋予在代码中指定的值)。
      • Ag. 对于static int i;,即使在代码中定义为static int i = 1;,在准备阶段i也会先被赋予0(int类型的默认值),而不是1
  4. 解析(Resolution):

    • JVM将常量池中的符号引用替换成直接引用。符号引用来自Java编译阶段的产物,直接引用是指向内存地址的引用。
  5. 初始化(Initialization):

    • 这是类加载过程的最后一步,也是饿汉式单例实例创建的关键所在。
    • 在这个阶段,JVM负责执行类构造器<clinit>()方法,这个方法包含了静态变量的赋值操作和静态代码块中的语句。
      • <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生的。
      • <clinit>()方法是同步的,这个过程保证了类变量在多线程环境中的线程安全性。(也保证了饿汉式单例的线程安全)
    • 对于饿汉式单例,单例实例在此阶段被创建,因为它是在静态变量赋值时实例化的。
  • 38
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值