返回

Win下Java System.err重定向(2>&1)为何慢? 原因与解决

java

深入剖析:为何 Java 的 System.err 在 Windows 下性能远低于 System.out?

写 Java 代码的时候,咱们经常用 System.out.println 来打印些普通信息,用 System.err.println 来输出错误提示。直觉上,这俩兄弟除了输出的目的地(标准输出和标准错误)可能不同外,性能应该差不离才对。然而,最近一次在 Windows 环境下的压测却颠覆了这个认知:System.err 的性能表现竟然比 System.out 慢了不止一点半点!这到底是咋回事?

一、问题浮现:令人困惑的性能差异

故事得从一个简单的压测说起。我用 Spring Boot 2.6.0(基于 Java 8)写了几个简单的 API,分别在循环里调用 System.out.printlnSystem.err.printlnlogger.info(SLF4J + Logback)。Logback 配置了异步 appender (AsyncAppender),按理说日志框架性能应该是最好的。

项目打成可执行 JAR 包,在 Windows 11 上用下面的命令运行:

java -jar -Xms256m -Xmx256m .\happyTest-1.0.2-SNAPSHOT.jar > .\logs\happyTest2.log 2>&1

注意这里的 > .\logs\happyTest2.log 2>&1,意思是把标准输出(stdout)重定向到 happyTest2.log 文件,并且把标准错误(stderr)也合并(2>&1)到标准输出那里去,最终都写入同一个文件。

测试代码很简单:

@RestController
@RequestMapping("/log")
public class LogController {

    private static final Logger logger = org.slf4j.LoggerFactory.getLogger(LogController.class);

    // 注意:后续测试为了区分效果,循环次数有调整
    private static final int LOOP_COUNT = 100; // 初始值,后调整为 500

    @GetMapping("/system-out")
    public String logSystemOut() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < LOOP_COUNT; i++) {
            System.out.println("hello world");
        }
        long endTime = System.currentTimeMillis();
        return "Logged messages in " + (endTime - startTime) + " ms";
    }

    @GetMapping("/system-err")
    public String logSystemErr() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < LOOP_COUNT; i++) {
            System.err.println("hello world");
        }
        long endTime = System.currentTimeMillis();
        return "Logged messages in " + (endTime - startTime) + " ms";
    }

    @GetMapping("/info")
    public String logInfo() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < LOOP_COUNT; i++) {
            logger.info("hello world");
        }
        long endTime = System.currentTimeMillis();
        return "Logged messages in " + (endTime - startTime) + " ms";
    }
}

Logback 配置(部分关键配置):

<configuration>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/happyTest.log</file>
        <!-- ... 省略滚动策略和 encoder 配置 -->
    </appender>
    
    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
        <discardingThreshold>0</discardingThreshold>
        <queueSize>8192</queueSize>
        <appender-ref ref="FILE"/>
    </appender>
    
    <root level="INFO">
        <appender-ref ref="ASYNC"/>
    </root>
</configuration>

用 JMeter 进行压测(50 线程,循环 10 次),结果如下:

  • logger.info:性能最佳,符合预期,毕竟是异步日志。
  • System.out:性能表现尚可。
  • System.err性能急剧下降! 吞吐量远低于 System.out

这个结果有点反直觉。翻看 JDK 源码 (java.lang.System),outerr 初始化时都用了 BufferedOutputStream,并非像网上某些说法那样 System.err 是无缓冲的。

// java.lang.System 部分源码
public final static PrintStream out = null;
public final static PrintStream err = null;

private static void initializeSystemClass() {
    // ...
    FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
    FileOutputStream fdErr = new FileOutputStream(FileDescriptor.err);
    // ...
    setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
    setErr0(newPrintStream(fdErr, props.getProperty("sun.stderr.encoding")));
    // ...
}

private static PrintStream newPrintStream(FileOutputStream fos, String enc) {
   // ... 省略 try-catch 和编码处理细节
    return new PrintStream(new BufferedOutputStream(fos, 128), true, enc);
    // ...
}

