如何修复Kotlin‘获取或创建’缓存中的竞态条件?
哥们,我之前也踩过这个「检查-创建-缓存」的竞态条件坑!你的代码问题出在:多个线程可以同时通过缓存检查的步骤,然后都跑去执行昂贵的createResource,最后不仅浪费资源,还可能导致缓存里的实例和线程返回的实例不一致。下面我给你唠唠Kotlin里常用的几种解决办法,还有各自的优缺点,你可以根据自己的场景选:
首先先明确问题根源:你的getResource里,缓存检查和更新不是原子操作——当线程A还在执行createResource的时候,线程B可能已经通过了缓存检查,也开始创建资源,最终导致同一个id的资源被创建多次。
1. 最省心的基础方案:用synchronized同步块
最简单粗暴的方式,给整个缓存检查+创建的逻辑加锁,保证同一时间只有一个线程能执行这段代码。
class ResourceProvider { private var cachedResource: CachedResource? = null private fun createResource(id: String): CachedResource { println("Creating new resource for id: $id...") Thread.sleep(1000) // 模拟耗时操作 return CachedResource(id) } fun getResource(id: String): CachedResource { synchronized(this) { // 加锁,同一时间仅一个线程能进入 val localCache = cachedResource if (localCache != null && localCache.id == id) { return localCache } val newResource = createResource(id) cachedResource = newResource return newResource } } }
优点:实现简单,逻辑清晰,完全杜绝竞态条件,新手也能快速理解。
缺点:每次调用getResource都要抢锁,哪怕缓存已经存在(命中),高并发场景下会因为锁竞争导致线程阻塞,拖慢性能。
2. 性能优化方案:双重检查锁定(Double-Checked Locking)
为了解决同步块的性能问题,我们可以做「两次检查」:先无锁快速检查缓存,只有当缓存无效时才加锁,加锁后再检查一次(防止加锁期间其他线程已经创建了资源)。
注意:这里必须给cachedResource加@Volatile注解,不然多线程环境下可能出现指令重排序,导致线程拿到半初始化的CachedResource实例。
class ResourceProvider { @Volatile // 必须加,保证内存可见性+禁止指令重排序 private var cachedResource: CachedResource? = null private fun createResource(id: String): CachedResource { println("Creating new resource for id: $id...") Thread.sleep(1000) return CachedResource(id) } fun getResource(id: String): CachedResource { var localCache = cachedResource // 第一次无锁检查,缓存命中直接返回 if (localCache != null && localCache.id == id) { return localCache } // 缓存无效,加锁 synchronized(this) { // 第二次检查,防止加锁期间其他线程已经创建了资源 localCache = cachedResource if (localCache != null && localCache.id == id) { return localCache } val newResource = createResource(id) cachedResource = newResource return newResource } } }
优点:缓存命中时不需要抢锁,性能比全同步块好很多,只有缓存未命中时才会有锁竞争。
缺点:代码复杂度略高,需要注意@Volatile的使用;而且这个方案只适合缓存单一实例,如果需要缓存多个不同id的资源,就不适用了。
3. 灵活扩展方案:ConcurrentHashMap+lazy委托
如果你的需求是缓存多个不同id的资源(而不是单一实例),可以用ConcurrentHashMap存储每个id对应的lazy实例。Kotlin的lazy默认是线程安全的,能保证每个id对应的资源只会被创建一次。
class ResourceProvider { // 用ConcurrentHashMap存储每个id对应的懒加载资源 private val resourceCache = ConcurrentHashMap<String, Lazy<CachedResource>>() private fun createResource(id: String): CachedResource { println("Creating new resource for id: $id...") Thread.sleep(1000) return CachedResource(id) } fun getResource(id: String): CachedResource { // 原子性获取或创建对应id的lazy实例 val lazyResource = resourceCache.computeIfAbsent(id) { lazy { createResource(it) } } // 调用value时才会触发createResource(仅第一次调用) return lazyResource.value } }
优点:
- 天然支持多id缓存,扩展性极强;
- 代码简洁,不用手动处理锁,利用Kotlin的委托特性和
ConcurrentHashMap的原子操作实现线程安全; lazy的线程安全模式可配置,默认的同步模式完全满足需求。
缺点:
- 缓存的资源会一直存在(除非手动清理
resourceCache),如果id数量很多,可能会占用较多内存; - 第一次获取新id资源时,会有一点点
computeIfAbsent和lazy初始化的额外开销,不过大多数场景下可以忽略。
4. 不推荐的方案:用AtomicReference的CAS操作
如果想完全避免显式锁,可以用AtomicReference的compareAndSet方法原子更新缓存,但这个方案有个致命问题:无法避免createResource被多次调用(只是保证缓存最终是正确的),会浪费资源,所以一般不推荐。
import java.util.concurrent.atomic.AtomicReference class ResourceProvider { private val cachedResource = AtomicReference<CachedResource?>() private fun createResource(id: String): CachedResource { println("Creating new resource for id: $id...") Thread.sleep(1000) return CachedResource(id) } fun getResource(id: String): CachedResource { var current = cachedResource.get() if (current != null && current.id == id) { return current } val newResource = createResource(id) // 尝试原子更新缓存,如果当前缓存还是原来的状态则更新成功 while (!cachedResource.compareAndSet(current, newResource)) { current = cachedResource.get() if (current != null && current.id == id) { return current } } return newResource } }
优点:无显式锁,低竞争场景下性能尚可;
缺点:代码逻辑复杂难理解,且多个线程同时调用时,createResource仍会被多次执行,浪费资源。
总结选择建议
- 只缓存单一实例、并发量不极高:选双重检查锁定,平衡性能和代码复杂度;
- 需要缓存多个id的资源:选ConcurrentHashMap + lazy,代码简洁扩展性好;
- 追求最简单实现、不介意性能开销:选synchronized同步块;
- 尽量避开AtomicReference的CAS方案,因为它无法避免资源的重复创建。
内容来源于stack exchange

