移动开发中的死锁:什么是死锁、产生原因以及避免相互阻塞的方法

作者: IT Sectr 发布日期: 2026-03-18 阅读时间: 10 分钟

死锁是一种状态,其中两个或多个线程无限期地等待其他参与者占用的资源释放。根据Oracle Java Tutorials (2024),当每个线程持有另一个线程所需的锁时,就会发生循环等待导致的死锁。没有特殊的检测手段,死锁会完全停止应用程序的执行,而不会出现可见的错误。

要点

  • 死锁 — 线程的相互阻塞,每个线程都在等待另一个线程占用的资源
  • Coffman四个条件(互斥、保持并等待、非抢占、循环等待)是死锁发生的必要条件
  • 死锁与Starvation的区别在于线程没有被阻塞,而是在循环依赖中主动等待
  • 线程转储 — 在JVM和Android Runtime中检测死锁的主要工具
  • 锁的层级结构和统一的资源获取顺序 — 防止相互阻塞的主要方法

什么是死锁?

死锁是多线程编程中的一种情况,其中两个或多个线程永久地相互阻塞。每个线程都持有另一个线程所需的资源,并且不释放它,等待获取缺失的资源。结果,没有一个线程能够继续执行。

在移动开发中,死锁尤其严重,因为它不会引发异常或崩溃。应用程序只是停止响应用户操作(ANR),唯一的出路是强制终止进程。根据Google的数据(Android Performance Patterns, 2023),Google Play Console中约15%的ANR报告与后台线程中的相互阻塞有关。

死锁与其他并发问题的关键区别在于——没有外部干预就无法逆转。线程不会自己释放资源,因为操作系统调度程序无法强制撤销锁。这将死锁与活锁区分开来,在活锁中线程是活跃的,但不执行有用的工作。

死锁产生的条件

1971年,Edward G. Coffman阐述了死锁发生所必需的四个必要条件。如果至少缺少一个条件,相互阻塞就不可能发生。这些条件被称为Coffman条件,是所有死锁预防算法的基础。

互斥(Mutual Exclusion)

资源在每个时刻只能被一个线程获取。如果资源允许多个线程同时读取(例如,ReadWriteLock处于读取模式),就不会发生死锁。这个条件源于Mutex和锁的本质特性。

保持并等待(Hold and Wait)

一个线程保持已获取的资源,同时等待获取另一个资源。如果线程可以在请求下一个资源之前释放当前资源(通过两阶段锁定),则违反了Hold and Wait条件。在Android中,这经常表现为一个线程持有数据库锁并试图获取SharedPreferences锁。

非抢占(No Preemption)

操作系统不能强制从线程中拿走锁。资源只有在线程自己释放时才被释放。在某些系统中(例如SQLite WAL模式),在单个操作级别实现了强制抢占,这降低了死锁风险。

循环等待(Circular Wait)

存在一个封闭的线程链,每个线程都在等待链中下一个线程持有的资源。例如,线程A持有资源1并等待资源2,线程B持有资源2并等待资源1。这是开发人员可以架构性消除的唯一条件——通过锁的层级结构。如果所有线程严格按照规定的全局顺序获取资源,循环在物理上是不可能的。

在实践中,在Android应用程序中,死锁通常是由于不同级别的锁隐式交叉而发生的:数据库锁(Room)、SharedPreferences锁和内存集合锁。这些锁中的每一个都由不同的组件管理,没有集中化的获取顺序协议,开发人员无意中创建了循环。

Kotlin代码中的死锁示例

让我们看一个经典的相互阻塞示例——两个线程以不同的顺序获取锁。如果第一个线程阻塞资源A并试图获取B,第二个线程阻塞B并试图获取A,就会发生死锁。

kotlin
class DeadlockExample {
    private val lockA = Any()
    private val lockB = Any()

    fun operationA() {
        synchronized(lockA) {
            Thread.sleep(50)  // 工作模拟
            synchronized(lockB) {
                println("operationA已完成")
            }
        }
    }

    fun operationB() {
        synchronized(lockB) {  // 相反顺序!
            Thread.sleep(50)
            synchronized(lockA) {
                println("operationB已完成")
            }
        }
    }
}

fun main() {
    val ex = DeadlockExample()
    Thread { ex.operationA() }.start()
    Thread { ex.operationB() }.start()
    // 应用程序将永远冻结 — 死锁!
}

在这个示例中,operationA获取lockA,operationB获取lockB。然后每个都试图获取第二个锁——两者都无限期等待。程序在没有错误消息的情况下冻结。唯一的修复方法是确保所有方法中锁的获取顺序相同。

死锁 vs 饥饿 vs 活锁

这三个并发问题经常被混淆,但它们的机制和后果根本不同。死锁 — 完全停止,饥饿 — 无限期等待资源,活锁 — 活跃的空闲。理解这些差异对于选择正确的消除策略至关重要。

特征死锁饥饿活锁
线程状态阻塞(BLOCKED)就绪(RUNNABLE)活跃(RUNNABLE)
工作执行是,但无用
原因循环等待不公平调度冲突处理不正确
检测线程转储,超时进度监控重试计数器