源码显示,两者确实都包裹了 BufferedOutputStream,缓冲区大小默认为 128 字节,并且都开启了 autoFlush(第二个 true 参数)。唯一明显的不同是底层的文件符 (FileDescriptor.out vs FileDescriptor.err) 和可能不同的编码配置(sun.stdout.encoding vs sun.stderr.encoding)。这似乎不足以解释如此大的性能鸿沟。

更有意思的是,当把完全相同的测试挪到 Ubuntu 环境下,调整循环次数 (LOOP_COUNT = 500) 并使用分离的重定向命令再次测试:

# Ubuntu 环境下的命令
nohup java -jar -Xms256m -Xmx256m ./happyTest-1.0.2-SNAPSHOT.jar > ./logs/happyTest2.log 2> ./logs/happyTest3.log &

或者也使用合并的重定向:

# Ubuntu 环境下合并重定向
nohup java -jar -Xms256m -Xmx256m ./happyTest-1.0.2-SNAPSHOT.jar > ./logs/happyTest2.log 2>&1 &

这时,System.outSystem.err 的性能表现变得非常接近!这强烈暗示问题根源不在 Java 代码本身,而是在特定操作系统(Windows)以及可能的 I/O 重定向方式上。

二、追根溯源:可能的原因分析

既然 Java 层面代码相似,Ubuntu 上表现一致,那问题很可能出在 Windows 如何处理标准错误流,特别是当它与标准输出流通过 2>&1 合并重定向到文件时。

1. Java 层面的同步机制

System.outSystem.err 都是 PrintStream 对象。PrintStream 的许多方法,包括 println,都是 synchronized 的。这意味着在多线程环境下,同时调用 System.out.printlnSystem.err.println 会发生锁竞争。

// java.io.PrintStream 部分源码
public void println(String x) {
    synchronized (this) { // 注意这里的同步锁
        print(x);
        newLine();
    }
}

虽然都有锁,但这本身不能解释为什么 errout 慢那么多,尤其是在 2>&1 的场景下。锁竞争应该对两者都有影响。除非……访问 stderr 底层资源的代价在 Windows 上更高,或者 2>&1 的合并操作在 Windows 上引入了额外的同步开销,并且这个开销对 stderr 的写入路径影响更大。

2. 操作系统层面的 I/O 处理与重定向 (2>&1)

这是最可疑的地方。>2>2>&1 是 shell(命令行解释器,如 Windows 的 cmd.exe 或 PowerShell,Linux 的 bash)提供的重定向功能。它们指示操作系统将进程的标准输出(文件符 1)或标准错误(文件描述符 2)连接到其他地方(比如文件)。

  • > file:将标准输出(FD 1)重定向到 file
  • 2> file:将标准错误(FD 2)重定向到 file
  • > file 2>&1:先将标准输出(FD 1)重定向到 file,然后将标准错误(FD 2)复制 标准输出的当前目标。最终结果是 FD 1 和 FD 2 都指向 file

关键在于这个“复制”或“合并”操作在不同操作系统上的实现可能不同。

  • 在类 Unix 系统 (如 Linux/Ubuntu) 上: 底层通常使用 dup2() 系统调用。dup2(fd1, fd2) 会让文件描述符 fd2 指向 fd1 所指向的同一个 打开文件表项 (open file description) 。这意味着后续通过 fd1fd2 写入,最终都会追加到同一个文件的同一个位置,由内核来处理并发写入的原子性(通常 C 库或内核保证单次 write() 系统调用是原子的,但多个 write() 调用之间不保证顺序)。它们共享文件偏移量。这种机制相对高效。

  • 在 Windows 系统上: cmd.exe 或 PowerShell 对 2>&1 的处理机制可能涉及更复杂的句柄 (handle) 操作。虽然最终效果也是写入同一个文件,但其内部实现可能涉及不同的同步原语或缓冲策略。一种猜测是,当多个线程通过不同的原始流 (System.out -> FD 1, System.err -> FD 2) 最终写入同一个被重定向的文件句柄时,Windows 的 C 运行时库 (CRT) 或者内核层为了保证某种程度的一致性(即使 PrintStream 内部有锁),可能对源自 stderr (FD 2) 的写入施加了更严格或效率较低的锁定机制。这可能是一种保护措施,因为 stderr 通常用于输出更紧急的信息,操作系统可能不希望它与其他输出(stdout)混杂得太随意或丢失。这种额外的协调成本在并发写入压力下会被放大,导致 System.err 的性能显著低于 System.out

