活跃性问题意味着程序永远无法得到运行的最终结果。与之前提到的线程安全问题导致的程序错误相比,活跃性问题的后果可能更严重。例如,若发生死锁,程序会完全卡死无法运行。
最典型的三种活跃性问题是死锁(Deadlock)、活锁(Livelock)和饥饿(Starvation)。下面逐一介绍。
1. 死锁(Deadlock)
最常见的活跃性问题是死锁。当两个线程互相等待对方持有的资源,且都不释放自己已持有的资源时,就会导致永久阻塞。
代码示例:
输出结果:
启动程序后会发现,程序一直在运行,但永远无法输出线程 1 和线程 2 的执行结果,说明两者都被卡住了。如果不强制终止进程,它们将永远等待。
注意:后续章节会详细讲解
synchronized
关键字,目前只需知道它能确保同一时刻最多一个线程执行代码(需持有对应锁),以控制并发安全。
死锁的必要条件
根据上述示例,可以分析死锁发生的四个必要条件:
- 互斥条件:资源一次只能被一个进程或线程使用。例如,锁被某个线程持有后,其他线程无法获取,直到释放。
- 请求与保持条件:线程在持有第一个锁的同时请求第二个锁。例如,线程 1 持有锁 A 后尝试获取锁 B,且不释放锁 A。
- 不可剥夺条件:锁不会被外部强制剥夺。即没有外界干预来终止死锁。
- 循环等待条件:多个线程形成环形等待链。例如,线程 A 等线程 B 释放资源,线程 B 等线程 A 释放资源;或多个线程形成 A→B→C→A 的循环等待链。
⚠️以上四个条件缺一不可!只要破坏任意一个条件,即可避免死锁!
如何预防死锁
如果线程一次只能获取一个锁,则不会发生死锁。虽然不太实用,但这是最彻底的解决方案。
以下是两种常用预防方法:
- 按固定顺序获取锁
如果必须获取多个锁,设计时需要确保所有线程按相同顺序获取锁。例如修改上述代码:
- 超时放弃
使用synchronized
内置锁时,线程会无限等待。而Lock
接口的tryLock(long time, TimeUnit unit)
方法允许设置等待时间。若超时未获锁,线程可主动释放已持有的锁,从而避免死锁。
2. 活锁(Livelock)
什么是活锁
活锁是第二种活跃性问题。与死锁类似,程序无法得到最终结果,但线程并非完全阻塞,而是不断尝试执行却无法推进。
例如:两人迎面相遇,互相让路,结果你往右我往左,再次相撞,最终谁也无法通过。
代码示例:
输出结果:
从日志可见,两个线程不断获取和释放锁,但都无法完成操作。
注意:由于线程调度,此示例可能在运行一段时间后自动解除活锁,但不影响理解其原理。
如何预防活锁
活锁的根源在于线程同时释放锁并重试。解决方法是为锁获取设置随机等待时间,打破同步释放的节奏:
修改代码:
修改后运行结果:
此时活锁问题基本消失。
典型场景:消息队列中某个错误消息反复重试,导致线程忙但无结果。解决方法:
- 将错误消息移至队列尾部延迟处理;
- 限制重试次数,超过后丢弃或特殊处理。
3. 饥饿(Starvation)
什么是饥饿
饥饿指线程长期无法获取资源(如 CPU 时间),导致无法运行。常见场景:
- 线程优先级过低,长期得不到调度;
- 某线程持有锁且不释放(如无限循环),其他线程长期等待。
饥饿的影响
导致程序响应性差。例如,浏览器前端线程因后台线程占用 CPU 无法响应操作。
如何预防饥饿
- 确保逻辑正确,及时释放锁;
- 合理设置线程优先级(或不设置优先级)。