tulip notes
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Star-Lord

希望一天成为大师的学徒
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Java基础与面向对象

  • 高级进阶

  • 并发合集

    • Java中创建线程的几种方式
    • 并发相关概念与体系图
    • 线程状态与操作系统的用户态、内核态
    • 线程中的声明与守护线程_基础
    • 程序中的幽灵错误_基础
    • JDK并发包
    • 线程池相关
    • 并发中的安全集合
    • 生产者和消费者
    • 玩转单例模式
      • 相关信息
        • 定义
        • 优点
        • 劣势
        • 应用场景
      • 不同的实现模式
        • 懒汉式
        • 饿汉式
        • 双检测锁模式
        • 加第二道锁
        • 枚举实现单例
        • 序列化
        • 反序列化
    • 一些工具类的原理
    • 并发包中的AQS
    • ThreadLocal与JMM
    • 锁的探究
  • JVM合集

  • 实战与细节

  • 代码之丑与提升

  • 《Java》学习笔记
  • 并发合集
EffectTang
2024-11-17
目录

玩转单例模式

# 玩转单例模式

# 相关信息

# 定义

单例模式(Singleton Pattern)是一种常用的软件设计模式,用于确保一个类只有一个实例,并提供一个全局访问点。在 Java 中,单例模式有多种实现方式,包括懒汉式、饿汉式、双重检查锁定(DCL)、静态内部类等。

# 优点

  1. 唯一性:
    • 确保一个类只有一个实例,这对于需要频繁创建和销毁的对象非常有用,可以节省系统资源。
  2. 全局访问:
    • 提供了一个全局访问点,方便在整个应用程序中使用同一个对象实例。
  3. 资源控制:
    • 由于只有一个实例,可以更好地控制对资源的访问,例如数据库连接、线程池等。
  4. 性能优化:
    • 避免了频繁创建和销毁对象带来的性能开销,特别是在对象创建成本较高的情况下。

# 劣势

以下是单例模式的主要劣势:

  1. 过度使用导致高耦合
  • 高耦合:单例模式通常提供一个全局访问点,这会导致其他类对单例类的高度依赖。这种高度依赖使得代码的耦合度增加,降低了模块的独立性和可重用性。
  • 难于替换:一旦某个类依赖于单例,替换这个单例类变得非常困难,因为需要修改所有依赖它的代码。
  1. 单元测试困难
  • 全局状态:单例模式维护的是全局状态,这使得在单元测试中很难模拟不同的实例或状态。每次测试都需要重置单例的状态,增加了测试的复杂性。
  • 难以模拟:在单元测试中,模拟单例的行为比模拟普通对象更复杂,因为单例的生命周期和全局状态使得依赖注入和模拟变得更加困难。
  1. 多线程问题
  • 线程安全:如果不正确地实现单例模式,可能会导致线程安全问题。例如,懒汉式单例在多线程环境下可能会创建多个实例,除非使用同步机制(如 synchronized 或 volatile)。
  • 性能影响:为了保证线程安全,通常需要使用同步机制,这可能会引入额外的性能开销,尤其是在高并发场景下。
  1. 扩展性差
  • 单一实例:单例模式限制了一个类只能有一个实例,这在某些需要多个实例的场景下是不合适的。例如,如果需要根据不同配置创建多个实例,单例模式就不适用。
  • 灵活性低:单例模式缺乏灵活性,一旦确定了单例类的行为,后续的修改和扩展变得困难。
  1. 内存泄漏
  • 长时间占用资源:单例对象的生命周期与应用程序的生命周期相同,这可能导致资源长时间占用,尤其是在单例对象持有大量资源的情况下。
  • 垃圾回收困难:由于单例对象不会被垃圾回收器回收,可能会导致内存泄漏,特别是在单例对象持有大量内存或资源时。

# 应用场景

所谓的场景就是,在哪些情况下,可以将它的优势最大限度的发挥出来,尽可能的避免它的劣势带来的问题。

以下是一些常见的应用场景:

  1. 数据库连接:
    • 数据库连接池通常使用单例模式,以确保整个应用程序使用同一个连接池,减少资源消耗。
  2. 日志记录:
    • 日志记录器通常使用单例模式,以确保所有的日志记录操作都通过同一个实例进行。
  3. 配置管理:
    • 配置管理类通常使用单例模式,以确保配置信息在整个应用程序中一致。
  4. 线程池:
    • 线程池通常使用单例模式,以确保整个应用程序使用同一个线程池,提高线程的复用率。

# 不同的实现模式

# 懒汉式

懒汉模式,顾名思义,就是在需要的时候才进行创建。

