java并发编程——LinkedBlockingQueue原理探究

LinkedBlockingQueue原理探究

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

一、类图结构

在这里插入图片描述
        从类图可以看到,LinkedBlockingQueue也是使用单向链表实现的,其也有两个Node,分别用来存放首、尾节点,并且还有一个初始值为0的原子变量count,用来记录队列元素个数。另外还有两个ReentrantLock的实例,分别用来控制元素入队和出队的原子性,其中takeLock用来控制同时只有一个线程可以从队列头获取元素,其他线程必须等待,putLock控制同时只能有一个线程可以获取锁,在队列尾部添加元素,其他线程必须等待。另外,notEmpty好notFull是条件变量,他们内部都有一个条件队列,用来存放进队和出队时被阻塞的线程,其实这就是生产者——消费者模型。如下是独占锁的创建代码。
在这里插入图片描述

  • 当调用线程在LinkedBlockingQueue实例上执行take、poll等操作时需要获取到takeLock锁,从而保证同时只有一个线程可以操作链表头节点。另外由于条件变量notEmpty内部的条件队列的维护使用的是takeLock的锁状态管理机制,所以在调用notEmpty的await和signal方法前调用线程必须先获取到takeLock锁,否则抛出IllegalMonitorStateException异常。notEmpty内部则维护着一个条件队列,当线程获取到takeLock锁后,调用notEmpty的await方法时,调用线程会被阻塞,然后该线程会被放到notEmpty内部的条件队列进行等待,直到有线程调用了notEmpty的signal方法。
  • 在LinkedBlockingQueue实例上执行put、offer等操作时,需要获取到putLock锁,从而保证同时只有一个线程可以操作链表尾节点。同样由于条件变量notFull内部的条件队列的维护使用的时putLock的锁状态管理机制,所以在调用notFull的await和signal方法前调用线程必须先获取到putLock锁,否则会抛出IllegalMonitorStateException异常。notFull内部则维护着一个条件队列,当线程获取到putLock锁后调用notFull的await方法时,调用线程会被阻塞,然后该线程会被放到notFull内部的条件队列进行等待,直到有线程调用了notFull的signal方法。

        如下是LinkedBlockingQueue的无参构造函数代码
在这里插入图片描述
        由该代码可知,默认队列容量为0x7fffffff,用户可以自己指定容量,所以从一定程度上说linkedBlockingQueue是有界阻塞队列。

二、LinkedBlockingQueue原理介绍

1、offer操作

        向队列尾部插入一个元素,如果队列中有空闲则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false。如果e元素为null则抛出NPE异常。另外,该方法是非阻塞的。

在这里插入图片描述
        代码(2)判断如果当前队列已满则丢弃当前元素并返回false。

        代码(3)获取到putLock锁,当前线程获取到该锁后,则其他调用put和offer操作的线程都会被阻塞(阻塞的线程被放到putLock锁的AQS阻塞队列中)。

        代码(4)这里重新判断当前队列是否已满,这是因为在执行代码(2)和获取到putLock锁期间可能其他线程通过put或者offer操作向队列中添加了新元素。重新判断队列确实不满则新元素入队,并递增计数器。

        代妈(5)判断如果新元素入队后队列还有空闲空间,则唤醒notFull条件队列里面因为调用notFull的await操作(比如执行put方法而队列满了的时候)而被阻塞的一个线程,因为队列现在有空闲所以这里可以提前唤醒一个入队线程。

        代码(6)则释放获取的putLock锁,这里要注意,锁的释放一定要在finally里面做,因为即使try块抛出异常了,finally也是会被执行到。另外释放锁后其他因为调用put操作而被阻塞的线程将会有一个获取到该锁。

        代码(7)中的 c == 0说明在执行代码(6)释放锁时队列中只有一个元素,队列里面有元素则执行signalNotEmpty操作,signalNotEmpty的代码如下
在这里插入图片描述
        该方法用来激活notEmpty的条件队列中因为调用notEmpty的await方法(比如调用take方法并且队列为空的时候)而被阻塞的一个线程,这也说明了调用条件变量的方法前要获取对应的锁。
综上可知,offer方法通过使用putLock锁保证了在队尾新增元素操作的原子性。另外,调用条件变量的方法前一定要记得获取对应的锁,并且注意进队时只操作队列链表的尾节点。

2、put操作

        向队列尾部插入一个元素,如果队列中有空闲则插入后直接返回,如果队列已满则阻塞当前线程,直到队列有空闲插入成功后返回。如果在阻塞时被其他线程设置了终端标志,则被阻塞线程会抛出InterruptedException异常而返回。另外,如果e元素为null则抛出NullPointerException异常。

        put操作的代码结构与offer类似。如下
在这里插入图片描述
        在代码(2)中使用putLock.lockInterruptibly()获取独占锁,相比在offer方法中获取独占锁的方法这个方法可以被中断。具体地说就是当前线程在获取锁的过程中,如果被其他线程设置了中断标志则当前线程会抛出InterruptedException异常,所以put操作在获取锁的过程中是可被中断的。

        代码(3)判断如果当前队列已满,则调用notFull的await()方法把当前线程放入notFull的条件队列,当前线程被阻塞挂起后会释放获取到的putLock锁。由于putLock锁被释放了,所以现在其他线程就有机会获取到putLock锁了。

        另外代码(3)判断队列是否为空时为何使用while循环而不是if语句?这是考虑到当前线程被虚假唤醒的问题,也就是其他线程没有调用notFull的signal方法时notFull.await在某种情况下会自动返回。如果使用if语句那么虚假唤醒后执行代码(4)的元素入队操作,并且递增计数器,而这个时候队列已经满了,从而导致队列元素个数大于队列设置的容量,进而导致程序出错。而使用while循环时,假如notFull.await()被虚假唤醒了,那么再次循环检查当前队列是否已满,如果是则再次进行等待。

