并发队列-无界阻塞队列LinkedBlockingQueue原理探究

一、前言

前面介绍了使用CAS实现的非阻塞队列ConcurrentLinkedQueue,下面就来介绍下使用独占锁实现的阻塞队列LinkedBlockingQueue的实现

阿里巴巴长期招聘Java研发工程师p6,p7,p8等上不封顶级别,有意向的可以发简历给我,注明想去的部门和工作地点:1064454834@qq.com

二、 LinkedBlockingQueue类图结构

image.png

如图LinkedBlockingQueue中也有两个Node分别用来存放首尾节点,并且里面有个初始值为0的原子变量count用来记录队列元素个数,另外里面有两个ReentrantLock的独占锁,分别用来控制元素入队和出队加锁,其中takeLock用来控制同时只有一个线程可以从队列获取元素,其他线程必须等待,putLock控制同时只能有一个线程可以获取锁去添加元素,其他线程必须等待。另外notEmpty和notFull用来实现入队和出队的同步。 另外由于出入队是两个非公平独占锁,所以可以同时又一个线程入队和一个线程出队,其实这个是个生产者-消费者模型。

/** 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();

/** Current number of elements */
private final AtomicInteger count = new AtomicInteger(0);

public static final int   MAX_VALUE = 0x7fffffff;

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);
}

如图默认队列容量为0x7fffffff;用户也可以自己指定容量。

三、必备基础

3.1 ReentrantLock

可以参考 https://www.atatech.org/articles/80539?flag_data_from=active

3.2 条件变量(Condition)

条件变量这里使用的是takeLock.newCondition()获取也就是说调用ReentrantLock的方法获取的,那么可预见Condition使用了ReentrantLock的state。上面的参考没有提到所以这里串串讲下

  • 首先看下类图结构
image.png

如图ConditionObject中两个node分别用来存放条件队列的首尾节点,条件队列就是调用条件变量的await方法被阻塞后的节点组成的单向链表。另外ConditionObject还要依赖AQS的state,ConditionObject是AQS类的一个内部类。

  • awaitNanos操作
public final long awaitNanos(long nanosTimeout)
        throws InterruptedException {

    //如果中断标志被设置了,则抛异常
    if (Thread.interrupted())
        throw new InterruptedException();

    //添加当前线程节点到条件队列,
    Node node = addConditionWaiter();

    //当前线程释放独占锁
    int savedState = fullyRelease(node);
    long lastTime = System.nanoTime();
    int interruptMode = 0;

    while (!isOnSyncQueue(node)) {
        if (nanosTimeout <= 0L) {
            transferAfterCancelledWait(node);
            break;
        }
        //挂起当前线程直到超时
        LockSupport.parkNanos(this, nanosTimeout);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;

        long now = System.nanoTime();
        nanosTimeout -= now - lastTime;
        lastTime = now;
    }

    //unpark后,当前线程重新获取锁,有可能获取不到被放到AQS的队列
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null)
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
    return nanosTimeout - (System.nanoTime() - lastTime);
}


    final int fullyRelease(Node node) {
        boolean failed = true;
        try {
            int savedState = getState();

            //释放锁,如果失败则抛异常
            if (release(savedState)) {
                failed = false;
                return savedState;
            } else {
                throw new IllegalMonitorStateException();
            }
        } finally {
            if (failed)
                node.waitStatus = Node.CANCELLED;
        }
    }

首先如果当前线程中断标志被设置了,直接抛出异常。添加当前线程节点(状态为:-2)到条件队列。

然后尝试释放当前线程拥有的锁并保存当前计数,可知如果当前线程调用awaitNano前没有使用当前条件变量所在的Reetenlock变量调用lock或者lockInterruptibly获取到锁,会抛出IllegalMonitorStateException异常。

然后调用park挂起当前线程直到超时或者其他线程调用了当前线程的unpark方法,或者调用了当前线程的interupt方法(这时候会抛异常)。

如果超时或者其他线程调用了当前线程的unpark方法,则当前线程从挂起变为激活,获取cpu资源后会继续执行,会重新获取锁。

  • signal操作

