无垃圾稳态日志记录

垃圾回收暂停是延迟峰值的常见原因,对于许多系统来说,大量精力都花在了控制这些暂停上。

许多日志记录库,包括 Log4j 的早期版本,在稳态日志记录期间会分配临时对象,例如日志事件对象、字符串、字符数组、字节数组等等。这会给垃圾回收器带来压力,并增加 GC 暂停发生的频率。

从 2.6 版本开始,Log4j 默认情况下以“无垃圾”模式运行,其中对象和缓冲区被重复使用,并且尽可能避免分配临时对象。还有一种“低垃圾”模式,它并非完全无垃圾,但不会使用 ThreadLocal 字段。当 Log4j 检测到它在 Web 应用程序中运行时,这是默认模式。最后,可以关闭所有无垃圾逻辑,并改为以“经典模式”运行。有关详细信息,请参阅下面的 配置 部分。

一个人为的例子

为了突出无垃圾日志记录所能带来的差异,我们使用 Java Flight Recorder 来测量一个简单的应用程序,该应用程序除了尽可能频繁地记录一个简单的字符串之外什么也不做,持续大约 12 秒。

该应用程序被配置为使用异步记录器、RandomAccessFile 附加器和“%d %p %c{1.} [%t] %m %ex%n” 模式布局。(异步记录器使用 Yield WaitStrategy。)

Mission Control 显示,使用 Log4j 2.5 时,该应用程序以约 809 MB/秒的速度分配内存,导致 141 次次要收集。Log4j 2.6 在此配置中不会分配临时对象,因此,使用 Log4j 2.6 的相同应用程序的内存分配率为 1.6 MB/秒,并且没有垃圾收集(0 次)。


使用 Log4j 2.5:内存分配率 809 MB/秒,141 次次要收集。

Log4j 2.6 没有分配临时对象:0 次垃圾收集。

配置

Log4j 2.6 中的无垃圾日志记录部分通过在 ThreadLocal 字段中重复使用对象来实现,部分通过在将文本转换为字节时重复使用缓冲区来实现。

持有非 JDK 类的 ThreadLocal 字段会导致 Web 应用程序中的内存泄漏,当应用程序服务器的线程池在 Web 应用程序被取消部署后继续引用这些字段时。为了避免导致内存泄漏,当 Log4j 检测到它在 Web 应用程序中使用时(当 javax.servlet.Servlet 类在类路径中,或者当系统属性 log4j2.isWebapp 设置为“true”时),它不会使用这些 ThreadLocal。

一些减少垃圾的功能不依赖于 ThreadLocal,并且默认情况下对所有应用程序都启用:在 Log4j 2.6 中,将日志事件转换为文本和将文本转换为字节可以通过直接将文本编码到重复使用的 ByteBuffer 中来完成,而无需创建中间字符串、字符数组和字节数组。因此,虽然 Web 应用程序的日志记录并非完全无垃圾,但对垃圾回收器的压力仍然可以显著降低。

注意 1:从 2.6 版本开始,包含 <Properties> 部分的 Log4j 配置会导致在稳态日志记录期间创建临时对象。

注意 2:从 2.18.0 版本开始,Log4j 使用的默认异步记录器等待策略(超时)是无垃圾的。LMAX disruptor 3.4.4 中包含的一些等待策略,尤其是 TimeoutBlockingWaitStrategyBlockingWaitStrategy(阻塞)不是无垃圾的,因为它们会导致创建 java.util.concurrent.locks.AbstractQueuedSynchronizer$Node 对象。Log4j 使用的默认等待策略使用同步块而不是 ReentrantLock 来避免这个问题。Yield 和 Sleep 等待策略是无垃圾的。(有关配置预定义等待策略的信息,请参阅 此处此处,您也可以配置 自定义等待策略。)

禁用无垃圾日志记录

