JDK 中基于数组的阻塞队列 ArrayBlockingQueue 原理剖析,ArrayBlockingQueue 内部如何基于一把独占锁以及对应的两个条件变量实现出入队操作的线程安全?

首先我们先大概的浏览一下ArrayBlockingQueue 的内部构造,如下类图:

如类图所示,可以看到ArrayBlockingQueue 内部有个数组items 用来存放队列元素,putIndex变量标示入队元素的下标,takeIndex是出队的下标,count是用来统计队列元素个数,

从定义可以知道,这些属性并没有使用valatile修饰,这是因为访问这些变量的使用都是在锁块内被用。而加锁了,就足以保证了锁块内变量的内存可见性。

另外还有个独占锁lock 用来保证出队入队操作的原子性,这保证了同时只有一个线程可以进行入队出队操作,另外notEmpty,notFull条件变量用来进行出队入队的同步。

由于ArrayBlockingQueue 是有界队列,所以构造函数必须传入队列大小的参数。

接下来我们进入ArrayBlockingQueue的源码看,如下:

  1. public ArrayBlockingQueue(int capacity) {
  2. this(capacity, false);
  3. }
  1. public ArrayBlockingQueue(int capacity, boolean fair) {
  2. if (capacity <= )
  3. throw new IllegalArgumentException();
  4. this.items = new Object[capacity];
  5. lock = new ReentrantLock(fair);
  6. notEmpty = lock.newCondition();
  7. notFull = lock.newCondition();
  8. }

如上面代码所示,默认情况下使用的是ReentrantLock提供的非公平独占锁进行入队出队操作的加锁。

接下来主要看看ArrayBlockingQueue的主要的几个操作的源码,如下:

  1.offer 操作,向队列尾部插入一个元素,如果队列有空闲容量,则插入成功后返回true,如果队列已满则丢弃当前元素,然后返回false,

如果e元素为null,则抛出 NullPointerException 异常,另外该方法是不阻塞的。源码如下:

  1.   public boolean offer(E e) {
  2. //(1)e为null,则抛出NullPointerException异常
  3. checkNotNull(e);
  4. //(2)获取独占锁
  5. final ReentrantLock lock = this.lock;
  6. lock.lock();
  7. try {
  8. //(3)如果队列满则返回false
  9. if (count == items.length)
  10. return false;
  11. else {
  12. //(4)否者插入元素
  13. enqueue(e);
  14. return true;
  15. }
  16. } finally {
  17. lock.unlock();
  18. }
  19. }

代码(2)获取独占锁,当前线程获取到该锁后,其他入队和出队操作的线程都会被阻塞挂起后放入lock锁的AQS阻塞队列。

代码(3)如果队列满则直接返回false,否则调用enqueue方法后返回true,enqueue的源码如下:

  1.   private void enqueue(E x) {
  2. //(6)元素入队
  3. final Object[] items = this.items;
  4. items[putIndex] = x;
  5. //(7)计算下一个元素应该存放的下标
  6. if (++putIndex == items.length)
  7. putIndex = ;
  8. count++;
  9. //(8)
  10. notEmpty.signal();
  11. }

可以看到上面代码首先把当前元素放入items数组,然后计算下一个元素应该存放的下标,然后递增元素个数计数器,最后激活 notEmpty 的条件队列中因为调用 poll 或者 take 操作而被阻塞的的一个线程。

这里由于在操作共享变量,比如count前加了锁,所以不存在内存不可见问题,加过锁后获取的共享变量都是从主存获取的,而不是在CPU缓存获取寄存器里面的值。

代码(5)释放锁,释放锁后会把修改的共享变量值,比如Count的值刷新回主内存中,这样其他线程通过加锁再次读取这些共享变量后就可以看到最新的值。

  2.put操作,向队列尾部插入一个元素,如果队列有空闲则插入后直接返回true,如果队列已满则阻塞当前线程直到队列有空闲插入成功后返回true,