public final void signal() {

    //如果当前线程没有持有锁,抛异常
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();

    //从条件队列找第一个状态为CONDITION的,然后把状态变为0
    Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

private void doSignal(Node first) {
    do {
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

final boolean transferForSignal(Node node) {
   

    //状态为CONDITION的,然后把状态变为0
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;
    

    //把条件队列的上面状态为0的节点放入AQS阻塞队列
    Node p = enq(node);
    int ws = p.waitStatus;

    //调用unpark激活挂起的线程
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        LockSupport.unpark(node.thread);
    return true;
}

首先看调用signal的线程是不是持有了独占锁,没有则抛出异常。
然后获取在条件队列里面待的时间最长的node,把它移动到线程持有的锁所在的AQS队列。

其中enq方法就是把当前节点放入了AQS队列,但是这时候该节点还是在条件队列里面那,那么什么时候从条件队列移除那?其实在await里面的unlinkCancelledWaiters方法。

总结: 无论是条件变量的await和singal都是需要先获取独占锁才能调用,因为条件变量使用的就是独占锁里面的state管理状态,否者会报异常。

四 、带超时时间的offer操作-生产者

在队尾添加元素,如果队列满了,那么等待timeout时候,如果时间超时则返回false,如果在超时前队列有空余空间,则插入后返回true。

public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {

    //空元素抛空指针异常
    if (e == null) throw new NullPointerException();
    long nanos = unit.toNanos(timeout);
    int c = -1;
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;

    //获取可被中断锁,只有一个线程克获取
    putLock.lockInterruptibly();
    try {

        //如果队列满则进入循环
        while (count.get() == capacity) {
            //nanos<=0直接返回
            if (nanos <= 0)
                return false;
            //否者调用await进行等待,超时则返回<=0(1)
            nanos = notFull.awaitNanos(nanos);
        }
        //await在超时时间内返回则添加元素(2)
        enqueue(new Node<E>(e));
        c = count.getAndIncrement();

        //队列不满则激活其他等待入队线程(3)
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        //释放锁
        putLock.unlock();
    }

    //c==0说明队列里面有一个元素,这时候唤醒出队线程(4)
    if (c == 0)
        signalNotEmpty();
    return true;
}

private void enqueue(Node<E> node) {   
    last = last.next = node;
}

    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }

如果获取锁前面有线程调用了putLock. interrupt(),并且后面没有调用interrupted()重置中断标志,调用lockInterruptibly时候会抛出InterruptedException异常。

队列满的时候调用notFull.awaitNanos阻塞当前线程,当前线程会释放获取的锁,然后等待超时或者其他线程调用了notFull.signal()才会返回并重新获取锁,或者其他线程调用了该线程的interrupt方法设置了中断标志,这时候也会返回但是会抛出InterruptedException异常。

如果超时则直接返回false,如果超时前调用了notFull.signal()则会退出循环,执行(2)添加元素到队列,然后执行(3),(3)的目的是为了激活其他入队等待线程。(4)的话c==0说明队列里面已经有一个元素了,这时候就可以激活等待出队线程了。

另外signalNotEmpty函数是先获取独占锁,然后在调用的signal这也证明了3.2节的结论。

五、 带超时时间的poll操作-消费者

获取并移除队首元素,在指定的时间内去轮询队列看有没有首元素有则返回,否者超时后返回null

public E poll(long timeout, TimeUnit unit) throws InterruptedException {
    E x = null;
    int c = -1;
    long nanos = unit.toNanos(timeout);
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;

    //出队线程获取独占锁
    takeLock.lockInterruptibly();
    try {

        //循环直到队列不为空
        while (count.get() == 0) {

            //超时直接返回null
            if (nanos <= 0)
                return null;
            nanos = notEmpty.awaitNanos(nanos);
        }

        //出队,计数器减一
        x = dequeue();
        c = count.getAndDecrement();

        //如果出队前队列不为空则发送信号,激活其他阻塞的出队线程
        if (c > 1)
            notEmpty.signal();
    } finally {
        //释放锁
        takeLock.unlock();
    }

    //当前队列容量为最大值-1则激活入队线程。
    if (c == capacity)
        signalNotFull();
    return x;
}

首先获取独占锁,然后进入循环当当前队列有元素才会退出循环,或者超时了,直接返回null。

超时前退出循环后,就从队列移除元素,然后计数器减去一,如果减去1前队列元素大于1则说明当前移除后队列还有元素,那么就发信号激活其他可能阻塞到当前条件信号的线程。

最后如果减去1前队列元素个数=最大值,那么移除一个后会腾出一个空间来,这时候可以激活可能存在的入队阻塞线程。

六、put操作-生产者

与带超时时间的poll类似不同在于put时候如果当前队列满了它会一直等待其他线程调用notFull.signal才会被唤醒。

七、 take操作-消费者

与带超时时间的poll类似不同在于take时候如果当前队列空了它会一直等待其他线程调用notEmpty.signal()才会被唤醒。

八、 size操作

当前队列元素个数,如代码直接使用原子变量count获取。

    public int size() {
        return count.get();
    }

九、peek操作

获取但是不移除当前队列的头元素,没有则返回null


    public E peek() {
        //队列空,则返回null
        if (count.get() == 0)
            return null;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            Node<E> first = head.next;
            if (first == null)
                return null;
            else
                return first.item;
        } finally {
            takeLock.unlock();
        }
    }

十、 remove操作

删除队列里面的一个元素,有则删除返回true,没有则返回false,在删除操作时候由于要遍历队列所以加了双重锁,也就是在删除过程中不允许入队也不允许出队操作

public boolean remove(Object o) {
    if (o == null) return false;

    //双重加锁
    fullyLock();
    try {

        //遍历队列找则删除返回true
        for (Node<E> trail = head, p = trail.next;
             p != null;
             trail = p, p = p.next) {
            if (o.equals(p.item)) {
                unlink(p, trail);
                return true;
            }
        }
        //找不到返回false
        return false;
    } finally {
        //解锁
        fullyUnlock();
    }
}

void fullyLock() {
    putLock.lock();
    takeLock.lock();
}

void fullyUnlock() {
    takeLock.unlock();
    putLock.unlock();
}

void unlink(Node<E> p, Node<E> trail) {
    
    p.item = null;
    trail.next = p.next;
    if (last == p)
        last = trail;
    //如果当前队列满,删除后,也不忘记最快的唤醒等待的线程
    if (count.getAndDecrement() == capacity)
        notFull.signal();
}

十一、开源框架中使用

tomcat中任务队列TaskQueue

11.1 类图结构

image.png

可知TaskQueue继承了LinkedBlockingQueue并且泛化类型固定了为Runnalbe.重写了offer,poll,take方法。

11.2 TaskQueue

tomcat中有个线程池ThreadPoolExecutor,在NIOEndPoint中当acceptor线程接受到请求后,会把任务放入队列,然后poller 线程从队列里面获取任务,然后就吧任务放入线程池执行。这个ThreadPoolExecutor中的的一个参数就是TaskQueue。

先看看ThreadPoolExecutor的参数如果是普通LinkedBlockingQueue是怎么样的执行逻辑:
当调用线程池方法 execute() 方法添加一个任务时:

  • 如果当前运行的线程数量小于 corePoolSize,则创建新线程运行该任务
  • 如果当前运行的线程数量大于或等于 corePoolSize,则将这个任务放入阻塞队列。
  • 如果当前队列满了,并且当前运行的线程数量小于 maximumPoolSize,则创建新线程运行该任务;
  • 如果当前队列满了,并且当前运行的线程数量大于或等于 maximumPoolSize,那么线程池将会抛出RejectedExecutionException异常。
    如果线程执行完了当前任务,那么会去队列里面获取一个任务来执行,如果任务执行完了,并且当前线程数大于corePoolSize,那么会根据线程空闲时间keepAliveTime回收一些线程保持线程池corePoolSize个线程。

首先看下线程池中exectue添加任务时候的逻辑:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
 
    //当前工作线程个数小于core个数则开新线程执行(1)
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //放入队列(2)
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }

    //如果队列满了则开新线程,但是个数要不超过最大值,超过则返回false
    //然后执行reject handler(3)
    else if (!addWorker(command, false))
        reject(command);
}

