MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java单例模式的懒加载与饿汉式加载对比分析

2021-09-234.3k 阅读

一、Java 单例模式概述

在 Java 编程中,单例模式是一种常见的设计模式,它确保一个类仅有一个实例,并提供一个全局访问点。这种模式在许多场景下都非常有用,比如数据库连接池、线程池、日志记录器等,这些组件在整个应用程序中通常只需要一个实例,以避免资源浪费和数据不一致的问题。

单例模式主要有两种常见的实现方式:懒加载(Lazy Loading)和饿汉式加载(Eager Loading)。这两种方式在创建单例实例的时机和线程安全性等方面存在差异。接下来我们将详细探讨这两种加载方式。

二、饿汉式加载

2.1 饿汉式加载原理

饿汉式加载,从名字上就能看出它比较“急切”。在类加载的时候,就立即创建单例实例。这种方式的优点是实现简单,并且天生线程安全。因为类加载过程是由 JVM 负责的,在多线程环境下,JVM 会保证类加载的唯一性,也就确保了单例实例的唯一性。

2.2 饿汉式加载代码示例

下面是一个简单的饿汉式单例类的代码示例:

public class EagerSingleton {
    // 在类加载时就创建实例
    private static final EagerSingleton instance = new EagerSingleton();

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

    // 提供全局访问点
    public static EagerSingleton getInstance() {
        return instance;
    }
}

在上述代码中,private static final EagerSingleton instance = new EagerSingleton(); 这行代码在类加载阶段就会执行,从而创建单例实例。private 修饰的构造函数保证了该类不能在外部被实例化,只有通过 getInstance() 方法才能获取到唯一的实例。

2.3 饿汉式加载的优缺点

  • 优点
    • 线程安全:由于在类加载时就创建实例,而类加载由 JVM 保证线程安全,所以饿汉式单例天然是线程安全的,无需额外的同步处理。
    • 实现简单:代码逻辑清晰,只需要在类加载时创建实例,并提供一个静态方法返回该实例即可。
  • 缺点
    • 资源浪费:如果单例实例在整个应用程序生命周期中不一定会被使用,那么在类加载时就创建实例可能会造成资源的浪费。比如,一个用于处理特定复杂业务的单例类,而这个业务在某些情况下可能不会执行,那么这个单例实例的提前创建就占用了内存等资源。

三、懒加载

3.1 懒加载原理

懒加载,也叫延迟加载,与饿汉式加载相反,它是在第一次使用该单例实例时才进行创建。这种方式的优点是在实际需要使用实例时才创建,避免了不必要的资源浪费。但同时,由于涉及到多线程环境下的实例创建,需要注意线程安全问题。

3.2 懒加载代码示例 - 非线程安全版本

public class LazySingleton {
    // 声明单例实例,但不立即初始化
    private static LazySingleton instance;

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

    // 提供全局访问点,在第一次调用时创建实例
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

在上述代码中,instance 初始值为 null,在 getInstance() 方法中,当第一次调用该方法时,instancenull,会执行 instance = new LazySingleton(); 创建实例。

然而,这种实现方式在多线程环境下是不安全的。假设有两个线程 A 和 B 同时调用 getInstance() 方法,并且此时 instancenull。那么线程 A 和 B 都可能通过 if (instance == null) 的判断,进而各自创建一个实例,这样就违背了单例模式的原则。

3.3 懒加载代码示例 - 线程安全版本(同步方法)

为了解决多线程环境下的线程安全问题,我们可以对 getInstance() 方法进行同步处理。

public class LazySingletonSynchronized {
    private static LazySingletonSynchronized instance;

    private LazySingletonSynchronized() {
    }

    // 同步方法,确保线程安全
    public static synchronized LazySingletonSynchronized getInstance() {
        if (instance == null) {
            instance = new LazySingletonSynchronized();
        }
        return instance;
    }
}

在上述代码中,通过在 getInstance() 方法上添加 synchronized 关键字,使得在多线程环境下,同一时间只有一个线程能够进入该方法,从而保证了单例实例的唯一性。

虽然这种方式解决了线程安全问题,但它也带来了性能问题。因为每次调用 getInstance() 方法都需要进行同步操作,而同步操作是比较耗时的,尤其是在高并发场景下,会严重影响系统性能。

3.4 懒加载代码示例 - 双重检查锁定(DCL)

为了在保证线程安全的同时提高性能,我们可以使用双重检查锁定(Double - Checked Locking,DCL)的方式。

public class LazySingletonDCL {
    // 使用 volatile 关键字确保可见性和禁止指令重排
    private static volatile LazySingletonDCL instance;

    private LazySingletonDCL() {
    }

    public static LazySingletonDCL getInstance() {
        // 第一次检查,减少不必要的同步开销
        if (instance == null) {
            synchronized (LazySingletonDCL.class) {
                // 第二次检查,确保只有一个线程创建实例
                if (instance == null) {
                    instance = new LazySingletonDCL();
                }
            }
        }
        return instance;
    }
}

在上述代码中,首先进行了 if (instance == null) 的第一次检查,这样在大多数情况下(实例已经创建),不需要进入同步块,从而减少了同步开销。然后在同步块内再次进行 if (instance == null) 的检查,确保只有一个线程能够创建实例。

这里 instance 使用 volatile 关键字修饰是非常重要的。volatile 关键字有两个作用:一是保证变量的可见性,即当一个线程修改了 instance 的值,其他线程能够立即看到;二是禁止指令重排,因为 instance = new LazySingletonDCL(); 这行代码在执行时,可能会被编译器和处理器进行指令重排,导致其他线程在 instance 还未完全初始化时就访问到它,使用 volatile 可以避免这种情况。

3.5 懒加载代码示例 - 静态内部类方式

除了 DCL 方式,还可以使用静态内部类的方式实现懒加载且线程安全。

public class LazySingletonInnerClass {
    // 私有构造函数,防止外部实例化
    private LazySingletonInnerClass() {
    }

