Day39 | Java中更灵活的锁ReentrantLock

0 阅读11分钟

此前,专门有一篇文章Day18 | 深入理解Object类,讲的是Object类中一些基础的方法。

当时挖了一个坑,今天我们来把他填上。

本文我们细致的讲一下,synchronized搭配wait()和notify()/notifyAll()方法以及ReentrantLock搭配Condition这两种线程间的协作与通信方式。

在并发编程中,线程基本上不会各干各的,而是需要协同配合。

为了协同完成某个任务,需要在共享数据的基础上进行信息交换和协调执行。

这里说的通信不是指的网络通信,而是在共享内存模型里,线程通过共享变量来传递信息,再加上wait/notify等机制实现协调。

一、wait/notify

wait(), notify(), notifyAll()都是java.lang.Object类里的方法。

这些方法都必须在synchronized同步块或同步方法里调用,因为调用这些方法之前,线程必须持有这个对象的监视器锁。

wait()方法调用之后,当前线程会释放持有的锁,并进入这个对象的等待集里,状态变成WAITING或 TIMED_WAITING。

notify()方法是唤醒这个对象等待集里的一个线程,具体是哪一个是由JVM决定的,不保证公平。

notifyAll()是唤醒这个对象等待集里所有的线程。

下面我们通过一个案例来看看wait/notify这一套机制是怎么实现线程通信的。

为了方便理解,我们假设有这样一个场景:

有一个生产线程负责做包子,还有一个消费者线程负责吃包子,生产的包子会放在蒸笼里,消费线程也需要从蒸笼里拿包子,他们公用一个蒸笼,而且这个蒸笼最多只能放5个包子。

就这样一个简单的场景,想想看有哪些点是需要协作的。

如果生产者发现蒸笼满了,他是不是应该停下来不做了,不然包子放哪儿。他得等消费者吃掉一些包子再做。

如果消费者发现蒸笼是空的,他是不是不能再继续吃了,不然就是在吃空气了。他应该停下来,等生产者做出新的包子再吃。

生产者做了一个新包子放到蒸笼里之后,他是不是应该通知正在等包子的消费者,嘿,有新包子了,可以来吃了。

如果消费者吃掉了一个包子,蒸笼就空了一个位置,他是不是应该通知正在等的生产者,嘿,有空位了,可以继续做了。

我们通过代码来看一下,具体的协作机制。

package com.lazy.snail.day39;

import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;

/**
 * @ClassName Day39Demo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/8/1 9:41
 * @Version 1.0
 */
