Java 懒加载(Lazy Loading)设计模式:按需初始化的性能优化实践
Java 懒加载(Lazy Loading)设计模式:按需初始化的性能优化实践
本文基于 java-design-patterns 仓库中的 lazy-loading 模块 及其源码,系统讲解 Lazy Loading(懒加载 / 延迟初始化)设计模式:它如何把昂贵对象的创建推迟到真正需要的那一刻,从而降低内存占用、加快应用启动速度。你将看到从"非线程安全"到"线程安全"再到"Java 8 高效实现"的三档代码演进,以及对应的单元测试验证和真实框架应用场景,可直接对照仓库源码学习与运行。
模式简介:又名"延迟初始化"
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"的状态机式设计**:
- 初始化时,
heavy字段指向方法引用this::createAndCacheHeavy(一个 lambda 形式的Supplier); - 首次调用
getHeavy()时,heavy.get()触发createAndCacheHeavy():进入synchronized方法,定义局部类HeavyFactory(其内部在字段初始化阶段就new Heavy()),然后判断当前heavy还不是HeavyFactory实例,便把它替换为新创建的HeavyFactory; - 之后所有
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、源码目录 与 测试目录 自行验证上述所有结论。
