JDK源码那些事儿之LinkedBlockingQueue

22次阅读

共计 9703 个字符,预计需要花费 25 分钟才能阅读完成。

今天继续讲解阻塞队列,涉及到了常用线程池的其中一个队列 LinkedBlockingQueue,从类命名部分我们就可以看出其用意,队列中很多方法名是通用的,只是每个队列内部实现不同,毕竟实现的都是同一个接口 BlockingQueue,可以自行查看接口源码,下面我们一起看下 LinkedBlockingQueue 实现的源码部分

前言

JDK 版本号:1.8.0_171

LinkedBlockingQueue 是链表实现的线程安全的无界的阻塞队列

  • 内部是通过 Node 节点组成的链表来实现的
  • 线程安全说明的是内部通过两个 ReentrantLock 锁保护竞争资源,实现了多线程对竞争资源的互斥访问,这里入队和出队互不影响
  • 无界,默认链表长度为 Integer.MAX_VALUE,本质上还是有界
  • 阻塞队列,是指多线程访问竞争资源时,当竞争资源已被某线程获取时,其它要获取该资源的线程需要阻塞等待

队列通过 Node 对象组成的链表实现,与 ArrayBlockingQueue 不同的地方在于,ArrayBlockingQueue 是有界的,初始化需指定长度,LinkedBlockingQueue 不定义长度时,默认 Integer.MAX_VALUE,相当于“无界”了,但是这样会造成一些问题,这部分后边说,同时保证并发和阻塞部分使用了 2 个互斥锁分别对入队和出队互斥操作,这样来看,独立开来提升了队列的吞吐量,入队和出队操作可同时进行

类定义

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable

常量 / 变量

    /**
     * Linked list node class
     * 
     * 链表 Node 
     * next 引用指向后一个 Node
     */
    static class Node<E> {
        E item;

        /**
         * One of:
         * - the real successor Node
         * - this Node, meaning the successor is head.next
         * - null, meaning there is no successor (this is the last node)
         */
        Node<E> next;

        Node(E x) {item = x;}
    }

    /** The capacity bound, or Integer.MAX_VALUE if none */
    // 链表容量大小,不传参数,默认 Integer.MAX_VALUE
    // 这里 final 说明一旦确定链表容量就不能再改变了
    private final int capacity;

    /** Current number of elements */
    // 队列实际包含元素的长度,这里使用了原子类保证数据的准确性
    private final AtomicInteger count = new AtomicInteger();

    /**
     * Head of linked list.
     * Invariant: head.item == null
     */
    // 链表头节点,head.item == null
    transient Node<E> head;

    /**
     * Tail of linked list.
     * Invariant: last.next == null
     */
    // 链表尾节点,last.next == null
    private transient Node<E> last;

    /** Lock held by take, poll, etc */
    // 出队操作互斥锁
    private final ReentrantLock takeLock = new ReentrantLock();
    /** Wait queue for waiting takes */
    // 非空信号量,当无元素时阻塞等待入队操作
    private final Condition notEmpty = takeLock.newCondition();
    // 入队操作互斥锁
    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();
    /** Wait queue for waiting puts */
    // 非满信号量,当队列已满时阻塞等待出队操作
    private final Condition notFull = putLock.newCondition();

构造方法

无参构造方法中默认取 Integer.MAX_VALUE,使得链表容量限制为最大值,同时初始化头尾节点,值置为 null

    public LinkedBlockingQueue() {this(Integer.MAX_VALUE);
    }
    
    public LinkedBlockingQueue(int capacity) {if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);
    }

    public LinkedBlockingQueue(Collection<? extends E> c) {this(Integer.MAX_VALUE);
        final ReentrantLock putLock = this.putLock;
        // 这里使用锁的原因和 ArrayBlockingQueue 相同,确保可见性,因为链表本身并不保证可见性,防止并发操作下链表不一致的情况出现
        putLock.lock(); // Never contended, but necessary for visibility
        try {
            int n = 0;
            for (E e : c) {if (e == null)
                    throw new NullPointerException();
                if (n == capacity)
                    throw new IllegalStateException("Queue full");
                enqueue(new Node<E>(e));
                ++n;
            }
            // 设置队列长度
            count.set(n);
        } finally {putLock.unlock();
        }
    }

