Kotlin by lazy 异常执行流程:初始化失败自动重试机制解析

发布时间:2026/9/11 19:52:56
Kotlin by lazy 异常执行流程:初始化失败自动重试机制解析 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): LazyT SynchronizedLazyImpl(initializer) public fun T lazy(mode: LazyThreadSafetyMode, initializer: () - T): LazyT 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 SynchronizedLazyImplout T(private val initializer: () - T, lock: Any? null) : LazyT, 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 标准库的设计初衷。LazyT接口的设计目标是保证“同一时刻只有一个线程执行初始化”并且“一旦初始化完成后续所有访问都拿到同一个实例”。它本质上是一个线程安全的值缓存容器。在这个目标里异常不是初始化完成的结果初始化只有成功一个终点。异常只是初始化过程中的一个临时中断它并不意味着 lazy 的使命结束了。换个角度理解lazy 和Result不一样。Result是为了捕获和传递异常而存在的lazy 是为了延迟创建值而存在的。让 lazy 记住异常反而会让语义变得更复杂——因为异常类型是Throwable的任意子类缓存异常意味着value的 getter 每次都要判断“缓存的是值还是异常”这会让SynchronizedLazyImpl内部的_value字段从Any?变成类似ResultT的包装类型引入额外的对象分配还破坏了现有对isInitialized()的直观理解。而且从使用场景看lazy 的异常重试语义在很多场景下反而是优势。比如配置中心的拉取、图片资源的加载第一次失败大概率是网络抖动或临时故障下一次访问时重试一次往往就能成功。如果 lazy 把异常缓存下来并持续抛出反而需要开发者额外写一层“手动重置”的逻辑那比现在的行为更麻烦。2.3 三种模式的异常行为对比PUBLICATION和NONE两种模式在异常处理上的语义和SYNCHRONIZED是一致的initializer()抛出异常时不会写入_value下次访问会重新尝试初始化。区别只体现在多线程并发访问时的交错行为上。private class UnsafeLazyImplout T(initializer: () - T) : LazyT, 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 lazyprivate 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 库也不用手动写循环。举个例子加载一张图片的 URLclass 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 MutableLazyT(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): MutableLazyT MutableLazy(initializer)使用时val config: MutableLazyRemoteConfig mutableLazy { fetchRemoteConfig() } // 需要强制刷新配置时 config.reset()这个实现和我前面贴的标准库SynchronizedLazyImpl的逻辑几乎一致只是额外增加了reset()。异常行为也和标准库一样初始化抛异常时不写缓存下次访问自动重试。如果想做到“初始化失败后进入冷却期冷却期内直接抛异常”可以再加一个时间戳判断但那就是另一个话题了。还有一种更细的做法用kotlinx.coroutines的Mutex配合Deferred来做一个**“失败后需要手动重置”的懒加载**。原因是Deferred一旦完成包括异常完成再次await()时会拿到同一个异常结果不会重新执行。这正好可以用来模拟“异常缓存”的语义class StrictLazyT(private val initializer: suspend () - T) { private val mutex Mutex() private var deferred: DeferredT? null suspend fun value(): T mutex.withLock { val current deferred if (current ! null) { returnwithLock 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 里有一个需要用户登录后才会按需创建的播放器实例。播放器初始化依赖登录 tokentoken 过期时初始化会抛异常。这种场景我一般会这样设计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 包一层用ResultT作为值类型或者在初始化块内部自己捕获异常后缓存结果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: LazyString 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 RetryLazyT( 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 时就能避免很多误判也能更自信地决定在什么场景下需要额外包装一层。希望这篇把源码、行为和实战经验都串起来的文章能帮你彻底搞明白这个被用烂了却很少被深入研究的语法糖。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询