JDK 中基于链表的阻塞队列 LinkedBlockingQueue 原理剖析,LinkedBlockingQueue 内部是如何使用两个独占锁 ReentrantLock 以及对应的条件变量保证多线程先入队出队操作的线程安全?为什么不使用一把锁,使用两把为何能提高并发度?

LinkedBlockingQueue的实现是使用独占锁实现的阻塞队列。首先看一下LinkedBlockingQueue 的类图结构,如下图所示:

如类图所示:LinkedBlockingQueue是使用单向链表实现,有两个Node分别来存放首尾节点,并且里面有个初始值为0 的原子变量count,它用来记录队列元素个数。

另外里面有两个ReentrantLock的实例,分别用来控制元素入队和出队的原子性,其中takeLock用来控制同时只有一个线程可以从队列获取元素,其他线程必须等待,

putLock控制同时只能有一个线程可以获取锁去添加元素,其他线程必须等待。另外notEmpty 和 notFull 是信号量,内部分别有一个条件队列用来存放进队和出队的时候被阻塞的线程,

说白了,这其实就是一个生产者 -  消费者模型。

我们首先看一下独占锁的源码,如下所示:

  1.   /** 执行take, poll等操作时候需要获取该锁 */
  2. private final ReentrantLock takeLock = new ReentrantLock();
  3.  
  4. /** 当队列为空时候执行出队操作(比如take)的线程会被放入这个条件队列进行等待 */
  5. private final Condition notEmpty = takeLock.newCondition();
  6.  
  7. /** 执行put, offer等操作时候需要获取该锁*/
  8. private final ReentrantLock putLock = new ReentrantLock();
  9.  
  10. /**当队列满时候执行进队操作(比如put)的线程会被放入这个条件队列进行等待 */
  11. private final Condition notFull = putLock.newCondition();
  12.  
  13.   /** 当前队列元素个数 */
  14. private final AtomicInteger count = new AtomicInteger();

接着我们要进入LinkedBlockingQueue 无参构造函数,源码如下:

  1. public static final int MAX_VALUE = 0x7fffffff;
  2.  
  3. public LinkedBlockingQueue() {
  4. this(Integer.MAX_VALUE);
  5. }
  6.  
  7. public LinkedBlockingQueue(int capacity) {
  8. if (capacity <= ) throw new IllegalArgumentException();
  9. this.capacity = capacity;
  10. //初始化首尾节点,指向哨兵节点
  11. last = head = new Node<E>(null);
  12.  }

从源码中可以看到,默认队列的容量为0x7fffffff; 用户也可以自己指定容量,所以一定程度上 LinkedBlockingQueue 可以说是有界阻塞队列。

接下来我们主要看LinkedBlockingQueue 的几个主要方法的源码,如下:

  1.offer操作,向队列尾部插入一个元素,如果队列有空闲容量则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false,如果 e元素为null,则抛出空指针异常(NullPointerException ),还有一点就是,该方法是非阻塞的。源码如下:

  1. public boolean offer(E e) {
  2.  
  3. //(1)空元素抛空指针异常
  4. if (e == null) throw new NullPointerException();
  5.  
  6. //(2) 如果当前队列满了则丢弃将要放入的元素,然后返回false
  7. final AtomicInteger count = this.count;
  8. if (count.get() == capacity)
  9. return false;
  10.  
  11. //(3) 构造新节点,获取putLock独占锁
  12. int c = -;
  13. Node<E> node = new Node<E>(e);
  14. final ReentrantLock putLock = this.putLock;
  15. putLock.lock();
  16. try {
  17. //(4)如果队列不满则进队列,并递增元素计数
  18. if (count.get() < capacity) {
  19. enqueue(node);
  20. c = count.getAndIncrement();
  21. //(5)
  22. if (c + < capacity)
  23. notFull.signal();
  24. }
  25. } finally {
  26. //(6)释放锁
  27. putLock.unlock();
  28. }
  29. //(7)
  30. if (c == )
  31. signalNotEmpty();
  32. //(8)
  33. return c >= ;
  34. }
  35.  
  36. private void enqueue(Node<E> node) {
  37. last = last.next = node;
  38. }

代码(2)判断的是如果当前队列已满则丢弃当前元素并返回false。

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

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

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

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

代码(7)c == 0说明在执行代码(6)释放锁的时候队列里面至少有一个元素,队列里面有元素则执行signalNotEmpty,signalNotEmpty的源码如下:

  1. private void signalNotEmpty() {
  2. final ReentrantLock takeLock = this.takeLock;
  3. takeLock.lock();
  4. try {
  5. notEmpty.signal();
  6. } finally {
  7. takeLock.unlock();
  8. }
  9. }

通过上面代码可以看到其作用是激活notEmpty 的条件队列中因为调用notEmpty的await方法(比如调用 take 方法并且队列为空的时候)而被阻塞的一个线程,这里也说明了调用条件变量的方法前,要首先获取对应的锁。