重要方法

signalNotEmpty/signalNotFull

在每次唤醒非空信号量的等待线程时,需要先获取出队互斥锁,简单说,就是当队列为空时,有线程在执行出队操作,通过 notEmpty.await() 阻塞等待,这时有线程入队操作,调用 signalNotEmpty() 唤醒执行 notEmpty.await() 的阻塞线程,在唤醒这个线程之前必须拿到 takeLock 互斥锁,为什么?因为执行唤醒操作的时候要获取到该 signal 对应的 Condition 对象的锁才行,在 ArrayBlockingQueue 中是同样的操作

    /**
     * Signals a waiting take. Called only from put/offer (which do not
     * otherwise ordinarily lock takeLock.)
     */
    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {notEmpty.signal();
        } finally {takeLock.unlock();
        }
    }

    /**
     * Signals a waiting put. Called only from take/poll.
     */
    private void signalNotFull() {
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {notFull.signal();
        } finally {putLock.unlock();
        }
    }

enqueue/dequeue

入队出队最终调用的方法,enqueue 方法首先将原尾节点的 next 引用指向新节点,然后将尾节点更新为新节点。dequeue 方法移除头节点,更新头节点, 注意这里实际上返回的节点是第二个节点,因为头节点 head.item == null

    /**
     * Links node at end of queue.
     *
     * @param node the node
     */
    private void enqueue(Node<E> node) {// assert putLock.isHeldByCurrentThread();
        // assert last.next == null;
        last = last.next = node;
    }

    /**
     * Removes a node from head of queue.
     *
     * @return the node
     */
    private E dequeue() {// assert takeLock.isHeldByCurrentThread();
        // assert head.item == null;
        // 使用 h 保存原头节点,头节点这里 head.item == null
        Node<E> h = head;
        // 使用 first 保存原头节点之后的节点,实际上的第一个节点,我们需要的出队节点也是这个节点
        Node<E> first = h.next;
        // 原头节点 next 指向自己, 这里指向自己在后边迭代器中 nextNode 方法中有用到,通过这种方式判断节点类型
        h.next = h; // help GC
        // 头节点更新为第二个节点
        head = first;
        // 保存第二个节点的值
        E x = first.item;
        // 更新头节点 head.item == null, 之后这个节点将作为头节点
        first.item = null;
        return x;
    }

fullyLock/fullyUnlock

两个操作全部加锁,在删除,验证是否包含某个元素,迭代等操作中使用

    /**
     * Locks to prevent both puts and takes.
     */
    void fullyLock() {putLock.lock();
        takeLock.lock();}

    /**
     * Unlocks to allow both puts and takes.
     */
    void fullyUnlock() {takeLock.unlock();
        putLock.unlock();}

put

put 入队操作,队列已满时阻塞等待,队列未满则插入队列同时判断是否唤醒其他入队线程和出队线程,几种入队操作区别同 ArrayBlockingQueue 中的说明,这里不再一一说明了


    public void put(E e) throws InterruptedException {if (e == null) throw new NullPointerException();
        // Note: convention in all put/take/etc is to preset local var
        // holding count negative to indicate failure unless set.
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        // 可中断 putLock 锁
        putLock.lockInterruptibly();
        try {
            // 队列已满则入队操作线程阻塞等待
            while (count.get() == capacity) {notFull.await();
            }
            // 队列未满则入队操作
            enqueue(node);
            // 原子类更新队列长度值,返回值为原 count 的值
            c = count.getAndIncrement();
            // 再次判断队列是否有可用空间,如果有唤醒下一个线程进行添加操作
            if (c + 1 < capacity)
                notFull.signal();} finally {putLock.unlock();
        }
        // 队列原本有 0 条数据,现在有了 1 条数据,则唤醒消费线程进行消费
        // 因为原本队列无元素,消费线程都被阻塞,只需要判断有一条数据的时候就可以
        if (c == 0)
            signalNotEmpty();}

