饥饿与公平性

更新于 2025-12-28

Jakob Jenkov 2014-06-23

如果一个线程因为其他线程总是抢占 CPU 时间而始终得不到执行机会,这种情况就称为“饥饿(Starvation)”。该线程被“饿死”,因为 CPU 时间总是被分配给其他线程。解决饥饿问题的方法被称为“公平性(Fairness)”——即所有线程都应被公平地给予执行机会。


Java 中导致线程饥饿的常见原因

以下三种常见情况可能导致 Java 中的线程发生饥饿:

  1. 高优先级线程吞噬了所有 CPU 时间,低优先级线程无法获得执行机会
  2. 线程无限期地阻塞在等待进入 synchronized 代码块的过程中,因为其他线程总是被优先允许进入。
  3. 调用了某个对象的 wait() 方法的线程一直等待下去,因为每次调用 notify() 时总是唤醒其他线程,而不是它。

高优先级线程吞噬低优先级线程的 CPU 时间

你可以为每个线程单独设置优先级。优先级越高,获得的 CPU 时间通常也越多。线程优先级范围是 1 到 10。但具体如何解释这些优先级,取决于运行应用程序的操作系统。对于大多数应用程序来说,最好保持默认优先级不变。

线程无限期阻塞在 synchronized 代码块之外

Java 的 synchronized 代码块也可能导致线程饥饿。synchronized 并不保证等待进入同步块的线程的执行顺序。这意味着理论上存在一种风险:某个线程可能永远无法进入同步块,因为其他线程总是被优先授予访问权限。这种问题就叫做“饥饿”——线程因始终无法获得 CPU 时间而被“饿死”。

调用 wait() 的线程无限期等待

当多个线程对同一个对象调用了 wait(),而此时调用 notify() 时,并不保证会唤醒哪一个线程——可能是任意一个等待中的线程。因此,存在一种风险:某个特定线程可能永远无法被唤醒,因为每次 notify() 都选择了其他线程。


在 Java 中实现公平性

虽然在 Java 中无法实现 100% 的公平性,但我们仍可以设计自己的同步机制,以提高线程之间的公平性。

首先,我们来看一个简单的 synchronized 代码块:

public class Synchronizer {
    public synchronized void doSynchronized() {
        // 执行大量耗时操作
    }
}

如果有多个线程调用 doSynchronized() 方法,其中一些线程将被阻塞,直到第一个获得访问权的线程退出该方法。如果有多个线程在等待,Java 并不保证下一个被允许进入的是哪一个线程。

使用 Lock 替代 synchronized 块

为了提高等待线程的公平性,我们首先将上述代码改为使用显式锁(Lock)而不是 synchronized 块:

public class Synchronizer {
    Lock lock = new Lock();

    public void doSynchronized() throws InterruptedException {
        this.lock.lock();
        // 临界区:执行大量耗时操作
        this.lock.unlock();
    }
}

注意,doSynchronized() 方法不再声明为 synchronized,而是通过 lock.lock()lock.unlock() 来保护临界区。

一个简单的 Lock 实现可能如下所示:

public class Lock {
    private boolean isLocked = false;
    private Thread lockingThread = null;

    public synchronized void lock() throws InterruptedException {
        while (isLocked) {
            wait();
        }
        isLocked = true;
        lockingThread = Thread.currentThread();
    }

    public synchronized void unlock() {
        if (this.lockingThread != Thread.currentThread()) {
            throw new IllegalMonitorStateException(
                "Calling thread has not locked this lock"
            );
        }
        isLocked = false;
        lockingThread = null;
        notify();
    }
}

观察上面的 Synchronizer 类和这个 Lock 实现,你会发现:当多个线程同时调用 lock() 时,它们会被阻塞在 lock() 方法内部;如果锁已被占用,线程将在 while(isLocked) 循环中的 wait() 调用处阻塞。

需要注意的是,调用 wait() 会释放 Lock 实例上的同步锁,因此其他线程现在可以进入 lock() 方法。结果是,多个线程最终都会在 lock() 方法内部调用 wait()

再回顾 doSynchronized() 方法中的注释:“临界区代码执行时间很长”。假设这段代码的执行时间远长于进入 lock() 方法和调用 wait() 所需的时间,那么线程等待获取锁的大部分时间实际上是在 wait() 中度过的,而不是在尝试进入 lock() 方法时被阻塞。

