type
status
date
slug
summary
tags
category
password

1、单例模式是什么

单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来访问该实例
单例模式的适用场景:
  • 需要频繁创建和获取的对象,例如 Spring 容器的各种 Bean。
  • 创建对象耗时或资源消耗大,例如 Servlet 的 ServletContext
  • 工具类对象,例如 Jackson 的 ObjectMapper
  • 频繁访问数据库或文件的对象,例如 Druid 的 DruidDataSource
  • 需要控制共享资源访问的场景,例如 Spring 容器的 ApplicationContext

2、单例模式的实现

2.1 懒汉式(非线程安全)

这种是最简单的单例写法,特点:
  • 构造方法使用 private关键字,确保外部只能通过 getInstance() 获取单例。
  • 实例对象使用 static 关键字,确保全局只有一个实例。
  • 线程不安全:并发时可能出现多个单例。
其中最大的问题就是线程不安全,后面的多种方案都是为了解决线程不安全的问题。

2.2 饿汉式(线程安全)

相比起非线程安全的懒汉式写法,最大区别就是在类初始化的时候提前创建好实例。
特点:
  • 线程安全:因为提前创建了,所以是天生的线程安全。
  • 性能影响:在类加载的时候就会初始化,这对应用的启动会造成一定程度的影响。

2.3 懒汉式(使用synchronized

相比起非线程安全的懒汉式写法,最大区别就是 getInstance() 方法使用 synchronized 修饰
特点:
  • 线程安全:因为使用了 synchronized 修饰 getIntance() 方法,可以保证获取实例一定是线程安全。
  • 性能较差:每次获取实例之前都需要先获取锁,导致性能较差。

2.4 懒汉式(Double Check)

单例模式最经典的双重检查(Double Check)写法,相比起使用 synchronized 修饰 getInstance() 的懒汉模式,最大区别就是
  • 使用 volatile 修饰实例(为什么要用 volatile 后面会解释)。
  • synchronized 不再用来修饰 getInstance() 方法,而是用来修饰 Singleton 类,并增加一段双重判断的逻辑。
特点:
  • 线程安全。
  • 性能较高:只在第一次创建实例的时候需要获取锁,后续在获取实例的时候可以直接返回,不需要获取锁。

2.5 静态内部类(推荐)

这种方式巧妙地解决了饿汉式(线程安全)的内存浪费问题和 synchronized 的性能问题。推荐使用这种方式。
  • 避免内存浪费:内部类在方法被调用之前才会初始化,所以不会造成内存浪费和影响程序启动速度。
  • 线程安全:内部类里面的实例其实就是饿汉式(线程安全)的写法,所以可以保证线程安全。

2.6 枚举实现(官方推荐)

《Effective Java》推荐枚举的方式,特点:
  • 线程安全:枚举类型默认就是安全的
  • 避免反序列化破坏单例
但是枚举类型会造成更多的内存消耗。枚举会比使用静态变量多消耗两倍的内存,如果是 Android 应用,尽量避免这种方式。

3、单例模式常见问题

3.1 为什么单例模式中的Double Check要加volatile

volatile 的作用是禁止指令重排序。在 java 里面 new 一个对象在 JVM 层面并不是一个原子操作,大概分为以下三条指令:
  1. 申请一块内存,此时成员变量赋默认值。
  1. 调用类的构造方法,给成员变量赋初始值。
  1. 将这块内存区域赋值给对应的变量。
JIT 编译器在进行代码优化的时候,有可能会进行指令重排序,把第2,3步骤调换。从 JVM 的角度来说这种调换并不会影响最终实例的创建,但是在这段 Double Check 代码里面,在高并发的情况下有可能会发生问题:
  1. 线程 A 在调用new Singleton()的时候,刚执行第二步(把内存区域赋值给INSTANCE 变量,此时内存里面的成员变量都是默认值),还没到第三步(调用类的构造方法,给成员变量赋初始值)的时候,线程 B 也调用了 getInstance 方法。
  1. 线程 B 判断INSTANCE == null,由于此时 INSTANCE 不为空,所以直接返回了INSTANCE 对象,但是此时 INSTANCE 才仅是半初始化状态(成员变量都是默认值),线程 B 拿到的其实是一个不完整的 INSTANCE 对象,这种情况下是会出问题的。
使用 volatile 来修饰 INSTANCE 对象 ,就可以避免指令重排序的问题。但是在实际的生产中,上述的情况发生的其实很少,只有在极高的并发量的时候才会偶尔出现。如果程序并发量不高,不使用 volatile 关键字一般也不会出现问题。

3.2 为什么静态内部类是线程安全的

简单来说,JVM虚拟机会保证一个类的类构造器 <clinit>() 在多线程环境中被正确的加锁和同步,使得静态内部类在被加载和初始化时是绝对线程安全的
以下面这个类为例:
getInstance() 被调用时发生的初始化过程:
  1. 首次调用 getInstance():当线程 A 第一次调用 Singleton.getInstance() 时,JVM 会开始加载并初始化 Holder 这个静态内部类。
  1. 触发初始化:根据 Java 规范,Holder.INSTANCE 是一个静态字段,它的使用(return Holder.INSTANCE)触发了 Holder 类的初始化条件。
  1. 获取初始化锁:JVM 会为 Holder 类获取一个唯一的初始化锁。假设此时线程 B 也来调用 getInstance(),它同样会遇到 return Holder.INSTANCE,并试图初始化 Holder 类。但由于线程 A 已经持有了 Holder 类的初始化锁,线程 B 会被阻塞 (Block),进入等待状态。
  1. 执行初始化 (<clinit>()):在线程 A 持有锁期间,JVM 执行 Holder 类的 <clinit>() 方法。这个方法是由编译器自动生成的,包含了所有静态字段的赋值操作。在这里,就是执行 private static final Singleton INSTANCE = new Singleton();
  1. 完成初始化并释放锁:当 INSTANCE 被成功创建并赋值后,Holder 类的初始化完成。线程 A 释放初始化锁。
  1. 其他线程继续:此时,线程 B(以及其他可能被阻塞的线程)获取到锁。它们会发现 Holder 类已经被初始化完毕,于是直接返回已经被初始化好的 INSTANCE 实例,而不会再次执行初始化过程。

3.3 枚举类实现单例模式的原理

枚举实现单例模式的原理,核心在于 Java 枚举类型的本质和 JVM 的底层保障
  1. 枚举是特殊的类
      • 在 Java 中,使用 enum 关键字定义的枚举类型,在编译后其实是一个继承了 java.lang.Enum 的 final 类。
      • 例如下面的 Singleton 枚举编译后大致相当于下面的类结构(这是一个概念上的类比,实际实现更复杂):
        • 从这个“等价代码”可以看出,枚举项 (INSTANCE) 本质上就是一个 public static final 的类实例。
    1. JVM 对枚举的保障(关键!):JVM 在加载枚举类时,会像处理静态常量一样,在类的初始化阶段(<clinit> 方法)创建所有的枚举实例。这个过程是线程安全的,因为 JVM 会保证一个类只被加载一次,并且类的初始化过程是同步的。这从根本上防止了通过反射创建多个实例的可能性(在类加载之后,实例已经创建好了)。
    1. 序列化与反序列化的安全:这是枚举单例相对于“双重检查锁”或“静态内部类”等方式最大的优势。
        • 普通单例类需要实现 Serializable 接口,并且必须额外提供 readResolve() 方法来防止反序列化时创建新的对象,破坏单例。
        • Java 规范中明确规定,枚举类型的序列化和反序列化是有特殊处理的。序列化时只会保存枚举的 name 属性;反序列化时,会通过 Enum.valueOf() 方法根据这个 name 去查找已经存在的枚举实例,而不会新建一个对象。这保证了反序列化后得到的仍然是同一个实例。
    1. 防止反射攻击:
        • 普通的单例模式(即使是饿汉式或静态内部类),其构造方法是私有的,但仍然可以通过反射机制 setAccessible(true) 来强制调用私有构造器,从而创建第二个实例。
        • Java 的 Constructor 类中明确禁止使用反射来创建枚举实例。如果你尝试这样做,会直接抛出 IllegalArgumentException 异常。这是 Java 语言层面的保障,使得枚举单例对反射攻击免疫。
    设计模式系列:委派模式Mysql集群篇:Binlog解析
    Loading...