饥饿与公平锁

747 阅读5分钟

由于其他线程占用了CPU的所有时间片,导致了当前线程抢不到时间片,这就是饥饿。由于线程不能强迫其他线程释放时间片,因此只能等待。为了解决饥饿问题,Java引入了公平锁,让每个线程都有抢到CPU时间片的机会。

造成线程饥饿的原因

  • 高优先级的线程抢占了所有CPU时间片,低优先级的线程分配不到
  • 一个线程总被阻塞在同步块外,因为其他线程持续在它之前抢到锁
  • 线程的wait()方法总迟于其他线程,唤醒不了对象

高优先级线程抢占了所有CPU时间片

我们可以为每个线程分别设置优先级,优先级越高CPU时间片就越容易得到。一般优先级从1到10,具体情况取决于运行你程序的操作系统。对于大多数线程还是不要随意改变优先级。

线程阻塞在同步块之外

Java同步块不保证线程先来先得,理论上存在线程永远阻塞在同步块外的风险,因为其他线程总能抢在该线程之前进入同步块。形象地将这个现象称为『饥饿』,该线程被阻塞得『饿死了』。

Java中的wait()notify()

notify()方法不保证会释放的资源会被哪个线程拿到,多线程下每个线程的wait()方法都有几率抢占资源,极端情况下,某个线程会永远拿不到资源。

Java公平锁的实现

要实现绝对公平的锁当然不可能,我们只能让锁尽量公平。从最简单的同步块代码入手:

public class Synchronizer {
    public synchronized void doSynchronized(){
        // your code here
    }
}

如果多线程调用该方法,大多线程会阻塞,直到第一个进入同步块的线程执行完毕释放资源。当然了,这个阻塞等待根据之前描述是不公平的。

使用锁来代替同步块

我们去掉synchronized关键字,用锁来保证线程之间同步。

public class Synchronizer{
    Lock lock = new Lock();
    public void doSynchronized() throws InterruptedException{
        this.lock.lock();
        //critical section, do a lot of work which takes a long time
        this.lock.unlock();
    }
}

锁这个类其实很简单,可以采用synchronized关键字实现,参考之前的文章

public class Lock {
    private boolean isLocked = false;
    private Thread lockingThread = null;
    
    public synchronized void lock() thrwos 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();
    }
}

如果多线程调用call()方法,线程会阻塞(因为只有一个线程会抢到锁)。其次,当资源被锁,线程会阻塞在while中的wait()调用上。调用wait()方法会释放类上的同步锁,因此其他等待抢占锁的线程现在就可以获取资源了。最终,多个线程都能在lock()内部调用wait()

lock()unlock()之间的代码可能需要很长时间去执行,相比之下进入lock()方法和调用wait()的时间可以忽略不计,这就意味着大部分等待锁以及执行代码的时间都被消耗在等待wait()方法上,而不是进入lock()方法。

如之前所述,如果有多个线程,则同步块不能保证授予哪个线程访问权限。wait()也不保证在调用notify()时唤醒了哪个线程。因此,当前的Lock类版本与doSynchronized()的在公平性方面基本相同,但是我们可以改变这一点。

当前版本的Lock类将调用其自己的wait()方法。相反,如果每个线程在一个单独的对象上调用wait(),从而只有一个线程在每个对象上调用了wait(),则Lock类可以决定在这些对象中的哪个上调用notify(),从而有效地准确选择要使用的线程。

公平锁

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

    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();
        }
    }
}

现在,每个调用lock()的线程都已排队,并且只有队列中的第一个线程才能抢到锁。所有其他线程将被暂存到它们到达队列的顶部。

首先,lock()方法不再使用synchronized关键字修饰,仅将同步所需的块嵌套在同步块内。

FairLock创建一个新的QueueObject实例,每个调用lock()的线程都会入队,调用unlock()的线程出队,并调用doNotify(),以唤醒在该对象上等待的线程。这样,一次仅唤醒一个等待线程,而不唤醒所有等待线程。这决定着FairLock的部分公平性。

QueueObject实际上是一个信号量。doWait()doNotify()方法将信号存储在QueueObject中。这样做是为了避免由于线程在调用queueObject.doWait()之前被另一个线程又调用unlock()从而调用queueObject.doNotify()先占而导致的信号丢失。queueObject.doWait()被置于synchronized(this)块的外部,以避免嵌套的监视器锁定,因此,当在lock()方法的synchronized(this)块内部没有执行任何线程时,另一个线程实际上可以调用unlock()

最后,在try-catch块内调用queueObject.doWait()。万一抛出InterruptedException,线程将离开lock()方法,我们需要将其出队。