2. Java日志体系全景

Java 日志生态的复杂,不在于“打印一行字”有多难,而在于历史演进留下了多套 API 与实现,它们彼此替代、桥接、适配,最终形成今天“门面 + 实现 + 桥接”的三层格局。理解这套格局,是后续做依赖治理、冲突排查和统一日志输出的前提。

一、六个组件,三种角色

把 JUL、JCL、Log4j、Log4j2、Logback、SLF4J 放在一起看,它们并不是同一层的竞争关系,而是分属三种角色:

  • 日志门面(Facade / API):只定义“怎么打日志”的接口,不负责真正写文件。代表是 SLF4J 和 JCL(Jakarta Commons Logging)。
  • 日志实现(Implementation / Binding):真正决定日志格式、级别过滤、输出目的地、滚动策略。代表是 Log4j 1.x、Log4j2、Logback,以及 JDK 自带的 JUL(java.util.logging)。
  • 桥接与适配(Bridge / Adapter):把一种 API 的调用转发到另一种实现上,用于统一历史遗留依赖。代表是 jcl-over-slf4j、log4j-over-slf4j、jul-to-slf4j、log4j-slf4j-impl 等。

JCL 比较特殊:它本身是门面,但内置了一套“运行时自动发现实现”的机制,会按 ClassLoader 顺序去探测 Log4j、JUL 等,这种隐式发现正是后来大量“日志冲突、类加载诡异”问题的根源。SLF4J 则反其道而行,采用“编译期静态绑定”,一个 classpath 上只允许存在一个 binding,冲突时直接在启动阶段报错,把问题暴露在早期。

二、依赖关系与桥接方向

下面这张图给出六者在 classpath 上的典型连接关系。实线表示“调用/绑定”,虚线表示“桥接转发”,箭头方向即日志事件的流动方向。

Java 日志体系:门面 → 绑定 → 实现,桥接负责收编历史 API SLF4J 门面 · 静态绑定 JCL 门面 · 运行时发现 JUL JDK 内置实现 Logback SLF4J 原生实现 Log4j2 异步 / 高性能 Log4j 1.x 已停止维护 logback-classic log4j-slf4j-impl JCL 探测 桥接层:jcl-over-slf4j · log4j-over-slf4j · jul-to-slf4j · log4j-1.2-api 把旧 API 的调用转发到 SLF4J,最终汇入唯一实现

读图要点:SLF4J 是唯一推荐的“入口”,Logback 与 Log4j2 是当前可选的“出口”,JUL 与 Log4j 1.x 属于需要被收编的历史资产,JCL 则是需要被替换掉的老门面。桥接层的作用,就是让这些历史 API 的调用最终都落到同一个实现上,避免同一份日志被写进多个文件、格式各异。

三、选型原则:先定门面,再定实现

选型不是“哪个库最好”的单点比较,而是一条有先后顺序的决策链:

  1. 门面统一选 SLF4J。它的占位符写法 log.info("orderId={}, cost={}ms", id, cost) 避免了字符串拼接,级别判断也更自然。JCL 已无新项目采用的理由。
  2. 实现优先 Logback 或 Log4j2。二者都原生支持 SLF4J,功能上都能满足滚动、异步、过滤、多输出等需求。Logback 与 SLF4J 同源、配置直观,适合大多数业务系统;Log4j2 在极高吞吐、需要无 GC 异步日志(Disruptor)时更有优势。
  3. 历史依赖用桥接收编。若某个三方库内部用的是 JCL 或 Log4j 1.x,不要改它的源码,而是引入对应的 over-slf4j 桥接包,把它的调用重定向到 SLF4J。
  4. 排除重复绑定。classpath 上同时出现 logback-classic 和 log4j-slf4j-impl 时,SLF4J 会打印 “Class path contains multiple SLF4J bindings” 并只选其一,必须用 <exclusions> 排掉多余的那个。
常见误区:把 log4j-over-slf4j 和 log4j 本体同时放进依赖。前者提供的是 org.apache.log4j 包下的同名类,与 Log4j 1.x 本体冲突,会导致 NoSuchMethodError 或类加载顺序不确定。桥接包与它要替代的本体,二者只能留一个。

四、一个可执行的依赖治理片段

以 Maven 为例,统一到“SLF4J + Logback”的最小依赖组合如下。注意排除项,它们决定了最终 classpath 上只剩一个 binding。

<dependencies>
  <!-- 门面 -->
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.13</version>
  </dependency>

  <!-- 实现:只保留一个 -->
  <dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.5.6</version>
  </dependency>

  <!-- 收编历史 API -->
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jcl-over-slf4j</artifactId>
    <version>2.0.13</version>
  </dependency>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>log4j-over-slf4j</artifactId>
    <version>2.0.13</version>
  </dependency>
</dependencies>

验证是否治理干净,最直接的办法是启动时观察控制台:若出现 “SLF4J: Class path contains multiple SLF4J bindings” 或 “Failed to load class org.slf4j.impl.StaticLoggerBinder”,说明 binding 数量不对。前者是多了,后者是少了。用 mvn dependency:tree -Dincludes=org.slf4j,log4j,ch.qos.logback 可以快速定位是哪个依赖把多余的实现带进来的。

判断一个项目日志体系是否健康,可以问三个问题:入口是不是只有 SLF4J?出口是不是只有一个实现?历史 API 是不是都被桥接收编?三个都“是”,后续的格式统一、级别治理、异步改造才有稳定的地基。

把六者的角色、连接方向和选型顺序理清之后,后面章节讨论的日志格式规范、级别使用、异步与滚动策略,才有明确的落点——它们最终都作用在“唯一实现”这一层上,而不是散落在多个互不相干的日志系统里。