【13】阻塞队列的介绍和实现

目录

1、普通队列的弊端

2、Java中线程安全问题解决方案

3、自定义实现阻塞队列


1、普通队列的弊端

之前的队列在很多场景下都不能很好地工作,例如

  1. 大部分场景要求分离向队列放入(生产者)、从队列拿出(消费者)两个角色、它们得由不同的线程来担当,而之前的实现根本没有考虑线程安全问题

  2. 队列为空,那么在之前的实现里会返回 null,如果就是硬要拿到一个元素呢?只能不断循环尝试

  3. 队列为满,那么再之前的实现里会返回 false,如果就是硬要塞入一个元素呢?只能不断循环尝试

因此我们需要解决的问题有

  1. 用锁保证线程安全

  2. 用条件变量让等待非空线程等待不满线程进入等待状态,而不是不断循环尝试,让 CPU 空转

有同学对线程安全还没有足够的认识,下面举一个反例,两个线程都要执行入队操作(几乎在同一时刻)

public class TestThreadUnsafe {
    private final String[] array = new String[10];
    private int tail = 0;
​
    public void offer(String e) {
        array[tail] = e;
        tail++;
    }
​
    @Override
    public String toString() {
        return Arrays.toString(array);
    }
​
    public static void main(String[] args) {
        TestThreadUnsafe queue = new TestThreadUnsafe();
        new Thread(()-> queue.offer("e1"), "t1").start();
        new Thread(()-> queue.offer("e2"), "t2").start();
    }
}

执行的时间序列如下,假设初始状态 tail = 0,在执行过程中由于 CPU 在两个线程之间切换,造成了指令交错

线程1 线程2 说明
array[tail]=e1 线程1 向 tail 位置加入 e1 这个元素,但还没来得及执行 tail++
array[tail]=e2 线程2 向 tail 位置加入 e2 这个元素,覆盖掉了 e1
tail++ tail 自增为1
tail++ tail 自增为2
最后状态 tail 为 2,数组为 [e2, null, null ...]

糟糕的是,由于指令交错的顺序不同,得到的结果不止以上一种,宏观上造成混乱的效果

2、Java中线程安全问题解决方案

Java 中要防止代码段交错执行,需要使用锁,有两种选择

  • synchronized 代码块,属于关键字级别提供锁保护,功能少

  • ReentrantLock 类,功能丰富

以 ReentrantLock 为例

ReentrantLock lock = new ReentrantLock();
​
public void offer(String e) {
    lock.lockInterruptibly();
    try {
        array[tail] = e;
        tail++;
    } finally {
        lock.unlock();
    }
}

只要两个线程执行上段代码时,锁对象是同一个,就能保证 try 块内的代码的执行不会出现指令交错现象,即执行顺序只可能是下面两种情况之一

线程1 线程2 说明
lock.lockInterruptibly() t1对锁对象上锁
array[tail]=e1
lock.lockInterruptibly() 即使 CPU 切换到线程2,但由于t1已经对该对象上锁,因此线程2卡在这儿进不去
tail++ 切换回线程1 执行后续代码
lock.unlock() 线程1 解锁
array[tail]=e2 线程2 此时才能获得锁,执行它的代码
tail++
  • 另一种情况是线程2 先获得锁,线程1 被挡在外面

  • 要明白保护的本质,本例中是保护的是 tail 位置读写的安全

事情还没有完,上面的例子是队列还没有放满的情况,考虑下面的代码(这回锁同时保护了 tail 和 size 的读写安全)

ReentrantLock lock = new ReentrantLock();
int size = 0;
​
public void offer(String e) {
    lock.lockInterruptibly();
    try {
        if(isFull()) {
            // 满了怎么办?
        }
        array[tail] = e;
        tail++;
        
        size++;
    } finally {
        lock.unlock();
    }
}
​
private boolean isFull() {
    return size == array.length;
}

之前是返回 false 表示添加失败,前面分析过想达到这么一种效果:

  • 在队列满时,不是立刻返回,而是当前线程进入等待

  • 什么时候队列不满了,再唤醒这个等待的线程,从上次的代码处继续向下运行

ReentrantLock 可以配合条件变量来实现,代码进化为

ReentrantLock lock = new ReentrantLock();
Condition tailWaits = lock.newCondition(); // 条件变量
int size = 0;
​
public void offer(String e) {
    lock.lockInterruptibly();
    try {
        while (isFull()) {
            tailWaits.await();  // 当队列满时, 当前线程进入 tailWaits 等待
        }
        array[tail] = e;
        tail++;
        
        size++;
    } finally {
        lock.unlock();
    }
}
​
private boolean isFull() {
    return size == array.length;
}
  • 条件变量底层也是个队列,用来存储这些需要等待的线程,当队列满了,就会将 offer 线程加入条件队列,并暂时释放锁

  • 将来我们的队列如果不满了(由 poll 线程那边得知)可以调用 tailWaits.signal() 来唤醒 tailWaits 中首个等待的线程,被唤醒的线程会再次抢到锁,从上次 await 处继续向下运行