take

take 出队操作,队列为空时阻塞等待,队列非空时则出队同时队列长度减 1,同时判断是否唤醒其他出队操作,方法区别同样可参考前面的 ArrayBlockingQueue 文章

    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();
            // 减 1
            c = count.getAndDecrement();
            // 队列是否有可用空间
            if (c > 1)
                notEmpty.signal();} finally {takeLock.unlock();
        }
        // 队列原本是满的状态,现在一条数据出队,则可以唤醒非满信号量,进行入队操作
        if (c == capacity)
            signalNotFull();
        return x;
    }

unlink

remove 操作调用,删除 p 与 trail 节点之间的关系,重新构建 p.next 节点和 trail 节点关系,相当于删除 p 节点后对引用的处理

    /**
     * Unlinks interior Node p with predecessor trail.
     */
    void unlink(Node<E> p, Node<E> trail) {// assert isFullyLocked();
        // p.next is not changed, to allow iterators that are
        // traversing p to maintain their weak-consistency guarantee.
        // 删除节点置空
        p.item = null;
        // 删除节点前一个节点指向删除节点的后一个节点
        trail.next = p.next;
        // p 是最后一个节点,则删除 p 后最后一个节点为 trail
        if (last == p)
            last = trail;
        // 删除 p 之前队列是已满状态则删除 p 后调用 notFull.signal() 唤醒入队线程操作
        if (count.getAndDecrement() == capacity)
            notFull.signal();}

contains

判断是否包含某个对象,在执行时需同时获得入队锁和出队锁,保证在判断过程中不会有数据的变更。在 toArray,toString,clear 方法中都是如此

    /**
     * Returns {@code true} if this queue contains the specified element.
     * More formally, returns {@code true} if and only if this queue contains
     * at least one element {@code e} such that {@code o.equals(e)}.
     *
     * @param o object to be checked for containment in this queue
     * @return {@code true} if this queue contains the specified element
     */
    public boolean contains(Object o) {if (o == null) return false;
        // 获取两个锁
        fullyLock();
        try {
            // 正常循环判断
            for (Node<E> p = head.next; p != null; p = p.next)
                if (o.equals(p.item))
                    return true;
            return false;
        } finally {fullyUnlock();
        }
    }

drainTo

一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁,在接口中已经声明这个方法,在 ArrayBlockingQueue 中同样有这个方法

    public int drainTo(Collection<? super E> c, int maxElements) {
        // 检查
        if (c == null)
            throw new NullPointerException();
        if (c == this)
            throw new IllegalArgumentException();
        if (maxElements <= 0)
            return 0;
        // 唤醒非满信号量为 false
        boolean signalNotFull = false;
        final ReentrantLock takeLock = this.takeLock;
        // 获取 takeLock 互斥锁
        takeLock.lock();
        try {
            // 获取能获取队列的正常长度
            int n = Math.min(maxElements, count.get());
            // count.get provides visibility to first n Nodes
            Node<E> h = head;
            int i = 0;
            try {
                // 将值放入 c
                while (i < n) {
                    Node<E> p = h.next;
                    c.add(p.item);
                    p.item = null;
                    h.next = h;
                    h = p;
                    ++i;
                }
                // 返回获取的队列长度 n
                return n;
            } finally {// Restore invariants even if c.add() threw
                // 重新保存常量
                if (i > 0) {
                    // assert h.item == null;
                    head = h;
                    // getAndAdd 返回未执行前的值,与队列容量相等,则说明之前队列是已满状态,入队线程全部阻塞,// 而这里 i > 0 则表明此时操作完队列未满可以唤醒入队线程
                    signalNotFull = (count.getAndAdd(-i) == capacity);
                }
            }
        } finally {takeLock.unlock();
            // 唤醒入队线程
            if (signalNotFull)
                signalNotFull();}
    }

迭代器及内部类

