BlockingQueue分析

172 阅读8分钟

BlockingQueue 是线程安全的,我们在很多场景下都可以利用线程安全的队列来优雅地解决我们业务自身的线程安全问题。比如说,使用生产者/消费者模式的时候,生产者向队列中添加元素,消费者从队列里取出元素!

如何保证线程安全的?

什么是线程安全?(之前的篇章都有说过,再来一次);

当类在操作内部元素时,能保证此对此元素的操作是安全的(原子性,有序性,可见性),那么我们认为这个就是线程安全的!

blockingQueue在操作内部元素时,都是基于ReentrantLock来实现的,每个操作都是在独占线程中执行!

注意BlockingQueue有如下的Condition

当阻塞队列插入数据时,如果队列已满,线程将会阻塞等待直到队列非满;

从阻塞队列取数据时,如果队列已空,线程将会阻塞等待直到队列非空。

BlockingQueue使用的经典场景

线程池中的任务队列通常是一个阻塞队列。当任务数超过线程池的容量时,新提交的任务将被放入任务队列中等待执行。线程池中的工作线程从任务队列中取出任务进行处理,如果队列为空,则工作线程会被阻塞,直到队列中有新的任务被提交。

JUC包下的阻塞队列

BlockingQueue 接口的实现类都被放在了 juc 包中,它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于take与put操作的原理却是类似的。

image.png

1.ArrayBlockingQueue

ArrayBlockingQueue是最典型的有界阻塞队列,其内部是用数组存储元素的,初始化时需要指定容量大小,利用 ReentrantLock 实现线程安全。ArrayBlockingQueue可以用于实现数据缓存、限流、生产者-消费者模式等各种应用。

在生产者-消费者模型中使用时,如果生产速度和消费速度基本匹配的情况下,使用ArrayBlockingQueue是个不错选择;当如果生产速度远远大于消费速度,则会导致队列填满,大量生产线程被阻塞。

1.1 使用方式

public void arrayBlockingQueueSample() {
    // 必须制定数组容量
    BlockingQueue<String> queue = new ArrayBlockingQueue<String>(16);

    try {
        queue.put("data-1");
    } catch (InterruptedException e) {
        // log.debug("this thread do not interrupted");
    }

    try {
        queue.take();
    } catch (InterruptedException e) {
        // log.debug("this thread do not interrupted");
    }
}

1.2 数据结构

    // 有界队列使用final修饰,属性无法更改,不能修改属性的引用
    final Object[] items;

    /** items index for next take, poll, peek or remove */
    int takeIndex;
    /** items index for next put, offer, or add */
    int putIndex;

    /** Number of elements in the queue */
    int count;

	// 使用reentrantLock保证对队列操作的线程安全
    final ReentrantLock lock;

    // 有界队列,需要处理的两个边界
    private final Condition notEmpty;
    private final Condition notFull;

	// 迭代器(忽略此字段)
	transient Itrs itrs = null;

重点是 takeIndex 和 putIndex,使用了双指针!

1.3 重点方法解析

开篇重点说过,blockingQueue的主要不同点,即在与put 和 take 方法!

1.put

    public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            // 重点关注,此处使用的while循环
            while (count == items.length)
                notFull.await();
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }

为何使用while来判断条件
重点思考,await被唤醒后,是会继续执行后续代码逻辑
如果在循环中,每次被唤醒后,会再一次判断 count == items.length 条件
使用while能防止虚假唤醒,保证对数组的安全操作!

    private void enqueue(E x) {
        final Object[] items = this.items;
        items[putIndex] = x;
    	// 重点关注putIndex,为何能重置为0
        if (++putIndex == items.length)
            putIndex = 0;
        count++;
        notEmpty.signal();
    }

putIndex重置为0,会不会覆盖了已存在的元素

首先,思考下,何时能将元素存放到数组中?只有当数组未满;

当前的存取方式

arrayBlockingQueue存放元素,是从头到尾,环形地存放元素;