有两个独立的系统属性用于手动控制 Log4j 用于避免创建临时对象的机制

  • log4j2.enableThreadlocals - 如果为“true”(非 Web 应用程序的默认值),则对象将存储在 ThreadLocal 字段中并重复使用,否则将为每个日志事件创建新对象。
  • log4j2.enableDirectEncoders - 如果为“true”(默认值),则日志事件将被转换为文本,并且此文本将被转换为字节,而无需创建临时对象。注意:同步日志记录性能在这种模式下对于多线程应用程序来说可能会更差,因为要对共享缓冲区进行同步。如果您的应用程序是多线程的并且日志记录性能很重要,请考虑使用异步记录器。
  • ThreadContext 映射默认情况下不是无垃圾的,但从 Log4j 2.7 开始,可以通过将系统属性 log4j2.garbagefreeThreadContextMap 设置为“true”来配置为无垃圾的。

除了系统属性之外,还可以通过在名为 log4j2.component.properties 的文件中指定上述属性,方法是在应用程序的类路径中包含此文件。有关系统属性的更多信息,请参阅 有关系统属性的手册

支持的附加器

以下 附加器 在稳态日志记录期间是无垃圾的

  • 控制台
  • 文件
  • 滚动文件(在文件滚动期间会创建一些临时对象)
  • RandomAccessFile
  • 滚动 RandomAccessFile(在文件滚动期间会创建一些临时对象)
  • 内存映射文件

上面列表中未列出的任何其他附加器(包括 AsyncAppender)在稳态日志记录期间都会创建临时对象。请使用 异步记录器 而不是 AsyncAppender 以无垃圾的方式异步记录日志。

支持的过滤器

以下 过滤器 在稳态日志记录期间是无垃圾的

  • 复合过滤器(添加和删除元素过滤器会为线程安全创建临时对象)
  • 动态阈值过滤器
  • 级别范围过滤器(从 2.8 版本开始无垃圾)
  • 映射过滤器(从 2.8 版本开始无垃圾)
  • 标记过滤器(从 2.8 版本开始无垃圾)
  • 结构化数据过滤器(从 2.8 版本开始无垃圾)
  • ThreadContextMap 过滤器(从 2.8 版本开始无垃圾)
  • 阈值过滤器(从 2.8 版本开始无垃圾)
  • 时间过滤器(从 2.8 版本开始无垃圾,但当需要每天重新计算范围时除外)

其他过滤器,如 BurstFilter、RegexFilter 和 ScriptFilter,并非易于实现无垃圾,目前没有计划更改它们。

支持的布局

GelfLayout

当与 compressionType="OFF" 一起使用时,GelfLayout 是无垃圾的,只要没有其他字段包含 '${'(变量替换)。

JsonTemplateLayout

JsonTemplateLayout 在 少数例外情况 下是无垃圾的。

PatternLayout

具有以下有限的转换模式集的 PatternLayout 是无垃圾的。控制字段宽度、填充、左对齐和右对齐等内容的格式修饰符不会生成垃圾。