offer的总结:offer方法中通过使用putLock锁保证了在队尾新增元素的原子性和队列元素个数的比较和递增操作的原子性。

  2.put操作,向队列尾部插入一个元素,如果队列有空闲则插入后直接返回true,如果队列已经满则阻塞当前线程知道队列有空闲插入成功后返回true,如果在阻塞的时候被其他线程设置了中断标志,

则被阻塞线程会抛出InterruptedException 异常而返回,另外如果 e 元素为 null 则抛出 NullPointerException 异常。源码如下:

  1.   public void put(E e) throws InterruptedException {
  2. //(1)空元素抛空指针异常
  3. if (e == null) throw new NullPointerException();
  4. //(2) 构建新节点,并获取独占锁putLock
  5. int c = -;
  6. Node<E> node = new Node<E>(e);
  7. final ReentrantLock putLock = this.putLock;
  8. final AtomicInteger count = this.count;
  9. putLock.lockInterruptibly();
  10. try {
  11. //(3)如果队列满则等待
  12. while (count.get() == capacity) {
  13. notFull.await();
  14. }
  15. //(4)进队列并递增计数
  16. enqueue(node);
  17. c = count.getAndIncrement();
  18. //(5)
  19. if (c + < capacity)
  20. notFull.signal();
  21. } finally {
  22. //(6)
  23. putLock.unlock();
  24. }
  25. //(7)
  26. if (c == )
  27. signalNotEmpty();
  28. }

代码(2)中使用 putLock.lockInterruptibly() 获取独占锁,相比 offer 方法中这个获取独占锁方法意味着可以被中断,具体说是当前线程在获取锁的过程中,如果被其它线程设置了中断标志则当前线程会抛出 InterruptedException 异常,

所以put操作在获取 锁过程中是可被中断的。

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

代码(3)判断队列是否为空为何使用 while 循环而不是 if 语句呢?

这是因为考虑到当前线程被虚假唤醒的问题,也就是其它线程没有调用 notFull 的 singal 方法时候,notFull.await() 在某种情况下会自动返回。

如果使用if语句简单判断一下,那么虚假唤醒后会执行代码(4),元素入队,并且递增计数器,而这时候队列已经是满了的,导致队列元素个数大于了队列设置的容量,导致程序出错。

而使用使用 while 循环假如 notFull.await() 被虚假唤醒了,那么循环在检查一下当前队列是否是满的,如果是则再次进行等待。

  3.poll操作,从队列头部获取并移除一个元素,如果队列为空则返回 null,该方法是不阻塞的。源码如下:

  1.   public E poll() {
  2. //(1)队列为空则返回null
  3. final AtomicInteger count = this.count;
  4. if (count.get() == )
  5. return null;
  6. //(2)获取独占锁
  7. E x = null;
  8. int c = -;
  9. final ReentrantLock takeLock = this.takeLock;
  10. takeLock.lock();
  11. try {
  12. //(3)队列不空则出队并递减计数
  13. if (count.get() > ) {//3.1
  14. x = dequeue();//3.2
  15. c = count.getAndDecrement();//3.3
  16. //(4)
  17. if (c > )
  18. notEmpty.signal();
  19. }
  20. } finally {
  21. //(5)
  22. takeLock.unlock();
  23. }
  24. //(6)
  25. if (c == capacity)
  26. signalNotFull();
  27. //(7)返回
  28. return x;
  29. }
  30. private E dequeue() {
  31. Node<E> h = head;
  32. Node<E> first = h.next;
  33. h.next = h; // help GC
  34. head = first;
  35. E x = first.item;
  36. first.item = null;
  37. return x;
  38. }

代码(1) 如果当前队列为空,则直接返回 null。

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

代码 (3) 如果当前队列不为空则进行出队操作,然后递减计数器。

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

代码(5)释放锁,一定要在finally里面释放锁。

代码(6)说明当前线程移除队头元素前当前队列是满的,移除队头元素后队列当前至少有一个空闲位置,那么这时候就可以调用signalNotFull激活因为调用put 或者 offer 而被阻塞放到 notFull 的条件队列里的一个线程,signalNotFull 源码如下:

  1.   private void signalNotFull() {
  2. final ReentrantLock putLock = this.putLock;
  3. putLock.lock();
  4. try {
  5. notFull.signal();
  6. } finally {
  7. putLock.unlock();
  8. }
  9. }