3、poll操作

        从队列头部获取并移除一个元素,如果队列为空则返回null,该方法是不阻塞的。
在这里插入图片描述
        代码(1)判断队列是否为空,为空则返回null。

        代码(2)获取独占锁takeLock,当前线程获取独占锁后,其他线程在调用poll或者take方法时会被阻塞挂起。

        代码(3)如果队列不为空,则进行出队操作然后递减计数器。这里需要思考,如何保证执行代码3.1时队列不为空,而执行代码3.2时也一定不会空呢?毕竟这不是原子性操作,会不会出现代码3.1判断队列不为空,但是执行代码3.2时队列为空了呢?那么我们看在执行到代码3.2前那些地方会修改count的计数。由于当前线程已经拿到了takeLock锁,所以其他调用poll或者take方法的线程不可能会走到修改count计数的地方。其实这时候如果能走到修改count计数的地方是因为其他线程调用了put和offer操作,由于这两个操作不需要获取takeLock锁而获取的是putLock锁,但是在put和offer操作内部都是增加count计数器值的,所以不会出现上面所说的情况。其实只需要看在那些地方递减了count计数器的值即可,只有递减count计数器的值才会出现上面说的,执行代码3.1时队列不为空,而执行代码3.2时队列为空的情况。我们查看代码,只有在poll、take或者remove操作的地方会递减count计数值,但是这三个方法都需要获取到takeLock锁才能进行操作,而当前线程已经获取到了takeLock锁,所以其他线程没有机会在当前情况下递减count计数值,所以看起来代码3.1、3.2不是原子性的,但是他们是线程安全的。

        代码(4)判断如果 c > 1说明当前线程移除队列里面的一个元素后队列不为空(c是删除元素前队列元素的个数),那么这时候既可以激活因为调用take方法而被阻塞到notEmpty的条件队列里面的一个线程。

        代码(6)说明当前线程移除队头元素前当前队列是满的,移除队头元素后当前队列至少有一个空闲位置,那么这时候就可以调用signalNotFull激活因为调用put方法而被阻塞到notFull条件队列里面的一个线程,signalNotFull的代码如下。
在这里插入图片描述
        poll代码逻辑比较简单,值的注意的是,获取元素时只操作队列的头节点。

4、peek操作

        获取队列头部元素但是不从列表里面移除它,如果队列为空则返回null。该方法是不阻塞的。
在这里插入图片描述
        peek操作的代码也比较简单,这里需要注意的是,代码(3)这里还是需要判断first是否为null,不能直接执行代码(4)。正常情况下执行到代码(2)说明队列不为空,但是代码(1)和(2)不是原子操作的,也就是在执行点(1)判断队列不为空后,在代码(2)获取到锁前有可能其他线程执行了poll或者take操作导致队列变为空。然后当前线程获取锁后,直接执行代码(4)(first.item)会抛出空指针异常。

5、take操作

        获取当前列表头部元素并从列表里面移除它。如果队列 为空则阻塞当前线程直到队列不为空然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。

在这里插入图片描述
        在代码(1)中,当前线程获取到独占锁,其他调用take或者poll操作的线程将会被阻塞挂起。

        代码(2)判断如果队列为空则阻塞挂起当前线程,并把当前线程放入notEmpty的条件队列。

        代码(3)进行出队操作并递减计数。

        代码(4)判断如果c > 1则说明当前队列不为空,那么唤醒notEmpty的条件队列里面的一个因为调用take操作而被阻塞的线程。

        代码(5)释放锁

        代码(6)判断如果c == capacity则说明当前队列至少有一个空闲位置,那么激活条件变量notFull的条件队列里面的一个因为调用put操作而被阻塞的线程。

6、remove操作

        删除队列里面的指定元素,有则删除并返回true,没有则返回false。
在这里插入图片描述
        代码(1)通过fullyLock获取双重锁,获取后,其他线程进行入队或者出队操作时都会被阻塞挂起。
在这里插入图片描述
        代码(2)遍历队列寻找要删除的元素,找不到则直接返回false,找到则执行unlink操作。unlink操作代码如下:
在这里插入图片描述
        删除元素后,如果发现当前队列有空闲空间,则唤醒notFull的条件队列中的一个因为调用put方法而被阻塞的线程。

        代码(5)调用fullyUnlock方法使用与加锁顺序相反的顺序释放双重锁。
在这里插入图片描述

  • 总结
            由于remove方法在删除指定元素前加了两把锁,所以在遍历队列查找指定元素的过程中是线程安全的,并且此时其他调用入队、出队操作的线程全部会被阻塞,另外,获取多个资源锁的顺序与释放的顺序是相反的。

7、size操作

        获取当前队列元素个数
在这里插入图片描述
        由于进行出队、入队操作时的count是加了锁的,所以结果相比ConcurrentLinkedQueue的size方法比较准确。这里考虑为何在ConcurrentLinkedQueue中需要遍历链表来获取size而不使用一个原子变量呢?这是因为使用原子变量保存队列元素个数需要保证入队、出队操作和原子变量操作是原子性操作。而ConcurrentLinkedQueue使用的是CAS无锁算法,所以无法做到这样。

三、总结

        LinkedBlockingQueue的内部是通过单向链表实现的,使用头、尾节点来进行入队、出队操作,也就是入队操作都是队尾节点进行操作,出队操作都是对头节点进行操作。

        如下所示,队头、尾节点的操作分别使用了单独的独占锁从而保证了原子性,所以出队和入队操作是可以同时进行的。另外对头、尾节点的独占锁都配备了一个条件队列,用来存放阻塞的线程,并结合入队、出队操作实现了一个生产消费模型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值