public class Singleton {
    private static Singleton instance;
		// 将构造方法私有化 避免被调用,目的实现 单一实例
    private Singleton() {}
		// 构造方法被私有化了 因此获取实例的方法 只能为静态方法
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

只有需要的时候,才进行调用,因此需要判断是否为空。

但上述代码存在线程安全问题。当多个线程同时调用 getLazyMode() 方法时,可能会导致多次创建 LazyMode 实例,从而破坏单例的唯一性。

因此我们给其加上锁。比如sychronized。

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 饿汉式

饿汉模式,顾名思义,就是一开始就进行创建。

public class Singleton {
  	// 因此 对象要加static
    private static final Singleton instance = new Singleton();
		// 方法私有化
    private Singleton() {}
		// 因为无法通过构造方法 构造,因此获得方法只能为 静态方法
    public static Singleton getInstance() {
      // 静态方法 不能控制实例方法 因此instance 也要为静态对象
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11

在上述代码中加了一个final,它的作用是什么?必须加吗?

它不是必须的,即使不加,它也是线程安全的。

原因:因为它对应的方法和对象都是静态的,也就是说在类被加载的过程中创建,类加载是一个由 JVM 负责的过程,当类第一次被加载到 JVM 中时,类的静态变量会被初始化。这个过程是线程安全的(类加载的过程是线程安全的),因为 JVM 会确保每个类只会被加载一次。

而final 关键字确保 instance 引用在初始化后不能被改变,进一步保证了单例的唯一性。进一步提高了实例对象的可用性。

同时,如果不加 final,理论上可以通过反射或其他手段改变 instance 的引用,这可能会破坏单例的唯一性。

因此强烈建议,虽然不加 final 也可以实现线程安全,但加 final 是更好的选择。

# 双检测锁模式

懒汉模式比饿汉对资源的利用更优秀,因为它只在需要的时候才创建,减少了一段时间的浪费。但上述加了锁的懒汉式还可以继续优化。

它其实存在一些性能问题。调用instance都需要获取锁,这会导致性能下降。这无可避免,但实际上,你仔细一些就会发现,我们只需要锁住创建对象的代码即可,对于检测对象是否为null,并没有必要。

于是我们将锁的部分代码进行优化:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { 
            synchronized (Singleton.class) {
                instance = new Singleton();
            }
        }
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

如此一来,锁只会在创建对象的时候生效,性能得到了进一步提高。但是改的似乎不彻底,这次改动带来了线程安全问题。

# 加第二道锁

假设有两个线程 T1 和 T2 同时调用 getInstance 方法:

  1. T1 进入 getInstance 方法:
    • T1 检查 instance == null,结果为 true。
    • T1 准备进入 synchronized 块,但还没有进入。
  2. T2 进入 getInstance 方法:
    • T2 检查 instance == null,结果也为 true。
    • T2 准备进入 synchronized 块,但还没有进入。
  3. T1 进入 synchronized 块:
    • T1 创建 Singleton 实例并赋值给 instance。
  4. T2 进入 synchronized 块:
    • T2 也创建 Singleton 实例并赋值给 instance,此时 instance 被覆盖,导致两个线程创建了两个不同的实例。

为了解决上述问题,我们还需要在锁的代码中加一个检测。判断当前对象是否为空,并实现,T1线程创建对象后,T2线程在外面能够及时感知到变化。于是又进行了一番优化:

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

volatile这个关键字的作用:

  • 确保了 instance 的可见性,即当一个线程修改了 instance 的值,其他线程能够立即看到这个变化。

  • 这防止了指令重排序问题,确保 instance 的初始化是原子性的。

关于volatile在双检锁中起到的作用,这里就不展开了,更多的请大家自行查阅资料。

以上代码就是著名的懒汉模式的双检锁模式(Double-Checked Locking, DCL)模式。

# 枚举实现单例

除了上述实现单例的方式,其实还有一种方式,就是通过枚举。

通过枚举是一种非常有效和推荐的方式,主要是因为它具有以下优势:

简洁性

  • 枚举实现单例模式的代码非常简洁,易于理解和维护。
  • 不需要手动处理线程安全问题,也不需要使用 synchronized 关键字或 volatile 变量。

线程安全

  • 枚举的实例化由 JVM 保证线程安全。枚举的实例在类加载时创建,并且只创建一次,因此天然支持线程安全。
  • 不需要额外的同步机制来确保线程安全。

防止反射攻击

  • 枚举的构造方法是私有的,并且不能被外部访问。即使通过反射也无法创建新的枚举实例,从而防止了反射攻击。
  • 这一点对于单例模式非常重要,因为反射攻击可能会破坏单例的唯一性。

序列化和反序列化安全

  • 枚举类型默认实现了 Serializable 接口,因此可以轻松地进行序列化和反序列化。

  • 枚举在序列化和反序列化过程中是安全的。JVM 会确保枚举的实例在反序列化时仍然是同一个实例,不会创建新的实例。

  • 这一点对于需要在网络中传输或持久化的单例对象非常重要。

自动实现 Cloneable

  • 枚举类型默认实现了 Cloneable 接口,但调用 clone 方法会抛出 CloneNotSupportedException 异常,从而防止了克隆攻击。
public enum Singleton {
    INSTANCE;

    // 可以在这里添加方法和属性
    public void doSomething() {
        System.out.println("Doing something...");
    }

    public static void main(String[] args) {
        Singleton singleton = Singleton.INSTANCE;
        singleton.doSomething();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

枚举在序列化和反序列化时是安全的,主要是因为 JVM 对枚举类型的特殊处理。

# 序列化

  • 唯一标识符:每个枚举常量在序列化时都会被赋予一个唯一的标识符(通常是枚举常量的名字)。这个标识符在反序列化时用于恢复原始的枚举常量。
  • 序列化机制:枚举类型的序列化机制由 JVM 自动管理,确保每个枚举常量在序列化时都被正确地表示。

# 反序列化

  • 唯一性保证:在反序列化时,JVM 会根据枚举常量的唯一标识符查找并返回对应的枚举常量。如果找不到对应的枚举常量,会抛出 InvalidObjectException。
  • 防止新实例创建:JVM 确保在反序列化过程中不会创建新的枚举实例,只会返回已存在的枚举常量。
上次更新: 2025/04/23, 16:23:16
生产者和消费者
一些工具类的原理

← 生产者和消费者 一些工具类的原理→

最近更新
01
面向切面跟自定义注解的结合
05-22
02
时间跟其他数据的序列化
05-19
03
数据加密与安全
05-17
更多文章>
Theme by Vdoing | Copyright © 2023-2025 EffectTang
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式