arrayBlockingQueue取出元素,是从头到尾,环形地取出元素;

当putIndex重置为0时,是不是代表,整个数组已经从0到length-1,都存放一次,而现在的数组未满,那只能是头部的数据被取出过;

双指针可以使得插入和取出元素的时间复杂度都是 O(1) 级别,提高了队列的性能。

关于 put offer add的区别

区别点只在于,队列满时的做法

put 若队列满了,则等待

offer 比put方法委婉一点,若队列满了,则放弃存放此任务

add 若队列满了,抛出异常

关于 take poll peek remove的区别

队列空时的区别

take 若队列空,则等待

poll peek 若队列空,则放弃取出,

remove 若队列空,抛出异常

若队列非空

poll 取出并删除头部元素

peek 仅查看头部元素

2 take

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();
            return dequeue();
        } finally {
            lock.unlock();
        }
    }
    private E dequeue() {
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        E x = (E) items[takeIndex];
        items[takeIndex] = null;
        if (++takeIndex == items.length)
            takeIndex = 0;
        count--;
        if (itrs != null)
            itrs.elementDequeued();
        notFull.signal();
        return x;
    }

take代码只是展示,其思考方式,和put代码一致!

2.LinkedBlockingQueue

LinkedBlockingQueue是一个基于链表实现的阻塞队列,默认情况下,该阻塞队列的大小为Integer.MAX_VALUE,由于这个数值特别大,所以 LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限,队列可以随着元素的添加而动态增长,但是如果没有剩余内存,则队列将抛出OOM错误。所以为了避免队列过大造成机器负载或者内存爆满的情况出现,我们在使用的时候建议手动传一个队列的大小。

你应当主动制定LinkedBlockingQueue的容量,防止过多任务,进而导致OOM!

使用方式和ArrayBlockingQueue没有区别!

2.1 数据结构

// 容量,指定容量就是有界队列
private final int capacity;
private final AtomicInteger count = new AtomicInteger();

transient Node<E> head;
private transient Node<E> last;

// 重点关注
private final ReentrantLock takeLock = new ReentrantLock();
private final ReentrantLock putLock = new ReentrantLock();

private final Condition notEmpty = takeLock.newCondition();
private final Condition notFull = putLock.newCondition();

//典型的单链表结构
static class Node<E> {
    E item;  //存储元素
    Node<E> next;  //后继节点 单链表结构
    Node(E x) { item = x; }
}

为什么存在两把锁?

LinkedBlockingQueue只记录头尾节点,我们能主动操作的元素也只有头尾节点!

将元素从头部加入,从队尾取出元素!先进先出策略!

takeLock 和 putLock 提醒读写分离的思想; 锁的粒度更细,读写操作效率更高!

2.3 重点方法解析

1 put

    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<E>(e);
        // 写入元素时,只需要写锁
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            // 防止虚假唤醒
            // 此策略已解释
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            // 注意此处的c是先获取,后++
            c = count.getAndIncrement();
            // 此处为何要唤醒notFull条件<1>
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        // 唤醒notEmpty条件,让取元素的线程激活
        // 注意此处需要切换锁
        if (c == 0)
            signalNotEmpty();
    }

为何需要额外唤醒notFull条件?
核心原因,还是读写锁是分离的;
我们只在边界值时进行唤醒的操作;
1.当取出元素, 在边界值,c==capacity时, 唤醒 写锁的notFull条件
2.当写入元素, 在边界值,c==0时,唤醒 读锁的notEmpty条件
思考 当在边界值出现大量并发,如果没有额外的唤醒,可能会导致,其他并发线程永远处于等待状态!

为什么 不使用 c>=0的条件来唤醒
if (c >= 0) signalNotEmpty();
切换锁的成本是更高;源码想避免这种不必要的成本!

    private void enqueue(Node<E> node) {
        // 执行顺序
        // last.next = node
        // last = last.next
        last = last.next = node;
    }

2 take

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

take代码只是展示,其思考方式,和put代码一致!