    // 静态内部类,在外部类被加载时不会被加载
    private static class InnerClass {
        private static final LazySingletonInnerClass instance = new LazySingletonInnerClass();
    }

    // 提供全局访问点
    public static LazySingletonInnerClass getInstance() {
        return InnerClass.instance;
    }
}

在上述代码中,InnerClass 是一个静态内部类,它只有在第一次调用 getInstance() 方法时才会被加载。由于类加载由 JVM 保证线程安全,所以这种方式既实现了懒加载,又保证了线程安全。

3.6 懒加载的优缺点

  • 优点
    • 资源优化:只有在真正需要使用单例实例时才进行创建,避免了提前创建实例可能造成的资源浪费,对于一些资源消耗较大且不一定会使用的单例类非常适用。
  • 缺点
    • 线程安全问题复杂:在多线程环境下实现懒加载需要额外处理线程安全问题,如使用同步方法、双重检查锁定或静态内部类等方式,代码相对复杂,容易出错。如果处理不当,可能会导致线程安全漏洞,影响程序的正确性。
    • 性能开销:为了保证线程安全,在多线程环境下可能需要使用同步机制,这会带来一定的性能开销,尤其是在高并发场景下,对系统性能有一定影响,尽管如双重检查锁定等方式尽量减少了这种开销,但仍然存在。

四、懒加载与饿汉式加载的深入对比分析

4.1 内存使用对比

饿汉式加载在类加载时就创建单例实例,无论该实例是否会被使用,都会占用内存空间。如果应用程序中有多个这样的饿汉式单例类,且部分单例实例在运行过程中可能根本不会被用到,那么就会造成内存的浪费。

而懒加载只有在第一次使用时才创建实例,对于那些在某些情况下可能不会被使用的单例类,懒加载可以有效地节省内存。例如,在一个大型企业级应用中,可能存在一些用于处理特定业务模块的单例类,而这些业务模块在某些用户操作路径下不会被触发,使用懒加载就可以避免这些单例类提前占用内存。

4.2 性能对比

饿汉式加载由于在类加载时就创建实例,在后续获取实例的过程中,无需额外的创建操作,直接返回已创建的实例,所以获取实例的性能较高,几乎没有额外的性能开销。

懒加载在第一次获取实例时,需要进行实例的创建操作。如果使用同步方法来保证线程安全,每次获取实例都要进行同步操作,性能开销较大,特别是在高并发场景下,会成为性能瓶颈。而双重检查锁定虽然在一定程度上减少了同步开销,但由于涉及到 volatile 关键字的使用以及复杂的指令重排处理,也会有一定的性能影响。静态内部类方式相对来说性能较好,因为它利用了类加载的机制,既保证了懒加载又保证了线程安全,且没有额外的同步开销在每次获取实例时。

4.3 线程安全对比

饿汉式加载因为是在类加载阶段由 JVM 保证实例的唯一性,所以天生线程安全,不需要开发人员额外处理线程同步问题。

懒加载在多线程环境下如果不进行适当处理,就会出现线程安全问题,如前面提到的非线程安全版本的懒加载实现。虽然可以通过同步方法、双重检查锁定或静态内部类等方式来保证线程安全,但这些方式都需要开发人员对多线程编程有深入的理解和掌握,否则容易出现线程安全漏洞。例如,在双重检查锁定中,如果忘记使用 volatile 关键字修饰 instance,就可能导致在多线程环境下出现实例未完全初始化就被访问的问题。

4.4 应用场景对比

饿汉式加载适用于那些在整个应用程序生命周期中一定会被使用,且创建实例的开销不大的场景。比如,一些全局配置类,它们在应用启动时就需要被加载并使用,使用饿汉式加载可以保证在应用启动时就准备好相关配置信息,并且由于创建开销不大,不会造成性能问题。

懒加载则适用于那些实例创建开销较大,且在某些情况下可能不会被使用的场景。例如,在一个电商系统中,可能存在一个用于处理复杂促销活动规则计算的单例类,这个类只有在用户触发相关促销活动时才会被使用,使用懒加载就可以避免在系统启动时就创建该实例,节省资源。

五、总结两种加载方式的选择要点

在选择使用懒加载还是饿汉式加载时,需要综合考虑以下几个方面:

  1. 资源消耗:如果单例实例创建和占用的资源较大,且不一定会被使用,懒加载是更好的选择,以避免资源浪费。反之,如果资源消耗较小且一定会被使用,饿汉式加载简单直接。
  2. 性能要求:对于性能要求较高,获取实例频率高的场景,如果能接受提前创建实例带来的资源消耗,饿汉式加载由于其获取实例的高效性更合适。而在对资源敏感且获取实例频率相对较低的场景,懒加载即使有一定的性能开销也可以接受。
  3. 线程安全处理能力:如果开发团队对多线程编程掌握较好,能够正确处理懒加载的线程安全问题,那么懒加载在资源优化方面的优势可以得到充分发挥。否则,饿汉式加载由于其天生的线程安全性,更不容易出错。
  4. 应用场景特性:根据具体应用场景的业务逻辑和使用频率来选择。如一些基础的、全局的、启动即需的服务适合饿汉式加载,而一些特定业务流程中按需使用的服务适合懒加载。

通过对 Java 单例模式中懒加载与饿汉式加载的原理、代码实现、优缺点以及应用场景的详细对比分析,开发人员可以根据具体项目的需求,更加合理地选择适合的单例实现方式,从而优化系统性能,提高资源利用率。