转换模式 描述
%c{precision}, %logger{precision} 记录器名称
%d, %date 注意:只有预定义的日期格式是无垃圾的:(毫秒分隔符可以是逗号 ',' 或句点 '.')
模式 示例
%d{DEFAULT} 2012-11-02 14:34:02,781
%d{ISO8601} 2012-11-02T14:34:02,781
%d{ISO8601_BASIC} 20121102T143402,781
%d{ABSOLUTE} 14:34:02,781
%d{DATE} 2012 年 11 月 2 日 14:34:02,781
%d{COMPACT} 20121102143402781
%d{HH:mm:ss,SSS} 14:34:02,781
%d{dd MMM yyyy HH:mm:ss,SSS} 2012 年 11 月 2 日 14:34:02,781
%d{HH:mm:ss}{GMT+0} 18:34:02
%d{UNIX} 1351866842
%d{UNIX_MILLIS} 1351866842781
%enc{pattern}, %encode{pattern} 对特殊字符(如 '\n' 和 HTML 字符)进行编码,以帮助防止日志伪造和在 Web 浏览器中显示日志时可能发生的某些 XSS 攻击 - 从 2.8 版本开始无垃圾
%equals{pattern}{test}{substitution}, %equalsIgnoreCase{pattern}{test}{substitution} 将字符串 'test' 的出现次数替换为其替换 'substitution',该字符串是评估模式的结果 - 从 2.8 版本开始无垃圾
%highlight{pattern}{style} 添加 ANSI 颜色 - 从 2.7 版本开始无垃圾(除非嵌套模式不是无垃圾的)
K{key}, map{key}, MAP{key} 输出 MapMessage 中的条目(如果事件中存在) - 从 2.8 版本开始无垃圾。
%m, %msg, %message 日志消息(除非消息文本包含 '${',否则为无垃圾)
%marker 标记的完整名称(包括父级) - 从 2.8 版本开始无垃圾
%markerSimpleName 标记的简单名称(不包括父级)
%maxLen, %maxLength 将另一个模式截断为一定数量的字符 - 从 2.8 版本开始无垃圾
%n 平台相关的行分隔符
%N, %nano 记录事件时 System.nanoTime()
%notEmpty{pattern}, %varsNotEmpty{pattern}, %variablesNotEmpty{pattern} 仅当模式中的所有变量都不为空时,才输出评估模式的结果 - 从 2.8 版本开始无垃圾
%p, %level 日志事件的级别
%r, %relative 从 JVM 启动到创建日志事件为止经过的毫秒数 - 从 2.8 版本开始无垃圾
%sn, %sequenceNumber 将在每个事件中递增的序列号 - 从 2.8 版本开始无垃圾
%style{pattern}{ANSI style} 设置消息样式 - 从 2.7 版本开始无垃圾(除非嵌套模式不是无垃圾的)
%T, %tid, %threadId 生成日志事件的线程的 ID
%t, %tn, %thread, %threadName 生成日志事件的线程的名称
%tp 生成日志事件的线程的优先级
%X{key[,key2...]}, %mdc{key[,key2...]}, %MDC{key[,key2...]} 输出与生成日志事件的线程关联的线程上下文映射(也称为映射诊断上下文或 MDC) - 从 2.8 版本开始无垃圾
文字文本 除非文字包含 '${'(变量替换),否则为无垃圾

其他 PatternLayout 转换模式和其他布局可能会在将来的版本中更新以避免创建临时对象。(欢迎补丁!)

注意:记录异常和堆栈跟踪将使用任何布局创建临时对象。(但是,布局仅在发生异常时才会创建这些临时对象。)我们还没有找到在不创建临时对象的情况下记录异常和堆栈跟踪的方法。这很不幸,但你可能仍然希望在发生异常时记录它们。

注意:包含正则表达式和属性替换查找的模式将在稳定状态日志记录期间导致创建临时对象。

包含位置信息是通过遍历异常的堆栈跟踪来完成的,这会创建临时对象,因此以下模式不是无垃圾的

  • %C, %class - 类名
  • %F, %file - 文件位置
  • %l, %location - 位置
  • %L, %line - 行位置
  • %M, %method - 方法位置
此外,用于格式化 Throwables 的模式转换器不是无垃圾的
  • %ex, %exception, %throwable - 与 LoggingEvent 绑定的 Throwable 跟踪
  • %rEx, %rException %rThrowable - 与 %ex 相同,但包含包装异常
  • %xEx, %xException, %xThrowable - 与 %ex 相同,但包含类打包信息
  • %u, %uuid - 在格式化时创建新的随机或基于时间的 UUID

API 更改

已向 Logger 接口添加方法,以便在使用最多十个参数记录消息时不创建任何 vararg 数组对象。

此外,已向 Logger 接口添加方法以记录 java.lang.CharSequence 消息。实现 CharSequence 接口的用户定义对象可以在不创建临时对象的情况下进行记录:Log4j 将尝试通过将它们追加到 StringBuilder 作为 CharSequence 来将 CharSequence 消息、Object 消息和消息参数转换为文本。这避免了对这些对象调用 toString()

另一种方法是实现 org.apache.logging.log4j.util.StringBuilderFormattable 接口。如果记录的对象实现了此接口,则会调用其 formatTo 方法而不是 toString()

