在Java多线程应用中,队列的使用率很高,多数生产消费模型的首选数据结构就是队列。Java提供的线程安全的Queue可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。
注:什么叫线程安全?这个首先要明确。线程安全的类 ,指的是类内共享的全局变量的访问必须保证是不受多线程形式影响的。如果由于多线程的访问(比如修改、遍历、查看)而使这些变量结构被破坏或者针对这些变量操作的原子性被破坏,则这个类就不是线程安全的。
今天就聊聊这两种Queue,本文分为以下两个部分,用分割线分开:
- BlockingQueue 阻塞算法
- ConcurrentLinkedQueue,非阻塞算法
首先来看看BlockingQueue:
Queue是什么就不需要多说了吧,一句话:队列是先进先出。相对的,栈是后进先出。如果不熟悉的话先找本基础的数据结构的书看看吧。
BlockingQueue,顾名思义,“阻塞队列”:可以提供阻塞功能的队列。
首先,看看BlockingQueue提供的常用方法:
| 可能报异常 | 返回布尔值 | 可能阻塞 | 设定等待时间 |
入队 | add(e) | offer(e) | put(e) | offer(e, timeout, unit) |
出队 | remove() | poll() | take() | poll(timeout, unit) |
查看 | element() | peek() | 无 | 无 |
从上表可以很明显看出每个方法的作用,这个不用多说。我想说的是:
- add(e) remove() element() 方法不会阻塞线程。当不满足约束条件时,会抛出IllegalStateException 异常。例如:当队列被元素填满后,再调用add(e),则会抛出异常。
- offer(e) poll() peek() 方法即不会阻塞线程,也不会抛出异常。例如:当队列被元素填满后,再调用offer(e),则不会插入元素,函数返回false。
- 要想要实现阻塞功能,需要调用put(e) take() 方法。当不满足约束条件时,会阻塞线程。
好,上点源码你就更明白了。以ArrayBlockingQueue类为例:
对于第一类方法,很明显如果操作不成功就抛异常。而且可以看到其实调用的是第二类的方法,为什么?因为第二类方法返回boolean啊。
Java代码
- public boolean add(E e) {
- if (offer(e))
- return true;
- else
- throw new IllegalStateException("Queue full");//队列已满,抛异常
- }
- public E remove() {
- E x = poll();
- if (x != null)
- return x;
- else
- throw new NoSuchElementException();//队列为空,抛异常
- }
对于第二类方法,很标准的ReentrantLock使用方式(不熟悉的朋友看一下我上一篇帖子吧http://hellosure.iteye.com/blog/1121157),另外对于insert和extract的实现没啥好说的。
注:先不看阻塞与否,这ReentrantLock的使用方式就能说明这个类是线程安全类。
Java代码
- public boolean offer(E e) {
- if (e == null)throw new NullPointerException();
- final ReentrantLock lock = this.lock;
- lock.lock();
- try {
- if (count == items.length)//队列已满,返回false
- return false;
- else {
- insert(e);//insert方法中发出了notEmpty.signal();
- return true;
- }
- } finally {
- lock.unlock();
- }
- }
- public E poll() {
- final ReentrantLock lock = this.lock;
- lock.lock();
- try {
- if (count == 0)//队列为空,返回false
- return null;
- E x = extract();//extract方法中发出了notFull.signal();
- return x;
- } finally {
- lock.unlock();
- }
- }
对于第三类方法,这里面涉及到Condition类,简要提一下,
await方法指:造成当前线程在接到信号或被中断之前一直处于等待状态。
signal方法指:唤醒一个等待线程。
Java代码
- public void put(E e)throws InterruptedException {
- if (e == null)throw new NullPointerException();
- final E[] items = this.items;
- final ReentrantLock lock = this.lock;
- lock.lockInterruptibly();
- try {
- try {
- while (count == items.length)//如果队列已满,等待notFull这个条件,这时当前线程被阻塞
- notFull.await();
- } catch (InterruptedException ie) {
- notFull.signal(); //唤醒受notFull阻塞的当前线程
- throw ie;
- }
- insert(e);
- } finally {
- lock.unlock();
- }
- }
- public E take() throws InterruptedException {
- final ReentrantLock lock = this.lock;
- lock.lockInterruptibly();
- try {
- try {
- while (count == 0)//如果队列为空,等待notEmpty这个条件,这时当前线程被阻塞
- notEmpty.await();
- } catch (InterruptedException ie) {
- notEmpty.signal();//唤醒受notEmpty阻塞的当前线程
- throw ie;
- }
- E x = extract();
- return x;
- } finally {
- lock.unlock();
- }
- }
-
第四类方法就是指在有必要时等待指定时间,就不详细说了。
再来看看BlockingQueue接口的具体实现类吧:
- ArrayBlockingQueue,其构造函数必须带一个int参数来指明其大小
- LinkedBlockingQueue,若其构造函数带一个规定大小的参数,生成的BlockingQueue有大小限制,若不带大小参数,所生成的BlockingQueue的大小由Integer.MAX_VALUE来决定
- PriorityBlockingQueue,其所含对象的排序不是FIFO,而是依据对象的自然排序顺序或者是构造函数的Comparator决定的顺序
上面是用ArrayBlockingQueue举得例子,下面看看LinkedBlockingQueue:
首先,既然是链表,就应该有Node节点,它是一个内部静态类:
Java代码
- static class Node<E> {
- /** The item, volatile to ensure barrier separating write and read */
- volatile E item;
- Node<E> next;
- Node(E x) { item = x; }
- }
然后,对于链表来说,肯定需要两个变量来标示头和尾:
Java代码
- /** 头指针 */
- private transient Node<E> head;//head.next是队列的头元素
- /** 尾指针 */
- private transient Node<E> last;//last.next是null
那么,对于入队和出队就很自然能理解了:
Java代码
- private void enqueue(E x) {
- last = last.next = new Node<E>(x);//入队是为last再找个下家
- }
- private E dequeue() {
- Node<E> first = head.next; //出队是把head.next取出来,然后将head向后移一位
- head = first;
- E x = first.item;
- first.item = null;
- return x;
- }
另外,LinkedBlockingQueue相对于ArrayBlockingQueue还有不同是,有两个ReentrantLock,且队列现有元素的大小由一个AtomicInteger对象标示。
注:AtomicInteger类是以原子的方式操作整型变量。
Java代码
- private final AtomicInteger count =new AtomicInteger(0);
- /** 用于读取的独占锁*/
- private final ReentrantLock takeLock =new ReentrantLock();
- /** 队列是否为空的条件 */
- private final Condition notEmpty = takeLock.newCondition();
- /** 用于写入的独占锁 */
- private final ReentrantLock putLock =new ReentrantLock();
- /** 队列是否已满的条件 */
- private final Condition notFull = putLock.newCondition();
有两个Condition很好理解,在ArrayBlockingQueue也是这样做的。但是为什么需要两个ReentrantLock呢?下面会慢慢道来。
让我们来看看offer和poll方法的代码:
Java代码
- public boolean offer(E e) {
- if (e == null)throw new NullPointerException();
- final AtomicInteger count = this.count;
- if (count.get() == capacity)
- return false;
- int c = -1;
- final ReentrantLock putLock =this.putLock;//入队当然用putLock
- putLock.lock();
- try {
- if (count.get() < capacity) {
- enqueue(e); //入队
- c = count.getAndIncrement(); //队长度+1
- if (c + 1 < capacity)
- notFull.signal(); //队列没满,当然可以解锁了
- }
- } finally {
- putLock.unlock();
- }
- if (c == 0)
- signalNotEmpty();//这个方法里发出了notEmpty.signal();
- return c >= 0;
- }
- public E poll() {
- final AtomicInteger count = this.count;
- if (count.get() == 0)
- return null;
- E x = null;
- int c = -1;
- final ReentrantLock takeLock =this.takeLock;出队当然用takeLock
- takeLock.lock();
- try {
- if (count.get() > 0) {
- x = dequeue();//出队
- c = count.getAndDecrement();//队长度-1
- if (c > 1)
- notEmpty.signal();//队列没空,解锁
- }
- } finally {
- takeLock.unlock();
- }
- if (c == capacity)
- signalNotFull();//这个方法里发出了notFull.signal();
- return x;
- }
看看源代码发现和上面ArrayBlockingQueue的很类似,关键的问题在于:为什么要用两个ReentrantLockputLock和takeLock?
我们仔细想一下,入队操作其实操作的只有队尾引用last,并且没有牵涉到head。而出队操作其实只针对head,和last没有关系。那么就是说入队和出队的操作完全不需要公用一把锁,所以就设计了两个锁,这样就实现了多个不同任务的线程入队的同时可以进行出队的操作,另一方面由于两个操作所共同使用的count是AtomicInteger类型的,所以完全不用考虑计数器递增递减的问题。
另外,还有一点需要说明一下:await()和singal()这两个方法执行时都会检查当前线程是否是独占锁的当前线程,如果不是则抛出java.lang.IllegalMonitorStateException异常。所以可以看到在源码中这两个方法都出现在Lock的保护块中。
************************************************************************************我是分割线************************************************************************************
2. ConcurrentLinkedQueue的介绍
ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,它采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部,当我们获取一个元素时,它会返回队列头部的元素。它采用了“wait-free”算法来实现,该算法在Michael & Scott算法上进行了一些修改, Michael & Scott算法的详细信息可以参见参考资料一。
3. ConcurrentLinkedQueue的结构
我们通过ConcurrentLinkedQueue的类图来分析一下它的结构。
(图1)
ConcurrentLinkedQueue由head节点和tair节点组成,每个节点(Node)由节点元素(item)和指向下一个节点的引用(next)组成,节点与节点之间就是通过这个next关联起来,从而组成一张链表结构的队列。默认情况下head节点存储的元素为空,tair节点等于head节点。
1 | private transient volatile Node<e> tail = head; |
4. 入队列
入队列就是将入队节点添加到队列的尾部。为了方便理解入队时队列的变化,以及head节点和tair节点的变化,每添加一个节点我就做了一个队列的快照图。
(图二)
- 第一步添加元素1。队列更新head节点的next节点为元素1节点。又因为tail节点默认情况下等于head节点,所以它们的next节点都指向元素1节点。
- 第二步添加元素2。队列首先设置元素1节点的next节点为元素2节点,然后更新tail节点指向元素2节点。
- 第三步添加元素3,设置tail节点的next节点为元素3节点。
- 第四步添加元素4,设置元素3的next节点为元素4节点,然后将tail节点指向元素4节点。
通过debug入队过程并观察head节点和tail节点的变化,发现入队主要做两件事情,第一是将入队节点设置成当前队列尾节点的下一个节点。第二是更新tail节点,如果tail节点的next节点不为空,则将入队节点设置成tail节点,如果tail节点的next节点为空,则将入队节点设置成tail的next节点,所以tail节点不总是尾节点,理解这一点对于我们研究源码会非常有帮助。
上面的分析让我们从单线程入队的角度来理解入队过程,但是多个线程同时进行入队情况就变得更加复杂,因为可能会出现其他线程插队的情况。如果有一个线程正在入队,那么它必须先获取尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队操作,然后重新获取尾节点。让我们再通过源码来详细分析下它是如何使用CAS算法来入队的。
01 | public boolean offer(E e) { |
03 | if (e == null ) throw new NullPointerException(); |
07 | Node</e><e> n = new Node</e><e>(e); |
23 | for ( int hops = 0 ; ; hops++) { |
27 | Node</e><e> next = succ(p); |
35 | if (hops > HOPS && t != tail) |
45 | else if (p.casNext( null , n)) { |
从源代码角度来看整个入队过程主要做二件事情。第一是定位出尾节点,第二是使用CAS算法能将入队节点设置成尾节点的next节点,如不成功则重试。
第一步定位尾节点。tail节点并不总是尾节点,所以每次入队都必须先通过tail节点来找到尾节点,尾节点可能就是tail节点,也可能是tail节点的next节点。代码中循环体中的第一个if就是判断tail是否有next节点,有则表示next节点可能是尾节点。获取tail节点的next节点需要注意的是p节点等于p的next节点的情况,只有一种可能就是p节点和p的next节点都等于空,表示这个队列刚初始化,正准备添加第一次节点,所以需要返回head节点。获取p节点的next节点代码如下
1 | final Node</e><e> succ(Node</e><e> p) { |
3 | Node</e><e> next = p.getNext(); |
5 | return (p == next) ? head : next; |
第二步设置入队节点为尾节点。p.casNext(null, n)方法用于将入队节点设置为当前队列尾节点的next节点,p如果是null表示p是当前队列的尾节点,如果不为null表示有其他线程更新了尾节点,则需要重新获取当前队列的尾节点。
hops的设计意图。上面分析过对于先进先出的队列入队所要做的事情就是将入队节点设置成尾节点,doug lea写的代码和逻辑还是稍微有点复杂。那么我用以下方式来实现行不行?
01 | public boolean offer(E e) { |
05 | throw new NullPointerException(); |
07 | Node</e><e> n = new Node</e><e>(e); |
13 | if (t.casNext( null , n) && casTail(t, n)) { |
让tail节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑非常清楚和易懂。但是这么做有个缺点就是每次都需要使用循环CAS更新tail节点。如果能减少CAS更新tail节点的次数,就能提高入队的效率,所以doug lea使用hops变量来控制并减少tail节点的更新频率,并不是每次节点入队后都将 tail节点更新成尾节点,而是当 tail节点和尾节点的距离大于等于常量HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来看它通过增加对volatile变量的读操作来减少了对volatile变量的写操作,而对volatile变量的写操作开销要远远大于读操作,所以入队效率会有所提升。
1 | private static final int HOPS = 1 ; |
还有一点需要注意的是入队方法永远返回true,所以不要通过返回值判断入队是否成功。
5. 出队列
出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用。让我们通过每个节点出队的快照来观察下head节点的变化。
从上图可知,并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。这种做法也是通过hops变量来减少使用CAS更新head节点的消耗,从而提高出队效率。让我们再通过源码来深入分析下出队过程。
09 | for ( int hops = 0 ;; hops++) { |
17 | if (item != null && p.casItem(item, null )) { |
23 | Node</e><e> q = p.getNext(); |
25 | updateHead(h, (q != null ) ? q : p); |
35 | Node</e><e> next = succ(p); |
首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。