Kotlin 协程环境下的 DCL 懒加载:别把线程时代的经验直接搬过来

很多 Android 开发者都知道双重检查锁定(Double-Checked Locking,DCL)。

但到了 Kotlin 协程时代,很多人依然下意识地拿起 synchronized 单例缓存那套老办法。

本文聊聊协程环境下 DCL 的正确实现方式,以及那些最容易被忽略的坑。


一、一个看起来没问题的实现

假设我们有这样一个需求:

  • Retrofit 初始化成本较高
  • BaseUrl 需要远程获取
  • 希望懒加载
  • 多个协程并发访问时只初始化一次

很多人第一反应会这样写:

kotlin 复制代码
object RetrofitClient {

    private var retrofit: Retrofit? = null

    fun getRetrofit(): Retrofit {
        return retrofit ?: synchronized(this) {
            retrofit ?: buildRetrofit().also {
                retrofit = it
            }
        }
    }

    private fun buildRetrofit(): Retrofit {
        val baseUrl = runBlocking {
            ConfigRepository.fetchRemoteConfig()
        }

        return Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

其中 fetchRemoteConfig 是挂起函数

代码:

  • 能编译
  • 能运行
  • 看起来还是标准 DCL

但问题很大。


二、问题不在 DCL,而在 synchronized

很多人误以为:

text 复制代码
协程 + synchronized = 安全

实际上:

text 复制代码
synchronized 保护的是线程
不是协程

更严重的是:

kotlin 复制代码
runBlocking {
    ConfigRepository.fetchRemoteConfig()
}

直接把原本应该挂起的异步逻辑变成了同步阻塞。

此时:

text 复制代码
协程优势全部消失

等待网络期间:

text 复制代码
线程被锁住
线程无法释放

如果发生在主线程:

text 复制代码
ANR

这其实已经退化成传统 Java 并发模型。


三、协程版 DCL 应该长什么样

正确思路:

text 复制代码
线程锁 → Mutex

阻塞等待 → suspend 挂起
kotlin 复制代码
object RetrofitClient {

    @Volatile
    private var retrofit: Retrofit? = null

    private val mutex = Mutex()

    suspend fun getRetrofit(): Retrofit {
        return retrofit ?: mutex.withLock {
            retrofit ?: buildRetrofit().also {
                retrofit = it
            }
        }
    }

    private suspend fun buildRetrofit(): Retrofit {
        val baseUrl = ConfigRepository.fetchRemoteConfig()

        return Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

这才是协程时代的 DCL。


四、为什么需要双重检查

kotlin 复制代码
suspend fun getRetrofit(): Retrofit {
    return mutex.withLock {
        retrofit ?: buildRetrofit().also {
            retrofit = it
        }
    }
}

这样不就行了吗?

当然能工作。

但性能不好。

因为:

text 复制代码
即使已经初始化完成

每次调用仍然要竞争锁

标准 DCL:

kotlin 复制代码
retrofit ?: mutex.withLock {
    retrofit ?: buildRetrofit()
}

分为两层。

第一层:

kotlin 复制代码
retrofit ?

快速返回。

第二层:

kotlin 复制代码
mutex.withLock

保证初始化只发生一次。

流程:

text 复制代码
已初始化
    ↓
直接返回

未初始化
    ↓
进入锁

再次检查

仍为空
    ↓
初始化

这才是 DCL 的本质。


五、为什么 @Volatile 不能省

有人认为:

text 复制代码
用了 Mutex

就不需要 Volatile

这个说法不严谨。


先看:

kotlin 复制代码
retrofit ?: mutex.withLock {
    retrofit ?: ...
}

注意:

text 复制代码
第一层读取发生在锁外

也就是说:

kotlin 复制代码
retrofit ?

根本没有经过 Mutex。


如果没有:

kotlin 复制代码
@Volatile
private var retrofit: Retrofit? = null

理论上可能发生:

CPU A:

text 复制代码
retrofit 已写入

CPU B:

text 复制代码
仍然看到旧值 null

于是:

text 复制代码
进入 Mutex

虽然最终不会重复构建,

但第一层检查已经失去意义。


因此正确说法应该是:

DCL 场景下需要 @Volatile,不是因为 Mutex 不安全,而是因为第一层读取绕过了 Mutex。


六、Mutex 真正解决了什么问题

Mutex 的价值不是线程安全。

而是:

text 复制代码
协程安全

例如:

kotlin 复制代码
mutex.withLock {
    buildRetrofit()
}

内部允许:

kotlin 复制代码
delay()

允许:

kotlin 复制代码
suspend fun

允许:

kotlin 复制代码
网络请求
数据库访问

而等待锁期间:

text 复制代码
协程挂起
线程释放

不会阻塞线程。


这一点和 synchronized 完全不同。

text 复制代码
synchronized
    ↓
阻塞线程

Mutex
    ↓
挂起协程

这才是协程锁最大的价值。


因此实际项目更推荐:

kotlin 复制代码
object RetrofitProvider {

    @Volatile
    private var retrofit: Retrofit? = null

    private val mutex = Mutex()

    suspend fun getRetrofit(): Retrofit {
        return retrofit ?: mutex.withLock {
            retrofit ?: buildRetrofit().also {
                retrofit = it
            }
        }
    }
}

然后:

kotlin 复制代码
val userApi =
    RetrofitProvider
        .getRetrofit()
        .create(UserApi::class.java)

这样更符合 Retrofit 的设计思想。

总结

协程环境下实现 DCL,需要牢记三个原则:

第一

不要把:

text 复制代码
synchronized + runBlocking

当成协程方案。

这是线程时代的思维。


第二

标准协程 DCL:

kotlin 复制代码
@Volatile
private var retrofit: Retrofit? = null

private val mutex = Mutex()

suspend fun getRetrofit(): Retrofit {
    return retrofit ?: mutex.withLock {
        retrofit ?: buildRetrofit().also {
            retrofit = it
        }
    }
}

第三

记住职责分工:

text 复制代码
@Volatile
负责可见性

Mutex
负责互斥初始化

suspend
负责非阻塞等待

三者解决的是三个不同问题。

缺少任何一个,都可能让 DCL 变成一个隐藏的并发 Bug。


在协程时代,要把非阻塞挂起放在首位,而不是把线程时代的经验原封不动地搬过来。 真正的协程思维,不仅仅是把把代码改成 suspend,而是从"阻塞线程的思维"转向"挂起协程的思维"。

参考

并发编程的新篇章:以Kotlin协程告别JUC的重锁与死锁风险

为什么 Java 的锁锁不住 Kotlin 协程?

Repository 方法设计:suspend 与 Flow 的决选择指南(以朋友圈为例)

Android Data 层设计的四条红线:为什么必须坚持、如何落地

Android 面试系列:runBlocking 到底该在哪用?

相关推荐
plainGeekDev1 小时前
Gson → kotlinx.serialization
android·java·kotlin
CYY9515 小时前
Compose 入门篇
android·kotlin
杉氧19 小时前
Compose 时代的 MVI 架构:如何用单向数据流驱动复杂 UI?
android·架构·android jetpack
杉氧19 小时前
Modifier 的艺术:为什么链式调用的顺序决定了UI 的生命周期?
android·架构·android jetpack
李斯维20 小时前
腾讯 XLog 日志框架 Android 端接入
android·android studio·android jetpack
黄林晴20 小时前
Kotlin Toolchain 0.11 发布:Amper 正式更名,统一 kotlin 命令
android·kotlin
雨白21 小时前
C语言基础快速入门与指针初探
android
Exploring1 天前
避坑指南:升级 AGP 8.0+ 导致第三方 SDK 编译崩溃的完美解决方案
android