Kotlin by lazy 初始化抛异常后,下一次访问会怎样?源码解析与生产实践
2026/9/16 15:26:20 网站建设 项目流程

很多接触过 Kotlin 的人都知道by lazy这个委托属性,但问到"初始化块抛异常后,懒加载属性在下一次访问时会走什么执行流程",能答清楚的人就不多了。某个对象创建时不需要立刻初始化,等到真正要用的那一刻才执行初始化逻辑,这就是 lazy 委托的核心价值。相比 Android 里常见的"先用 lateinit 声明、手动判空初始化",by lazy把延迟初始化做成了语言级特性,简单干净,所以流行度一直很高。

但越是这种看似简单的东西,越容易在边界场景里暴露出理解深度的问题。初始化块一旦抛出异常,lazy 内部的状态标记是保持在"未初始化"还是被改写成"已缓存异常"?锁会不会释放?下一次访问是直接抛出同一个异常,还是重新执行初始化?这些问题如果不读源码,很难给出准确答案。这篇文章就从异常时的执行流程切入,把 lazy 三层模式的行为差异、底层状态机逻辑、生产环境里的坑一次讲透,顺带聊聊这类问题在面试里应该怎么回答。

适合谁来读:正在用 Kotlin 做 Android、服务端、Kotlin Multiplatform 的开发者都算,尤其是那些在代码里大量使用by lazy、但对底层机制没有完整把握的人。读完你至少能搞明白一件事——当初始化函数抛异常的那一瞬间,lazy 内部到底发生了什么。

1. 一个被多数人忽略的问题:by lazy 初始化抛异常后,下次访问会怎样?

1.1 先还原一个真实的使用现场

假设你在 Android 项目里写了这么一段代码:

private val appConfig: AppConfig by lazy { loadConfigFromNetwork() // 可能抛 IOException }

用户进入页面时第一次访问appConfig,此时网络抖动了一下,loadConfigFromNetwork()抛出异常,页面崩溃。用户退出去再进来,第二次访问appConfig,你猜这一回会发生什么?

很多人第一反应是"肯定继续崩,而且崩得一样",第二反应是"应该会重新走一次 loadConfigFromNetwork"。这两个反应其实指向一个更深的问题:lazy 在初始化失败之后,究竟处于什么状态?

为了严谨一点,我在讲结论前先明确几个说法。本文讨论的"异常",指的是初始化 lambda 内部主动抛出的异常,也就是initializer!!()这个调用点抛出来的异常。至于OutOfMemoryError这类 Error,行为逻辑类似,但不在生产环境的常规考虑范围内。

1.2 先把结论摆出来:异常不会被缓存,下一次访问会重新执行初始化

直接说结论:lazy 委托遇到初始化函数抛异常时,不会把这个异常缓存起来,也不会进入什么"永久失败"状态。异常会原样抛给当前调用者,但 lazy 内部仍然保持"未初始化"状态,下一次任何线程再访问这个属性,都会重新执行一遍初始化函数。

这个结论可以用一段极简代码来验证:

var initCount = 0 val target: String by lazy { initCount++ if (initCount == 1) { throw IllegalStateException("第一次初始化失败") } "初始化成功,当前执行次数: $initCount" } fun main() { try { println(target) } catch (e: IllegalStateException) { println("第一次访问捕获异常: ${e.message}") } println(target) }

输出结果:

第一次访问捕获异常: 第一次初始化失败 初始化成功,当前执行次数: 2

注意看initCount的增长轨迹:第一次访问执行了一次初始化并且抛异常,第二次访问又执行了一次初始化。这说明抛异常的那一次并没有把执行结果"定格",lazy 在等待下一次触发。

1.3 为什么很多人会在这里想岔

有相当一部分开发者默认 lazy 内部会"记住"一次异常,理由是缓存机制嘛,缓存了成功结果,理论上也能缓存失败原因。这个想法在思路上不奇怪,但不符合 Kotlin 官方对 lazy 的语义定义。lazy 的核心语义是"延迟到第一次访问时执行一次初始化,然后把结果缓存起来",注意前提是"初始化成功"。一旦初始化函数抛出异常,这次初始化就没完成,"缓存结果"这个动作根本没有发生,状态自然停留在未初始化。

还有一部分人担心的是并发问题:如果线程 A 正在执行初始化、抛了异常,锁会不会死锁?后面线程还能不能进入?这个问题在讲源码的章节会专门展开。先记住一个结论:synchronized 的锁在异常抛出后会自动释放,标准的 double-check 逻辑会保证后续线程有机会重新尝试。

2. 从源码看 lazy 的三副面孔:不同模式在异常路径上的分岔

2.1 SYNCHRONIZED:默认锁模式,异常后的重试会发生什么

Kotlin 里使用by lazy时可以通过参数指定线程安全模式:

val a by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { ... } // 默认 val b by lazy(LazyThreadSafetyMode.PUBLICATION) { ... } val c by lazy(LazyThreadSafetyMode.NONE) { ... }

最常见的默认模式对应标准库里的SynchronizedLazyImpl。源码核心逻辑就像这样:

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable { private var initializer: (() -> T)? = initializer @Volatile private var _value: Any? = UNINITIALIZED_VALUE private val lock = lock ?: this override val value: T get() { val _v1 = _value if (_v1 !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return _v1 as T } return synchronized(lock) { val _v2 = _value if (_v2 !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") _v2 as T } else { val typedValue = initializer!!() _value = typedValue initializer = null typedValue } } } }

我把异常路径在这段代码里一步步拆开看:

第一步,_valueUNINITIALIZED_VALUE,说明此前没有初始化成功过,进入synchronized(lock)块。 第二步,在锁内部再次检查_value,仍然等于UNINITIALIZED_VALUE,于是执行initializer!!()。 第三步,如果initializer!!()抛异常,typedValue根本不会赋值,也就是说_value = typedValue这一行永远没机会执行,initializer = null这行也不会执行。 第四步,异常从synchronized块里向外传播时,JVM 会负责释放这个锁。 第五步,下一次访问时,_value仍然是UNINITIALIZED_VALUEinitializer仍然非空,于是从头再来一遍。

这个流程很好记忆:初始化函数只有正常 return 才会顺手更新状态标记;只要它是抛着异常出去的,一切维持原状,下次访问等于重新执行。

2.2 PUBLICATION:并发初始化,某个线程失败不影响最终结果

LazyThreadSafetyMode.PUBLICATION对应PublicationLazyImpl,它的设计初衷是允许多个线程同时执行初始化函数,谁先跑完谁发布结果。源码关键逻辑大致长这样:

private class PublicationLazyImpl<out T>(initializer: () -> T, private val final: Any? = null) : Lazy<T>, Serializable { private var initializer: (() -> T)? = initializer @Volatile private var _value: Any? = UNINITIALIZED_VALUE @Volatile private var initialized: Boolean = false override val value: T get() { if (initialized) { return _value as T } val newValue = initializer!!() if (_value === UNINITIALIZED_VALUE || final == null) { _value = newValue if (final == null) { initialized = true } } return _value as T } }

注意这里和SynchronizedLazyImpl的一个显著区别:它没有全局锁,多个线程可以同时进入initializer!!()。如果线程 A 和线程 B 同时执行初始化,A 抛出异常,B 正常返回并发布了结果,那么 B 的失败实际上不影响最终的属性状态,后续访问直接走initialized == true的分支返回 B 的结果。

那如果所有线程都抛异常呢?结果是initialized保持false_value保持UNINITIALIZED_VALUE,下一次访问照样重新走一遍初始化流程。这一点上,PUBLICATION 和 SYNCHRONIZED 的"失败后可重试"语义是一致的。

不过这里有一个容易忽略的副作用问题:PUBLICATION 模式下,初始化函数可能被多个线程同时执行,而且执行次数无法精确预期。如果初始化逻辑里有埋点上报、文件写入这类带副作用的操作,你会在同一时刻看到多份重复副作用。这一点到了生产环境很容易变成事故。

2.3 NONE:非线程安全的裸奔模式,异常行为反而最直观

LazyThreadSafetyMode.NONE对应的UnsafeLazyImpl源码是最短的:

internal class UnsafeLazyImpl<out T>(initializer: () -> T) : Lazy<T>, Serializable { private var initializer: (() -> T)? = initializer private var _value: Any? = UNINITIALIZED_VALUE override val value: T get() { if (_value === UNINITIALIZED_VALUE) { _value = initializer!!() initializer = null } return _value as T } }

没有任何锁,没有任何 double-check,只是单纯的"没初始化就执行一次初始化"。异常发生时,_value依旧保持UNINITIALIZED_VALUEinitializer依旧保持原样,下次访问重新执行。这在三种模式里是最容易理解的。

但我要提醒一句:这个模式用了Unsafe前缀不是没理由的。你在多线程环境下用它,初始化函数就可能被多个线程同时执行,读到的值也可能是尚未完全发布的对象。仅适合你能百分之百确认单线程访问的场景,例如只在主线程访问的 UI 相关配置,或者明确靠外部锁保护访问路径的代码。

2.4 三种模式在异常路径上的行为对比

对比维度SYNCHRONIZEDPUBLICATIONNONE
线程安全策略double-check + synchronized 锁多线程竞争,无锁发布完全无锁
初始化函数并发执行同一时间只有一个线程执行多个线程可能同时执行多个线程可能同时执行
初始化抛异常后是否缓存异常
初始化抛异常后的下次访问重新执行初始化函数重新执行初始化函数重新执行初始化函数
同一时刻部分线程失败、其他线程成功时不可能同时执行某个线程成功即最终成功以最后写入者为准
典型使用场景默认选择,无脑安全初始化无副作用且对性能敏感严格单线程环境

这张表建议你收藏。大多数时候你只需要记住"核心结论一致,区别在于并发表现"这一点就够了。

3. 实测验证:用可复现代码观察异常路径上的每一步

3.1 验证一:默认模式下异常后二次访问,初始化函数真的执行了两次

这一节我把测试代码写得稍微完整一些,方便你直接拷贝到本地跑。用一个计数器区分每次执行:

class LazyExceptionTest { private var executeCount = 0 val value: String by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { executeCount++ println("初始化函数执行,次数: $executeCount") if (executeCount == 1) { throw RuntimeException("模拟首次初始化失败") } "第 $executeCount 次执行后的结果" } } fun main() { val test = LazyExceptionTest() try { println("第一次访问: ${test.value}") } catch (e: RuntimeException) { println("捕获到异常: ${e.message}") } println("第二次访问: ${test.value}") println("第三次访问: ${test.value}") }

输出结果:

初始化函数执行,次数: 1 捕获到异常: 模拟首次初始化失败 初始化函数执行,次数: 2 第二次访问: 第 2 次执行后的结果 第三次访问: 第 2 次执行后的结果

这个输出非常直观:第一次访问执行了初始化并抛异常,第二次访问又重新执行初始化,并且这次成功了,第三次访问直接命中缓存。从中能推导出的关键事实是:在初始化失败到下一次访问之间,lazy 内部不会保存任何"失败残留"信息。

3.2 验证二:SYNCHRONIZED 模式下线程竞争时,异常不会造成锁泄漏

有人担心初始化抛异常后锁没释放导致死锁。写个多线程用例看看实际情况:

fun main() { var initCount = 0 val target: String by lazy { initCount++ println("初始化执行: 第 $initCount 次, 线程: ${Thread.currentThread().name}") if (initCount == 1) { throw IllegalStateException("首个线程初始化失败") } "success" } val executor = Executors.newFixedThreadPool(4) val futures = mutableListOf<Future<*>>() repeat(8) { futures.add(executor.submit { try { println("${Thread.currentThread().name} -> ${target}") } catch (e: IllegalStateException) { println("${Thread.currentThread().name} 捕获: ${e.message}") } }) } futures.forEach { it.get() } executor.shutdown() }

我实际跑了一次,输出大致是:

初始化执行: 第 1 次, 线程: pool-1-thread-1 pool-1-thread-1 捕获: 首个线程初始化失败 初始化执行: 第 2 次, 线程: pool-1-thread-5 pool-1-thread-5 -> success pool-1-thread-2 -> success pool-1-thread-3 -> success ...

线程 1 初始化失败后,线程 2、3、4 没有卡死。锁在异常抛出的那一刻已经被 JVM 释放,后续线程会继续竞争锁,直到某一个线程成功完成初始化。整个过程验证了一个结论:初始化异常和锁的生命周期是解耦的,异常只会中止当前的初始化和缓存动作,不会污染后续线程对锁的获取。

3.3 验证三:PUBLICATION 模式下多线程同时初始化,重复副作用肉眼可见

默认 SYNCHRONIZED 模式因为有锁,初始化函数同一时刻只会被一个线程执行。PUBLICATION 模式就不一样了,它的设计目标就是让所有线程一起跑初始化,谁先到谁发布。我用一个带sleep的初始化函数看看到底会执行几次:

fun main() { val target: String by lazy(LazyThreadSafetyMode.PUBLICATION) { println("初始化函数开始执行: ${Thread.currentThread().name}") Thread.sleep(100) val result = "result from ${Thread.currentThread().name}" println("初始化函数执行结束: ${Thread.currentThread().name}") result } val executor = Executors.newFixedThreadPool(6) val futures = mutableListOf<Future<*>>() repeat(6) { futures.add(executor.submit { println("${Thread.currentThread().name} 访问 -> ${target}") }) } futures.forEach { it.get() } executor.shutdown() }

因为初始化函数有 100ms 延迟,6 个线程几乎同时进入初始化函数,结果"初始化函数开始执行"这一行会打印 6 次。最终只有其中一个线程的结果会被发布,其他线程打印出的访问结果可能各不相同,取决于谁先把_value写进去。

这个测试在生产环境里的意义是:PUBLICATION 模式只适合初始化函数完全无副作用的场景。如果初始化里有网络请求、数据库写入、埋点上报,并发情况下相当于同一份请求被放大好几倍。更经典的坑是初始化函数里依赖一个不能并发调用的单例,PUBLICATION 模式可能直接造成数据错乱。

3.4 关于异常类型的延伸测试:异常会被包一层吗

再补一个大家关心的小点:初始化函数抛出的异常,传给调用者时会不会被 lazy 包一层,比如变成IllegalStateException?测试结果是不会。lazy 不捕获、不包装、不缓存异常,它只是让异常原样穿透。你抛的是IOException,调用方 catch 到的就是IOException;你抛的是自定义ConfigLoadException,调用方 catch 到的就是同一个对象。

这一点很符合 Kotlin 标准库的一贯风格:在关键路径上不做多余的隐式包装,保持行为透明。但也正因为透明,很多人才会没意识到"失败后下次访问会重新执行初始化"这一系列连锁行为。

4. 生产场景中的坑与设计建议:从"能用"到"可靠"

4.1 典型坑位一:重试开销被忽略,初始化逻辑太"重"

既然 lazy 在初始化失败后会自动重试,那么一个"高成本初始化 + 大概率失败"的组合就会变成事故。比如从网络加载配置、构建数据库连接池、反射加载一个重型类,首访失败后,用户每多访问一次属性就多一次完整重试。如果访问路径还嵌套在循环里,那就是灾难。

我见过一个线上问题:某个服务用by lazy加载远程配置,配置中心短暂抖动导致第一次加载失败,结果流量高峰时每次请求都触发一次重新加载,把配置中心打到雪崩。根因不是配置中心不稳,而是 lazy 的隐式重试把一次抖动放大成了持续抖动。

应对思路是:初始化函数内部要做好失败分类。临时性故障(网络超时、服务端 5xx)可以接受 lazy 重试,但要在重试之间加退避逻辑;永久性故障(配置格式错误、参数非法)就不要再反复尝试,应该快速失败并尽量用兜底值。

private val config: AppConfig by lazy { runCatching { loadConfig() } .getOrElse { error -> if (error is ConfigFormatException) { ConfigFallback.DEFAULT } else { throw error // 临时故障,允许 lazy 下次重试 } } }

4.2 典型坑位二:副作用重复执行,日志和埋点是重灾区

很多人喜欢在 lazy 初始化函数里顺手打个日志:"config loaded"。这行日志在正常情况下只会打一次,但一旦初始化抛异常,下次访问会再打一次,再抛异常就再打一次。如果用的是 PUBLICATION 模式,一次并发访问能打出好几条。结果就是想查一个"初始化到底执行了几次"的问题,反而被日志刷屏。

更严重的是埋点。初始化里如果带了"上报用户进入页面"这类逻辑,异常重试就可能导致同一行为被重复上报,数据直接失真。建议把初始化函数设计成纯计算逻辑,所有副作用放到初始化成功后的调用方去执行,或者用幂等机制保证重复执行不产生重复影响。

4.3 典型坑位三:与 Android 组件生命周期结合时的隐性风险

在 Android 里,by lazy很常被用来延迟初始化 Activity 或 Fragment 的视图绑定、ViewModel 等对象。这里有一个生命周期相关的隐性风险:by lazy绑定的是对象的生命周期,不是页面不可见之后就会自动释放的。如果你的初始化逻辑依赖于某个 View 已经 attach,而访问时机发生在 View detach 之后,初始化可能抛异常,下次回到页面又触发一次重新初始化。

等于是把"重试"和"生命周期时机"搅在了一起,排查起来会比较绕。我的建议是:凡是初始化强依赖 UI 生命周期的,不要用 lazy,直接改成手动管理的 nullable 属性 + 安全调用。lazy 更适合绑定到生命周期稳定的对象上,比如 Application 级别的单例配置。

4.4 设计建议:如何封装一个"失败后不自动重试"的懒加载委托

如果 lazy 的自动重试不是你要的行为,完全可以自己做一个委托类。核心思路是在第一次初始化失败时,把这个异常缓存下来,后续访问直接抛同一异常。我写了一个简版实现:

class FailFastLazy<T>(private val initializer: () -> T) : Lazy<T> { @Volatile private var cachedValue: Any? = UNINITIALIZED_VALUE private var failure: Throwable? = null override val value: T get() { val current = cachedValue if (current !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return current as T } failure?.let { throw it } synchronized(this) { // double-check val again = cachedValue if (again !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return again as T } try { val result = initializer() cachedValue = result return result } catch (t: Throwable) { failure = t throw t } } } } fun <T> failFastLazy(initializer: () -> T): Lazy<T> = FailFastLazy(initializer)

用起来和by lazy一样:

private val config: AppConfig by failFastLazy { loadConfig() // 只执行一次,失败后抛缓存异常 }

我自己在项目里就用过这个模式。场景是一次性加载超大的模型文件,失败后重试几乎没有意义,反而会拖垮主线程,fail-fast 加页面提示才合理。

4.5 为什么 Kotlin 官方没有把异常状态缓存

这个问题我琢磨过一阵子,最后得出的结论是:lazy 的职责边界被刻意限定得很窄,它只管"延迟执行 + 成功结果缓存",不管失败策略。失败之后是重试、是降级、还是快速失败,应该由开发者根据业务场景决定,标准库不该替你做这个决定。

类比一下就很好理解:一个普通函数抛异常后,你调下一个语句再调它,它照样重新执行。lazy 只是"函数 + 结果缓存"的组合,函数抛异常代表这一次没有产出结果,那么缓存自然不发生,下次访问等于重新调用函数。这个行为对很多有经验的开发者来说是符合直觉的,只是大家平时没往这个方向想。

5. 面试题里的 lazy:考官想听到的深度是什么

5.1 "by lazy 是线程安全的吗?"——标准答案的表与里

这道题几乎人人会背:默认的LazyThreadSafetyMode.SYNCHRONIZED线程安全,PUBLICATION允许并发初始化,NONE不安全。但面试官要听到的肯定不止这个结论,更想看你有没有读过源码。

比较好的回答结构是:先说三种模式的线程安全定位,然后补充底层实现——默认模式用的 synchronized 加 double-check;PUBLICATION 用的是无锁发布,多个线程可以同时执行初始化;NONE 就是裸读写。然后自然过渡到异常场景:如果初始化函数抛异常,synchronized 锁会由 JVM 自动释放,状态不会进入"已缓存",下一次访问依然会重新初始化。这就能把话题引向更深的执行流程讨论。

5.2 "lazy 和 lateinit 有什么区别?"——答案里能带出的异常细节

lazy 和 lateinit 的区别也是高频题。常规答案包括:lazy 用于 val,lateinit 用于 var;lazy 适用于非空类型,lateinit 不能用于基础类型;lazy 默认线程安全,lateinit 不具备任何同步;lazy 把初始化延迟到首次访问,lateinit 实际上是用一个未初始化的占位状态维持属性存在。

异常细节是加分项:如果你在 lateinit 属性未初始化时访问它,会抛UninitializedPropertyAccessException;而 lazy 属性如果初始化成功则永远不会处于"未初始化"状态,如果初始化失败也不会抛属性未初始化的异常,而是把初始化函数里的异常原样抛出来,并且下一次访问还存在重试的可能。两者在异常路径上的差异,能体现你对 Kotlin 属性委托机制的掌握程度。

5.3 "你如何看待 lazy 内部的异常传播"——如何把本文知识组织成答案

如果面试官追问异常传播,我会按下面这个顺序组织回答。

第一层,结论:lazy 不捕获、不包装、不缓存异常,初始化函数抛出的异常会原样穿透给调用者。 第二层,机制:lazy 内部靠一个UNINITIALIZED_VALUE哨兵值和 initializer 是否为空来判断是否需要执行初始化。异常发生时,哨兵值没有被替换,initializer 也没有被清空,所以状态仍然停留在未初始化。 第三层,应用:默认 SYNCHRONIZED 锁模式下,多线程竞争时只有一个线程能执行初始化,失败的线程会解锁,后续线程会继续重试;PUBLICATION 模式下多线程同时执行初始化,某个线程失败不影响最终成功发布。 第四层,落地:基于这个行为,生产环境要注意高成本初始化的重复开销、副作用重复执行,必要时自定义委托做 fail-fast。

能讲到第四层,基本可以说明你对这个语言特性的理解已经到了"能指导工程实践"的程度。

5.4 给面试准备的一个加分项:Lazy 接口的三个实现类

最后一个细节可能很多人没注意到:Lazy接口有三个实现类,分别对应三种线程安全模式。SynchronizedLazyImplPublicationLazyImpl都继承了Serializable,而UnsafeLazyImpl没有。如果你在分布式环境里要把 lazy 对象序列化,或者在一个支持 Kotlin 序列化的框架里误用 NONE 模式,可能在运行时出现序列化异常。

这种细节面试官不一定都会问,但主动提出来会显得你确实读过标准库源码,而不是只看过二手技术博客。类似的冷门细节还包括:by lazy的默认 lock 就是this,也就是Lazy实例本身;SynchronizedLazyImplsynchronized(lock)块内部对_value做了二次检查,这就是典型的 double-checked locking 实践。


我在实际项目里踩过 lazy 异常重试的坑,线上出现过一次配置加载失败之后,用户频繁触发页面导致的反复重试,高成本初始化被打了几十次。后来把所有"失败重试没有意义"的初始化逻辑都换成了自研的 fail-fast 委托,才算彻底解决问题。所以最后想提醒一句:lazy 的自动重试是把双刃剑,默认行为只保证"未成功就重来",不保证"重来对你有利"。知道异常时它到底在执行什么流程,你才能在真正重要的路径上做出对的决策。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询