可知当当前工作线程个数为corePoolSize后,如果在来任务会把任务添加到队列,队列满了或者入队失败了则开启新线程。

然后看看TaskQueue中重写的offer方法的逻辑:

public boolean offer(Runnable o) {
    // 如果parent为null则直接调用父类方法
    if (parent==null) return super.offer(o);
    //如果当前线程池中线程个数达到最大,则无条件调用父类方法
    if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o);
    //如果当前提交的任务小于当前线程池线程数,说明线程用不完,没必要重新开线程
    if (parent.getSubmittedCount()<(parent.getPoolSize())) return super.offer(o);
    //如果当前线程池线程个数>core个数但是小于最大个数,则开新线程代替放入队列
    if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false;
    //到了这里,无条件调用父类
    return super.offer(o);
}

可知parent.getPoolSize()<parent.getMaximumPoolSize()普通队列会把当前任务放入队列,TAskQueue则是返回false,因为这会开启新线程执行任务,当然前提是当前线程个数没有达到最大值。

然后看下Worker线程中如果从队列里面获取任务执行的:

 final void runWorker(Worker w) {
               ...
        try {
            while (task != null || (task = getTask()) != null) {
               ...
            }
            completedAbruptly = false;
        } finally {
              ...
        }
}

private Runnable getTask() {
        boolean timedOut = false; // Did the last poll() time out?

        for (;;) {
            int c = ctl.get();
            int rs = runStateOf(c);
            ...

            int wc = workerCountOf(c);

            ...

            try {
                //根据timed决定调用poll还是take
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
}

十二、总结

12.1 并发安全总结

仔细思考下阻塞队列是如何实现并发安全的维护队列链表的,先分析下简单的情况就是当队列里面有多个元素时候,由于同时只有一个线程(通过独占锁putLock实现)入队元素并且是操作last节点(,而同时只有一个出队线程(通过独占锁takeLock实现)操作head节点,所以不存在并发安全问题。

image.png
  • 考虑当队列为空的时候队列状态为:
image.png

这时候假如一个线程调用了take方法,由于队列为空,所以count.get()==0所以当前线程会调用notEmpty.await()把自己挂起,并且放入notEmpty的条件队列,并且释放当前条件变量关联的通过takeLock.lockInterruptibly()获取的独占锁。由于释放了锁,所以这时候其他线程调用take时候就会通过takeLock.lockInterruptibly()获取独占锁,然后同样阻塞到notEmpty.await(),同样会被放入notEmpty的条件队列,也就说在队列为空的情况下可能会有多个线程因为调用take被放入了notEmpty的条件队列。

这时候如果有一个线程调用了put方法,那么就会调用enqueue操作,该操作会在last节点后面添加新元素并且设置last为新节点。然后count.getAndIncrement()先获取当前队列元个数为0保存到c,然后自增count为1,由于c==0所以调用signalNotEmpty激活notEmpty的条件队列里面的阻塞时间最长的线程,这时候take中调用notEmpty.await()的线程会被激活await内部会重新去获取独占锁获取成功则返回,否者被放入AQS的阻塞队列,如果获取成功,那么count.get() >0因为可能多个线程put了,所以调用dequeue从队列获取元素(这时候一定可以获取到),然后调用c = count.getAndDecrement() 把当前计数返回后并减去1,如果c>1 说明当前队列还有其他元素,那么就调用 notEmpty.signal()去激活 notEmpty的条件队列里面的其他阻塞线程。

  • 考虑当队列满的时候:
    当队列满的时候调用put方法时候,会由于notFull.await()当前线程被阻塞放入notFull管理的条件队列里面,同理可能会有多个调用put方法的线程都放到了notFull的条件队列里面。

这时候如果有一个线程调用了take方法,调用dequeue()出队一个元素,c = count.getAndDecrement();count值减一;c==capacity;现在队列有一个空的位置,所以调用signalNotFull()激活notFull条件队列里面等待最久的一个线程。

12.2简单对比

LinkedBlockingQueue与ConcurrentLinkedQueue相比前者前者是阻塞队列使用可重入独占的非公平锁来实现通过使用put锁和take锁使得入队和出队解耦可以同时进行处理,但是同时只有一个线程可以入队或者出队,其他线程必须等待,另外引入了条件变量来进行入队和出队的同步,每个条件变量维护一个条件队列用来存放阻塞的线程,要注意这个队列和AQS的队列不是一个东东。LinkedBlockingQueue的size操作通过使用原子变量count获取能够比较精确的获取当前队列的元素个数,另外remove方法使用双锁保证删除时候队列元素保持不变,另外其实这个是个生产者-消费者模型。

而ConcurrentLinkedQueue则使用CAS非阻塞算法来实现,使用CAS原子操作保证链表构建的安全性,当多个线程并发时候CAS失败的线程不会被阻塞,而是使用cpu资源去轮询CAS直到成功,size方法先比LinkedBlockingQueue的获取的个数是不精确的,因为获取size的时候是通过遍历队列进行的,而遍历过程中可能进行增加删除操作,remove方法操作时候也没有对整个队列加锁,remove时候可能进行增加删除操作,这就可能删除了一个刚刚新增的元素,而不是删除的想要位置的。

欢迎关注微信公众号:‘技术原始积累’ 获取更多技术干货__

image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容