每次调用 iterator 创建 Itr 内部类,与 ArrayBlockingQueue 不同,LinkedBlockingQueue,内部类 Itr 没有那么复杂,通过 fullyLock 和 fullyUnlock 方法在每次迭代时需要获取锁才能操作,保证不会数据错乱

    public Iterator<E> iterator() {return new Itr();
    }

    private class Itr implements Iterator<E> {
        /*
         * Basic weakly-consistent iterator.  At all times hold the next
         * item to hand out so that if hasNext() reports true, we will
         * still have it to return even if lost race with a take etc.
         */

        private Node<E> current;
        private Node<E> lastRet;
        private E currentElement;

        Itr() {fullyLock();
            try {
                // 保存头节点的下一个节点, 头节点是无值的
                current = head.next;
                if (current != null)
                    // 获取第一个有值的数据
                    currentElement = current.item;
            } finally {fullyUnlock();
            }
        }

        public boolean hasNext() {return current != null;}

        /**
         * Returns the next live successor of p, or null if no such.
         *
         * Unlike other traversal methods, iterators need to handle both:
         * - dequeued nodes (p.next == p)
         * - (possibly multiple) interior removed nodes (p.item == null)
         */
        // 找到迭代的下一个节点
        private Node<E> nextNode(Node<E> p) {for (;;) {
                Node<E> s = p.next;
                // p.next == p 说明已经出队,上边方法 dequeue 中有提到
                // 这里直接使用 head.next 即可
                if (s == p)
                    return head.next;
                // 节点为空或者节点值不为空则返回这个节点
                // 节点为空说明是队列尾,直接返回即可
                // 节点值不为空说明已找到下一个节点,同样返回
                if (s == null || s.item != null)
                    return s;
                // s.item == null 可能节点被删除了,则继续判断下一个节点
                p = s;
            }
        }

        public E next() {fullyLock();
            try {if (current == null)
                    throw new NoSuchElementException();
                E x = currentElement;
                // 保存上次迭代的值
                lastRet = current;
                // 计算保存下次迭代值
                current = nextNode(current);
                currentElement = (current == null) ? null : current.item;
                return x;
            } finally {fullyUnlock();
            }
        }

        // 调用迭代的 remove 删除的是 lastRet 对应的值
        public void remove() {if (lastRet == null)
                throw new IllegalStateException();
            fullyLock();
            try {
                Node<E> node = lastRet;
                lastRet = null;
                for (Node<E> trail = head, p = trail.next;
                     p != null;
                     trail = p, p = p.next) {if (p == node) {
                        // 删除 p, 调整链表
                        unlink(p, trail);
                        break;
                    }
                }
            } finally {fullyUnlock();
            }
        }
    }

使用说明

对于新手而言,LinkedBlockingQueue 队列在线程池的使用中可能会出现一些问题,主要问题在于其创建的方式,新手使用封装类提供的方法,比如下面示例代码:

    ExecutorService pool = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 10; i++) {pool.submit(() -> System.out.println(Thread.currentThread().getName()));
    }

从源码中我们可以看到其创建线程池时使用的队列方式如下:

new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());

这会造成什么问题呢?

默认无参构造,从 LinkedBlockingQueue 源码部分我们也能看到无参时默认链表最大容量为 Integer.MAX_VALUE,假如我们设置了核心线程数和最大线程数都为 5 之后,如果线程一直被占用而没有释放,同时又有很多任务向线程池申请线程使用,这时我们会将任务放入队列中保存,生产者的速度远大于消费者,堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM

所以阿里规范中提及了这部分内容,指出了其中存在的隐患,需要规避资源耗尽的风险,开发人员应直接使用 ThreadPoolExecutor 来创建线程池,每个参数需要根据自己的需求进行设置

总结

通过对 LinkedBlockingQueue 源码的解读我们可以了解到如下与 ArrayBlockingQueue 不同的内容:

  1. 内部通过 Node 对象链表实现
  2. 内部有 2 个 Lock,分别对应入队和出队操作,两者可同时进行,提高了吞吐量
  3. 线程池使用 LinkedBlockingQueue 时,最好根据需要自行创建,设置大小,否则有可能引起 OOM 问题

以上内容如有问题欢迎指出,笔者验证后将及时修正,谢谢

正文完
 0