线上日志被清空?这段仅10行的 IO 代码里竟然藏着3个毒瘤

0 阅读6分钟

#Java #IO流 #避坑指南 #代码规范

摘要: 明明本地跑得通的日志写入代码,一上生产环境却频频爆雷?一段看似简单的 10 行 IO 流操作,竟然暗藏了内存无意义常驻、核心数据被覆盖、文件句柄异常抛出三个致命隐患。本文将从一次真实的 Code Review 现场切入,带你扒开 JVM 缓冲区与 OS 底层资源的边界,彻底终结 flush()close() 的恩怨局。

今天下午做 Code Review,看到一段让我后背发凉的代码。

业务背景很简单:系统要在本地临时记录一批长时间运行的巡检数据。某个新来的兄弟洋洋洒洒写了一段传统的 I/O 流读写代码,本地跑通了,没报错。

但当我仔细审视这段代码时,发现里面埋了三个随时会引爆生产环境的雷:内存无意义常驻、核心日志被覆盖、文件句柄(FD)异常

这是他的原版代码,大家可以先看看能挑出几个毛病:

public void writeLog() {
    String filePath = "/data/logs/patrol.log";
    FileWriter fileWriter = null;
    char[] buf = new char[1024 * 1024]; 
    
    try {
        fileWriter = new FileWriter(filePath); 
        fileWriter.write("巡检数据已更新...");
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            if (fileWriter != null) {
                fileWriter.close(); 
                fileWriter.flush(); 
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

如果你只觉得“代码不够优雅”,那说明你对底层资源管理的边界还不够清晰。我们来逐一拔掉这三个毒瘤。


一、内存刺客:被放逐在 try 外面的大数组

很多开发者习惯把变量统统声明在方法的最顶部,就像上面的 char[] buf = new char[1024 * 1024](假设是个 1MB 的大缓冲区)。

为什么会炸?

从 JVM 内存分配的角度看,这个数组对象一旦 new 出来,其引用就存在于当前方法的栈帧中。如果紧接着下面的 new FileWriter 因为权限不足抛出异常,try 块立刻中断跳入 catch。此时,流根本没创建成功,写操作也不会执行。但那个 1MB 的 buf 数组呢?

它孤零零地躺在堆内存里,而且因为它的作用域在 try 外部,在整个 writeLog 方法结束前,GC绝对不敢回收它,因为它仍是可达的。如果在高并发场景下频繁触发异常,这种无意义的内存常驻会瞬间顶爆年轻代,引发 OOM。

最佳实践是将数组声明移到try块内部:

try {
    fileWriter = new FileWriter(filePath, true);
	// [核心优化]:只在真正需要抽水的时候,才去造水桶
	char[] buf = new char[1024 * 1024]; 
	// ... 读写逻辑
} catch (IOException e) {
	// ...
}

放到 try 内部,一旦抛出异常直接跳出作用域,失去强引用,立刻变成垃圾等待 GC 回收。


二、追加模式 vs 覆盖模式:漏掉一个 true,数据全丢

看这行代码:fileWriter = new FileWriter(filePath);

如果你用这段代码去写增量业务日志,准备提桶跑路吧。在操作系统底层,打开文件有多种模式。当你只传一个 filePath 时,底层默认调用的是 O_TRUNC(截断模式)。这意味着,只要这行代码一执行,原文件里的所有内容都会被瞬间清空,文件指针强制归零。昨天写好的重要日志,今天一运行全没了。

必须加上 true 开启追加模式(对应底层的 O_APPEND):

fileWriter = new FileWriter(filePath, true);

这个 true 参数决定了你是续写还是覆盖。对于业务日志、监控数据等需要累积的场景,永远记得加上它。


三、flush 和 close 的底层逻辑

finally 块里,原作者先调用了 close(),紧接着调用了 flush()。这必然会抛出 java.io.IOException: Stream closed

如果改成先 flush()close(),总没问题了吧?

这引出了一个很多资深开发都容易忽略的盲点:既然 close 包含了 flush,我们在真实业务中,到底什么时候才需要手动调用 flush?

答案是:当你想让流保持开启,但又急着把数据刷下去的时候。

[Java 用户态缓冲区]
        │
      flush() ──强行将缓冲数据推入──> [OS 内核态 PageCache] ──> [物理硬盘]
        │
      close() ──隐式执行flush(),随后彻底砸碎管道(释放 FD)

真实场景还原(服务器核心日志落盘):

假设你的监控程序要运行一整天不能停,所以流对象 FileWriter 一直保持 open 状态。

  1. 如果不 flush:程序跑了 5 个小时,产生了一大堆日志,全堆在 JVM 内存缓冲区里。突然机房停电(或进程被 kill -9 强杀),因为没执行到最终的 close(),内存里的日志全部灰飞烟灭

  2. 配合 flush:每当捕获到重要的数据库断连异常,立刻调用 write(),然后紧接着调用一次 flush()。这样即使下一秒进程崩溃,这条核心报错也已经“落袋为安”进入了操作系统的 PageCache。而此时,水管依然没断,服务重启恢复后还可以继续写。

至于那个“先 close 后 flush”的低级报错,生产环境真的会发生吗?

绝对会。在复杂的企业级项目中,流对象往往作为参数在多层方法间传递。程序员 A 在底层工具类里把流 close() 释放了,而外层的程序员 B 不知道,还在上层业务里试图往里面 write()flush()。这个经典的 Stream closed 异常就会在生产环境瞬间引爆。


避坑指南

写 I/O 代码,防泄漏比实现功能更重要。直接记住最终的 Action Item:

永远使用 Java 7 引入的 try-with-resources 语法,把资源释放和隐式的 flush 丢给 JVM。而在长连接/长生命周期的流写入中,务必在关键节点手动 flush 保证数据落盘。

public void writeLogSafe() {
    String filePath = "/data/logs/patrol.log";
    try (FileWriter fileWriter = new FileWriter(filePath, true)) {
	    //将流声明在 try 的括号内,自动执行释放
        char[] buf = new char[1024 * 1024];
        fileWriter.write("巡检数据已更新...");
    } catch (IOException e) {
        log.error("写日志失败", e);
    }
}

面试官问 flush 和 close 的区别,怎么答出深度?

Q:“能说说 flush() 和 close() 在底层资源管理上的本质区别吗?”

高分回答:

flush() 的本质是触发系统调用,将 JVM 用户态缓冲区的数据强制刷入操作系统的 PageCache,它不涉及文件句柄(FD)状态的改变,流依然可用。

close() 是一次不可逆的终结操作。它在执行前会自动做一次 flush,随后向操作系统发起请求,彻底释放关联的文件句柄(File Descriptor)资源。一旦释放,任何针对该流的写操作都会因底层管道断开而抛出 Stream closed 异常。在复杂对象的传递中,极易因为生命周期管理混乱导致流被提前关闭。”