poll 代码逻辑比较简单,值得注意的是获取元素时候只操作了队列的头节点。

  4.peek 操作,获取队列头部元素但是不从队列里面移除,如果队列为空则返回 null,该方法是不阻塞的。源码如下:

  1.   public E peek() {
  2. //(1)
  3. if (count.get() == )
  4. return null;
  5. //(2)
  6. final ReentrantLock takeLock = this.takeLock;
  7. takeLock.lock();
  8. try {
  9. Node<E> first = head.next;
  10. //(3)
  11. if (first == null)
  12. return null;
  13. else
  14. //(4)
  15. return first.item;
  16. } finally {
  17. //(5)
  18. takeLock.unlock();
  19. }
  20. }

可以看到代码(3)这里还是需要判断下 first 是否为 null 的,不能直接执行代码(4)。

正常情况下执行到代码(2)说明队列不为空,但是代码(1)和(2)不是原子性操作,也就是在执行代码(1)判断队列不为空后,

在代码(2)获取到锁前,有可能其他线程执行了poll 或者 take 操作导致队列变为了空,然后当前线程获取锁后,直接执行 first.item 会抛出空指针异常。

  5.take 操作,获取当前队列头部元素并从队列里面移除,如果队列为空则阻塞调用线程。如果队列为空则阻塞当前线程知道队列不为空,然后返回元素,如果在阻塞的时候被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException 异常而返回。源码如下:

  1.   public E take() throws InterruptedException {
  2. E x;
  3. int c = -;
  4. final AtomicInteger count = this.count;
  5. //(1)获取锁
  6. final ReentrantLock takeLock = this.takeLock;
  7. takeLock.lockInterruptibly();
  8. try {
  9. //(2)当前队列为空则阻塞挂起
  10. while (count.get() == ) {
  11. notEmpty.await();
  12. }
  13. //(3)出队并递减计数
  14. x = dequeue();
  15. c = count.getAndDecrement();
  16. //(4)
  17. if (c > )
  18. notEmpty.signal();
  19. } finally {
  20. //(5)
  21. takeLock.unlock();
  22. }
  23. //(6)
  24. if (c == capacity)
  25. signalNotFull();
  26. //(7)
  27. return x;
  28. }

代码(1)当前线程获取到独占锁,其他调用take 或者 poll的线程将会被阻塞挂起。

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

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

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

代码(5)释放锁。

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

  6.remove操作,删除队列里面指定元素,有则删除返回 true,没有则返回 false,源码如下:

  1. public boolean remove(Object o) {
  2. if (o == null) return false;
  3.  
  4. //(1)双重加锁
  5. fullyLock();
  6. try {
  7.  
  8. //(2)遍历队列找则删除返回true
  9. for (Node<E> trail = head, p = trail.next;
  10. p != null;
  11. trail = p, p = p.next) {
  12. //(3)
  13. if (o.equals(p.item)) {
  14. unlink(p, trail);
  15. return true;
  16. }
  17. }
  18. //(4)找不到返回false
  19. return false;
  20. } finally {
  21. //(5)解锁
  22. fullyUnlock();
  23. }
  24. }

代码(1)通过fullyLock获取双重锁,当前线程获取后,其他线程进行入队或者出队的操作就会被阻塞挂起。双重锁方法fullyLock的源码如下:

  1. void fullyLock() {
  2. putLock.lock();
  3. takeLock.lock();
  4. }

代码(2)遍历队列寻找要删除的元素,找不到则直接返回false,找到则执行unlink操作,unlink的源码如下:

  1.   void unlink(Node<E> p, Node<E> trail) {
  2. p.item = null;
  3. trail.next = p.next;
  4. if (last == p)
  5. last = trail;
  6. 如果当前队列满,删除后,也不忘记唤醒等待的线程
  7. if (count.getAndDecrement() == capacity)
  8. notFull.signal();
  9. }

可以看到删除元素后,如果发现当前队列有空闲空间,则唤醒 notFull 的条件队列中一个因为调 用 put 或者 offer 方法而被阻塞的线程。

代码(5)调用 fullyUnlock 方法使用与加锁顺序相反的顺序释放双重锁,源码如下:

  1. void fullyUnlock() {
  2. takeLock.unlock();
  3. putLock.unlock();
  4. }

  7.size操作,获取当前队列元素个数。源码如下:

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

总结:由于在操作出队入队的时候操作Count的时候加了锁,因此相比ConcurrentLinkedQueue 的size方法比较准确。

最后用一张图来加深LinkedBlockingQueue的理解,如下图:

因此我们要思考一个问题:为何 ConcurrentLinkedQueue 中需要遍历链表来获取 size 而不适用一个原子变量呢?

这是因为使用原子变量保存队列元素个数需要保证入队出队操作和操作原子变量是原子操作,而ConcurrentLinkedQueue 是使用 CAS 无锁算法的,所以无法做到这个。