饥饿发生在调度程序持续推迟低优先级线程的执行以利于其他线程时。与死锁不同,线程没有被阻塞 — 它准备执行但没有获得CPU时间。在Android中,典型场景是低优先级后台线程永远不会执行,如果UI和Service线程持续活跃的话。

活锁是一种线程没有被阻塞,但无限地响应彼此的操作,而不执行有用工作的情况。经典的类比是两个人走道相遇,都试图让路,却向同一方向移动。与死锁不同,活锁中的线程消耗CPU,耗尽设备电池。

如何检测死锁

线程转储是在JVM和Android Runtime中检测相互阻塞的主要工具。在转储时,JVM自动分析监视器之间的依赖图并标记死锁循环。在Android Studio中,可以通过Android Profiler或从ADB Shell执行kill -3 PID命令获取线程转储。

运行时的自动死锁检测通过看门狗定时器实现。如果一个线程在指定的超时时间内没有完成操作,看门狗启动转储生成并将报告发送到崩溃报告系统(Firebase Crashlytics、Sentry)。根据Sentry的数据(Issue Resolution Report, 2024),配置看门狗将死锁诊断时间从几周缩短到几个小时。

在开发阶段,JetBrains的ThreadSafe静态分析器和带有Lock Checker模块的Checker Framework非常有效。这些工具在源代码级别分析锁的获取顺序,并警告潜在循环。此外,建议使用测试驱动的死锁检测——在数百个线程中运行不同锁定顺序操作的压力测试。

特别值得关注的是协作式死锁检测——一种线程通过全局注册表交换已获取锁信息的方法。如果线程检测到潜在循环,它会释放所有资源并重复操作。这种方法用于分布式系统(Apache ZooKeeper、Google Chubby),并通过像Jetpack Sync这样的库逐步引入移动开发。

防止相互阻塞的方法

锁的层级结构(Lock Ordering)

最可靠的方法是在整个应用程序中建立全局的锁获取顺序。如果所有线程总是先获取编号较小的锁,然后获取编号较大的锁,循环等待(Circular Wait条件)就不可能发生。在大型项目中,顺序在文档中固定并通过代码审查验证。

带超时的TryLock

TryLock是一种锁定方法,它不会无限期地阻塞线程,而是如果在指定时间内未获得锁则返回false。在Java中,这是通过ReentrantLock.tryLock(timeout, TimeUnit)实现的,在Kotlin Coroutines中是通过带超时的Mutex.withLock实现的。如果失败,线程释放所有已获取的资源并稍后重试。

银行家算法

银行家算法是一种预防死锁的理论方法,由Edsger Dijkstra提出。它将资源分配建模为银行交易:系统不分配资源,如果这可能导致不安全状态(死锁)。在实践中,该算法很少应用于移动开发,因为预先知道线程的最大需求很复杂,但其原理用于SQLite数据库和文件系统中。

常见问题

单线程应用程序中会发生死锁吗?

不会,相互阻塞至少需要两个线程。在单线程代码中,所有操作都是顺序执行的,因此循环等待是不可能的。但是,在使用文件锁或进程间信号量时,死锁可能发生在进程之间。

Kotlin协程中的死锁与线程中的死锁有何不同?

在协程中,死锁发生在挂起函数级别,并且不会阻塞OS线程,这使得它不太明显。kotlinx.coroutines中的Mutex是挂起式的,它不会阻塞线程,但协程不执行。对于检测,使用kotlinx-coroutines-debug模块中的DebugProbes。

Android上SQLite中的死锁是什么?

SQLite死锁发生在两个数据库连接试图以不同顺序执行事务时。SQLite检测此类情况并返回SQLITE_BUSY或SQLITE_LOCKED错误代码。在Android中,建议使用Room与单个数据库实例和通过@Transaction的事务,这消除了连接间的死锁。

Android如何检测死锁?

Android运行时有一个内置的死锁检测器,在生成ANR时激活。系统分析应用程序所有线程的线程转储并标记相互阻塞。结果可在/data/anr/traces.txt和Google Play Console的ANR Reports部分查看。

如果在生产环境中发现死锁怎么办?

首先,获取应用程序所有线程的线程转储。分析每个线程持有哪些锁以及试图获取哪些锁。实施看门狗定时器,在超过时间限制时自动转储。修复后,在CI管道中添加ThreadSafety lint规则以防止复发。

总结

  • 死锁 — 线程无限期等待彼此占用的资源的相互阻塞
  • Coffman四个条件(互斥、保持并等待、非抢占、循环等待)是死锁发生的必要条件
  • 线程转储 — 在JVM和Android Runtime中检测相互阻塞的标准方法
  • 锁的层级结构具有统一的全局顺序完全消除了循环等待条件
  • 带超时的TryLock防止无限期等待,并允许线程正确处理资源不可用的情况
  • 死锁 vs 饥饿 — 死锁中线程被阻塞,饥饿中线程准备执行但未获得CPU
  • 看门狗定时器和静态分析器(ThreadSafe、Checker Framework) — CI/CD中防止死锁的基本保护

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读