1. 认识 Livelock
如果 deadlock 是线程彼此相互等待、永远停在那里,那么 livelock(活锁)则是线程看起来“活着”,不断做事、相互礼让,但……没有人真正向前推进!想象两位很有礼貌的人在狭窄走廊中:“哎,请您先走!”——“不,您先!”——“不,您先!”——如此循环不止。
形式化定义
Livelock:线程没有被阻塞,但由于不断根据其他线程的动作改变自身状态,导致无法完成工作。它们“活着”、在积极响应,但没有产生有效进展。
实际表现如何?
- 线程不会永久阻塞,但会陷入无休止的相互让步循环。
- 系统没有“卡死”,却也没有完成应做的工作。
生活中的例子
- 两个机器人需要在狭窄通道里错身而过,但它们每次都会同时朝对方那一侧挪一步,结果再次互相干扰。
- 两个线程每次发现资源被占用时就彼此让步……无穷无尽。
2. Java 中的 livelock 示例
我们来在代码里模拟一个 livelock。为简单起见,假设两个“工人”需要同一把勺子。不同于 deadlock,如果勺子被占用,他们会很“礼貌”地让出并重试——但会同步地一起这么做。
代码示例:“礼貌的工人”
public class LivelockDemo {
static class Spoon {
private Worker owner;
public Spoon(Worker owner) {
this.owner = owner;
}
public Worker getOwner() {
return owner;
}
public synchronized void setOwner(Worker owner) {
this.owner = owner;
}
public synchronized void use() {
// 使用勺子(什么也不做)
}
}
static class Worker {
private final String name;
private boolean isHungry = true;
public Worker(String name) {
this.name = name;
}
public String getName() {
return name;
}
public boolean isHungry() {
return isHungry;
}
public void eatWith(Spoon spoon, Worker other) {
while (isHungry) {
// 如果勺子不在我这里——等待
if (spoon.getOwner() != this) {
try {
Thread.sleep(1); // 等待勺子空出来
} catch (InterruptedException ignored) {}
continue;
}
// 如果对方还饿——把勺子让给他
if (other.isHungry()) {
System.out.println(name + ":把勺子让给 " + other.getName());
spoon.setOwner(other);
continue;
}
// 开吃!
System.out.println(name + ":我在吃!");
spoon.use();
isHungry = false;
System.out.println(name + ":我吃饱了!");
spoon.setOwner(other);
}
}
}
public static void main(String[] args) {
final Worker alice = new Worker("爱丽丝");
final Worker bob = new Worker("鲍勃");
final Spoon spoon = new Spoon(alice);
Thread t1 = new Thread(() -> alice.eatWith(spoon, bob));
Thread t2 = new Thread(() -> bob.eatWith(spoon, alice));
t1.start();
t2.start();
}
}
发生了什么?
- 爱丽丝和鲍勃都很饿,勺子最开始在爱丽丝手里。
- 爱丽丝看到鲍勃也很饿,于是把勺子让给他。
- 现在勺子在鲍勃手里,但他看到爱丽丝很饿,又把勺子让回去。
- 勺子在两位工人之间来回“奔跑”,却没有人真正吃到——没有进展。
控制台输出会是什么样?
爱丽丝:把勺子让给 鲍勃
鲍勃:把勺子让给 爱丽丝
爱丽丝:把勺子让给 鲍勃
鲍勃:把勺子让给 爱丽丝
...
如何消除 livelock?
要消除 livelock,可以降低线程的“过度礼貌”。一种有效方法是在重试前添加随机暂停(例如用 Thread.sleep),这样线程就不会同步地作出反应。还可以采用更“坚持”的策略:一旦让出,就在下一次尝试前等待更久。不要把“绅士风度”用过头——过度让步同样会导致系统停滞。
3. Starvation(线程饥饿)
如果 livelock 是“永恒的礼貌”,那么 starvation(饥饿)就是一个或多个线程几乎拿不到资源或 CPU 时间,因为其他线程总是把它们压在后面。
形式化定义
Starvation:线程无法获得所需资源(CPU、内存、锁),因为其他线程不断地在它之前获得资源。结果“饥饿”的线程要么极少执行,要么几乎不执行。
导致 starvation 的原因
- 不公平锁。 例如,普通的 synchronized 块并不保证等待最久的线程会先进入。
- 线程优先级。 如果高优先级线程持续占用处理器,低优先级线程可能会“饿死”(setPriority)。
- 其他线程中的无限循环。 如果某些线程从不让出 CPU(不调用 Thread.sleep 或 Thread.yield()),其他线程可能拿不到执行时间。
4. Java 中的 starvation 示例
示例:低优先级线程几乎得不到执行
public class StarvationDemo {
public static void main(String[] args) {
Runnable highPriorityTask = () -> {
while (true) {
// 密集工作,不主动让出 CPU
}
};
Runnable lowPriorityTask = () -> {
while (true) {
System.out.println("我是低优先级线程!");
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {}
}
};
Thread high1 = new Thread(highPriorityTask);
Thread high2 = new Thread(highPriorityTask);
Thread low = new Thread(lowPriorityTask);
high1.setPriority(Thread.MAX_PRIORITY); // 10
high2.setPriority(Thread.MAX_PRIORITY); // 10
low.setPriority(Thread.MIN_PRIORITY); // 1
high1.start();
high2.start();
low.start();
}
}
会如何表现?
- 高优先级线程一直在忙碌,不让出 CPU。
- 低优先级线程几乎不执行(甚至完全不执行)。
- 在现代 JVM/操作系统 上,调度器可能会平滑优先级的影响,但在某些系统上饥饿会十分明显。
另一个示例:因不公平锁导致的 starvation
public class StarvationLockDemo {
private static final Object lock = new Object();
public static void main(String[] args) {
// 5 个线程一直抢占 lock
for (int i = 0; i < 5; i++) {
new Thread(() -> {
while (true) {
synchronized (lock) {
// 长时间占用 lock
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {}
}
}
}).start();
}
// 一个“饥饿”的线程
new Thread(() -> {
while (true) {
synchronized (lock) {
System.out.println("饥饿线程拿到了 lock!");
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {}
}
}
}).start();
}
}
在此示例中,如果其他线程持续占用 lock,这个“饥饿”的线程可能会非常久都拿不到锁。
5. 如何发现并预防 livelock 与 starvation
如何发现?
- Livelock:程序在运行,线程没有卡住,但没有进展(没有结果、无法跳出循环)。
- Starvation:某些线程几乎不执行(日志里很少或没有相关输出)。
工具
- 日志:标记工作开始/结束、资源的获取/释放。
- 监控:VisualVM、Java Mission Control——查看哪些线程活跃以及它们在做什么。
- Thread dump:检查线程是否卡在等待 lock。
如何避免?
针对 livelock:
- 不要做过于“礼貌”的让步——在重试前添加小的随机延迟(Thread.sleep)。
- 在重试顺序上引入随机性,摆脱线程的同步行为。
- 使用非阻塞结构/算法(原子变量、CAS 思路)。
针对 starvation:
- 使用“公平”的锁。例如,带公平性的 ReentrantLock:
java.util.concurrent.locks.ReentrantLock lock = new java.util.concurrent.locks.ReentrantLock(true); // 公平模式
- 不要滥用线程优先级——更多情况下保持默认优先级。
- 尽量缩短临界区时间(synchronized/Lock)。
- 使用接近 FIFO 的任务队列。
表格:Deadlock、Livelock、Starvation——对比
| 问题 | 发生了什么 | 线程“活着”吗? | 有进展吗? | 典型症状 |
|---|---|---|---|---|
| Deadlock | 互相等待 | 否 | 否 | 程序“卡死” |
| Livelock | 大家都在让步,但不前进 | 是 | 否 | 线程在运行,但没有结果 |
| Starvation | 有的在工作,其他几乎不工作 | 是(部分) | 部分 | 一些线程“挨饿” |
类比与有趣事实
- Livelock——就像两个人同时向左侧迈步以便错身,结果又撞在一起。
- Starvation——就像商店里只优先招呼“熟人”的收银员,其余人一直排队。
有趣的事实:livelock 比 deadlock 更少见,但更难发现——程序“没卡死”,却在做一些无效的事!
6. 处理 livelock 与 starvation 时的常见错误
错误 1:“礼貌让步”但没有延迟。 如果线程在不暂停的情况下频繁互相让步,就可能陷入 livelock。在重试获取资源前加入小的随机延迟(Thread.sleep)。
错误 2:只依赖 synchronized 等待,而不使用公平锁。 在线程很多时,普通的 synchronized 并不保证“最饥饿”的线程能先获得访问。如有需要,请使用带公平性的 ReentrantLock。
错误 3:滥用线程优先级。 试图通过 setPriority “加速”重要线程,往往会让其他线程产生 starvation。没有明确需求不要随意调整优先级。
错误 4:缺乏监控与日志。 没有日志很难发现 Livelock 与 starvation:程序“在运行”,却没有结果。记录关键事件,并使用分析器/线程转储。
错误 5:临界区过长。 如果线程长时间持有 lock,其他线程就会等待(甚至“挨饿”)。尽量缩短 synchronized/Lock 块中的执行时间。
GO TO FULL VERSION