当无垃圾日志记录被禁用时(当系统属性 log4j2.enableThreadlocals 设置为“false”时),Log4j 可能会对消息和参数对象调用 toString()

对应用程序代码的影响:自动装箱

我们努力使日志记录在不需修改现有应用程序中的代码的情况下实现无垃圾,但有一个方面无法做到。在记录原始值(即 int、double、boolean 等)时,JVM 会将这些原始值自动装箱到其 Object 包装器等效项,从而产生垃圾。

Log4j 提供了一个 Unbox 实用程序来防止对原始参数进行自动装箱。此实用程序包含一个线程局部池,其中包含可重复使用的 StringBuilderUnbox.box(primitive) 方法直接写入 StringBuilder,生成的文本将被复制到最终的日志消息文本中,而不会创建临时对象。

import static org.apache.logging.log4j.util.Unbox.box;

...
public void garbageFree() {
    logger.debug("Prevent primitive autoboxing {} {}", box(10L), box(2.6d));
}

注意:并非所有日志记录都是无垃圾的。具体来说

  • 默认情况下,ThreadContext 映射不是无垃圾的,但可以通过将系统属性 log4j2.garbagefreeThreadContextMap 设置为“true”来配置为无垃圾的。
  • ThreadContext 堆栈不是无垃圾的。
  • 记录超过 10 个参数会创建 vararg 数组。
  • 当所有记录器都是异步记录器时,记录非常大的消息(超过 518 个字符)会导致 RingBuffer 中的内部 StringBuilder 被修剪回其最大大小。
  • 记录包含 '${' 的消息:替换 ${variable} 会创建临时对象。
  • 将 lambda 作为参数 记录(logger.info("lambda value is {}", () -> callExpensiveMethod()))会创建 vararg 数组。单独记录 lambda 表达式是无垃圾的:logger.debug(() -> callExpensiveMethod())
  • Logger.traceEntryLogger.traceExit 方法会创建临时对象。

性能

响应时间延迟

响应时间是在特定负载下记录消息所需的时间。通常报告为延迟的实际上是服务时间:执行操作所需的时间。这掩盖了这样一个事实,即服务时间的单次峰值会为许多后续操作增加排队延迟。服务时间很容易测量(并且在纸面上通常看起来很好),但对于用户来说无关紧要,因为它省略了等待服务的时间。出于这个原因,我们报告响应时间:服务时间加上等待时间。有关更多详细信息,请参阅性能页面的 响应时间部分

以下响应时间测试结果均来自运行 ResponseTimeTest 类,该类可以在 Log4j 2 单元测试源目录中找到。如果你想自己运行这些测试,以下是我们使用的命令行选项

  • -Xms1G -Xmx1G(防止在测试期间堆调整大小)
  • -DLog4jContextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector -DAsyncLogger.WaitStrategy=busyspin(使用异步记录器。BusySpin 等待策略减少了一些抖动。)
  • 经典模式: -Dlog4j2.enable.threadlocals=false -Dlog4j2.enable.direct.encoders=false
    无垃圾模式: -Dlog4j2.enable.threadlocals=true -Dlog4j2.enable.direct.encoders=true
  • -XX:CompileCommand=dontinline,org.apache.logging.log4j.core.async.perftest.NoOpIdleStrategy::idle
  • -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCApplicationStoppedTime(查看 GC 和安全点暂停)

异步记录器

下图比较了 Log4j 的异步记录器的“经典”日志记录和无垃圾日志记录响应时间行为。在图中,“100k”表示以每秒 100,000 条消息的持续负载进行日志记录,“800k”表示以每秒 800,000 条消息的持续负载进行日志记录。

经典模式下,我们看到许多次小垃圾回收,这些回收会使应用程序线程暂停 3 毫秒或更长时间。这很快就会累积到近 10 毫秒的响应时间延迟。正如你在图中看到的,增加负载会将曲线向左移动(会有更多峰值)。这是有道理的:记录更多意味着对垃圾回收器的压力更大,从而导致更多的小 GC 暂停。我们尝试过将负载降低到 50,000 甚至 5000 条消息/秒,但这并没有消除 3 毫秒的暂停,只是使它们发生的频率降低了。请注意,此测试中的所有 GC 暂停都是小 GC 暂停。我们没有看到任何完整的垃圾回收。