Java并发编程笔记之LinkedBlockingQueue源码探究的更多相关文章

  1. Java并发编程笔记之ConcurrentLinkedQueue源码探究

    JDK 中基于链表的非阻塞无界队列 ConcurrentLinkedQueue 原理剖析,ConcurrentLinkedQueue 内部是如何使用 CAS 非阻塞算法来保证多线程下入队出队操作的线程 ...

  2. Java并发编程笔记之CopyOnWriteArrayList源码分析

    并发包中并发List只有CopyOnWriteArrayList这一个,CopyOnWriteArrayList是一个线程安全的ArrayList,对其进行修改操作和元素迭代操作都是在底层创建一个拷贝 ...

  3. Java并发编程笔记之ThreadLocalRandom源码分析

    JDK 并发包中 ThreadLocalRandom 类原理剖析,经常使用的随机数生成器 Random 类的原理是什么?及其局限性是什么?ThreadLocalRandom 是如何利用 ThreadL ...

  4. Java并发编程笔记之ThreadLocal源码分析

    多线程的线程安全问题是微妙而且出乎意料的,因为在没有进行适当同步的情况下多线程中各个操作的顺序是不可预期的,多线程访问同一个共享变量特别容易出现并发问题,特别是多个线程需要对一个共享变量进行写入时候, ...

  5. Java并发编程笔记之FutureTask源码分析

    FutureTask可用于异步获取执行结果或取消执行任务的场景.通过传入Runnable或者Callable的任务给FutureTask,直接调用其run方法或者放入线程池执行,之后可以在外部通过Fu ...

  6. Java并发编程笔记之SimpleDateFormat源码分析

    SimpleDateFormat 是 Java 提供的一个格式化和解析日期的工具类,日常开发中应该经常会用到,但是由于它是线程不安全的,多线程公用一个 SimpleDateFormat 实例对日期进行 ...

  7. Java并发编程笔记之Timer源码分析

    timer在JDK里面,是很早的一个API了.具有延时的,并具有周期性的任务,在newScheduledThreadPool出来之前我们一般会用Timer和TimerTask来做,但是Timer存在一 ...

  8. Java并发编程笔记之CyclicBarrier源码分析

    JUC 中 回环屏障 CyclicBarrier 的使用与分析,它也可以实现像 CountDownLatch 一样让一组线程全部到达一个状态后再全部同时执行,但是 CyclicBarrier 可以被复 ...

  9. Java并发编程笔记之PriorityBlockingQueue源码分析

    JDK 中无界优先级队列PriorityBlockingQueue 内部使用堆算法保证每次出队都是优先级最高的元素,元素入队时候是如何建堆的,元素出队后如何调整堆的平衡的? PriorityBlock ...

随机推荐

  1. Linux驱动之输入子系统简析

    输入子系统由驱动层.输入子系统核心.事件处理层三部分组成.一个输入事件,如鼠标移动.键盘按下等通过Driver->Inputcore->Event handler->userspac ...

  2. Robot Framework浏览器驱动下载

    运行robot framework 有时打不开浏览器,可能用到的驱动不对,以下是各浏览器驱动下载,仅供参考!~ 各浏览器下载地址: Firefox浏览器驱动:geckodriver    https: ...

  3. tween 缓动动画

    在讲tween类之前,不得不提的是贝塞尔曲线了.首先,贝塞尔曲线是指依据四个位置任意的点坐标绘制出的一条光滑曲线.它在作图工具或动画中中运用得比较多,例如PS中的钢笔工具,firework中的画笔等等 ...

  4. shell脚本编写informix数据库中表的导入和导出

    表的导入: 第一行:是指此脚本使用/bin/bash来解释执行. 第四行:定义一个list,里面存放表的名称,之间用空格隔开. 第七行:dbaccess tofpe(数据库名) <<EOF ...

  5. zeromq学习记录(四)使用ZMQ_ROUTER ZMQ_DEALER

    /************************************************************** 技术博客 http://www.cnblogs.com/itdef/   ...

  6. frp使用笔记

    参考文档: https://github.com/fatedier/frp/blob/master/README_zh.md#%E9%80%9A%E8%BF%87-frpc-%E6%89%80%E5% ...

  7. Zookeeper系列2 原生API 以及核心特性watcher

    原生API 增删改查询 public class ZkBaseTest { static final String CONNECT_ADDR = "192.168.0.120"; ...

  8. CCNA笔记

    *****************交换机********************一:交换机:具有多个交换端口,专用集成电路技术使得交换器以线路速率在所有的端口并行转发数据,有很高的总体吞吐率;虚拟网V ...

  9. 长方体类Java编程题

    1. 编程创建一个Box类(长方体),在Box类中定义三个变量,分别表示长方体的长(length).宽(width)和高(heigth),再定义一个方法void setBox(int l, int w ...

  10. iOS 抓包

    通过tcpdump对iOS进行流量分析(无需越狱 iOS Packet Tracing 将 iOS 设备通过 USB 连接到 Mac 打开 terminal rvictl -s $UDID 运行 tc ...