3. 控制台历史与缓冲差异

另一个(可能性较低的)因素是,即使重定向到文件,Windows 控制台子系统对于 stdoutstderr 的处理可能仍有历史遗留的差异。比如,过去图形界面程序或特定类型的应用在处理错误流时可能有特殊的交互或缓冲行为。虽然现在重定向到文件了,但底层的 API 路径或默认行为可能依然保留了某些性能上的不同。不过,考虑到 BufferedOutputStream 已经在 Java 层统一处理了,这个因素影响应该较小。

综合来看,最有可能的解释是 Windows 在处理 2>&1 重定向时,其底层机制对源自标准错误流的写入引入了额外的、性能代价更高的同步或协调操作,导致在高并发下 System.err 的吞吐量远低于 System.out

三、动手验证与解决方案

知道了可能的原因,我们可以尝试一些方法来验证推断,并找到解决或规避这个问题的办法。

1. 方案一:分离输出流,避免 2>&1

既然怀疑是 2>&1 合并操作在 Windows 上搞鬼,那最直接的验证方法就是不合并,让 stdoutstderr 各自输出到不同的文件。

操作步骤:

修改启动命令,将 stderr 重定向到另一个独立的文件:

# Windows 环境下,分离输出
java -jar -Xms256m -Xmx256m .\happyTest-1.0.2-SNAPSHOT.jar > .\logs\stdout.log 2> .\logs\stderr.log

然后重新运行 System.outSystem.err 的压测。

预期结果:

如果 2>&1 合并确实是 Windows 上的性能瓶颈所在,那么使用分离输出后,System.err 的性能应该会大幅提升,与 System.out 更加接近(可能仍有微小差异,取决于底层文件系统等因素,但不会是之前那么悬殊)。这个结果也能佐证之前的推断。

安全建议:

将错误流与标准输出流分开记录通常是良好的实践,便于问题排查。错误日志专门存放错误和异常信息,更容易被监控和分析。

2. 方案二:拥抱专业的日志框架 (推荐)

面对 System.out/err 的种种限制和潜在的性能问题,现代 Java 应用开发中,最佳实践是使用成熟的日志框架,如 SLF4J 作为门面,Logback 或 Log4j2 作为具体实现。

原理与作用:

  • 异步处理: 像 Logback 的 AsyncAppender 或 Log4j2 的异步 Logger,可以将日志事件放入内存队列,由专门的后台线程负责写入磁盘或其他目标。这样,业务线程(处理请求的线程)只需将日志事件放入队列即可快速返回,大大减少了 I/O 等待时间,在高并发下性能优势明显。这就是为什么测试中 logger.info 表现最好的原因。
  • 灵活配置: 可以轻松配置日志级别、输出格式、输出目标(控制台、文件、数据库、消息队列等),以及滚动策略、压缩、过滤等高级功能。
  • 避免平台陷阱: 日志框架通常会更精细地处理底层 I/O 操作,可能直接使用 java.nio 或其他优化手段,从而绕过 System.out/err 及其可能依赖的、存在平台差异的 C 库函数或 OS 行为。

操作步骤:

就像你在项目中已经做的那样,引入 SLF4J 和 Logback/Log4j2 依赖,配置好 logback.xmllog4j2.xml 文件,然后在代码中使用 Logger 对象来记录日志。

// 示例(已在问题描述中提供)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

// ... in class ...
private static final Logger logger = LoggerFactory.getLogger(LogController.class);

// ... in method ...
logger.info("这是一条 info 级别的日志");
logger.error("发生了一个错误", exception); // 记录错误和异常堆栈

确保 Logback 配置中使用了 AsyncAppender (或其他异步机制,如 Log4j2 的 Async Loggers)。