public class Day39Demo {
    public static void main(String[] args) {
        // 只能装5个包子的蒸笼
        SharedQueue sharedQueue = new SharedQueue(5);

        // 做包子的线程
        Thread producerThread = new Thread(() -> {
            while (true) {
                try {
                    sharedQueue.produce();
                    Thread.sleep(new Random().nextInt(1000));
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "生产者");

        // 吃包子的线程
        Thread consumerThread = new Thread(() -> {
            while (true) {
                try {
                    sharedQueue.consume();
                    Thread.sleep(new Random().nextInt(1500));
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "消费者");

        producerThread.start();
        consumerThread.start();
    }
}
class SharedQueue {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;
    private int itemNumber = 0;

    public SharedQueue(int capacity) {
        this.capacity = capacity;
    }

    // 做包子
    public void produce() throws InterruptedException {
        synchronized (this) {
            while (queue.size() == capacity) {
                System.out.println("蒸笼满了,【" + Thread.currentThread().getName() + "】暂时不做了...");
                // 调用wait(),释放锁并进入等待
                this.wait();
            }
            int producedItem = ++itemNumber;
            queue.add(producedItem);
            System.out.println("【" + Thread.currentThread().getName() + "】做了个包子: " + producedItem + ",蒸笼里还有" + queue.size() + "个包子");

            // 通知其他可能在等待的线程
            this.notifyAll();
        }
    }

    // 吃包子
    public void consume() throws InterruptedException {
        synchronized (this) {
            while (queue.isEmpty()) {
                System.out.println("蒸笼空了,【" + Thread.currentThread().getName() + "】暂时不吃了...");
                // 调用wait(),释放锁并进入等待
                this.wait();
            }
            int consumedItem = queue.poll();
            System.out.println("【" + Thread.currentThread().getName() + "】吃了一个包子: " + consumedItem + ",蒸笼里还有" + queue.size() + "个包子");

            // 通知其他可能在等待的线程
            this.notifyAll();
        }
    }
}

我在关键的地方都写上了注释。

梳理一下整个过程,这里假设吃包子线程先启动并抢到锁,他发现蒸笼是空的,就调用了wait方法,这个时候吃包子线程就释放了锁,进入了等待状态。

做包子线程后启动,因为吃包子线程已经释放了锁,他进入produce后能干顺利的拿到锁,之后发现蒸笼是空,就开始做包子,做完放到了蒸笼里。之后调用了notifyAll方法,去唤醒所有在this对象上等待的线程(这里只有我们的吃包子线程)。

此时吃包子线程被唤醒了,从WAITING状态变成了RUNNABLE状态,但是这个时候他不会马上运行,因为他还需要重新去竞争锁。这个点,做包子线程还在synchronized块里,锁还没释放。

等做包子线程退出了synchronized块,锁也释放了,吃包子线程就可以和其他线程(我们这里没有其他线程了)竞争锁了。如果他成功的拿到了锁,就会在当初调wait方法的地方醒过来。

吃包子线程醒过来之后,还在while循环里,所以又会看看蒸笼是不是空,不是空的(之前做包子线程已经做了包子在里面),就开始取出包子吃了。吃完这个包子,吃包子线程又通过notifyAll方法,去唤醒所有在this对象上等待的线程(这里只有我们的做包子线程)。

然后synchronized块执行完,把锁释放了,又轮到了被唤醒的做包子线程,周而复始。

wait/notify机制通过“释放锁-等待”和“通知-重竞争锁”这两个操作,实现了线程之间在特定条件下的协作。

但是从最开始的定义就能看出,wait/notify机制还是有一些局限性。

比如notify不能控制唤醒的到底是哪一个线程,具体是由JVM决定的。

notifyAll会唤醒所有等待的线程,但是通常情况下只有一个线程能满足条件继续后续的逻辑处理,其他被唤醒的线程在重新获取锁后,发现条件还是不满足,只能再次进入等待状态。这样肯定会有一些不必要的上下文切换和资源竞争,影响效率。

说完wait/notify机制,我们来看一下另一种方式。

二、Condition接口

从上面我们已经知道,跟一个锁(对象监视器)关联的只有一个等待集。所有等待的线程都在里面,notifyAll一喊,所有线程都得出来看看,没法精准的控制,效率也低。

Condition接口就是为了解决这个问题,他允许一个Lock关联多个独立的等待集。

Condition对象跟Lock绑定在一起,不能单独的创建一个Condition,必须通过lock.newCondition()来获取。

创建的Condition实例,你可以理解成是专属于这个Lock的一个独立的、可命名的等待集。

还是用做包子、吃包子的案例来讲解。

wait/notify机制就好比,所有吃包子、做包子的人都在一个唯一的大厅里,包子做好了,所有在等的人,不管是要吃包子的还是等空位的都能听见他吼了一声,包子好了,人一多,就会感觉非常混乱。

Condition接口就好比给同一个蒸笼,配备了多个独立的VIP等候室。然后Condition对象和Lock(我们的蒸笼访问权)绑定在一起。每一个实例,你都可以想象成一个有特定用途的等候室。

比如:

Condition notFull = lock.newCondition();

这是给做包子的线程准备的,专门给蒸笼满了的时候等待空位的做包子线程使用。

Condition notEmpty = lock.newCondition();

这是给吃包子的线程准备的,专门给蒸笼没有包子的时候等待包子的吃包子线程使用。

那Condition到底是怎么工作的呢?

await()方法

如果一个吃包子线程拿到了锁,发现蒸笼是空的,他不能傻站着。他就会调用notEmpty.await(),这会发生:

吃包子线程被放到"吃包子等候室"(notEmpty的条件队列)里。

他会自动释放Lock,这样其他线程(比如做包子线程)才能拿到锁。

吃包子线程在等候室里开始休息(线程进入WAITING状态)。

同样,当一个做包子线程拿到锁后发现蒸笼是满的,他会调用notFull.await(),进入"做包子等候室"休息,并同样释放锁。

signal()方法

当一个做包子线程拿到了锁,做好一个包子放到蒸笼后,他需要通知可能在等待的消费者。

于是,他调用notEmpty.signal(),这就像有人来到"吃包子等候室"门口,打开门,说了句,有包子了。

系统会选择其中一个正在休息的等着吃包子的人。这个被叫醒的人不会马上冲向蒸笼。他会跟其他想吃包子的人一起排队拿锁,拿到锁之后才会从之前await()的地方继续执行。

下面来看一下包子铺的代码:

package com.lazy.snail.day39;

import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @ClassName Day39Demo2
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/8/1 11:16
 * @Version 1.0
 */
public class Day39Demo2 {
    public static void main(String[] args) {
        // 基于Condition的新蒸笼
        ConditionSharedQueue sharedQueue = new ConditionSharedQueue(5);

        // 做包子的线程
        Thread producerThread = new Thread(() -> {
            while (true) {
                try {
                    sharedQueue.produce();
                    Thread.sleep(new Random().nextInt(1000));
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "生产者");

        // 吃包子的线程
        Thread consumerThread = new Thread(() -> {
            while (true) {
                try {
                    sharedQueue.consume();
                    Thread.sleep(new Random().nextInt(1500));
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "消费者");

        producerThread.start();
        consumerThread.start();
    }
}

class ConditionSharedQueue {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;
    private int itemNumber = 0;
    
    private final Lock lock = new ReentrantLock();
    // 做包子线程使用的条件:当蒸笼满了,做包子线程在这里等
    private final Condition notFull = lock.newCondition();
    // 吃包子使用的条件:当蒸笼空了,吃包子线程在这里等
    private final Condition notEmpty = lock.newCondition();

    public ConditionSharedQueue(int capacity) {
        this.capacity = capacity;
    }

    // 做包子
    public void produce() throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                System.out.println("蒸笼满了,【" + Thread.currentThread().getName() + "】进入做包子等候室...");
                notFull.await();
            }
            int producedItem = ++itemNumber;
            queue.add(producedItem);
            System.out.println("【" + Thread.currentThread().getName() + "】做了个包子: " + producedItem + ",蒸笼里还有" + queue.size() + "个包子");
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    // 吃包子
    public void consume() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                System.out.println("蒸笼空了,【" + Thread.currentThread().getName() + "】进入吃包子等候室...");
                notEmpty.await();
            }
            int consumedItem = queue.poll();
            System.out.println("【" + Thread.currentThread().getName() + "】吃了一个包子: " + consumedItem + ",蒸笼里还有" + queue.size() + "个包子");
            notFull.signal();
        } finally {
            lock.unlock();
        }
    }
}

代码里没有使用对象内置的监视器锁了(依赖synchronized),而是显式的创建了一 ReentrantLock实例。

为了案例需要,在代码中创建了两个独立的Condition对象:

notFull表示蒸笼满了,做包子线程的等待室。

notEmpty表示蒸笼空了,吃包子线程的等待室。

在produce方法里,如果队列满了,原先的this.wait()被替换notFull.await()。表示做包子线程进入notFull这个专门的等候室。

同理,consume方法中也是一样。

在produce方法最后,用condition.signal()替代notifyAll(),只唤醒对应等待室里的线程。

consume方法中的condition.signal()也是同理。

这种方式有点像从广播模式变成了对讲机模式。

当让除了我们在示例代码中使用的await/signal方法。

Condition还提供了await(long time, TimeUnit unit):如果在指定时间内没有被signal,线程会自动醒来并返回false,避免无限期等待。

await()方法本身就是响应中断的。如果在等待时线程被interrupt(),它会抛出InterruptedException并醒来。

如果不想在等待的时候被中断,可以使用awaitUninterruptibly()。

还有基于截止日期的等待,awaitUntil(Date deadline),可以在一个绝对的时间点前等待。

其实关于Condition的使用及与监视器锁的对比描述在Condition类的源码中已经描述得比较清晰。

如果能把源码中的注释梳理清楚,也就掌握了二者的差异和Condition的使用。

这里还是建议大家自己去看一下,至于注释是英文的,有一万种方法可以解决。

结语

本文通过基于监视器锁的wait/notify与基于Condition搭配ReentrantLock的对比使用,简单的体验了多线程之间的协作过程。

其实ReentrantLock就是负责提供锁机制的,控制谁能进门,谁不能。

而Condition就负责提供精细的、分组的等待/通知机制,控制进门后去哪个房间已经哪个房间的会被叫醒。

两者结合就形成了一套可以处理复杂、高性能的并发系统开发工具。

下一篇预告

Day40 | Java中的ReadWriteLock读写锁

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

更多文章请关注我的公众号《懒惰蜗牛工坊》