不知道你看《Java并发编程的艺术》此书的5.6Condition接口这一节内容时,在查看BoundedQueue.java代码的时候是否有疑问:为何有两个Condition对象:
private Condition notEmpty = lock.newCondition();
private Condition notFull = lock.newCondition();
这个疑问是否来源于,既然是一个锁,为何需要两个Conditon接口来进行消费者-生产者模式下的线程管理呢?下面我先给出完整的书上源码,以及我个人对于源码的解读,其次再来回答这个问题。
public class BoundedQueue<T> {
private Object[] items;
// 添加的下标,删除的下标和数组当前数量
private int addIndex, removeIndex, count;//注意默认初始值都是0
private Lock lock = new ReentrantLock();//锁结构为独占的重入锁
private Condition notEmpty = lock.newCondition();
private Condition notFull = lock.newCondition();
public BoundedQueue(int size) {
items = new Object[size];//队列的构造器
}
// 添加一个元素,如果数组满,则添加线程进入等待状态,直到有"空位"
public void add(T t) throws InterruptedException {
lock.lock();//写之前需要先获取锁资源
try {
while (count == items.length)//意味着队列已满,不能够再添加元素
notFull.await();//使当前线程进入休眠状态
items[addIndex] = t;//以下是环形队列增加元素的代码,相信我说了此为环形,你一定能够理解以下代码
if (++addIndex == items.length)
addIndex = 0;
++count;
notEmpty.signal();//唤醒一个等待当前锁的线程
} finally {
lock.unlock();//最终总是要释放锁资源的。
}
}
// 由头部删除一个元素,如果数组空,则删除线程进入等待状态,直到有新添加元素
@SuppressWarnings("unchecked")
public T remove() throws InterruptedException {
lock.lock();
try {
while (count == 0)//判断当前队列为空
notEmpty.await();
Object x = items[removeIndex];//以下为环形队列删去元素的相关代码实现
if (++removeIndex == items.length)
removeIndex = 0;
--count;
notFull.signal();
return (T) x;
} finally {
lock.unlock();//
}
}
}
代码分析:
首先我给出两个Condition接口对象调用相关方法的时机:
notEmpty.signal()
以及notFull.signial()
方法分别是在判断当前队列不为空,当前队列不为满时执行的。
notEmpty.await()
以及notFull.await()
方法则是分别在当前队列为空时调用的,当前队列为满的时候调用的。
其次我来说明使用两个Condition接口对象实现线程管理的原因:
其主要目的就是方便地在调用线程唤醒、休眠操作的时候,我们通过其对象以及其方法名就能够知道此时满足什么条件,这正如上面对于4种方法调用时机的分析所示(比方说我们在代码种读到:notEmpty.signal()
,那么马上就可猜测此时条件为队列不为空)。也就是说,将上述代码改成只有一个Condition接口对象实例是完全没有问题的。希望你理解以下的一个知识点:唤醒、休眠操作的对象总是当前占据锁资源的线程,而不是Condition接口实例。
只有一个Condition接口实例的版本:将notFull
对象删除,仅仅留下notEmpty
对象。并且附上了额外的测试代码。
/**
* @author Fisherman
*/
public class BoundedQueue<T> {
private Object[] items;
// 添加的下标,删除的下标和数组当前数量
private int addIndex, removeIndex, count;//注意默认初始值都是0
private Lock lock = new ReentrantLock();//锁结构为独占的重入锁
private Condition notEmpty = lock.newCondition();
public BoundedQueue(int size) {
items = new Object[size];//队列的构造器
}
// 添加一个元素,如果数组满,则添加线程进入等待状态,直到有"空位"
public void add(T t) throws InterruptedException {
lock.lock();//写之前需要先获取锁资源
try {
while (count == items.length)//意味着队列已满,不能够再添加元素
notEmpty.await();
items[addIndex] = t;
if (++addIndex == items.length)
addIndex = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 由头部删除一个元素,如果数组空,则删除线程进入等待状态,直到有新添加元素
@SuppressWarnings("unchecked")
public T remove() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
Object x = items[removeIndex];
if (++removeIndex == items.length)
removeIndex = 0;
--count;
notEmpty.signal();
return (T) x;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
/**
* 使用以下代码进行测试:
*/
BoundedQueue boundedQueue = new BoundedQueue(5);
new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
boundedQueue.add(1);
Thread.sleep(500);
System.out.printf("增加第%d号元素队列元素成功\n",i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
Thread.sleep(800);
boundedQueue.remove();
System.out.printf("移除第%d号元素队列元素成功\n",i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
两个Condition接口实例对象的设计更像是我们平常生活中:生产者和消费者所占得等待队列不一样,生产者走在商场的后门进行生产和等待,消费者在商场的柜台进行排队购买,如下图所示:
相当于我们使生产者和消费者在两个不同相隔离的等待队列中进行相关等待操作。所以说第二个只使用一个上锁结构的代码块可能会造成代码执行效率的降低,举个例子:消费和生产都是需要耗时的,所以会有以下可能的情况出现:
由于队列资源的有限,有负责生产的线程加入了等待队列中,又有负责消费的线程也进入了同一个等待队伍中,由于调用的方法是signal
方法,不会唤醒所有等待的线程,而是会地唤醒一个等待时间最长的线程(也就是等待队列中的首节点),而首节点可能是消费者节点也可能是生产者节点,所以这样一来,一个消费线程消费完毕后,唤醒的线程可能还是一个消费线程,但是此时并没有新的产品可以提供其消费,所以其又会执行signal
方法,而这个方法又不一定唤醒负责生产的等待线程。虽然最终是能够唤醒生产线程,但是效率上却是降低了。所以,使用两个Condition接口实现的生产者-消费者模式最大的好处就是可以在调用signal
方法的时候精确地唤醒等待或生产线程,而不是有随机性地唤醒。多创建的线程还是体现了**以空间换时间的思想。**但是有人会说,使用signalAll
方法调用,就不会有这样的问题,但是线程signalAll
方法还是会相对地比signal
方法效率低。