今天线上业务因为日志打印问题,影响了服务的稳定性,因此了解了下log4j2的日志打印的异步模式。本文先提供下配置异步模式的方法,然后跟踪源码,看下log4j2是如何实现异步日志打印的,然后说明一些配置异步模式过程中容易弄错的点以及原因,最后总结下流程。通过和作者一起debug跟踪代码,大家可以了解到一些debug查看源码的思路,并且可以了解log4j2实现的关键节点和主要流程。
日志打印模式
首先需要说明的是log4j2组件中有几种日志打印模式,同步模式,异步模式,以及无锁异步模式。
- 同步模式是默认的日志打印模式,他是在处理业务的线程上执行日志打印逻辑,当业务线程执行到 log.info() 这种打印日志代码时,需要执行日志打印代码,将日志输出完成后,才能往后执行业务逻辑,因此会导致业务处理上有一定延迟
- 异步模式通过ArrayBlockingQueue队列,让业务线程往队列中存日志数据,然后专门独立跑一个线程用于执行日志写入逻辑,因此可以解放业务线程,不需要等待日志写入完成就可以执行后续业务逻辑,不过当多个线程写入数据时,是需要获取队列的锁的,因此对业务线程也有一定影响。
- 无锁异步模式是通过使用Disruptor技术,一个无锁的线程间通信库,通过使用CAS技术来最大化的降低多线程写入时的同步延时。
我们这里要讨论的也就是无锁异步模式,其他模式感兴趣的可以在log4j2的官网进行了解。
无锁异步模式配置方式
这个配置起来还是比较简单,只需要两步:
- 首先是需要在java启动时增加jvm参数
-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector
- 然后是在pom中引入必要组件disruptor的依赖
xml
<dependency> <groupId>com.lmax</groupId> <artifactId>disruptor</artifactId> <version>3.4.4</version> </dependency>
无锁异步模式源码解析
我们首先介绍下无锁异步模式的总体流程,让大家心里有个基本的了解,这样后续看源码时就能有一个大致的方向,然后再深入到各个流程中,查看源码到底是怎么实现对应功能的。
总体流程
无锁异步模式的总体流程可以分为三个部分
- 首先是业务代码执行log.info()逻辑时,会通过Disruptor组件将日志存入 ringbuffer中,然后快速返回并执行后续业务代码,从而减少了系统调用的延迟,提高了业务处理的吞吐量
- Disruptor组件通过CAS来进行多线程同步,确保多个业务线程可以尽快的将数据存入ringbuffer中,并且单独的日志处理线程可以拿到日志数据并进行处理
- 无锁异步模式单独启动了一个线程进行日志处理,他可以不断地获取到业务的日志并写入到磁盘中
下面是我整理的一个流程的类图,大家可以在debug时根据图中的类找到方法打个断点,这样就能在idea的线程栈中找到程序调用的方法栈,从而查看源码逻辑。
业务线程日志打印逻辑
我们先来看下业务线程执行log.info()这种日志打印代码时,背后发生了什么。首先,我们debug跟踪log.info的代码

跟踪之后,发现他进入了AsyncLogger类的publish方法,我们发现这个方法使用到了Disruptor组件,通过其tryPublish进行尝试发布日志,同时下面也有个handleRingBufferFull方法,这个看名字就知道是队列满了之后会进行的处理,有兴趣的话大家可以自行查看代码逻辑。

继续跟踪代码,可以发现他通过tryNext方法获取到了一个序列号,可以大概知道这个方法后就是多线程同步的逻辑,他是通过每次加1,使用cursor字段的compareAndSet方法,用cas的方式获取下一个值,多线程同时进入此段逻辑后,如果获取下个值失败就再继续循环获取下一个序列,直到超出最大数量,抛出InsufficientCapacityException异常,此时就是日志队列已经满了,会执行队列满的操作,即前面的handleRingBufferFull方法。


如果成功获取到了序列号,则代表业务线程已经提前占了队列的一个坑,可以往队列里面放日志了,存放日志的代码在translateAndPublish方法中,跟踪进去后的代码如下图,其中跟踪translateTo方法可以看到,他将日志的各个信息设置进了RingBufferLogEvent类型的event对象中,这个就是存放日志数据的类,而这个对象是前面通过this.get(sequence) 获取到的。
也就是说,ringBuffer这个日志队列是一个类似数组的数据结构,有最大的数量,并且每个位置存放着一个代表日志的RingBufferLogEvent类型的类,代码通过cas获取ringBuffer的序列号来提前预定一个位置,成功后会通过get方式获取这个位置的日志类,并且将要写入的日志数据设置进去,从而实现了存储日志的操作。