如前所述,synchronized 块在多个线程等待时并不保证谁先获得锁;同样,wait()/notify() 机制也不保证哪个线程会被唤醒。因此,当前这个 Lock 实现与 synchronized 版本在公平性上并无本质区别。

但我们可以通过修改来改善这一点。

当前版本中,所有线程都在同一个 Lock 对象上调用 wait()。如果我们让每个线程在各自独立的对象上调用 wait(),那么 Lock 就可以选择具体唤醒哪一个线程,从而实现更公平的调度。


公平锁(Fair Lock)

下面我们将前面的 Lock 类改造为一个名为 FairLock 的公平锁。你会注意到,相对于之前的 Lock,其同步和 wait()/notify() 的实现方式已有显著变化。

注:从原始 LockFairLock 的设计过程涉及多个中间步骤,包括解决嵌套监视器锁死条件错失 等问题。为保持本文简洁,这些中间步骤在此省略,但相关链接已提供供深入阅读。

关键点在于:每个调用 lock() 的线程都会被加入一个队列,只有队列中的第一个线程才被允许获取锁(前提是锁未被占用)。其他线程则等待,直到轮到自己位于队首

public class FairLock {
    private boolean isLocked = false;
    private Thread lockingThread = null;
    private List<QueueObject> waitingThreads = new ArrayList<>();

    public void lock() throws InterruptedException {
        QueueObject queueObject = new QueueObject();
        boolean isLockedForThisThread = true;

        synchronized (this) {
            waitingThreads.add(queueObject);
        }

        while (isLockedForThisThread) {
            synchronized (this) {
                isLockedForThisThread = isLocked || waitingThreads.get(0) != queueObject;
                if (!isLockedForThisThread) {
                    isLocked = true;
                    waitingThreads.remove(queueObject);
                    lockingThread = Thread.currentThread();
                    return;
                }
            }
            try {
                queueObject.doWait();
            } catch (InterruptedException e) {
                synchronized (this) {
                    waitingThreads.remove(queueObject);
                }
                throw e;
            }
        }
    }

    public synchronized void unlock() {
        if (this.lockingThread != Thread.currentThread()) {
            throw new IllegalMonitorStateException(
                "Calling thread has not locked this lock"
            );
        }
        isLocked = false;
        lockingThread = null;
        if (waitingThreads.size() > 0) {
            waitingThreads.get(0).doNotify();
        }
    }
}

public class QueueObject {
    private boolean isNotified = false;

    public synchronized void doWait() throws InterruptedException {
        while (!isNotified) {
            this.wait();
        }
        this.isNotified = false;
    }

    public synchronized void doNotify() {
        this.isNotified = true;
        this.notify();
    }

    public boolean equals(Object o) {
        return this == o;
    }
}

几点说明:

  • lock() 方法不再声明为 synchronized,仅在必要时使用细粒度的 synchronized 块。
  • 每个调用 lock() 的线程都会创建一个 QueueObject 实例并加入等待队列。
  • 调用 unlock() 的线程会唤醒队列中的第一个 QueueObject,从而只唤醒一个等待线程,而不是全部。这正是实现公平性的关键。
  • 锁的状态检查和修改仍在同一个 synchronized 块中完成,以避免“条件错失”(slipped conditions)。
  • QueueObject 本质上是一个信号量(semaphore),其 doWait()doNotify() 方法将信号存储在对象内部,以防止因线程调度导致的“信号丢失”(missed signals)。
  • queueObject.doWait() 被放在 synchronized(this) 块之外调用,以避免“嵌套监视器锁死”(nested monitor lockout),确保其他线程能正常调用 unlock()
  • doWait() 被包裹在 try-catch 中,以便在线程被中断时能从队列中移除对应的 QueueObject

关于性能的说明

对比 LockFairLock 可以发现,FairLocklock()unlock() 方法中包含更多操作。这些额外代码会使 FairLock 的同步开销略高于普通 Lock

这种性能影响的程度取决于临界区代码的执行时间:

  • 如果临界区代码执行时间很长,那么同步器本身的开销就相对较小;
  • 如果临界区非常短且频繁调用,则公平锁的额外开销可能变得显著。

因此,在实际应用中需要根据具体场景权衡公平性与性能。