一次日志引发的“血案”:从 Log4j 1.x 锁竞争到 Log4j 2.x 异步写入实战

高并发下,一个小小的日志配置也能把整个服务拖垮。本文记录了一次因 Log4j 1.x 的 LocationInfo 锁竞争导致服务响应变慢的完整排查与优化过程。

一、问题现象

某个下午,监控系统突然告警:核心接口 响应时间从 50ms 飙升到 3s+,部分请求超时。

  • Tomcat BIO 线程池占满,新请求被拒绝
  • CPU 使用率并不高(30% 左右)
  • GC 正常,无频繁 Full GC
  • 数据库、Redis 等依赖均无异常

奇怪的是,业务逻辑没有任何变更,只是流量比平时高了 20%。

二、问题排查过程

第一步:查看线程堆栈

  • 使用 jstack 导出进程线程快照:

jstack <pid> > stack.log

  • 查看堆栈,发现大量线程处于 RUNNABLE 状态,但卡在同一个地方:

http-bio-8080-exec-297" #1003 daemon prio=5

java.lang.Thread.State: RUNNABLE

at java.lang.Throwable.getStackTrace(Throwable.java:828)

  • locked <0x0000000475a3a9b8> (a java.lang.Throwable)

at org.apache.log4j.spi.LocationInfo.<init>(LocationInfo.java:139)

at org.apache.log4j.PatternLayout.format(PatternLayout.java:506)

  • 关键信息:

· Throwable.getStackTrace() 被频繁调用

· 存在显式的 locked 对象

· 线程名称为 http-bio-8080-exec-xxx(Tomcat BIO 线程池)

第二步:检查日志配置

  • 打开 log4j.properties,发现 ConversionPattern 中包含:

properties

log4j.appender.stdout.layout.ConversionPattern=%d [%t] %-5p %c %M %L - %m%n
其中 %M(方法名)和 %L(行号) 正是导致问题的元凶。

三、根因分析

1. "Log4j 1.x" 的 LocationInfo 实现原理

当 PatternLayout 中配置了 %C、%M、%L、%F 等位置信息占位符时,Log4j 会为每一条日志创建一个 LocationInfo 对象。

  • LocationInfo 的构造方法内部会:

java

Throwable t = new Throwable();

StackTraceElement[] trace = t.getStackTrace();

2. Throwable.getStackTrace() 的代价

Throwable.fillInStackTrace() 需要遍历并填充当前线程的完整调用栈(通常几十层)

JVM 层面存在锁:多线程同时调用 getStackTrace() 时会竞争同一把锁(VMThread::get_stack_trace)

高并发下,这个锁成为系统瓶颈

3. 锁竞争链条

线程打印日志 → 需要位置信息 → 创建 Throwable → 调用 getStackTrace() → 获取 JVM 内部锁 → 阻塞等待 → 业务线程被拖慢
这就是为什么 CPU 不高但服务变慢的原因------线程都在排队等锁,而不是在计算。

4. 为什么平时没问题,流量高了 20% 就爆发?

锁竞争与并发量的平方成正比。当并发超过某个阈值,排队时间急剧增加,导致响应时间雪崩。

四、解决方案设计

临时止血(不改代码,分钟级生效)

  • 修改 log4j.properties,去掉所有位置信息占位符:

properties

log4j.appender.stdout.layout.ConversionPattern=%d [%t] %-5p %c - %m%n

重启应用后,响应时间立刻恢复正常。

长期方案目标

从根本上解决问题:升级到 Log4j 2.x 并启用异步日志。

  1. 彻底消除日志打印过程中的锁竞争
  2. 支持更高的日志吞吐量(10w+ 条/秒)
  3. 日志对业务线程的影响降到最低(异步 + 非阻塞)

五、落地配置详解

1. 依赖替换(Maven)

  • 移除 Log4j 1.x:

xml

<dependency>

<groupId>log4j</groupId>

<artifactId>log4j</artifactId>

<version>1.2.17</version>

</dependency>

  • 添加 Log4j 2.x:

xml

<dependency>

<groupId>org.apache.logging.log4j</groupId>

<artifactId>log4j-core</artifactId>

<version>2.20.0</version>

</dependency>

<dependency>

<groupId>org.apache.logging.log4j</groupId>

<artifactId>log4j-api</artifactId>

<version>2.20.0</version>

</dependency>

<!-- 如果使用 Slf4j API,还需要桥接包 -->

<dependency>

<groupId>org.apache.logging.log4j</groupId>