如果在阻塞的时候被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException 异常而返回,另外如果 e 元素为 null 则抛出 NullPointerException 异常。源码如下:

  1. public void put(E e) throws InterruptedException {
  2. //(1)
  3. checkNotNull(e);
  4. final ReentrantLock lock = this.lock;
  5.  
  6. //(2)获取锁(可被中断)
  7. lock.lockInterruptibly();
  8. try {
  9.  
  10. //(3)如果队列满,则把当前线程放入notFull管理的条件队列
  11. while (count == items.length)
  12. notFull.await();
  13.  
  14. //(4)插入元素
  15. enqueue(e);
  16. } finally {
  17. //(5)
  18. lock.unlock();
  19. }
  20. }

代码(2)在获取锁的过程中当前线程被其它线程中断了,则当前线程会抛出 InterruptedException 异常而退出。

代码(3)判断如果当前队列满了,则把当前线程阻塞挂起后放入到 notFull 的条件队列,注意这里是使用了 while 而不是 if。为什么需要while呢?

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

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

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

代码(4)如果队列不满则插入当前元素。

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

  1. public E poll() {
  2. //(1)获取锁
  3. final ReentrantLock lock = this.lock;
  4. lock.lock();
  5. try {
  6. //(2)当前队列为空则返回null,否者调用dequeue()获取
  7. return (count == ) ? null : dequeue();
  8. } finally {
  9. //(3)释放锁
  10. lock.unlock();
  11. }
  12. }

代码(1)获取独占锁

代码(2)如果队列为空则返回 null,否者调用 dequeue() 方法,dequeue 源码如下:

  1. private E dequeue() {
  2. final Object[] items = this.items;
  3.  
  4. //(4)获取元素值
  5. @SuppressWarnings("unchecked")
  6. E x = (E) items[takeIndex];
  7. //(5)数组中值值为null;
  8. items[takeIndex] = null;
  9.  
  10. //(6)队头指针计算,队列元素个数减一
  11. if (++takeIndex == items.length)
  12. takeIndex = ;
  13. count--;
  14.  
  15. //(7)发送信号激活notFull条件队列里面的一个线程
  16. notFull.signal();
  17. return x;
  18. }

如上代码,可以看到首先获取当前队头元素保存到局部变量,然后重置队头元素为null,并重新设置队头下标,元素计数器递减,最后发送信号激活notFull 的条件队列里面一个因为调用 put 或者 offer 而被阻塞的线程。

  4.take 操作,获取当前队列头部元素,并从队列里面移除,如果队列为空则阻塞调用线程。如果队列为空则阻塞当前线程知道队列不为空,然后返回元素,

如果如果在阻塞的时候被其它线程设置了中断标志,则被阻塞线程会抛出 InterruptedException 异常而返回。源码如下:

  1. public E take() throws InterruptedException {
  2. //(1)获取锁
  3. final ReentrantLock lock = this.lock;
  4. lock.lockInterruptibly();
  5. try {
  6.  
  7. //(2)队列为空,则等待,直到队列有元素
  8. while (count == )
  9. notEmpty.await();
  10. //(3)获取队头元素
  11. return dequeue();
  12. } finally {
  13. //(4) 释放锁
  14. lock.unlock();
  15. }
  16. }

可以看到take操作的代码也比较简单,与poll相比,只是步骤(2)如果队列为空,则把当前线程挂起后放入到notEmpty的条件队列,等其他线程调用notEmpty.signal() 方法后在返回,

需要注意的是这里也是使用 while 循环进行检测并等待而不是使用 if。之所以这样做,道理都是一样。这里就不在解释了。

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

  1. public E peek() {
  2. //(1)获取锁
  3. final ReentrantLock lock = this.lock;
  4. lock.lock();
  5. try {
  6. //(2)
  7. return itemAt(takeIndex);
  8. } finally {
  9. //(3)
  10. lock.unlock();
  11. }
  12. }
  13.  
  14. @SuppressWarnings("unchecked")
  15. final E itemAt(int i) {
  16. return (E) items[i];
  17. }

peek的实现更加简单,首先获取独占锁,然后从数组items 中获取当前队头下标的值并返回,在返回之前释放了获取的锁。

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

  1. public int size() {
  2. final ReentrantLock lock = this.lock;
  3. lock.lock();
  4. try {
  5. return count;
  6. } finally {
  7. lock.unlock();
  8. }
  9. }

size 操作是简单的,获取锁后直接返回 count,并在返回前释放锁。也许你会有疑问这里有没有修改Count的值,只是简单的获取下,为何要加锁呢?