完成这些之后,代码就可以返回并执行后续业务逻辑了。不过如果仔细跟踪代码,其实业务线程在返回之前,还会调用一个this.waitStrategy.signalAllWhenBlocking()方法,用以唤醒处理日志的线程。
那么问题来了,上面都是我们直接从log.info()代码直接跟踪过来看到的,那么处理日志的线程是在什么地方启动的呢?又怎么知道他的处理逻辑代码在哪呢?
日志打印线程处理逻辑
我们前面说到,业务线程在代码返回前还会走到this.waitStrategy.signalAllWhenBlocking()方法,看名字可以知道他是唤醒线程让他进行处理日志的,因此日志打印线程必然有在监听,我们可以通过processorNotifyCondition这个条件,在当前类中搜索,查找监听的程序,并加入断点,这样日志线程执行等待操作时就会执行到断点,我们可以通过debug的线程栈查看断点之前执行的代码,也可以追踪日志打印线程。


我们通过线程栈往前看,可以发现BatchEventProcessor类的processEvents方法就是日志打印线程处理日志的逻辑,总体流程为while循环获取下个可用的序列号,如果有的话,就可以通过this.dataProvider.get(nextSequence)获取到日志数据,然后通过this.eventHandler.onEvent方法执行具体的日志写入逻辑。你可以查看dataProvider变量的值,会发现他就是ringbuffer,也就和我们之前看到的日志存储逻辑对上了。


跟踪this.eventHandler.onEvent方法,会发现他进入到下面截图的代码,通过获取激活的LoggerConfig类,然后让这个类具体执行写入日志的逻辑。这个类就是我们在log4j2配置文件里面配置的数据解析而来的,往下看的话会发现他会调用callAppenders方法,这个就是我们配置中的Appender的组件了,根据组件的不同,可以写日志到文件,也可以打印到控制台等。


现在日志处理线程的处理代码也大致清楚了,但是我们还不知道这个日志线程是什么时候创建出来的,怎么看他创建时的代码。
日志打印线程启动逻辑
我们从前面知道,BatchEventProcessor类里面有日志打印线程的总体处理逻辑,启动一个线程需要执行其run方法,那启动之前肯定要初始化这个类的,因此我们可以在BatchEventProcessor类的创建方法中打断点,然后重启项目。
等到执行到断点后查看线程栈,可以发现,BatchEventProcessor类的创建逻辑是从AsyncLoggerDisruptor类的start方法进来的。这个正是log4j2的无锁异步代码的核心组件,可以详细看下他的创建方法。
首先可以看到的是,他通过传入RingBufferLogEventHandler类,然后封装并创建了BatchEventProcessor类,也就是说之前的this.eventHandler.onEvent处理日志的具体逻辑,其类型就是RingBufferLogEventHandler类,也就是在这里传入的。
其次我们可以看到new Disruptor代码,他有几个比较关键的入参,ringBufferSize和waitStrategy等,如果你看过官方的文档,就会知道这两个重要参数的含义,ringBufferSize是用于控制ringbuffer这个队列的长度,waitStrategy用于管控日志处理线程的睡眠等待策略。


其中可以关注一下的是ringBufferSize这个值,他控制队列的长度,影响日志可以存放的个数。通过查看代码,可以知道他是通过AsyncLogger.RingBufferSize这个jvm参数控制的,同时也能看到他默认值的取值逻辑,如果程序是web服务,他会使用262144这个比较大的默认值,如果不是web服务,他会使用4096这个默认值。



配置易错点
在配置无锁异步模式时,很多人在配置log4j2配置时,容易将Loggers下的Root节点换为AsyncRoot节点,其实这是有问题的。AsyncRoot节点是异步模式使用的,如果在无锁异步模式中配置了这个节点,相当于是启动了两个异步模式。
写日志时,开始的流程和之前描述的无锁异步模式一样,但是到无锁异步模式的日志处理线程准备写日志时,因为是AsyncRoot节点,他写日志的方式也是通过单独启动一个线程进行处理,相当于是日志先从业务线程传给a日志处理线程,a线程再把日志传给b线程,b线程执行写入操作,虽然日志写入不会有问题,但是多了一个做无用功的线程,会导致性能有所下降。
我们修改配置后重新debug,在之前写入日志时获取LoggerConfig的地方打断点,可以发现,他变成了AsyncLoggerConfig类,这个和我们配置的AsyncRoot节点的操作对的上。
跟踪这个类的方法,会发现他并不是像之前那样调用Appender的方法进行日志写入,而是又通过this.delegate.tryEnqueue方法,执行了和之前类似的传递日志给异步线程的操作,因此可以知道,AsyncRoot节点本身就是会启动异步线程的配置,如果使用了无锁异步配置,就不要配置AsyncRoot节点了,避免性能浪费。



总结
本文通过带领大家一起通过跟踪代码的方式查看log4j2的源码,在这个过程中讲解了一些debug查看源码的方法,同时也和大家一起了解了log4j2的实现原理,大家了解原理后就可以更好的使用无锁异步模式,同时也能更好的进行参数的配置调优。