先说一个我自己的真实经历。有一段时间我在项目里用by lazy包了一个远程配置的加载逻辑,第一次访问时网络超时抛了 IOException,界面直接弹了个加载失败的提示。本来以为这事就完了,结果用户多点了两下,日志里出现了好几条重复的加载记录,我当时第一反应是“代码里哪里写了循环”,排查了半天才发现,问题根本不在调用方,而在lazy委托的异常执行流程上。
Kotlin 的lazy委托在初始化块抛出异常时,并不会把这个异常缓存下来,也不会让对象进入“失败”状态。它会保持内部哨兵值不变,下一次访问属性时重新执行初始化逻辑。换句话说,lazy默认不是“失败即熔断”,而是“失败即重试”。这个行为在没有仔细看源码之前,几乎很难凭直觉猜到,但它背后的实现逻辑、对线程安全模型的影响,以及在生产环境里可能引发的重复执行、状态不一致问题,都值得好好拆一拆。
今天这篇就专门聊 Kotlinlazy委托在异常场景下的完整执行流程,我会从三种线程安全模式讲起,逐步拆到源码层面,再给出一套可复现的验证方法和生产环境里的避坑建议。
1. 先从lazy委托的三种模式说起
1.1 三种模式的核心区别
lazy是 Kotlin 标准库提供的一个委托工具,底层返回的是Lazy<T>接口的实现类。调用by lazy { ... }时,如果不传任何参数,默认走的是LazyThreadSafetyMode.SYNCHRONIZED。但很多初学者甚至有一定经验的开发者在选择模式时,并没有意识到这个参数会影响异常出现后的行为。
三种模式分别是:
SYNCHRONIZED:默认模式。初始化逻辑由锁保护,同一时刻只有一个线程能执行初始化,其余线程阻塞等待,初始化完成后直接读取结果。PUBLICATION:允许多个线程同时进入初始化代码,但最终只有一个线程的结果被发布为最终值。NONE:不做任何线程同步,谁先访问谁初始化。JVM 环境下如果多线程同时访问,可能出现多次初始化,极端情况下还会产生对象逸出的问题。
从源码视角看,这三种模式分别对应SynchronizedLazyImpl、PublicationLazyImpl、UnsafeLazyImpl三个实现类。这三个类虽然线程安全策略完全不同,但在“初始化抛异常”这个路径上,行为高度一致——它们都不会把异常写入缓存字段。
理解这一点很关键。很多人在开发中把lazy当成一种“单例资源”来用,心理预期是“反正只会执行一次”。这个预期在初始化成功时是对的,一旦初始化抛异常,这个预期就被打破了。你以为是单例,实际上变成了“每次访问都可能重新执行”的可重试初始化器。
1.2 模式选型对异常行为的影响
既然异常后都会重试,那选哪种模式还有区别吗?有,而且区别不小。
先说说SYNCHRONIZED。在这种模式下,如果线程 A 第一次访问进入初始化块并抛异常,锁会被正常释放,此时其他线程会依次进入同步块。注意,等待的线程并不会拿到线程 A 抛出的异常,它们会看到哨兵值仍然是未初始化状态,然后由其中一个线程重新执行初始化。如果这个线程也抛异常,异常就从这个线程向外抛出,而下一个访问者还会继续重试。
PUBLICATION模式下的情况更特殊。它使用 CAS(AtomicReferenceFieldUpdater)来保证只有一个线程的结果会成为最终值。多个线程可以同时执行 initialization,谁先成功谁把值发布出去。但如果某个线程的初始化抛了异常,异常只影响这个线程自身的调用链,不会影响其他线程的初始化尝试。如果当前所有并发访问的线程都抛异常,那么 CAS 永远不会成功,下一次访问还会重新来一遍。
NONE模式就完全是裸奔了。单线程内访问一次抛一次异常,状态会一直保持未初始化;多线程环境下出现并发重入时,甚至可能同时在执行多个初始化块,异常路径变得更不可控。因此我个人的建议是,除非你百分百确定只有单线程访问,否则生产环境尽量不要用NONE模式。
一句话总结:三种模式在异常后的共同点是“不缓存异常、下一次访问继续初始化”,不同之处在于并发访问时的竞争方式和等待线程的行为。
2. 源码级拆解:异常发生时内部状态如何变化
2.1 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 } } } }第一层判断是快速路径。如果_value已经不是哨兵值,说明初始化已完成,不会进锁,也没必要关心异常。
重点在synchronized(lock)内部的这段。进入后它会再次检查_value是否是哨兵值,这一步是 double-check,防止两个线程在锁外同时通过第一层判断。真正的初始化逻辑是initializer!!()。假设这个方法内部直接抛出了异常,JVM 会怎么处理?关键在于,_value = typedValue这个赋值语句根本不会执行,initializer = null这行也不会执行,异常会直接沿着当前线程的调用栈向上抛出去。等于说,初始化代码的执行结果没有被记录,内部字段还原封不动地停留在UNINITIALIZED_VALUE。
但要注意,锁已经被正常释放了。Java 的synchronized块在退出时无论是否发生异常都会释放 monitor,所以不会有死锁风险。这意味着,AB 两个线程同时访问时,即使 A 初始化抛了异常,B 依然可以正常进入同步块重新执行初始化。从 B 的视角看,它拿到的可能是一个成功值,而不是 A 抛出的那个异常。
这个行为对很多开发者来说是反直觉的。你以为“初始化的异常会传染给后续访问者”,实际上并不会。后续访问者自己做了一次新的初始化尝试,成功与否完全取决于初始化方法本身的稳定性。
2.2 PublicationLazyImpl与UnsafeLazyImpl的异常路径
再看PublicationLazyImpl的核心逻辑:
override val value: T get() { val value = _value if (value !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return value as T } val initializerValue = initializer if (initializerValue != null) { val newValue = initializerValue() if (valueUpdater.compareAndSet(this, UNINITIALIZED_VALUE, newValue)) { initializer = null return newValue } } @Suppress("UNCHECKED_CAST") return _value as T }这段代码很有意思。它没有加锁,所有线程都能执行initializerValue(),但如果多个线程同时拿到不同结果,只有第一个 CAS 成功的能把它发布出去,后续线程即使算出一个新值,也会因为 CAS 失败而被丢弃。异常发生时,newValue压根不存在,CAS 不可能成功,_value保持哨兵值。所以它的异常路径和SYNCHRONIZED很相似,只不过少了“锁等待”这个过程。
UnsafeLazyImpl就更简单了:
override val value: T get() { if (_value === UNINITIALIZED_VALUE) { _value = initializer!!() initializer = null } @Suppress("UNCHECKED_CAST") return _value as T }同样,initializer!!()如果抛异常,赋值不会执行。由于没有同步机制,异常后的状态完全取决于并发访问情况,大多数情况下和“单例只初始化一次”的直觉相差甚远。
2.3 为什么不缓存异常:设计取舍与边界
看到这里你可能会问,为什么 Kotlin 官方不设计成“初始化抛异常就缓存异常,后续访问直接抛同一个异常”?这样不是更符合“只执行一次”的语义吗?
我的理解是,lazy的核心定位不是“错误状态管理”,而是“值的延迟初始化”。它假设初始化逻辑最终会成功,异常只是一个意外情况。如果把异常作为值缓存,会让内部状态变得很复杂:需要区分“未初始化”“初始化中”“初始化成功”“初始化失败”四种状态,还要考虑异常对象的序列化、跨线程传播等问题。标准库为了保持简单,选择了用哨兵值UNINITIALIZED_VALUE来标记状态,异常发生时状态自然回滚,逻辑上也非常自洽。
而且这种设计并不是没有优点。对于网络抖动、文件暂时不可读这类临时性故障,lazy的“失败后自动重试”反而避免了静态缓存永久卡死在失败状态的问题。换句话说,官方给了你一个可变的异常语义,把“如何处理失败”的决策权交给了调用方。代价是,如果你恰好需要“失败后固定异常”的语义,那就得自己在初始化块里做捕获和包装。
3. 一次异常访问的完整执行流程推演
3.1 单线程场景下的执行流程
理论讲完,直接上代码验证。下面这个例子模拟了一个初始化前两次必然失败、第三次成功的场景:
class LazyExceptionDemo { var count = 0 val resource: String by lazy { count++ if (count < 3) { throw IllegalStateException("init failed, attempt $count") } "ok" } } fun main() { val demo = LazyExceptionDemo() repeat(4) { try { println("value = ${demo.resource}") } catch (e: Exception) { println("caught: ${e.message}") } } println("initializer called ${demo.count} times") }运行结果:
caught: init failed, attempt 1 caught: init failed, attempt 2 value = ok initializer called 3 times这个例子足够说明问题。前两次访问,初始化块抛异常,lazy内部没有留下任何失败痕迹;第三次成功赋值后,后续访问直接走快速路径返回结果,不再执行初始化块。整个过程和源码逻辑完全一致。
如果你把count条件去掉,让它永远抛异常,那么每访问一次,就会抛一次异常,同时count会持续增加。这在实际项目里是非常危险的行为,因为调用方如果不断地重试访问,初始化块里的副作用代码就会被反复执行。
3.2 多线程竞争下的执行流程
再推演一个多线程场景。假设demo实例被 4 个线程同时首次访问,当线程 T1 抢先进入初始化并抛异常后,后续线程会怎么走?
用SYNCHRONIZED模式时,T2、T3、T4 可能正在锁外等待。T1 抛异常后,锁被释放,等待线程中有一个会获得锁,再次检查哨兵值,发现仍未初始化,于是重新执行初始化。也就是说,T1 的异常不会被其他线程感知,其他线程相当于获得了“重新尝试”的资格。
用PUBLICATION模式时,4 个线程都直接执行初始化。T1 抛异常、T2 抛异常,但 T3 成功算出结果并通过 CAS 发布,那么 T4 即使算出的值不同也会被丢弃,最终_value被 T3 的结果填充。之后所有访问返回 T3 的结果。
用NONE模式时,极端情况下 4 个线程可能同时执行初始化,谁先赋值谁生效,异常路径完全不受控,调试起来非常困难。
在实际业务里,我见过有人为了一个小优化把lazy改成NONE,结果线上偶发出现重复初始化的问题,排查了一整天。这种问题一旦出现,日志里的调用栈完全一样,根本没法快速定位是并发访问导致的。
3.3 重试与幂等:初始化块需要具备的性质
结合上面的推演,可以得出一个很重要的结论:lazy的初始化块在异常场景下可能会被多次执行,因此初始化逻辑必须具备一定的幂等性。
什么算幂等?至少要做到两点。第一,执行结果对系统状态的影响是可重复的,比如重复创建数据库连接、重复发送埋点事件、重复写入配置项,这类操作一旦重复执行,就可能产生脏数据或重复副作用。第二,如果初始化里包含资源分配逻辑,比如打开文件、创建线程池、申请连接池,那么多次执行必须保证不会资源泄漏。
我看到很多项目里这么写:
val db by lazy { Database.connect(url, user, password) }如果Database.connect内部在建立连接时会先打印一条日志,或者会分配一个固定大小的线程池,那么异常后第二次访问就会重复打印、重复分配资源。第一次抛异常可能连接建立到一半就中断了,但线程池可能已经创建了一部分,最终导致资源泄漏。
所以我的习惯是,凡是放进by lazy里的初始化逻辑,都会先问一句:这段逻辑如果执行两次,系统会不会出问题?如果会,那就不能直接丢给lazy,需要加上状态管理或者用异常安全的写法。
4. 生产环境中的避坑实践
4.1 不要在lazy初始化块里做不可重试的操作
这一条其实是上文的自然延伸,但在生产环境里太容易踩了,值得单独拎出来说。
所谓“不可重试”,是指执行一次和执行多次会产生不同结果的操作。最典型的是:发送 HTTP 请求并期望服务端只处理一次、向消息队列写入一条数据、更新本地数据库中的计数器、向第三方 SDK 注册设备 token。这些操作的共同点是,它们有外部可见的副作用,重复执行会导致数据重复、接口幂等性被破坏,甚至触发风控。
比如推送注册场景:
val pushToken by lazy { pushService.register() }如果register()第一次因为网络超时抛异常,下次访问就会重新注册。每次重新注册,服务端都可能生成新的 token 或者增加一个注册记录。用户只是多切换了几次页面,推送注册就被执行了好几次。
如果你的初始化逻辑本身是不可重试的,正确做法不是依赖lazy的默认语义,而是先把失败封装起来,再决定是缓存失败结果还是主动做重试。
4.2 失败后固定异常不重试的几种写法
如果你希望初始化失败后,后续访问不要重复执行,而是直接抛出同一个固定异常,有几种常见的实现方式。
最直接的一种是在初始化块内部捕获异常,把结果包装成带错误的状态对象:
val resource: Result<String> by lazy { runCatching { loadResource() } } fun useResource() { val result = resource result.onSuccess { println(it) } .onFailure { throw IllegalStateException("资源加载失败", it) } }这里有个容易忽略的点:runCatching会把异常捕获并包装到Result里,而lazy看到的是初始化块正常返回了一个Result.Failure。因此lazy内部状态会正常更新为“已初始化”,后续访问直接返回同一个Result.Failure,不会重复执行loadResource()。
如果你需要更精细的状态管理,比如区分“加载中”“加载成功”“加载失败”,可以自定义一个状态容器:
sealed class ResourceState<out T> { object Loading : ResourceState<Nothing>() data class Success<T>(val value: T) : ResourceState<T>() data class Failure(val error: Throwable) : ResourceState<Nothing>() } class SafeLazyHolder<T>(private val loader: () -> T) { private var state: ResourceState<T> = ResourceState.Loading private val lock = Any() fun get(): T = when (val s = state) { is ResourceState.Success -> s.value is ResourceState.Failure -> throw s.error ResourceState.Loading -> synchronized(lock) { when (val s2 = state) { is ResourceState.Success -> s2.value is ResourceState.Failure -> throw s2.error ResourceState.Loading -> try { ResourceState.Success(loader()).also { state = it }.value } catch (e: Exception) { ResourceState.Failure(e).also { state = it } throw e } } } } }这个实现的核心思路是:一旦初始化抛异常,立即缓存Failure状态。也就是说,第一次访问抛异常,后续访问同样抛同一个异常,但不会重复调用loader。不过要注意,这个方案里的锁粒度比较大,如果loader本身很耗时,后续线程会阻塞等待首次初始化完成,这一点和SynchronizedLazyImpl是一致的。
如果你追求极简,还有一种取巧的写法:内部用一个布尔变量标记是否已经尝试过。但这会引入额外的状态维护逻辑,不如状态容器来得规范。
4.3 与依赖注入、生命周期结合时的注意点
在实际项目里,lazy经常和依赖注入框架配合使用。比如 Koin 的by inject()底层就借助了lazy,Hilt 的某些场景也有类似机制。这种情况下,异常的语义会被放大。
我印象比较深的一个问题是,在 Android 里用 Koin 加载一个依赖,第一次解析时由于依赖图不完整抛了异常,后面即使依赖图已经修好了,有些框架会继续重试,有些则直接把异常缓存了。不同版本、不同框架的表现差异很大,不能一概而论。
更常见的是把lazy包里含有Context的初始化。如果你在 Activity 里这样写:
val analytics by lazy { AnalyticsManager.init(applicationContext) }首次访问发生在 Activity 销毁之后,applicationContext本身没问题,但如果初始化逻辑持有了 Activity 的引用,就会造成内存泄漏。更尴尬的是,如果初始化抛了个异常,下次访问时初始化代码又会执行一次,这个时候持有的引用可能已经失效了,很容易出现二次崩溃。
所以我的建议是,涉及生命周期和依赖注入的初始化,尽量把lazy放在更稳定的作用域里,比如 Application 级别或单例对象中,并且初始化块内部要保持“无状态、可重入”的特性。
5. 常见异常表现与排查思路
5.1 常见问题速查表
| 现象 | 直接原因 | 处理方向 |
|---|---|---|
| 初始化日志打印了多次 | 初始化抛异常后lazy不缓存异常,下次访问重新执行 | 确认初始化逻辑是否幂等;如果只想执行一次,用runCatching包装 |
| 多个线程同时访问时初始化计数器超过 1 | PUBLICATION模式下允许并发初始化 | 检查之前是否显式指定了模式;如果严格要求单次初始化,不要用PUBLICATION |
| 等待线程拿到的是成功值而不是异常 | SYNCHRONIZED模式下锁释放后等待线程会重新初始化 | 这不是 bug,是lazy的正常语义;若需要共享失败,建议自定义状态容器 |
| 每次访问都抛异常,且初始化块副作用不断叠加 | lazy内置“失败即重试”逻辑,初始化块可重试性差 | 利用Result或自定义状态机缓存失败结果 |
synchronized块内初始化抛异常后其他线程死锁 | Java 的synchronized在异常退出时也会释放 monitor | 不会死锁,但耗时初始化需要谨慎控制锁粒度 |
| 初始化中线程被中断,访问表现异常 | JVM 的synchronized不响应中断,除非初始化代码内部自检中断状态 | 在初始化逻辑中自行检查Thread.currentThread().isInterrupted并安全退出 |
5.2 一个可复现的并发实验
为了验证SYNCHRONIZED模式下异常与等待线程的关系,可以写个简单实验:
class ConcurrentLazyDemo { var atomicCount = AtomicInteger(0) val value: String by lazy { val current = atomicCount.incrementAndGet() if (current == 1) { throw IllegalStateException("first attempt fail") } "success-$current" } } fun main() { val demo = ConcurrentLazyDemo() val threadCount = 4 val latch = CountDownLatch(threadCount) val results = Collections.synchronizedList(mutableListOf<String>()) repeat(threadCount) { Thread { try { results.add(demo.value) } catch (e: Exception) { results.add("caught: ${e.message}") } finally { latch.countDown() } }.start() } latch.await() println("atomicCount = ${demo.atomicCount.get()}") println("results = $results") }由于存在竞争,每次运行的输出细节会略有差异,但基本上你会看到类似这样的结果:初始化块的执行次数为 2,有的线程捕获到第一次失败的异常,有的线程成功拿到了最终值。只要初始化代码不是每次都抛异常,总会有线程成功完成初始化并把值发布出去。这进一步印证了前文说的逻辑:异常并不等同于终态。
5.3 排查建议
排查lazy相关异常问题时,我一般会按三个方向走。
先确认模式。看代码里by lazy是否有显式传入LazyThreadSafetyMode,如果没传且是多线程环境,大概率是默认的SYNCHRONIZED,这种情况下关注点应该放在“谁先访问、谁在等待、谁重新初始化”上。
再确认初始化块的副作用。把初始化块里的日志、外部调用、资源申请逐条列出来,看它们是否能容忍重复执行。这一步往往能直接定位到生产问题,因为很多诡异现象都是重复初始化导致的。
最后做一个异常注入测试。故意让初始化抛一次异常,观察后续访问的表现。如果行为不符合预期,再决定是改用Result包装,还是自定义状态容器。测试时建议用计数器和日志辅助观察,别只靠肉眼盯日志,不然很容易漏掉重复执行的细节。
最后分享一个我自己的习惯。现在我在项目里默认不会直接用by lazy去包“可能抛异常的外部资源加载逻辑”,而是先问一句:如果这次初始化失败,我希望后续访问是继续尝试,还是固定失败?如果需要继续尝试,那lazy的默认行为其实够用,但初始化块一定要幂等;如果需要固定失败,那我就会用runCatching或自定义状态容器把失败结果缓存起来。搞清楚这个前提,再写代码,能省掉后面很多排查时间。