Java 懒加载(Lazy Loading)设计模式:按需初始化的性能优化实践

原创2026-09-29 22:41:181,709 阅读
文章标签:示例工程后端

Java 懒加载(Lazy Loading)设计模式:按需初始化的性能优化实践

本文基于 java-design-patterns 仓库中的 lazy-loading 模块 及其源码,系统讲解 Lazy Loading(懒加载 / 延迟初始化)设计模式:它如何把昂贵对象的创建推迟到真正需要的那一刻,从而降低内存占用、加快应用启动速度。你将看到从"非线程安全"到"线程安全"再到"Java 8 高效实现"的三档代码演进,以及对应的单元测试验证和真实框架应用场景,可直接对照仓库源码学习与运行。

Lazy Loading 时序图:对象创建被延迟到首次请求时才触发

模式简介:又名"延迟初始化"

Lazy Loading 设计模式在 Java 中也常被称为 Lazy Initialization(延迟初始化)。其核心意图是:将对象的初始化推迟到该对象真正被使用的时刻,以最小化内存占用、缩短启动时间。这种技术对优化 Java 应用性能至关重要——尤其当某些对象构造代价高昂、却并非每次运行都会用到时。

用一句话概括:Lazy Loading 模式把对象或资源的创建推迟到实际需要时,以此优化内存使用并提升性能。

Wikipedia 对懒加载的定义补充了背景:懒加载(也称异步加载)是一种用于推迟对象初始化直到其被需要时的编程技术。如果使用得当,它可以显著提升程序运行效率,特别适合那些"初始化耗时需要尽量压低"的场景,例如需要访问网络内容的网页——把图片的加载推迟到需要查看时,可以让页面首屏显示更快。与懒加载相对的是急加载(Eager Loading),即在对象创建的同时立刻初始化其全部依赖。

现实世界类比:智能家居的感应灯

一个贴近生活的类比是智能家居中的灯光控制:当有人进入房子时,系统并不会一次性把所有房间的灯全部打开,而是通过运动传感器只点亮当前有人使用房间的灯。同样的思路映射到 Java 开发中,就是延迟对象的创建、避免一次性初始化全部资源,从而优化性能与内存。

何时使用 Lazy Loading 模式

根据 lazy-loading/README.md 的说明,在以下场景适合使用 Lazy Loading:

  • 对象的创建代价高昂,且不一定每次都会被用到——例如重量级数据库连接、大对象、复杂领域模型;
  • 需要推迟对象创建以优化内存使用或改善启动时间——把昂贵的初始化成本从应用启动阶段转移到首次访问时刻;
  • 数据或资源的加载应该"即时"发生,而非在应用启动时全部加载——即 just-in-time 按需加载。

程序化示例:仓库源码中的三种实现

在 lazy-loading/src/main/java/com/iluwatar/lazy/loading 目录下,本模块用一个"代价昂贵的 Heavy 对象"演示了懒加载的完整演进路线,涉及四个类:

  • App —— 程序入口;
  • HolderNaive —— 最朴素的懒加载实现(非线程安全);
  • HolderThreadSafe —— 线程安全实现(每次访问都有同步开销);
  • Java8Holder —— 利用 Java 8 特性的高效线程安全实现。

1. 昂贵的目标对象:Heavy

Heavy.java 模拟了一个创建成本极高的对象:构造函数中打印日志并 Thread.sleep(1000) 阻塞 1 秒,代表"创建一次就要付出显著开销"。

@Slf4j
public class Heavy {

  /** Constructor. */
  public Heavy() {
    LOGGER.info("Creating Heavy ...");
    try {
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      LOGGER.error("Exception caught.", e);
    }
    LOGGER.info("... Heavy created");
  }
}

这个 1 秒的构造耗时非常关键:如果应用启动时就创建多个 Heavy,启动时间会被成倍拖慢;而懒加载可以把这 1 秒推迟到真正需要时。

2. 入口类:App

App.java 是程序入口,依次从三种 Holder 中获取 Heavy 对象,并打印日志:

@Slf4j
public class App {

  public static void main(String[] args) {

    // Simple lazy loader - not thread safe
    var holderNaive = new HolderNaive();
    var heavy = holderNaive.getHeavy();
    LOGGER.info("heavy={}", heavy);

    // Thread safe lazy loader, but with heavy synchronization on each access
    var holderThreadSafe = new HolderThreadSafe();
    var another = holderThreadSafe.getHeavy();
    LOGGER.info("another={}", another);

    // The most efficient lazy loader utilizing Java 8 features
    var java8Holder = new Java8Holder();
    var next = java8Holder.getHeavy();
    LOGGER.info("next={}", next);
  }
}

