ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Kotlin lazy委托异常机制解析:三种线程安全模式与源码级避坑

Kotlin lazy委托异常机制解析:三种线程安全模式与源码级避坑 先说一个我自己的真实经历。有一段时间我在项目里用by lazy包了一个远程配置的加载逻辑第一次访问时网络超时抛了 IOException界面直接弹了个加载失败的提示。本来以为这事就完了结果用户多点了两下日志里出现了好几条重复的加载记录我当时第一反应是“代码里哪里写了循环”排查了半天才发现问题根本不在调用方而在lazy委托的异常执行流程上。Kotlin 的lazy委托在初始化块抛出异常时并不会把这个异常缓存下来也不会让对象进入“失败”状态。它会保持内部哨兵值不变下一次访问属性时重新执行初始化逻辑。换句话说lazy默认不是“失败即熔断”而是“失败即重试”。这个行为在没有仔细看源码之前几乎很难凭直觉猜到但它背后的实现逻辑、对线程安全模型的影响以及在生产环境里可能引发的重复执行、状态不一致问题都值得好好拆一拆。今天这篇就专门聊 Kotlinlazy委托在异常场景下的完整执行流程我会从三种线程安全模式讲起逐步拆到源码层面再给出一套可复现的验证方法和生产环境里的避坑建议。1. 先从lazy委托的三种模式说起1.1 三种模式的核心区别lazy是 Kotlin 标准库提供的一个委托工具底层返回的是LazyT接口的实现类。调用by lazy { ... }时如果不传任何参数默认走的是LazyThreadSafetyMode.SYNCHRONIZED。但很多初学者甚至有一定经验的开发者在选择模式时并没有意识到这个参数会影响异常出现后的行为。三种模式分别是SYNCHRONIZED默认模式。初始化逻辑由锁保护同一时刻只有一个线程能执行初始化其余线程阻塞等待初始化完成后直接读取结果。PUBLICATION允许多个线程同时进入初始化代码但最终只有一个线程的结果被发布为最终值。NONE不做任何线程同步谁先访问谁初始化。JVM 环境下如果多线程同时访问可能出现多次初始化极端情况下还会产生对象逸出的问题。从源码视角看这三种模式分别对应SynchronizedLazyImpl、PublicationLazyImpl、UnsafeLazyImpl三个实现类。这三个类虽然线程安全策略完全不同但在“初始化抛异常”这个路径上行为高度一致——它们都不会把异常写入缓存字段。理解这一点很关键。很多人在开发中把lazy当成一种“单例资源”来用心理预期是“反正只会执行一次”。这个预期在初始化成功时是对的一旦初始化抛异常这个预期就被打破了。你以为是单例实际上变成了“每次访问都可能重新执行”的可重试初始化器。1.2 模式选型对异常行为的影响既然异常后都会重试那选哪种模式还有区别吗有而且区别不小。先说说SYNCHRONIZED。在这种模式下如果线程 A 第一次访问进入初始化块并抛异常锁会被正常释放此时其他线程会依次进入同步块。注意等待的线程并不会拿到线程 A 抛出的异常它们会看到哨兵值仍然是未初始化状态然后由其中一个线程重新执行初始化。如果这个线程也抛异常异常就从这个线程向外抛出而下一个访问者还会继续重试。PUBLICATION模式下的情况更特殊。它使用 CASAtomicReferenceFieldUpdater来保证只有一个线程的结果会成为最终值。多个线程可以同时执行 initialization谁先成功谁把值发布出去。但如果某个线程的初始化抛了异常异常只影响这个线程自身的调用链不会影响其他线程的初始化尝试。如果当前所有并发访问的线程都抛异常那么 CAS 永远不会成功下一次访问还会重新来一遍。NONE模式就完全是裸奔了。单线程内访问一次抛一次异常状态会一直保持未初始化多线程环境下出现并发重入时甚至可能同时在执行多个初始化块异常路径变得更不可控。因此我个人的建议是除非你百分百确定只有单线程访问否则生产环境尽量不要用NONE模式。一句话总结三种模式在异常后的共同点是“不缓存异常、下一次访问继续初始化”不同之处在于并发访问时的竞争方式和等待线程的行为。2. 源码级拆解异常发生时内部状态如何变化2.1 SynchronizedLazyImpl的异常路径直接看标准库源码里最关键的一段逻辑private class SynchronizedLazyImplout T(initializer: () - T, lock: Any? null) : LazyT, 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: ResultString 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 ResourceStateout T { object Loading : ResourceStateNothing() data class SuccessT(val value: T) : ResourceStateT() data class Failure(val error: Throwable) : ResourceStateNothing() } class SafeLazyHolderT(private val loader: () - T) { private var state: ResourceStateT 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()底层就借助了lazyHilt 的某些场景也有类似机制。这种情况下异常的语义会被放大。我印象比较深的一个问题是在 Android 里用 Koin 加载一个依赖第一次解析时由于依赖图不完整抛了异常后面即使依赖图已经修好了有些框架会继续重试有些则直接把异常缓存了。不同版本、不同框架的表现差异很大不能一概而论。更常见的是把lazy包里含有Context的初始化。如果你在 Activity 里这样写val analytics by lazy { AnalyticsManager.init(applicationContext) }首次访问发生在 Activity 销毁之后applicationContext本身没问题但如果初始化逻辑持有了 Activity 的引用就会造成内存泄漏。更尴尬的是如果初始化抛了个异常下次访问时初始化代码又会执行一次这个时候持有的引用可能已经失效了很容易出现二次崩溃。所以我的建议是涉及生命周期和依赖注入的初始化尽量把lazy放在更稳定的作用域里比如 Application 级别或单例对象中并且初始化块内部要保持“无状态、可重入”的特性。5. 常见异常表现与排查思路5.1 常见问题速查表现象直接原因处理方向初始化日志打印了多次初始化抛异常后lazy不缓存异常下次访问重新执行确认初始化逻辑是否幂等如果只想执行一次用runCatching包装多个线程同时访问时初始化计数器超过 1PUBLICATION模式下允许并发初始化检查之前是否显式指定了模式如果严格要求单次初始化不要用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(mutableListOfString()) 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或自定义状态容器把失败结果缓存起来。搞清楚这个前提再写代码能省掉后面很多排查时间。
返回列表