1. 先搞清楚 lazy 到底怎么工作的
1.1 一个被用烂了却很少有人深挖的语法糖
by lazy应该是 Kotlin 开发者在日常编码中使用频率最高的委托之一。拿到一个只读属性,先写个by lazy { ... },几乎是肌肉记忆。但说实话,在我看过的绝大多数项目里,大家对 lazy 的理解基本停留在“第一次访问时才初始化”这个层面。至于它内部是怎么做到线程安全的,初始化逻辑执行到一半抛了异常会发生什么,异常之后再次访问这个属性是重新执行初始化还是继续抛异常,这些问题真正能答上来的人并不多。
这也是我把这个主题拿出来单独写一篇的原因。lazy 委托的异常执行流程,尤其是在初始化块抛出异常时,表现出的行为和大多数人默认的直觉是不一致的。如果没搞懂这个细节,即使在代码里写下看起来很优雅的懒加载逻辑,实际运行到异常路径时,可能就会踩到一个隐蔽的坑,而且这个坑排查起来非常头疼,因为问题往往不是必现的。
先看一个最基础的用法:
val config: AppConfig by lazy { loadConfigFromRemote() // 假设这里可能抛 IOException }这是很典型的写法:配置文件懒加载,第一次用到的时候才去远程拉取。如果loadConfigFromRemote()抛了异常,你认为下一次再访问config属性时,会走哪条分支?是再次执行loadConfigFromRemote()重试,还是直接把上次的异常继续抛出来?
答案是:默认情况下,它会再次执行初始化逻辑。这一点和大部分人对“缓存”的直觉相悖。下面我详细拆解。
1.2 SYNCHRONIZED、PUBLICATION、NONE 三种模式的内部差异
要理解异常流程,必须先从 lazy 的三种线程安全模式说起。lazy()函数的完整签名是:
public fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer) public fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> = when (mode) { LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer) LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer) LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer) }日常不传 mode 时,默认使用的是SynchronizedLazyImpl,也就是SYNCHRONIZED模式。这个模式下,Kotlin 用了一把锁加上双重检查锁定(double-checked locking)来保证多线程环境下初始化逻辑只会被一个线程执行。
SynchronizedLazyImpl的核心代码大致是这样的(Kotlin 标准库源码,我加了注释):
private class SynchronizedLazyImpl<out T>(private val initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable { 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 typedValue } } } override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE override fun toString(): String { return if (isInitialized()) "Lazy value is already initialized: $_value" else "Lazy value not initialized yet." } }这段代码的逻辑不复杂,但要注意一个关键点:_value = typedValue这一行,是在initializer()正常返回之后才执行的。如果initializer()在执行过程中抛出了异常,那么typedValue根本不会被赋值,_value始终停留在UNINITIALIZED_VALUE状态。
这意味着什么?意味着异常发生后,整个 Lazy 实例仍然处于“未初始化”状态。下一次访问value属性时,第一重检查_v1 !== UNINITIALIZED_VALUE就会通过,代码会再次进入synchronized块,再次调用initializer()。异常会被上一次调用点正常抛出,但lazy 内部不会记住这个异常。
这就是核心结论:lazy 初始化块中抛出的异常,会被正常向上抛出,但不会被缓存;每次访问未成功初始化的 lazy 属性,都会尝试重新执行初始化块。
单独看代码可能还不够直观,我先跑了一个简单的验证例子,后面在第二节里给出完整的输出结果。
2. 异常发生时 lazy 的真实执行流程
2.1 初始化抛异常时,一步步发生了什么
假设我们写了这样一段代码:
var attempt = 0 val lazyValue: String by lazy { attempt++ println("初始化逻辑执行,当前 attempt = $attempt") if (attempt < 3) { throw IllegalStateException("模拟初始化失败,第 $attempt 次") } "初始化成功" } fun main() { repeat(5) { index -> try { println("第 ${index + 1} 次访问结果:${lazyValue}") } catch (e: IllegalStateException) { println("第 ${index + 1} 次访问捕获异常:${e.message}") } } }跑出来的输出是:
初始化逻辑执行,当前 attempt = 1 第 1 次访问捕获异常:模拟初始化失败,第 1 次 初始化逻辑执行,当前 attempt = 2 第 2 次访问捕获异常:模拟初始化失败,第 2 次 初始化逻辑执行,当前 attempt = 3 第 3 次访问结果:初始化成功 第 4 次访问结果:初始化成功 第 5 次访问结果:初始化成功从这个输出可以看得很清楚:
- 第 1 次访问触发了初始化,抛异常后
_value没有写入; - 第 2 次访问又触发了初始化,再次抛异常;
- 第 3 次访问时初始化成功,
_value被写入; - 第 4 次、第 5 次访问直接命中缓存。
用一句话总结就是:lazy 的失败不是一次性的,它本质上是一个“每次访问都尝试初始化,直到成功为止”的语义。如果你的初始化逻辑有副作用(比如发送网络请求、插入数据库记录、上报日志),那么每次访问都会重复执行这些副作用,直到某一次没有抛异常。这个行为在用到资源密集型或副作用明显的初始化逻辑时,需要格外警惕。
2.2 异常后的再次访问:为什么没有“异常缓存”
上一节看完可能有读者会问:为什么不把异常也缓存起来?这样可以避免每次访问都重新执行失败的逻辑,有些时候重试反而会导致问题。
要回答这个,得回到 Kotlin 标准库的设计初衷。Lazy<T>接口的设计目标是保证“同一时刻,只有一个线程执行初始化”,并且“一旦初始化完成,后续所有访问都拿到同一个实例”。它本质上是一个线程安全的值缓存容器。在这个目标里,异常不是初始化完成的结果,初始化只有成功一个终点。异常只是初始化过程中的一个临时中断,它并不意味着 lazy 的使命结束了。
换个角度理解:lazy 和Result不一样。Result是为了捕获和传递异常而存在的;lazy 是为了延迟创建值而存在的。让 lazy 记住异常反而会让语义变得更复杂——因为异常类型是Throwable的任意子类,缓存异常意味着value的 getter 每次都要判断“缓存的是值还是异常”,这会让SynchronizedLazyImpl内部的_value字段从Any?变成类似Result<T>的包装类型,引入额外的对象分配,还破坏了现有对isInitialized()的直观理解。
而且从使用场景看,lazy 的异常重试语义在很多场景下反而是优势。比如配置中心的拉取、图片资源的加载,第一次失败大概率是网络抖动或临时故障,下一次访问时重试一次,往往就能成功。如果 lazy 把异常缓存下来并持续抛出,反而需要开发者额外写一层“手动重置”的逻辑,那比现在的行为更麻烦。
2.3 三种模式的异常行为对比
PUBLICATION和NONE两种模式在异常处理上的语义和SYNCHRONIZED是一致的:initializer()抛出异常时不会写入_value,下次访问会重新尝试初始化。区别只体现在多线程并发访问时的交错行为上。
private 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 } @Suppress("UNCHECKED_CAST") return _value as T } // ... }在NONE模式下,没有任何同步保护。如果线程 A 和线程 B 同时进入value的 getter,两个线程都看到_value === UNINITIALIZED_VALUE,那么两个线程会各自执行一次initializer()。如果恰好两次都抛异常,那么异常会在两个线程各自抛出;如果一个成功一个失败,则可能出现一种极端情况:线程 A 的初始化抛异常,线程 B 的初始化成功并写入了_value,但线程 A 的异常仍然向上抛出,而后续访问拿到的是线程 B 写入的结果。
PUBLICATION模式稍微特殊一点,如果有多个线程同时进入初始化,允许其中几个线程并行执行初始化逻辑,但是只有第一个成功返回的值会被发布到_value。它在异常路径上的行为是这样的:所有执行了初始化的线程,如果抛异常,都会向各自调用方抛出异常;如果某一线程成功,其他还没执行完的线程即使最后也成功了,它们的返回值会被丢弃,但它们的异常不会被抑制。
我把三种模式的异常行为整理成一个速查表,方便对比:
| 模式 | 是否有并发保护 | 初始化异常时_value会写入吗 | 异常后再次访问 | 特点 |
|---|---|---|---|---|
SYNCHRONIZED | 有锁 + 双重检查 | 不会 | 重新执行初始化 | 最安全,默认模式 |
PUBLICATION | 无锁,允许并发初始化 | 不会 | 重新执行初始化 | 多线程可能重复执行初始化,返回值以先到者为准 |
NONE | 无 | 不会 | 重新执行初始化 | 仅单线程环境使用,可能产生多个实例 |
其实不管你是用默认的SYNCHRONIZED还是显式指定其它模式,在“异常是否被缓存”这个问题上,Kotlin 的设计是一致的:异常不被缓存,初始化失败可重试。这算是理解整个问题的基调。
3. 实战:异常场景下的正确打开方式
3.1 一个真实的业务场景还原
理论说完了,讲一个两年前我踩过坑的场景。
当时在做 Android 端的性能监控 SDK,有一个配置模块,需要在 App 启动后惰性加载一份远程白名单配置。最初的实现是利用by lazy:
private val remoteConfig: RemoteConfig by lazy { api.fetchConfig().execute().body() ?: RemoteConfig.EMPTY }看起来没什么问题。但灰度期间,后端有一次发布配置格式不兼容,导致接口返回的数据反序列化失败,fetchConfig()内部抛出了一个JsonParseException。诡异的事情来了:这个异常只在 App 冷启动后的第一次访问时崩了一次,后面再访问同一个属性时,一切正常,配置也能正常加载出来了。这给排查造成了很大的干扰,因为崩溃日志往往只有一条,而且是偶发的,很难稳定复现。
后来回过头看,原因恰恰就是 lazy 的异常重试语义。第一次访问时抛异常,此时_value没有被写入,用户在首页触发了一个兜底逻辑,在另一个线程再次访问了remoteConfig,这时 lazy 的初始化逻辑又被执行了一次,后端此时已经修复了数据格式,初始化成功,_value被缓存下来了。于是异常就只出现了一次,而且因为崩溃被某个全局异常捕获器记录,用户无感,后续访问全部命中缓存。
你说这个行为是 bug 吗?站在 lazy 的角度,它不是 bug。它只是把“每次访问都尝试初始化”这个语义暴露了出来。但站在 SDK 开发的角度,这个行为是可以被利用的——它可以变成一个天然的重试机制。
3.2 用 lazy 的异常重试语义做“自动重试”
既然默认的 lazy 在初始化失败后,不会缓存异常,下一次访问会自动重试,那其实我们可以直接利用这个特性来写一个简单的“自动重试懒加载”。不用引入额外的 retry 库,也不用手动写循环。
举个例子,加载一张图片的 URL:
class ImageLoader { private val imageUrl: String by lazy { val url = fetchImageUrlFromServer() // 网络请求 if (url.isBlank()) { throw IllegalStateException("image url is empty") } url } fun showImage() { val url = try { imageUrl } catch (e: Exception) { // 第一次失败,可以记录日志,但不要缓存失败状态 // 下一次调用时会自动重新拉取 log(e) null } // ... } }如果fetchImageUrlFromServer()因为网络原因失败,这次的访问会抛异常,但下次用户再次触发showImage()时,lazy 会重新尝试请求。这就做到了一个“按需重试”的效果,不需要手动管理状态。你唯一要做的就是用 try-catch 包住访问点,别让异常直接崩出去。
但这里有一个很关键的前提条件:你的初始化函数必须是“可重入”的。也就是说,重复执行它不能产生重复副作用、不能污染状态、不能有不可逆的外部影响。如果你的初始化逻辑里有发送埋点、插入数据库、创建文件这样的操作,用默认 lazy 做重试就得特别小心——它会把这些副作用重复执行多次,直到成功为止。
3.3 更可控:自定义 mutableLazy 实现手动重置
如果 lazy 默认的“失败后自动重试”语义不适合你的场景,你需要的是“失败后控制重试时机”或“外部条件满足后手动重置缓存”,那可以考虑自己实现一个可变 lazy。这个在业界有个常用的思路:直接用Mutex加状态判断,或者简单一点,把_value暴露出来手动重置。
下面这个是我项目里正在用的一个轻量实现,核心思路是把 lazy 包装一层,暴露一个reset()方法,方便外部主动让缓存失效:
class MutableLazy<T>(private val initializer: () -> T) { @Volatile private var cachedValue: Any? = UNINITIALIZED_VALUE val value: T get() { val current = cachedValue if (current !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return current as T } return synchronized(this) { val currentAgain = cachedValue if (currentAgain !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") currentAgain as T } else { val result = initializer() cachedValue = result result } } } fun reset() { synchronized(this) { cachedValue = UNINITIALIZED_VALUE } } fun isInitialized(): Boolean = cachedValue !== UNINITIALIZED_VALUE private companion object { private val UNINITIALIZED_VALUE = Any() } } // 委托扩展 fun <T> mutableLazy(initializer: () -> T): MutableLazy<T> = MutableLazy(initializer)使用时:
val config: MutableLazy<RemoteConfig> = mutableLazy { fetchRemoteConfig() } // 需要强制刷新配置时 config.reset()这个实现和我前面贴的标准库SynchronizedLazyImpl的逻辑几乎一致,只是额外增加了reset()。异常行为也和标准库一样:初始化抛异常时不写缓存,下次访问自动重试。如果想做到“初始化失败后进入冷却期,冷却期内直接抛异常”,可以再加一个时间戳判断,但那就是另一个话题了。
还有一种更细的做法:用kotlinx.coroutines的Mutex配合Deferred来做一个**“失败后需要手动重置”的懒加载**。原因是Deferred一旦完成(包括异常完成),再次await()时会拿到同一个异常结果,不会重新执行。这正好可以用来模拟“异常缓存”的语义:
class StrictLazy<T>(private val initializer: suspend () -> T) { private val mutex = Mutex() private var deferred: Deferred<T>? = null suspend fun value(): T = mutex.withLock { val current = deferred if (current != null) { return@withLock current.await() } val newDeferred = CoroutineScope(Dispatchers.Default).async { initializer() } deferred = newDeferred newDeferred.await() } fun reset() { mutex.withLock { deferred = null } } }这个实现里,如果initializer()抛异常,deferred会以异常完成状态保存下来,之后每次value()都会await()到这个异常,而不是重新执行初始化。只有手动调reset()清空deferred,下一次访问才会重新初始化。这两种方案没有孰优孰劣,本质上是两种完全不同的失败语义,看你业务上到底需要“自动重试”还是“保持失败状态”。
3.4 配合 sealed class 管理初始化状态
如果你的业务对初始化结果要求更复杂,比如需要区分“未初始化”“初始化中”“初始化成功”“初始化失败”这四种状态,那光靠 lazy 就不够用了,建议用sealed class把这些状态建模出来。lazy 的value属性只负责提供值,而状态管理应该放在上层。
拿一个典型场景举例:App 里有一个需要用户登录后才会按需创建的播放器实例。播放器初始化依赖登录 token,token 过期时初始化会抛异常。这种场景我一般会这样设计:
sealed class PlayerInitState { object Idle : PlayerInitState() object Loading : PlayerInitState() data class Ready(val player: Player) : PlayerInitState() data class Failed(val reason: String) : PlayerInitState() } class PlayerManager { private val mutex = Mutex() private var state: PlayerInitState = PlayerInitState.Idle suspend fun getPlayer(): Player { return mutex.withLock { when (val current = state) { is PlayerInitState.Ready -> current.player is PlayerInitState.Failed -> { // 失败状态下,允许重试 state = PlayerInitState.Loading try { val player = createPlayer() state = PlayerInitState.Ready(player) player } catch (e: Exception) { state = PlayerInitState.Failed(e.message ?: "unknown error") throw e } } is PlayerInitState.Idle, is PlayerInitState.Loading -> { state = PlayerInitState.Loading try { val player = createPlayer() state = PlayerInitState.Ready(player) player } catch (e: Exception) { state = PlayerInitState.Failed(e.message ?: "unknown error") throw e } } } } } }这套方案的优点是可以精确控制失败之后的行为:外部可以调用一个markFailed()或定时重试,也可以根据Failed状态在 UI 上展示错误页。但它的缺点也很明显——代码量比 lazy 大得多。所以我个人的原则是:惰性加载只是“延迟创建”,不是“状态机”。如果你的场景真的需要四种状态流转,请老实写状态管理;如果只是为了延迟创建,那by lazy默认的行为完全够用。
4. 常见问题与排查技巧实录
4.1 异常行为速查表
我把这个主题涉及的典型问题和答案整理成一个速查表,方便大家在查问题时快速定位:
| 问题 | 答案 |
|---|---|
by lazy初始化抛异常后,异常会被缓存吗 | 不会,下次访问会重新执行初始化逻辑 |
初始化第一次失败,第二次成功,_value会怎么变化 | 第一次异常不写入,第二次成功写入,之后走缓存 |
| 多线程同时访问,初始化抛异常,其他线程会怎样 | 取决于模式:默认会有锁保护,异常只在该访问线程抛出,其它线程继续等锁后重新尝试 |
isInitialized()在初始化抛异常后返回什么 | 返回false,因为它检查的是_value !== UNINITIALIZED_VALUE |
| 能否在初始化块里捕获自己的异常并返回默认值 | 可以,捕获后正常返回就不会抛异常 |
初始化块里发生OutOfMemoryError这类 Error 呢 | 一样不会被缓存,下次访问会重新执行,但如果每次都 OOM,就会每次都 OOM |
| 怎么让 lazy 失败后不再重试 | 给 lazy 包一层,用Result<T>作为值类型,或者在初始化块内部自己捕获异常后缓存结果 |
| lazy 内部会不会自动打印异常日志 | 不会,异常直接抛给调用方 |
4.2 那些年我踩过的坑,帮你提前排掉
第一个坑是关于初始化逻辑的幂等性。这个前面已经提过。凡是写在by lazy初始化块里的逻辑,默认就必须是幂等的、可重复执行的。尤其是那些有外部副作用的操作。我见过有人在 lazy 里做了埋点上报,初始化失败一次,埋点就重复上报几次,最后上报系统的数据直接翻倍。这个不是 lazy 的问题,是使用姿势的问题。
第二个坑是关于NONE模式的多线程误用。LazyThreadSafetyMode.NONE并不是“不需要线程安全”,而是“告诉你这里很危险,你确定没有多线程才用”。如果在多线程环境下用了NONE,初始化代码可能被多个线程同时执行,抛出异常的次数也会翻倍。如果你发现初始化日志里同一个错误出现了两次,先检查是不是有人把 lazy 模式配成了NONE或PUBLICATION。
第三个坑和 App 开发里的主线程有关。by lazy默认是SYNCHRONIZED,如果初始化逻辑比较耗时,在 Android 主线程上首次访问时会导致卡顿。有些人会在 lazy 里写网络请求、数据库查询,这属于典型的用错场景。lazy 不是把耗时操作变快,它只是把耗时操作延迟到首次访问。该用协程、该用异步加载的地方,还是得用异步方案。
第四个坑是委托属性与 lazy 的isInitialized()不兼容。如果你写的是:
class Example { val delegate: Lazy<String> = lazy { "hello" } val value: String by delegate fun check() { if (delegate.isInitialized()) { println(value) } } }这种写法是没问题的。但如果直接用by lazy语法,外部不好拿到Lazy实例,自然也没法调用isInitialized()。需要判断是否初始化的时候,可以把Lazy对象单独提取出来。这个不算坑,只是 API 设计的约束,提一句免得大家绕弯路。
第五个坑,也是我今天最想强调的:千万不要在 lazy 初始化块里做“线程切换”或“等待另一个 lazy”。比如初始化块里访问了另一个 lazy 属性,而另一个 lazy 也在等待当前线程释放锁,就会形成死锁。SYNCHRONIZED模式用的是对象锁,嵌套访问同一个锁是重入的,但如果两个不同的 lazy 互相引用对方的属性,仍然可能死锁。规避方式就是:lazy 初始化块里面只做纯计算或独立的外部请求,不要依赖其它 lazy 状态。
4.3 从源码角度排查:怀疑 lazy 异常问题时的套路
如果你在项目里遇到了和 lazy 相关的诡异异常,建议按这个顺序排查:
先确认你的 lazy 访问点是否真的走的是lazy()工厂。有些人会用observable()、vetoable()这类其它委托代替,它们的行为和 lazy 完全不同,不要混为一谈。
再看初始化块里有没有捕获异常后“吞掉”的逻辑。很多时候不是 lazy 没缓存异常,而是你的初始化块内部自己 catch 了异常并返回了一个中间值。比如:
val x: String by lazy { try { riskyCall() } catch (e: Exception) { "" // 错误被吞掉,lazy 认为初始化成功了 } }这种情况下 lazy 理所当然会缓存空字符串,以后再也不会重试。这不算 bug,但很可能不符合你的初心。写的时候要明确自己想表达的是“失败后给个默认值”还是“失败后下次重试”。
最后,可以去看反编译后的字节码,或者直接用javap -c看合成类的getValue()方法。比如 Java/Kotlin 混编项目里,如果一个 Java 类用LazyKt.lazy(...)创建 Lazy 实例,再通过getValue()读取,异常路径的行为和纯 Kotlin 完全一致,不会有差异。真正的差异只会出现在你手动写的包装层里,比如你包了一层缓存异常的逻辑,那就是你自己的子类行为,别误认为是 Kotlin 标准库的锅。
4.4 一个容易被忽略但很实用的小技巧
最后分享一个我日常写代码会用到的小技巧:用 lazy 做“一次性的重试上限控制”。如果不想无限重试,也不想手动写状态机,可以结合atomicInteger控制最大重试次数:
class RetryLazy<T>( private val maxAttempts: Int = 3, private val initializer: () -> T, ) { private val counter = AtomicInteger(0) val value: T by lazy { if (counter.incrementAndGet() > maxAttempts) { throw IllegalStateException("lazy init failed after $maxAttempts attempts") } initializer() } }这个写法本质上利用了 lazy 的“失败后重试”语义,但给它加上了一个次数闸门。第一次初始化失败,第二次访问会再执行计数器加一后的初始化;一旦重试次数超过设定值,后续访问直接抛错,不再反复执行昂贵的初始化逻辑。counter是线程安全的,默认的SYNCHRONIZED模式下整个属性又是锁保护的,所以不用担心并发问题。
这个技巧在加载远端配置、初始化数据库连接这类场景下非常实用。它把“最多试几次”的策略很自然地融入了 lazy 的异常重试流程,不需要额外引入状态管理。
回到最初的问题:kotlin lazy 委托在异常时的执行流程,说白了就是一句话——异常不会被缓存,初始化未成功,访问即重试。搞懂这一点,你在使用 lazy 时就能避免很多误判,也能更自信地决定在什么场景下需要额外包装一层。希望这篇把源码、行为和实战经验都串起来的文章,能帮你彻底搞明白这个被用烂了却很少被深入研究的语法糖。