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

372 阅读4分钟

一、问题起手:一个“看起来完全没问题”的代码

很多人在协程里第一次写到下面这样的代码时,都会觉得这没什么问题

image.png

image.png

  • 用了 ReentrantLock
  • 做了余额判断
  • 甚至切到 单线程 dispatcher image.png

但真实运行结果却可能是:

currentBalance = -500

更诡异的是:

  • 多线程:直接抛异常

image.png

  • 单线程:不报错,但结果错

这就引出了本文的核心问题:

为什么 Java 的锁,在协程里看起来“完全不可靠”?


二、synchronized 与协程的根本冲突

2.1 synchronized 的语义:线程绑定的 JVM monitor

在 JVM 层面,synchronized 的语义非常明确:

  • 锁的拥有者是 Thread

  • 进入同步块 → 当前线程获取 monitor

  • 退出同步块 → 必须由同一个线程释放

  • JVM 假设:

    持锁期间,线程不会“消失”


2.2 协程的语义:可挂起、可恢复、可迁移

而协程的 suspend 意味着:

  • 当前逻辑执行可以 挂起

  • 线程会被 释放

  • 恢复时:

    • 可能是同一个线程
    • 也可能是另一个线程

也就是说:

协程绑定的是“逻辑执行流”, 而不是某个固定线程。


2.3 语义正面冲突

如果允许下面的代码存在:

synchronized(lock) {
    delay(1000)
}

那么 JVM 将面对一个无法成立的事实:

  • monitor 是线程 A 拿的
  • delay 后代码可能在线程 B 执行
  • 线程 B 却在“同步块”中运行

这会直接破坏 JVM 对 monitor 的基本假设。

👉 因此 Kotlin 的选择是:

在编译期直接禁止: synchronized / @Synchronized 中不能出现挂起点


三、为什么 ReentrantLock 却能“正常编译”?

3.1 语言级 vs 库级

  • synchronized语言级特性
  • ReentrantLock普通 Java 类
lock.lock()
delay(1000)
lock.unlock()

在 Kotlin 编译器看来,这只是普通方法调用:

“我不知道你这是锁, 我也不知道你有什么语义。”

所以:

  • 编译期 不拦
  • 风险 全部留给运行期

四、同一段代码,ReentrantLock 的两种结局

我们统一看下面这段代码:

image.png

4.1 多线程调度:直接抛异常

如果在多线程 dispatcher 下运行:

image.png

这个启动协程去线程调度和在方法上线程调度 本质是一样的. image.png

你很快会看到:

IllegalMonitorStateException

原因非常直接:

  • lock.lock()线程 A
  • delay() 挂起后释放线程
  • 协程恢复时可能在 线程 B
  • lock.unlock() 却由线程 B 执行

而 JVM 的规则是:

只能由拿锁的线程释放锁

👉 这是 JVM 级非法行为,直接抛异常。

4.2 单线程调度:不报错,但结果是 -500

很多人会尝试“绕过”这个问题:

val dispatcher = newSingleThreadContext("single")

在这种情况下:

  • lock.lock() / unlock() 确实发生在同一个 Thread 实例
  • 不会抛异常

但结果却可能是:

currentBalance = -500

4.3 为什么单线程下也会算错?

因为问题已经不是“线程安全”,而是:

挂起把“时间”引进了临界区

真实执行时间线(单线程):

balance = 1000
启动 3 个协程
  • 协程 A:判断通过 → delay
  • 1 秒后:扣 500 → balance = 500
  • 协程 B:判断通过 → delay
  • 1 秒后:扣 500 → balance = 0
  • 协程 C:判断在 delay 前已成立 → 再扣 500

最终结果:

balance = -500

⚠️ 注意:

  • 没有线程并发
  • 锁没有失效
  • 只是判断结果在 suspend 后已经过期

4.4 小结:两种死法

场景表现根因
多线程 + Lock抛异常锁 owner 是 Thread
单线程 + Lock结果错误时间竞态
任意线程 + Mutex语义成立锁绑定协程

五、为什么 Mutex 在协程里是“语义正确”的

image.png

Mutex 的关键区别在于:

  • 它锁的不是线程
  • 而是 协程执行权

也就是说:

ReentrantLock → owner = Thread
Mutex         → owner = Coroutine

因此:

  • 挂起不会破坏锁语义
  • 恢复后仍是同一个逻辑执行者

六、总结

ReentrantLock 的世界观是: ❌ 锁的 owner 必须是同一个线程

而协程的世界观是: ✅ 只要是“同一个协程”,逻辑就是连续的

当线程锁遇到可迁移执行的协程时, 两套宇宙法则发生了正面冲突。

  • synchronized → JVM 线程模型 → 编译期禁止 suspend
  • ReentrantLock → 库级 API → 运行期风险自负
  • Mutex → 协程原生同步 → 唯一语义正确的选择