答案很简单,如果count声明为volatile,这里就不需要加锁了,因为因为 volatile 类型变量保证了内存的可见性,而 ArrayBlockingQueue 的设计中 count 并没有声明为 volatile,

这是因为count的操作都是在获取锁后进行的,而获取锁的语义之一就是获取锁后访问的变量都是从主内存获取的,这就保证了变量的内存可见性。

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

总结:ArrayBlockingQueue 通过使用全局独占锁实现同时只能有一个线程进行入队或者出队操作,这个锁的粒度比较大,有点类似在方法上添加 synchronized 的意味。ArrayBlockingQueue 的 size 操作的结果是精确的,因为计算前加了全局锁。

二、Logback 框架中异步日志打印中 ArrayBlockingQueue 的使用

在高并发并且响应时间要求比较小的系统中同步打日志已经满足不了需求了,这是因为打日志本身是需要同步写磁盘的,会造成 响应时间 增加,如下图同步日志打印模型为:

异步模型是业务线程把要打印的日志任务写入一个队列后直接返回,然后使用一个线程专门负责从队列中获取日志任务写入磁盘,其模型具体如下图:

如图可知其实 logback 的异步日志模型是一个多生产者单消费者模型,通过使用队列把同步日志打印转换为了异步,业务线程调用异步 appender 只需要把日志任务放入日志队列,日志线程则负责使用同步的 appender 进行具体的日志打印到磁盘。

接下来看看异步日志打印具体实现,要把同步日志打印改为异步需要修改 logback 的 xml 配置文件如下:

  1. <appender name="PROJECT" class="ch.qos.logback.core.FileAppender">
  2. <file>project.log</file>
  3. <encoding>UTF-</encoding>
  4. <append>true</append>
  5.  
  6. <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
  7. <!-- daily rollover -->
  8. <fileNamePattern>project.log.%d{yyyy-MM-dd}</fileNamePattern>
  9. <!-- keep days' worth of history -->
  10. <maxHistory></maxHistory>
  11. </rollingPolicy>
  12. <layout class="ch.qos.logback.classic.PatternLayout">
  13. <pattern>
  14. <![CDATA[%n%-4r [%d{yyyy-MM-dd HH:mm:ss}] %X{productionMode} - %X{method} %X{requestURIWithQueryString} [ip=%X{remoteAddr}, ref=%X{referrer},
  15. ua=%X{userAgent}, sid=%X{cookie.JSESSIONID}]%n %-5level %logger{} - %m%n]]>
  16. </pattern>
  17. </layout>
  18. </appender>
  19.  
  20. <appender name="asyncProject" class="ch.qos.logback.classic.AsyncAppender">
  21. <discardingThreshold></discardingThreshold>
  22. <queueSize></queueSize>
  23. <neverBlock>true</neverBlock>
  24. <appender-ref ref="PROJECT" />
  25. </appender>
  26. <logger name="PROJECT_LOGGER" additivity="false">
  27. <level value="WARN" />
  28. <appender-ref ref="asyncProject" />
  29. </logger>

可知 AsyncAppender 是实现异步日志的关键,下节主要讲这个的内部实现。

异步原理实现

  首先从 AsyncAppender 的类图结构来从全局了解下 AsyncAppender 中组件构成,类图如下所示:

从上面的类图我们可以知道如下两点:

  1.如上图可知 AsyncAppender 继承自 AsyncAppenderBase,其中后者具体实现了异步日志模型的主要功能,前者只是重写了其中的一些方法。另外从上类图可知 logback 中的异步日志队列是一个阻塞队列, 后面会知道其实是一个有界阻塞队列 ArrayBlockingQueue, 其中 queueSize 是有界队列的元素个数默认为 256。

  2.worker 是个线程,也就是异步日志打印模型中的单消费者线程,aai 是一个 appender 的装饰器里面存放同步日志的 appender, 其中 appenderCount 记录 aai 里面附加的同步 appender 的个数;neverBlock 是当日志队列满了的时候是否阻塞打日志的线程的一个开关;discardingThreshold 是一个阈值,当日志队列里面空闲个数小于该值时候新来的某些级别的日志会被直接丢弃,下面会具体讲到。

