在高并发、高流量并且响应时间要求比较小的系统中同步打印日志已经满足不了需求了,这是因为打印日志本身是需要写磁盘的,写磁盘的操作会暂时阻塞调用打印日志的业务线程,这会造成调用线程的rt增加。
如图所示为同步日志打印模型。
同步日志打印模型的缺点是将日志写入磁盘的操作是业务线程同步调用完成的,那么是否可以让业务线程把要打印的日志任务放入一个队列后直接返回,然后使用一个线程专门负责从队列中获取日志任务并将其写入磁盘呢?这样的话,业务线程打印日志的耗时就仅仅是把日志任务放入队列的耗时了,其实这就是logback提供的异步日志打印模型要做的事,具体如图所示。
其实logback的异步日志模型是一个多生产者-单消费者模型,其通过使用队列把同步日志打印转换为了异步,业务线程只需要通过调用异步appender把日志任务放入日志队列,而日志线程则负责使用同步的appender进行具体的日志打印。
日志打印线程只需要负责生产日志并将其放入队列,而不需要关心消费线程何时把日志具体写入磁盘。
AsyncAppender 继承自 AsyncAppenderBase,其中后者具体实现了异步日志模型的主要功能,前者只是重写了其中的一些方法。
由该图可知,logback 中的异步日志队列是一个阻塞队列,其实就是有界阻塞队列ArrayBlockingQueue,其中 queueSize表示有界队列的元素个数,默认为256个。
worker是个线程,也就是异步日志打印模型中的单消费者线程。
aai 是一个 appender的装饰器,里面存放同步日志的appender,其中 appenderCount 记录 aai里面附加的同步 appender 的个数。
neverBlock用来指示当日志队列满时是否阻塞打印日志的线程。
discardingThreshold 是一个阈值,当日志队列里面的空闲元素个数小于该值时,新来的某些级别的日志会被直接丢弃,下面会具体讲。
首先我们来看何时创建日志队列,以及何时 启动消费线程,这需要看AsyncAppenderBase 的 start方法。该方法在解析完配置 AsyncAppenderBase 的 xml 的节点元素后被调用。
由以上代码可知,logback使用的是有界队列 ArrayBlockingQueue,之所以使用有界队列是考虑内存溢出问题。
在高并发下写日志的QPS会很高,如果设置为无界队列,队列本身会占用很大的内存,很可能会造成OOM。
这里消费日志队列的 worker线程被设置为守护线程,这意味着当主线程运行结束并且当前没有用户线程时,该worker线程会随着JVM的退出而终止,而不管日志队列里面是否还有日志任务未被处理。
另外,这里设置了线程的名称,这是个很好的习惯,因为在查找问题时会很有帮助,根据线程名字就可以定位线程。
既然是有界队列,那么肯定需要考虑队列满的问题,是丢弃老的日志任务,还是阻塞日志打印线程直到队列有空余元素呢?要回答这个问题,我们需要看看具体进行日志打印的AsyncAppenderBase的append方法。
其中代码(5)调用了AsyncAppender重写的isDiscardable方法,该方法的具体内容为
结合代码(5)和代码(7)可知,如果当前日志的级别小于等于INFO_INT并且当前队列的剩余容量小于discardingThreshold则会直接丢弃这些日志任务。
下面看具体代码(6)中的put方法。
如果neverBlock被设置为false(默认为false)则会调用阻塞队列的put方法,而put是阻塞的,也就是说如果当前队列满,则在调用put方法向队列放入一个元素时调用线程会被阻塞直到队列有空余空间。
这里可以看下put方法的实现。
这里有必要解释下代码(9),当日志队列满时put方法会调用await()方法阻塞当前线程,而如果其他线程中断了该线程,那么该线程会抛出InterruptedException异常,并且当前的日志任务就会被丢弃。
在logback-classic的1.2.3版本中,则添加了不对中断进行响应的方法。
如果当前日志打印线程在调用blockingQueue.put时被其他线程中断,则只是记录中断标志,然后继续循环调用blockingQueue.put,尝试把日志任务放入日志队列。
新版本的这个实现通过使用循环保证了即使当前线程被中断,日志任务最终也会被放入日志队列。
如果neverBlock被设置为true则会调用阻塞队列的offer方法,而该方法是非阻塞的,所以如果当前队列满,则会直接返回,也就是丢弃当前日志任务。这里回顾下offer方法的实现。
最后来看addAppender方法都做了什么。
由如上代码可知,一个异步appender只能绑定一个同步appender。这个appender会被放到AppenderAttachableImpl的appenderList列表里面。
到这里我们已经分析完了日志生产线程把日志任务放入日志队列的实现,下面一起来看消费线程是如何从队列里面消费日志任务并将其写入磁盘的。
由于消费线程是一个线程,所以就从worker的run方法开始。