<artifactId>log4j-slf4j-impl</artifactId>

<version>2.20.0</version>

</dependency>

2. 配置文件 log4j2.xml

  • log4j2.x 配置-省略
  • 配置要点说明:
  • Async Appender 的 blocking="false":队列满时丢弃日志事件,而不是阻塞业务线程
  • bufferSize="1024":队列大小适中,避免内存溢出
  • pattern 中无 %C、%M、%L、%l ------ 完全避免了堆栈获取

3. 代码适配

  • 如果之前代码中直接使用 org.apache.log4j.Logger,需要全局替换为:

java

import org.apache.logging.log4j.Logger;

import org.apache.logging.log4j.LogManager;

private static final Logger logger = LogManager.getLogger(YourClass.class);

  • 如果已经使用 Slf4j API(LoggerFactory.getLogger),则只需替换依赖,代码无需改动。

六、优化效果对比

  • 我们在预发环境进行了压测(100 并发,持续 5 分钟):

|-----------|------------------|----------------|
| 指标 | Log4j 1.x(带位置信息) | Log4j 2.x + 异步 |
| 接口TP99 耗时 | 2850ms | 62ms |
| 日志平均写入耗时 | ~48μs | ~1.8μs |
| 最大日志吞吐量 | ~5,200 条/秒 | ~112,000 条/秒 |
| 业务线程阻塞情况 | 严重排队 | 几乎无阻塞 |
| CPU 使用率 | 32% | 28% |

  • 结论: 升级后接口响应时间恢复至正常水平,并且日志能力提升了 20 倍以上。

七、经验总结与最佳实践

1. 日志框架选型建议

  • 不要再用 Log4j 1.x ------ 官方已 EOL,存在多处性能缺陷
  • 新项目直接使用 Log4j 2.x 或 Logback + 异步 Appender
  • 强烈推荐 Log4j 2.x 的异步 Logger(AsyncLogger),基于 LMAX Disruptor,无锁设计,性能最高

2. 日志配置黄金法则

|---------------|---------------------------------|
| 规则 | 说明 |
| 避免位置信息占位符 | 不要在生产环境使用 %C、%M、%L、%l、%F |
| 使用异步 Appender | 日志写入操作不要阻塞业务线程 |
| 合理控制日志级别 | 生产环境 INFO 起步,DEBUG 只在排查问题时开启 |
| 动态调整能力 | 支持通过配置中心热修改日志级别(如 Apollo、Nacos) |
| 定期滚动与压缩 | 防止磁盘写满,.gz 压缩节省空间 |

3. 遇到类似问题的排查路径

  1. 服务响应慢,先 jstack 看看线程都在干什么
  2. 如果大量线程卡在 Throwable.getStackTrace 或 LoggingEvent.getLocationInformation → 立即检查日志 Pattern
  3. 去掉位置信息占位符后,问题消失 → 确认根因
  4. 升级日志框架 + 异步写入 → 彻底解决

4. 监控与告警建议

  • 监控日志框架的队列积压情况(Log4j 2.x 暴露了相关 Metrics)
  • 设置日志文件增长速率告警
  • 定期 review 日志配置,避免新同学不小心加上 %M

八、写在最后

日志是我们排查问题的重要工具,但用错了工具反而会成为问题的制造者。一条小小的 %M,在高并发下足以让整个服务瘫痪。

希望这篇文章能帮你避开这个经典又隐蔽的性能陷阱。如果你也遇到过类似问题,欢迎留言交流。

相关推荐
zdl6864 天前
SpringBoot Test详解
spring boot·后端·log4j
lierenvip5 天前
Spring Boot 整合 log4j2 日志配置教程
spring boot·单元测试·log4j
武超杰5 天前
SpringBoot 进阶实战:异常处理、单元测试、多环境、日志配置全解析
spring boot·单元测试·log4j
小江的记录本5 天前
【Logback】Logback 日志框架 与 SLF4J绑定、三层模块、MDC链路追踪、异步日志、滚动策略
java·spring boot·后端·spring·log4j·maven·logback
Samson Bruce5 天前
【单元测试】
单元测试·log4j
不吃香菜学java6 天前
苍穹外卖-删除菜品
java·spring boot·spring·tomcat·log4j·maven
qqacj6 天前
SpringBoot Test详解
spring boot·后端·log4j
工具人55556 天前
pytest 测试项目指南
log4j
不吃香菜学java8 天前
苍穹外卖-菜品分页查询
数据库·spring boot·tomcat·log4j·maven·mybatis