首先我们来看下何时创建的日志队列以及何时启动的消费线程,这需要看下 AsyncAppenderBase 的 start 方法,该方法是在解析完毕配置 AsyncAppenderBase 的 xml 的节点元素后被调用,源码如下:

  1. public void start() {
  2. ...
  3. //(1)日志队列为有界阻塞队列
  4. blockingQueue = new ArrayBlockingQueue<E>(queueSize);
  5. //(2)如果没设置discardingThreshold则设置为队列大小的1/5
  6. if (discardingThreshold == UNDEFINED)
  7. discardingThreshold = queueSize / ;
  8. //(3)设置消费线程为守护线程,并设置日志名称
  9. worker.setDaemon(true);
  10. worker.setName("AsyncAppender-Worker-" + worker.getName());
  11. //(4)设置启动消费线程
  12. super.start();
  13. worker.start();
  14. }

从上代码可知如下两点:

  1. logback 使用的队列是有界队列 ArrayBlockingQueue,之所以使用有界队列是考虑到内存溢出问题,在高并发下写日志的 qps 会很高如果设置为无界队列队列本身会占用很大内存,很可能会造成 内存溢出。

  2.这里消费日志队列的 worker 线程被设置为了守护线程,意味着当主线程运行结束并且当前没有用户线程时候该 worker 线程会随着 JVM 的退出而终止,而不管日志队列里面是否还有日志任务未被处理。另外这里设置了线程的名称是个很好的习惯,因为这在查找问题的时候很有帮助,根据线程名字就可以定位到是哪个线程。

既然是有界队列那么肯定需要考虑如果队列满了,该如何处置,是丢弃老的日志任务,还是阻塞日志打印线程直到队列有空余元素那?

要回答这个问题,我们需要看看具体进行日志打印的AsyncAppenderBase 的 append 方法,源码如下:

  1. protected void append(E eventObject) {
  2. //(5)调用AsyncAppender重写的isDiscardable
  3. if (isQueueBelowDiscardingThreshold() && isDiscardable(eventObject)) {
  4. return;
  5. }
  6. ...
  7. //(6)放入日志任务到队列
  8. put(eventObject);
  9. }
  10.  
  11. private boolean isQueueBelowDiscardingThreshold() {
  12. return (blockingQueue.remainingCapacity() < discardingThreshold);
  13. }

其中 (5) 调用了 AsyncAppender 重写的 isDiscardable,源码如下:

  1. //(7)
  2. protected boolean isDiscardable(ILoggingEvent event) {
  3. Level level = event.getLevel();
  4. return level.toInt() <= Level.INFO_INT;
  5. }

结合 代码(5)和代码(7) 可知如果当前日志的级别小于 INFO_INT 级别并且当前队列的剩余容量小于 discardingThreshold 时候会直接丢弃这些日志任务。

接下来看具体步骤 (6) 的 put 方法,源码如下:

  1. private void put(E eventObject) {
  2. //(8)
  3. if (neverBlock) {
  4. blockingQueue.offer(eventObject);
  5. } else {
  6. try {//(9)
  7. blockingQueue.put(eventObject);
  8. } catch (InterruptedException e) {
  9. // Interruption of current thread when in doAppend method should not be consumed
  10. // by AsyncAppender
  11. Thread.currentThread().interrupt();
  12. }
  13. }
  14. }

可知如果 neverBlock 设置为了 false(默认为 false)则会调用阻塞队列的 put 方法,而 put 是阻塞的,也就是说如果当前队列满了,如果在企图调用 put 方法向队列放入一个元素则调用线程会被阻塞直到队列有空余空间。这里有必要提下其中第 (9) 步当日志队列满了的时候 put 方法会调用 await() 方法阻塞当前线程,如果其它线程中断了该线程,那么该线程会抛出 InterruptedException 异常,那么当前的日志任务就会被丢弃了。如果 neverBlock 设置为了 true 则会调用阻塞队列的 offer 方法,而该方法是非阻塞的,如果当前队列满了,则会直接返回,也就是丢弃当前日志任务。

最后看下 addAppender 方法内是什么的,源码如下:

  1. public void addAppender(Appender<E> newAppender) {
  2. if (appenderCount == ) {
  3. appenderCount++;
  4. ...
  5. aai.addAppender(newAppender);
  6. } else {
  7. addWarn("One and only one appender may be attached to AsyncAppender.");
  8. addWarn("Ignoring additional appender named [" + newAppender.getName() + "]");
  9. }
  10. }