观察运行输出可以发现:HolderNaive created、HolderThreadSafe created、Java8Holder created 会先出现,而 Creating Heavy ... / ... Heavy created 只在调用 getHeavy() 时才出现——这就是"延迟创建"的直接证据。

3. 第一档:HolderNaive(非线程安全)

HolderNaive.java 是最简单的实现:持有可空的 heavy 字段,getHeavy() 中先判空、再创建:

@Slf4j
public class HolderNaive {

  private Heavy heavy;

  public HolderNaive() {
    LOGGER.info("HolderNaive created");
  }

  public Heavy getHeavy() {
    if (heavy == null) {
      heavy = new Heavy();
    }
    return heavy;
  }
}

它的优点是直观易懂;缺点是非线程安全:在多线程并发调用 getHeavy() 时,多个线程可能同时观察到 heavy == null 并各自 new 一个 Heavy,导致重复创建。

4. 第二档:HolderThreadSafe(线程安全但有同步开销)

HolderThreadSafe.java 在 HolderNaive 基础上给 getHeavy() 加了 synchronized:

@Slf4j
public class HolderThreadSafe {

  private Heavy heavy;

  public HolderThreadSafe() {
    LOGGER.info("HolderThreadSafe created");
  }

  public synchronized Heavy getHeavy() {
    if (heavy == null) {
      heavy = new Heavy();
    }
    return heavy;
  }
}

从源码注释可以确认其设计定位:"Same as HolderNaive but with added synchronization. This implementation is thread safe, but each getHeavy() call costs additional synchronization overhead."——即线程安全,但每次 getHeavy() 调用都要付出同步开销。即使 heavy 早已创建完成,后续的每次读取仍要经过 synchronized 加锁,这在读多写少的高频访问场景下并不划算。

5. 第三档:Java8Holder(线程安全 + 高效)

Java8Holder.java 是本模块中最精巧的实现,利用 Java 8 的函数式接口 Supplier<Heavy> 作为惰性工厂:

@Slf4j
public class Java8Holder {

  private Supplier<Heavy> heavy = this::createAndCacheHeavy;

  public Java8Holder() {
    LOGGER.info("Java8Holder created");
  }

  public Heavy getHeavy() {
    return heavy.get();
  }

  private synchronized Heavy createAndCacheHeavy() {
    class HeavyFactory implements Supplier<Heavy> {
      private final Heavy heavyInstance = new Heavy();

      @Override
      public Heavy get() {
        return heavyInstance;
      }
    }

    if (!(heavy instanceof HeavyFactory)) {
      heavy = new HeavyFactory();
    }

    return heavy.get();
  }
}

这段代码的精妙之处在于**"以 Supplier 换 Supplier"的状态机式设计**:

  1. 初始化时,heavy 字段指向方法引用 this::createAndCacheHeavy(一个 lambda 形式的 Supplier);
  2. 首次调用 getHeavy() 时,heavy.get() 触发 createAndCacheHeavy():进入 synchronized 方法,定义局部类 HeavyFactory(其内部在字段初始化阶段就 new Heavy()),然后判断当前 heavy 还不是 HeavyFactory 实例,便把它替换为新创建的 HeavyFactory;
  3. 之后所有 getHeavy() 调用,heavy 已经指向 HeavyFactory,直接返回其缓存的 heavyInstance,不再进入 synchronized 方法——加锁只发生在第一次创建时。

这正是它比 HolderThreadSafe 更高效的原因:把同步范围缩小到"仅首次初始化",后续读取完全无锁。对应的 UML 类结构可以在 lazy-loading/etc/lazy-loading.urm.puml 中查看(HeavyFactory 作为局部类与 Java8Holder、Heavy 的关系清晰可见)。

单元测试如何验证"延迟"行为

仓库为这个模块编写了专门的测试,用于验证懒加载的两个核心不变量:"访问前内部值为空" 与 "多次获取返回同一实例"。

AbstractHolderTest:懒加载的通用契约

AbstractHolderTest.java 是一个抽象测试基类,三种 Holder 的测试都继承它:

@Test
void testGetHeavy() {
  assertTimeout(
      ofMillis(3000),
      () -> {
        assertNull(getInternalHeavyValue());
        assertNotNull(getHeavy());
        assertNotNull(getInternalHeavyValue());
        assertSame(getHeavy(), getInternalHeavyValue());
      });
}