进阶使用技巧:

  • 调整 AsyncAppender 参数:
    • queueSize: 异步队列的大小。如果队列满了,默认行为是阻塞调用线程(neverBlock=false)或丢弃日志(neverBlock=truediscardingThreshold > 0)。需要根据应用负载和可接受的日志丢失程度来调整。
    • discardingThreshold: 当队列剩余容量低于此阈值时开始丢弃级别较低(如 TRACE, DEBUG, INFO)的日志,以保护 ERROR 日志。设为 0 表示不丢弃。
    • includeCallerData: 是否包含调用者信息(类名、方法名、行号)。生成调用者信息代价较高,生产环境通常建议关闭 (false)。
  • 使用 Markers: SLF4J 的 Marker 可以在日志事件上附加标签,用于更精细的过滤或路由。
  • 结构化日志: 考虑使用 JSON 或其他结构化格式输出日志,便于机器解析和日志聚合系统(如 ELK Stack, Splunk)的处理。可以使用 LogstashEncoder 等库。

3. 方案三:(非常规) 程序内重定向 System.err

这是一个比较 Hack 的方法,通常不推荐,但可以用于理解机制或特定极端场景。可以在 Java 代码启动早期,强制将 System.err 指向 System.out 指向的同一个 PrintStream 对象。

原理与作用:

通过 System.setErr(System.out),让后续所有对 System.err 的调用实际上都委托给了 System.out 对象。这样,从 Java 层面看,所有输出都通过同一个 PrintStream 实例的同步锁和同一个底层流(最终指向 FD 1)。这或许能绕过 Windows 处理 FD 2 -> FD 1 合并时的特殊逻辑。

代码示例:

在你的应用启动逻辑(例如 Spring Boot 的 main 方法或 @PostConstruct 初始化块)的非常早期执行:

public static void main(String[] args) {
    // 在应用初始化早期执行
    System.setErr(System.out); 
    
    SpringApplication.run(YourApplication.class, args);
}

警告与安全建议:

  • 严重不推荐! 这种做法完全混淆了标准输出和标准错误,使得区分正常信息和错误信息变得极其困难,极不利于问题排查和监控。错误信息可能会淹没在大量常规输出中。
  • 它并没有解决根本问题,只是用一种取巧的方式绕过症状。如果底层原因确实是 Windows 处理合并 stderr 效率低,这种方法可能有效,但也可能引入其他意想不到的问题。
  • 优先选择方案一(分离输出)或方案二(使用日志框架)。此方法仅供技术探索或理解之用。

四、关键 Takeaways 与建议

这次 System.err 在 Windows 下性能异常低的问题,根源很可能不在 Java 本身,而在于 Windows 操作系统处理标准错误流(stderr)与标准输出流(stdout)合并重定向 (2>&1) 的底层机制。当并发写入压力增大时,这种机制可能引入了额外的同步开销,拖慢了 System.err 的速度。

从这个案例中,我们可以得到几个有用的启示:

  1. System.outSystem.err 不适合用于生产环境的大量日志输出。 它们是同步的,缺乏灵活的配置能力,且可能受到平台相关性能问题的影响。
  2. I/O 重定向 (>2>2>&1) 的行为和性能可能因操作系统而异。 在进行跨平台开发或性能敏感型应用开发时,需要留意这些差异。
  3. 专业的日志框架 (如 SLF4J + Logback/Log4j2) 是处理应用日志的最佳选择。 它们提供了异步处理、灵活配置、更好的性能和平台无关性。特别是在需要高吞吐量日志记录的场景下,异步日志是必备的。
  4. 如果在特定场景下确实需要直接使用 System.out/err 并重定向,且遇到了类似 Windows 上的性能问题,考虑分离输出 (> out.log 2> err.log) ,这通常更清晰也可能性能更好。

所以,下次再遇到类似的性能“怪事”,不妨跳出 Java 代码本身,思考一下操作系统、底层库以及你使用的命令行工具是如何与你的程序互动的。当然,对于日志记录这个具体场景,尽早切换到专业的日志框架总是个明智的选择。