如上代码可知一个异步 appender 只能绑定一个同步 appender, 这个 appender 会被放到 AppenderAttachableImpl 的 appenderList 列表里面。

到这里我们已经分析完了日志生产线程放入日志任务到日志队列的实现,下面一起来看下消费线程是如何从队列里面消费日志任务并写入磁盘的,由于消费线程是一个线程,那就从 worker 的 run 方法看起,源码如下所示:

  1. class Worker extends Thread {
  2.  
  3. public void run() {
  4.  
  5. AsyncAppenderBase<E> parent = AsyncAppenderBase.this;
  6. AppenderAttachableImpl<E> aai = parent.aai;
  7.  
  8. //(10)一直循环直到该线程被中断
  9. while (parent.isStarted()) {
  10. try {//(11)从阻塞队列获取元素
  11. E e = parent.blockingQueue.take();
  12. aai.appendLoopOnAppenders(e);
  13. } catch (InterruptedException ie) {
  14. break;
  15. }
  16. }
  17.  
  18. //(12)到这里说明该线程被中断,则吧队列里面的剩余日志任务
  19. //刷新到磁盘
  20. for (E e : parent.blockingQueue) {
  21. aai.appendLoopOnAppenders(e);
  22. parent.blockingQueue.remove(e);
  23. }
  24. ...
  25. .. }
    }

其中(11)从日志队列使用 take 方法获取一个日志任务,如果当前队列为空则当前线程会阻塞到 take 方法直到队列不为空才返回,获取到日志任务后会调用 AppenderAttachableImpl 的 aai.appendLoopOnAppenders 方法,该方法会循环调用通过 addAppender 注入的同步日志 appener 具体实现日志打印到磁盘的任务。

Java并发编程笔记之ArrayBlockingQueue源码分析的更多相关文章

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    ReentrantLock是可重入的独占锁,同时只能有一个线程可以获取该锁,其他获取该锁的线程会被阻塞后放入该锁的AQS阻塞队列里面. 首先我们先看一下ReentrantLock的类图结构,如下图所示 ...

随机推荐

  1. SSL、TLS协议格式、HTTPS通信过程、RDP SSL通信过程(缺heartbeat)

    SSL.TLS协议格式.HTTPS通信过程.RDP SSL通信过程   相关学习资料 http://www.360doc.com/content/10/0602/08/1466362_30787868 ...

  2. unbuntu 安装 teamviewer

    下载 teamviewer 安装包 使用 dpkg 安装 deb 安装包 使用 sudo apt-get install -f 解决依赖问题

  3. How to configure Samba Server share on Debian 9 Stretch Linux

    Lubos Rendek Debian 13 June 2017 Contents 1. Objective 2. Operating System and Software Versions 3.  ...

  4. Java集合:LinkedList源码解析

    Java集合---LinkedList源码解析   一.源码解析1. LinkedList类定义2.LinkedList数据结构原理3.私有属性4.构造方法5.元素添加add()及原理6.删除数据re ...

  5. hadoop启动

    安装完hadoop集群之后,第一次启动之前必须初始化,之后就可以不用再初始化(注意:初始化操作只可以一次) hdfs namenode -format (hadoop namenode -format ...

  6. node.js获取参数的常用方法

    1.req.body 2.req.query 3.req.params 一.req.body例子 body不是nodejs默认提供的,你需要载入body-parser中间件才可以使用req.body, ...

  7. sql pivot(行转列) 和unpivot(列转行)的用法

    1.PIVOT用法(行转列) select * from Table_Score as a pivot (sum(score) for a.name in ([语文],[数学],[外语],[文综],[ ...

  8. windows 函数

    数组清零 ::ZeroMemory(首地址,大小);

  9. 子div撑不开父div

    方法一:推荐 设置父div的overflow:hidden; 方法二: 父div结束前增加一个空div style=”clear:both;” .clear { clear:both; } <d ...

  10. 第一个SpringBoot应用

    第一个SpringBoot应用 新建eclipse项目 编写pom文件,配置maven导入的springboot的jar包 <?xml version="1.0" encod ...