它验证了完整的懒加载语义:

  • 调用 getHeavy() 之前,Holder 内部的 heavy 字段为 null(对象确实还没有创建);
  • 调用 getHeavy() 之后,内部值非空;
  • 再次调用 getHeavy() 返回的是同一个实例(assertSame),确保只创建一次;
  • 整体在 3 秒内完成(包含 Heavy 构造的 1 秒耗时)。

Java8HolderTest:用反射验证 Supplier 替换

Java8HolderTest.java 的验证手法非常巧妙:通过反射读取 Java8Holder 的 heavy 字段并判断其运行时类型——懒加载前它是 lambda(不是局部类),加载后被替换成了局部类 HeavyFactory,从而从内部状态层面证明"惰性 Supplier 已被缓存 Supplier 取代"。测试注释也坦言这是"a little fishy"(略显取巧),但从侧面印证了 Java8Holder 的实现机制。

AppTest:入口冒烟测试

AppTest.java 只做一件事:调用 App.main(new String[]{}) 并断言不抛异常,确保三种实现可以串联运行。

如何在本仓库中运行与测试

lazy-loading 是一个独立 Maven 模块,其 pom.xml 声明了 slf4j-api、logback-classic(日志输出)与 junit-jupiter-engine(测试)依赖,并通过 maven-assembly-plugin 将主类设置为 com.iluwatar.lazy.loading.App。仓库根目录提供了 Maven Wrapper(mvnw),在仓库根目录下可以这样运行:

# 运行懒加载模块的全部单元测试
./mvnw -pl lazy-loading test

# 编译并运行 App 主程序,观察 "Creating Heavy ..." 延迟出现
./mvnw -pl lazy-loading compile exec:java -Dexec.mainClass=com.iluwatar.lazy.loading.App

前提说明:命令需在仓库根目录执行,且本机需具备 Maven Wrapper 可用的 Java 环境;具体构建方式以根目录 pom.xml 的约定为准。

真实世界中的懒加载应用

Lazy Loading 是 Java 生态中被广泛使用的成熟技术,文档中列举的代表性应用包括:

  • Hibernate(Java ORM 框架):延迟加载关联对象,直到这些对象真正被访问时才从数据库取出,避免一次查询拉回整棵对象图;
  • JPA 注解与抓取策略:@OneToOne、@OneToMany、@ManyToOne、@ManyToMany 配合 fetch = FetchType.LAZY,把关联实体的加载推迟到首次访问;
  • Spring Framework(依赖注入容器):仅在需要时加载 Bean(如 @Lazy 注解),从而减少应用启动时间。

这些框架的实践与本模块的 Heavy 演示遵循同一原理:把昂贵资源的创建/获取推迟到首次使用点。

收益与权衡

收益(Benefits):

  • 降低内存占用:只在需要时初始化对象,不会为"可能永远用不到"的资源预留内存;
  • 改善启动性能:把昂贵的对象创建从应用启动阶段推迟,启动更快;
  • 按需加载:数据或资源的加载发生在首次访问时刻,而非启动时全量加载。

权衡(Trade-offs):

  • 实现复杂度上升:如果对象之间存在相互依赖,懒加载的编排会明显复杂化;
  • 潜在的延迟尖峰(latency spikes):如果初始化发生在用户不可预期的时刻(例如请求处理中途才创建重量级对象),可能造成响应变慢;
  • 并发正确性成本:正如 HolderThreadSafe 所示,保证线程安全需要付出同步开销,或像 Java8Holder 那样引入更精巧的实现。

相关设计模式

  • Proxy(代理):代理可以作为懒加载对象的占位符,把真实对象的加载推迟到必要时——本模块的时序图展示的正是"代理检查-延迟创建-缓存复用"的流程;
  • Virtual Proxy(虚拟代理):Proxy 的一种特化,专门负责按需创建对象;
  • Singleton(单例):常与懒加载结合使用,在保证"全局唯一实例"的同时把创建推迟到首次访问,经典做法如"懒汉式单例 + 双重检查锁"。

参考资料

本模块文档列出的延伸阅读资料(外部书籍,非本仓库内容):

  • 《J2EE Design Patterns》
  • 《Java Performance: The Definitive Guide: Getting the Most Out of Your Code》
  • 《Patterns of Enterprise Application Architecture》

如需深入研读,可直接在仓库中对照 lazy-loading/README.md、源码目录 与 测试目录 自行验证上述所有结论。

登录后查看全文
java-design-patterns