思考为何要用 while 而不是 if,设队列容量是 3

操作前 OFFER(4) OFFER(5) POLL() 操作后
[1 2 3] 队列满,进入tailWaits 等待 [1 2 3]
[1 2 3] 取走 1,队列不满,唤醒线程 [2 3]
[2 3] 抢先获得锁,发现不满,放入 5 [2 3 5]
[2 3 5] 从上次等待处直接向下执行 [2 3 5 ?]

关键点:

  • 从 tailWaits 中唤醒的线程,会与新来的 offer 的线程争抢锁,谁能抢到是不一定的,如果后者先抢到,就会导致条件又发生变化

  • 这种情况称之为虚假唤醒,唤醒后应该重新检查条件,看是不是得重新进入等待

3、自定义实现阻塞队列


​
/**
 * @author zjj_admin
 */
public interface BlockingQueue {
​
    /**
     * 无限期阻塞添加一个数据
     *
     * @param value
     * @throws InterruptedException
     */
    void offer(E value) throws InterruptedException;
​
    /**
     * 限时阻塞添加一个数据,超时返回 null
     *
     * @param value
     * @param timeout
     * @throws InterruptedException
     */
    void offer(E value, long timeout) throws InterruptedException;
​
    /**
     * 无限期阻塞添加数据
     *
     * @return
     * @throws InterruptedException
     */
    E poll() throws InterruptedException;
}

最后的实现代码


​
​
/**
 * 单锁实现
 *
 * @param  元素类型
 * @author zjj_admin
 */
public class BlockingQueue1 implements BlockingQueue {
    private final E[] array;
    private int head = 0;
    private int tail = 0;
    /**
     * 元素个数
     */
    private int size = 0;
​
    @SuppressWarnings("all")
    public BlockingQueue1(int capacity) {
        array = (E[]) new Object[capacity];
    }
​
    /**
     * 声明一把锁
     */
    ReentrantLock lock = new ReentrantLock();
    /**
     * 声明两个 Condition,一个头部一个尾部
     * 当添加数据时,若队列已经满了就让尾部陷入阻塞状态,只有释放元素之后再将尾部唤醒。
     * 当移除数据时,若队列已经空了就让头部陷入阻塞状态,只有添加元素之后再将头部唤醒。
     */
    Condition tailWaits = lock.newCondition();
    Condition headWaits = lock.newCondition();
​
    @Override
    public void offer(E e) throws InterruptedException {
        //将锁声明为可打断的
        //和 lock 方法不同,lockInterruptibly 方法在等待获取时,如果遇到线程中断会放弃获取锁
        lock.lockInterruptibly();
        try {
            while (isFull()) {
                tailWaits.await();
            }
            array[tail] = e;
            if (++tail == array.length) {
                tail = 0;
            }
            size++;
            //每一次添加数据之后就将头部唤醒
            headWaits.signal();
        } finally {
            lock.unlock();
        }
    }
​
    @Override
    public void offer(E e, long timeout) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            long t = TimeUnit.MILLISECONDS.toNanos(timeout);
            while (isFull()) {
                if (t <= 0) {
                    return;
                }
                t = tailWaits.awaitNanos(t);
            }
            array[tail] = e;
            if (++tail == array.length) {
                tail = 0;
            }
            size++;
            //每一次添加数据之后就将头部唤醒
            headWaits.signal();
        } finally {
            lock.unlock();
        }
    }
​
    @Override
    public E poll() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (isEmpty()) {
                headWaits.await();
            }
            E e = array[head];
            // help GC
            array[head] = null;
            if (++head == array.length) {
                head = 0;
            }
            size--;
            tailWaits.signal();
            return e;
        } finally {
            lock.unlock();
        }
    }
​
    private boolean isEmpty() {
        return size == 0;
    }
​
    private boolean isFull() {
        return size == array.length;
    }
}
  • public void offer(E e, long timeout) throws InterruptedException 是带超时的版本,可以只等待一段时间,而不是永久等下去,类似的 poll 也可以做带超时的版本,这个留给大家了

注意

  • JDK 中 BlockingQueue 接口的方法命名与我的示例有些差异

    • 方法 offer(E e) 是非阻塞的实现,阻塞实现方法为 put(E e)

    • 方法 poll() 是非阻塞的实现,阻塞实现方法为 take()

你可能感兴趣的:(数据结构和算法学习,java,开发语言)