3.DelayQueue

DelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素。延迟队列的特点是:不是先进先出,而是会按照延迟时间的长短来排序,下一个即将执行的任务会排到队列的最前面。

它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,代码如下:

public interface Delayed extends Comparable<Delayed> {

    /**
     * Returns the remaining delay associated with this object, in the
     * given time unit.
     *
     * @param unit the time unit
     * @return the remaining delay; zero or negative values indicate
     * that the delay has already elapsed
     */
    long getDelay(TimeUnit unit);
}

3.1 使用方式

首先创建一个实现了Delayed的类

public class Order implements Delayed {
    private String orderId;
    private long createTime;
    private long delayTime;

    public Order(String orderId, long createTime, long delayTime) {
        this.orderId = orderId;
        this.createTime = createTime;
        this.delayTime = delayTime;
    }

    public String getOrderId() {
        return orderId;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        long diff = createTime + delayTime - System.currentTimeMillis(); <1>
        return unit.convert(diff, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        long diff = this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS);
        return Long.compare(diff, 0);
    }
}

<1> 注意到delay值,是应该能递减的;

public class DelayQueueSample {

    public static void main(String[] args) throws InterruptedException {
        DelayQueue<Order> delayQueue = new DelayQueue<>();

        // 添加三个订单,分别延迟 5 秒、2 秒和 3 秒
        delayQueue.put(new Order("order1", System.currentTimeMillis(), 5000));
        delayQueue.put(new Order("order2", System.currentTimeMillis(), 2000));
        delayQueue.put(new Order("order3", System.currentTimeMillis(), 3000));

        // 循环取出订单,直到所有订单都被处理完毕
        while (!delayQueue.isEmpty()) {
            Order order = delayQueue.take();
            System.out.println("处理订单:" + order.getOrderId());
        }
    }
}

在当前业务场景中,在业务中需要使用延迟队列;

主流的思路,还是使用MQ中间件,提供的延迟队列,来帮助我们处理消息!

我们到时,可以观察下,在rocketMq中,如何实现延迟队列!

3.2 数据结构

private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();

// 重点关注
// leader = currentThread,表示当前线程正在队首,且在等待延迟时间结束
private Thread leader = null;

// 用于表示现在是否有可取的元素 当新元素到达,或新线程可能需要成为leader时被通知
private final Condition available = lock.newCondition();

3.2 重点方法解析

1 put

    public boolean offer(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            // 向优先队列中添加元素
            q.offer(e);
            // 如果加入的元素,正好为队首
            // 快速地进行一次激活
            if (q.peek() == e) {
                // leader 置为null,让take方法能进行一次唤醒,并取出队首节点
                leader = null; 
                available.signal();
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

2 take

	public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            for (;;) {
                
                E first = q.peek();
                // 没有元素,则等待
                if (first == null)
                    available.await();
                else {
                    long delay = first.getDelay(NANOSECONDS);
                    // 队首元素已到期,则直接返回
                    if (delay <= 0)
                        return q.poll();
                    first = null; // don't retain ref while waiting
                    // 有leader线程正在执行,等待这个独占线程执行完毕
                    if (leader != null)
                        available.await();
                    else {
                        Thread thisThread = Thread.currentThread();
                        leader = thisThread;
                        try {
                            available.awaitNanos(delay);
                        } finally {
                            if (leader == thisThread)
                                leader = null;
                        }
                    }
                }
            }
        } finally {
            if (leader == null && q.peek() != null)
                available.signal();
            lock.unlock();
        }
    }

4.线程池中使用的阻塞队列

线程池有很多种,不同种类的线程池会根据自己的特点,来选择适合自己的阻塞队列。

Executors类下的线程池类型:

  • FixedThreadPool(SingleThreadExecutor 同理)选取的是 LinkedBlockingQueue
  • CachedThreadPool 选取的是 SynchronousQueue
  • ScheduledThreadPool(SingleThreadScheduledExecutor同理)选取的是延迟队列