无垃圾模式下,最大响应时间在各种负载下都保持在 1 毫秒以下。(在每秒 800,000 条消息时最大值为 780 微秒,在每秒 600,000 条消息时最大值为 407 微秒,对于所有负载(高达每秒 800,000 条消息)的 99% 约为 5 微秒。)增加或减少负载不会改变响应时间行为。我们没有调查在此测试中看到的 200-300 微秒暂停的原因。

当我们进一步增加负载时,我们开始看到经典日志记录和无垃圾日志记录的响应时间暂停更大。在每秒 100 万条消息或更高的持续负载下,我们开始接近底层 RandomAccessFile 附加程序的最大吞吐量(请参阅下面的同步日志记录吞吐量图表)。在这些负载下,环形缓冲区开始填满,反压开始起作用:当环形缓冲区已满时尝试添加另一条消息将阻塞,直到有空闲插槽可用。我们开始看到响应时间为数十毫秒或更长时间;尝试进一步增加负载会导致响应时间峰值更大。

同步文件日志记录

使用同步文件日志记录,无垃圾日志记录的性能仍然优于经典日志记录,但差异不那么明显。

在每秒 100,000 条消息的工作负载下,经典日志记录的最大响应时间略高于 2 毫秒,而无垃圾日志记录略高于 1 毫秒。当工作负载增加到每秒 300,000 条消息时,经典日志记录显示响应时间暂停为 6 毫秒,而无垃圾响应时间小于 3 毫秒。这可能还有改进的空间,我们还没有进一步调查。

以上结果是在 RHEL 6.5(Linux 2.6.32-573.1.1.el6.x86_64)上运行 JDK 1.8.0_45,使用 10 核 Xeon CPU E5-2660 v3 @2.60GHz,超线程开启(20 个虚拟核心)的 ResponseTimeTest 类获得的,该类可以在 Log4j 2 单元测试源目录中找到。

经典日志记录的吞吐量略高

与经典日志记录相比,无垃圾日志记录的吞吐量略低。这对于同步和异步日志记录都是如此。下图比较了 Log4j 2.6 在无垃圾模式、经典模式和 Log4j 2.5 中将同步日志记录到文件的持续吞吐量。

Throughput of Log4j 2.6 in garbage-free mode is slightly worse than in classic mode, but on par with 2.5 and much better than alternatives logging libraries

以上结果使用 JMH Java 基准测试工具获得。请参阅 log4j-perf 模块中的 FileAppenderBenchmark 源代码。

幕后

实现 org.apache.logging.log4j.util.StringBuilderFormattable 的自定义消息实现可以通过无垃圾布局转换为文本,而无需创建临时对象。PatternLayout 使用此机制,其他将 LogEvents 转换为文本的布局也可能会查找此接口。

想要无垃圾的自定义布局应该实现 Encoder<LogEvent> 接口。对于将 LogEvent 转换为文本表示的自定义布局,org.apache.logging.log4j.core.layout.StringBuilderEncoder 类可能有助于以无垃圾的方式将此文本转换为字节。

想要无垃圾的自定义附加器应该为其布局提供 ByteBufferDestination 实现,布局可以直接写入该实现。

AbstractOutputStreamAppender 已被修改以使 ConsoleAppender、(Rolling)FileAppender、(Rolling)RandomAccessFileAppender 和 MemoryMappedFileAppender 无垃圾。已尽力将对扩展 AbstractOutputStreamAppender 的自定义附加器的影响降至最低,但无法保证更改超类不会影响所有子类。扩展 AbstractOutputStreamAppender 的自定义附加器应验证它们是否仍然正常运行。如果出现问题,可以将系统属性 log4j2.enable.direct.encoders 设置为“false”以恢复到 Log4j 2.6 之前的行为。