Starvation(线程饥饿)是一种线程无法获得继续工作所需资源的情况,尽管线程本身已准备好执行。根据Baeldung(Java Thread Starvation, 2024),饥饿是由于不公平的调度造成的,低优先级线程不断被延迟以让位于更高优先级的线程。与Deadlock不同,Starvation不会阻塞线程——它保持在RUNNABLE状态,但永远无法获得处理器时间。
要点
Starvation(线程饥饿)是多线程编程中的一个问题,即线程无法获得执行任务所需的资源,尽管该资源并未被其他线程永久阻塞。线程处于RUNNABLE状态,但调度器或同步机制系统性地将其执行延迟以利于其他线程。
在移动开发中,Starvation表现为任务执行不均匀:某些操作立即执行,而其他操作则出现严重的延迟。例如,如果UI线程和动画处理程序不断抢占同步数据的后台线程,该后台线程可能永远无法访问数据库。根据Android Developer Blog(Performance Matters, 2023),Android中约12%的丢帧(jank)情况是由渲染所依赖的后台任务的Starvation引起的。
Starvation与Deadlock的关键区别——可逆性。如果系统负载降低或优先级重新分配,饥饿线程可以获得资源并完成工作。然而,在持续高负载条件下,Starvation可能无限期持续,造成应用程序冻结的印象。
Java和Kotlin中的synchronized是不公平机制的经典例子。在高竞争情况下,JVM可能无限期地将锁交给同一活动线程,而其他线程不断在竞争中失败。这不是JVM的错误,而是实现的特点:不公平的锁以牺牲访问均匀性为代价提供更高的吞吐量。对于拥有4-8个线程的移动应用程序,这个问题尤其重要。
设置不同线程优先级可能导致低优先级线程的Starvation。在Android Runtime中,Linux的CFS(完全公平调度器)按比例分配处理器时间,如果高优先级线程持续活跃,低优先级线程可能永远无法获得CPU。Google强烈不建议在Android中更改线程优先级——系统自行管理它们。
如果一个线程过长地持有锁(在synchronized块内执行繁重计算、网络请求或文件操作),等待此锁的其他线程就会饥饿。这在Android中尤其危险,UI线程中的长操作会导致ANR,而将这些操作移至后台线程而不优化临界区,会将Starvation问题转移到工作线程。
让我们看一个示例,其中一个线程由于不公平的调度过于频繁地获取锁。Starvation通过高优先级线程的无限循环来演示,该循环阻止低优先级线程访问共享资源。
class SharedResource {
private val lock = Any()
fun criticalSection(id: String) {
synchronized(lock) {
println("$id获得了访问")
Thread.sleep(10) // 工作模拟
}
}
}
fun main() {
val resource = SharedResource()
// 高优先级线程——持续活跃
val highPriority = Thread {
while (true) {
resource.criticalSection("High")
}
}
// 低优先级线程——可能永远无法获得访问
val lowPriority = Thread {
while (true) {
resource.criticalSection("Low")
}
}
highPriority.start()
lowPriority.start()
// “Low”可能永远不会输出消息——Starvation!
}
在此示例中,highPriority线程不断获取锁并仅释放10毫秒。由于synchronized的不公平性质,JVM调度器极有可能再次将锁交给刚刚释放它的同一线程——低优先级线程饥饿。解决方案——使用带有fair标志的ReentrantLock(true),它保证等待队列中的顺序。
使用fair lock的修正版本确保了对资源的公平访问分配。
class FairSharedResource {
private val fairLock = ReentrantLock(true) // fair = true
fun criticalSection(id: String) {
fairLock.lock()
try {
println("$id获得了访问(fair)")
Thread.sleep(10)
} finally {
fairLock.unlock()
}
}
}
三个经典的多线程问题——Starvation、Deadlock和Livelock——经常被混为一谈,但它们的机制和消除方法各不相同。Starvation——线程已准备好但未获得资源。Deadlock——线程被循环等待阻塞。Livelock——线程活跃但无法推进。
| 参数 | Starvation | Deadlock | Livelock |
|---|---|---|---|
| 线程状态 | RUNNABLE | BLOCKED | RUNNABLE |
| 进展 | 无 | 无 | 无(尽管活跃) |
| CPU消耗 | 低 | 最小 | 高(高达100%) |
| 原因 | 不公平的调度 | 循环等待 | 对冲突的相同反应 |
| 主要解决方案 | Fair Lock,减少临界区 | 锁的层次结构 | 重试限制,指数退避 |
Starvation被认为不如Deadlock严重,因为它不是致命的——当负载降低时,饥饿线程最终会执行。然而,在内存和CPU受限的Android应用程序的实际使用条件下,Starvation可能持续数分钟,造成不可接受的用户体验。
Thread Dump在短间隔内重复抓取——检测饥饿的基本方法。如果一个线程始终处于RUNNABLE状态,但其调用栈在多个转储中未发生变化——这是Starvation的典型标志。在Android Studio中,使用Android Profiler随时间记录线程状态。
自动化检测可以通过监控任务执行时间来实现。如果具有可预测执行时间(例如50毫秒)的任务执行了5秒或更长时间——则Starvation的可能性很高。在移动应用程序中,Firebase Performance Monitoring允许为临界区配置自定义跟踪(custom traces),并在超过阈值时接收通知。
为了诊断由synchronized块引起的Starvation,请使用Java Flight Recorder(JFR)(通过OpenJDK API在Android上可用)或Async Profiler。这些工具显示哪些监视器具有最长的等待时间以及哪些线程在竞争每个监视器。JFR数据通过内置的分析器与IntelliJ IDEA Ultimate集成。
ReentrantLock(true)保证线程按队列顺序(FIFO)获得锁。与synchronized不同,fair lock不允许刚刚释放锁的线程立即重新获取它。这完全消除了Starvation,尽管由于维护队列的开销,整体吞吐量会降低10-20%。
Lock-free数据结构(ConcurrentHashMap、AtomicReference、LongAdder)从定义上排除了Starvation,因为它们不包含可被单个线程持有的锁。所有操作都使用处理器的CAS指令,保证至少一个线程在有限步数内取得进展。对于移动开发,优先选择ConcurrentLinkedList作为任务队列。
最小化锁的持有时间是降低Starvation风险的通用方法。将繁重操作(网络、磁盘I/O、复杂计算)移出synchronized块。对于读者不应因稀有写入者而饥饿的场景,使用ReadWriteLock。Kotlin Coroutines库提供具有挂起(suspending)机制的Mutex,它不会阻塞操作系统线程。
Condition.await()和signal()应谨慎使用:在Condition上等待的线程会与其他线程一起唤醒(spurious wakeup),所有线程都竞争锁。如果一个线程在await后立即返回等待,而其他线程成功获取了锁——饥饿线程可能会无限地唤醒和睡眠。始终在while循环中检查条件,而不是if,以确保重新检查。
常见问题
Priority Inversion是低优先级线程持有了高优先级线程所需的锁的情况。结果,高优先级线程等待低优先级线程——优先级反转。Starvation是一个更广泛的问题:线程不依赖于优先级,由于不公平的调度或长的临界区而无法获得资源。
不能,Starvation是多线程的问题。在单线程代码中,没有资源竞争和线程调度。然而,Starvation可能在异步单线程代码中发生(例如JavaScript事件循环),如果一个微任务通过setTimeout以零延迟无限期地延迟其他任务的执行。
JMM(Java内存模型)定义了线程间更改可见性的规则,但不保证公平调度。符合JMM的synchronized提供了顺序一致性——基本正确性——但不能防止Starvation。为了实现公平性,需要JMM规范之外的额外机制。
UI线程(主线程)在传统意义上不会饥饿,因为它具有最高优先级。然而,当UI线程等待饥饿的后台线程的结果时,就会发生Starvation。典型场景:AsyncTask或协程加载数据,但由于与其他线程的竞争无法访问数据库,UI在等待中冻结。
在协程中,为了防止Starvation,在Dispatchers.IO上使用limitedParallelism以避免线程耗尽。对于同步,使用kotlinx.coroutines.sync中的Mutex——它挂起协程,而不是阻塞线程,从而降低饥饿风险。避免在协程中使用runBlocking,因为它可能占用池中的线程并导致其他协程的Starvation。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。