
很多人第一次被flush坑到都是在写了这样一行代码之后out.flush()然后心安理得地认为数据已经躺在磁盘文件里了。结果另一头读文件的程序什么都没读到或者断电重启之后文件里空空如也。问题出在一个非常反直觉的事实上FileOutputStream这个类根本没有重写flush方法它继承的是OutputStream里那个方法体为空的实现。换句话说你在FileOutputStream上调用flush()JVM 什么都没做它不报错、不抛异常、也没有任何效果就像一个被按下去但没接线的开关。这个标题里说的“有时不生效”其实是两个不同的问题叠在了一起一个是flush调错了对象另一个是把“写进了内核”误当成了“写进了磁盘”。这篇东西会把这两件事拆开讲清楚包括缓冲流的阈值为什么会制造出“有时灵有时不灵”的假象、JVM 到磁盘之间到底隔了几层、真正要保证持久化该调什么、以及我在排查这类问题时固定会走的那套链路。适合已经会写 Java 文件读写、但被这类“玄学”现象卡住过的开发同学也适合刚接触文件 IO、想一次性把概念理顺的新手。1. 先破一个前提FileOutputStream 的 flush 是个空方法1.1 把 JDK 源码摊开看答案一直摆在那里打开任意一个 JDK 版本的src.zip找到java.io.OutputStream你会看到这样的代码public void flush() throws IOException { }方法体是空的只有一个签名和一对大括号。再看FileOutputStream它的方法列表里有write(int)、write(byte[])、write(byte[], int, int)、close()、getFD()、getChannel()唯独没有flush()。这意味着什么意味着对一个纯粹的FileOutputStream实例调用flush()实际执行的是父类里那个空方法JVM 直接返回一个字节的搬运都不会发生。这个设计不是偷懒而是语义决定的。flush的语义是“把调用方自己持有的缓冲区内容推给下游”而FileOutputStream本身没有任何用户态缓冲区。它是从 Java 字节数组直接走到系统调用write(2)的没有中间层所以也就没有东西需要“冲出去”。同理OrderOutputStream、ByteArrayOutputStream、FileInputStream这类不带缓冲的流flush要么是空实现要么只有极轻微的语义比如ByteArrayOutputStream.flush也是空的。真正让flush有意义的是那些内部藏了一块byte[]的流BufferedOutputStream、BufferedWriter、PrintWriter、OutputStreamWriter、GZIPOutputStream、CipherOutputStream等等。我见过不少人以为FileOutputStream.flush()会去调用fsync这是一个更深的误解。即使flush真的做了点什么它最多也就是把数据从 Java 堆推到内核的页缓存离“数据落到磁盘介质上”还隔着文件系统这一整层。flush从来就不等于持久化这一点在后面第 4 章还会展开。提示如果你手里有一个OutputStream引用不确定它到底是什么实现先别急着调flush。打印一行out.getClass().getName()比你翻半小时代码有用得多。1.2 一次 write 之后数据到底停在了哪一层要理解“为什么没生效”得先知道数据从你的byte[]出发要经过几道关。以 Linux 上的普通本地文件系统为例路径大致是这样的第一层是 Java 堆里的字节数组也就是你传给write(byte[])的那个b。第二层是 JVM 内部的实现FileOutputStream.write最终会走到native writeBytes把 Java 数组的地址交给 JNIJNI 再决定是否需要做一次内存拷贝。第三层是系统调用write(2)这一步把数据从用户态拷进内核的页缓存page cache。第四层才是文件系统在某个时刻把这些脏页刷到块设备上这一步由内核的 writeback 线程异步完成或者由fsync/fdatasync强制触发。关键点在第三层和第四层之间。一旦write(2)返回成功数据就已经在内核页缓存里了此时同一台机器上的任何进程去读这个文件都能读到新内容哪怕它一秒钟之后才落盘哪怕你现在直接拔电源会丢掉它。所以“write 之后别的进程读不到”这件事在本地文件系统上几乎不可能是内核层的问题问题一定出在更上面——数据还卡在 Java 用户态的某个缓冲区里没出来。这个认识非常重要它把排查方向一下子收紧了一半如果你的现象是“另一个进程读不到”那你应该去查 Java 层的包装流如果你的现象是“断电或崩溃后数据丢了”那才是页缓存和落盘的问题。两者根本不是同一类故障用药也完全不同。1.3 “没写出去”和“没落盘”是两码事我把这两类问题整理成一张对照表排查的时候先对号入座能省掉大量来回试错现象数据停在哪通常的根因对症的处理同机另一个进程读不到新内容Java 用户态缓冲区外层缓冲流没 flush / flush 错了对象 / 根本没调flush 或 close最外层的流本进程 flush 后自己再读能读到别人读不到内核页缓存或观察工具的缓冲观察端编辑器的重载、tail实现没刷新或跨节点挂载换观察方式验证必要时 close 后重试进程正常退出后再读内容正常一过性问题finally里补了 closeclose 顺带刷了缓冲排查为什么依赖了 close 而不是显式 flush断电、宕机、容器被 kill 后数据丢失内核页缓存尚未回写只 flush 没 fsyncFileDescriptor.sync()或FileChannel.force(true)数据量小时丢数据量大时正常缓冲区未满缓冲流阈值没触发自动刷显式 flush别依赖缓冲区写满最后一行是理解“有时不生效”的钥匙。缓冲区这个东西的行为就是装满了自动倒没装满就一直攒着。你写 100 字节它纹丝不动你写 100KB它中途自动倒了好几次。同一个人、同一段代码换个数据量结果就不一样看起来当然像“有时灵有时不灵”。这不是玄学是确定性的阈值行为。2. 包装层才是重灾区flush 调用打到了空气上2.1 BufferedOutputStream 的 8192 阈值解释了“有时”二字BufferedOutputStream的默认缓冲区大小是 8192 字节这个数字在BufferedOutputStream的构造器里写死虽然可以自己指定但绝大多数人用的都是无参构造。它的write(int b)逻辑大致是缓冲区满了就先flushBuffer()再把字节塞进去write(byte[], off, len)则是如果一次写入的长度超过缓冲区剩余空间就直接绕过缓冲区调底层的write。于是就有了下面这组非常典型的行为差异BufferedOutputStream bos new BufferedOutputStream(new FileOutputStream(a.txt)); bos.write(hello.getBytes(StandardCharsets.UTF_8)); // 5 字节留在缓冲区里 // 此时另一个进程读 a.txt读到的是空文件 bos.write(new byte[9000]); // 超过 8192触发一次自动 flush // 此时前 5 字节已经被推到内核了注意这里最关键的一点new BufferedOutputStream(new FileOutputStream(...))之后你手里的引用指向的是外层那个BufferedOutputStream。如果你不小心把内层的FileOutputStream也保存成了成员变量然后在别处对那个内层引用调flush()等于什么都没做。这种情况在真实代码里极其常见尤其是流对象是通过构造器注入或者工厂方法创建、层层传递的时候// 常见的错误写法 FileOutputStream fos new FileOutputStream(log.txt); BufferedOutputStream bos new BufferedOutputStream(fos); this.fos fos; // 存了内层 this.bos bos; // ... 别的地方 fos.flush(); // 空方法无效 bos.flush(); // 这才是有效的BufferedOutputStream.flush()的实现是先flushBuffer()——把buf[0..count)这段数据写给底层out然后把count归零然后再调out.flush()。所以只要你对最外层调 flush整条链上的缓冲都会被清空。反过来对任何内层调 flush外层缓冲区里的东西一律不动。2.2 Writer 家族的字节缓冲藏在 StreamEncoder 里比字节流更隐蔽的是字符流。FileWriter在较早的 JDK 里是直接包装FileOutputStream并持有一个StreamEncoder新版 JDK 里它直接继承OutputStreamWriter但底层机制没变。StreamEncoder内部有一个ByteBuffer默认容量 8192用来存放编码后但还没写出去的字节同时它的父层Writer可能还有一个char[]缓冲BufferedWriter的默认 8192 个字符。这就造成了三层缓冲叠加的可能性BufferedWriter bw new BufferedWriter(new FileWriter(b.txt)); // bw 的 char[] 缓冲 8192 字符 // FileWriter 内部的 StreamEncoder 的 ByteBuffer 8192 字节 // 底层 FileOutputStream 无缓冲你要么对bw调flush()它会先刷字符缓冲再刷编码器的字节缓冲再到底层要么老老实实close()。单独对FileWriter调 flush 只能清掉编码器那一层清不掉BufferedWriter那一层的字符缓冲——这是很多人栽的地方。他们看到自己明明对FileWriter调了 flush程序还是不生效其实是因为外面还套了一个BufferedWriter字符压根还没进入编码器。一个简单的判断方法从你创建流的那行代码开始往外数一共有几个包装层flush 只能打在最外面那一层。中间任何一层被跨过去调都是无效的。2.3 PrintWriter 吃掉异常这件事没人提醒过你PrintWriter是另一个高频雷区而且它的坑比缓冲更深它会把IOException吞掉。看它的设计意图就明白了——PrintWriter是为了“像System.out那样方便地打印”而设计的所以它的write、print、println系列方法都不抛出IOException。内部实现是try { out.write(...) } catch (InterruptedIOException x) { Thread.currentThread().interrupt(); } catch (IOException x) { trouble true; }。也就是说写入失败时它只是把内部一个trouble标志置为 true然后继续往下走你的代码毫无感知。这就制造了一类特别难查的“不生效”磁盘满了、权限不对、文件被别的进程独占、路径跨设备挂载异常这些都会让底层写入抛异常但PrintWriter一律静默吞掉你的程序照常跑完日志里干干净净。PrintWriter pw new PrintWriter(new FileWriter(c.txt)); pw.println(something); pw.flush(); if (pw.checkError()) { // 必须手动检查否则永远不知道出过错 // 这里才是真正出问题的地方 }checkError()会顺手把内部的out.flush()也调一遍所以它同时完成了“刷缓冲”和“查错误”两件事。我在任何用PrintWriter写文件的场景里都会在最后加一次checkError()判断成本几乎为零收益是避免了一整类无声故障。3. 一次“小文件不刷、大文件刷”的排查全过程3.1 现场还原症状比想象中更诡异前段时间排查一个模拟项目里的问题现象是这样某个后台任务会往一个文本文件里追加处理进度前端有个监控页面定时去读这个文件。开发同学反馈“进度更新不及时有时候等半天不动有时候一秒跳好几条”。代码大体长这样public void report(String line) throws IOException { FileOutputStream fos new FileOutputStream(logFile, true); BufferedOutputStream bos new BufferedOutputStream(fos); bos.write((line \n).getBytes(StandardCharsets.UTF_8)); fos.flush(); fos.close(); }乍看没毛病而且他还挺“规范”地加了 flush。但问题就出在那个fos.flush()上——它是空方法。真正持有缓冲区的是bos而bos从头到尾没有被 flush 过。至于为什么“有时候会跳好几条”是因为他fos.close()了close会关闭文件描述符但注意BufferedOutputStream并没有被 close所以bos缓冲区里的数据在方法返回后就成了垃圾等 GC 回收时才会被finalize或者干脆丢掉。等等这里有个细节需要澄清。上面这段代码里fos.close()之后bos里的缓冲还有 8192 字节没推下去但底层 fd 已经关了再推就会抛IOException: Stream Closed。所以他实际观察到的“有时候能更新”其实是那些恰好超过 8192 字节触发过自动 flush 的批次。每次report调用产生的数据量不固定有的几百字节有的几 KB累积到某个批次超过阈值就刷一批出去于是监控页面就“跳好几条”。这就是“有时”的完整解释。3.2 打印真实类型让调用链自己说话我的第一反应不是改代码而是先确认到底有几个缓冲层、谁持有了谁。加了两行日志System.out.println(outer bos.getClass().getName()); System.out.println(inner fos.getClass().getName()); System.out.println(outer buffer size 8192); // 无参构造固定 8192结果印证了判断outer是java.io.BufferedOutputStreaminner是java.io.FileOutputStream。到这一步问题已经非常明确了——对inner调 flush 是无效的而outer从来没被 flush 过。这一步看起来简单但在真实排查里很多人会跳过。他们会凭印象去改代码“我觉得我 flush 了呀”。而getClass().getName()是唯一不带主观判断的证据它告诉你运行时到底是哪个类在处理这个调用。我建议把这一步固化成习惯任何一次 flush 相关的排查第一件事就是打印所有流对象的真实类型。3.3 用二分法定位到 8192 这个阈值为了确认“阈值”这个假设而不是别的什么原因我写了个十几行的小程序做验证for (int size 100; size 16000; size * 2) { File f new File(probe_ size .bin); try (BufferedOutputStream b new BufferedOutputStream(new FileOutputStream(f))) { b.write(new byte[size]); // 故意不 flush直接看文件大小 System.out.println(size - f.length()); } }输出很有说服力100、200、400……一直到 4096 都输出08192 输出0因为write一次写入正好等于缓冲区大小时实现上的处理是先判断len buf.length走直写所以 8192 会直接写出去16384 输出16384。这个梯度正好卡在 8192 上下和缓冲区默认容量完全吻合。到这里“有时生效”这个描述就被还原成了一个精确的、可复现的边界条件。顺带说一句BufferedOutputStream.write(byte[], off, len)里有一个判断如果len大于等于缓冲区长度就直接调out.write(b, off, len)绕过缓冲区。所以一次性写大于 8192 字节的数据是不经过缓冲区的你会觉得“诶这次怎么生效了”。这也是“有时”的另一个来源跟上一节说的是同一件事的两面。3.4 三种改法代价各不一样定位清楚之后改法就不止一种选哪种取决于这个report方法的调用频率和实时性要求第一种改成bos.flush()。最小改动一行。缺点是每次调用都触发一次系统调用如果report被高频调用比如每条日志都调一次系统调用的开销会累积起来。第二种把bos提升为成员变量只在合适的时机 flush。这样能摊薄系统调用但引入了对象生命周期管理要保证进程退出、异常路径、以及空闲一段时间之后都有人负责 flush否则数据会滞留。同时多线程访问成员变量需要注意同步。第三种干脆不要BufferedOutputStream直接用FileOutputStream写。因为FileOutputStream本身无缓冲每次 write 直接进内核天然满足“实时可见”的需求。代价是每次 write 都是一次系统调用写小数据时效率不如缓冲版本。对于“频率不高但要求实时”的进度汇报场景这反而是最合适的选择。我最后选的是第三种。理由很简单这个report一分钟最多调几次性能根本不是瓶颈而无缓冲带来的“写完即刻可见”正是这个场景最需要的语义。用缓冲换来的性能只有在高频写入时才值得低频场景下缓冲只是一个额外的故障源。4. 要真正的持久化flush 之后还差这一步4.1 sync、force 与 fsync、fdatasync 的对应关系假设你已经在最外层调了正确的 flush数据成功进了内核页缓存。这时候如果机器突然断电数据还是会丢。要让它真正落到磁盘介质上Java 提供两个入口FileOutputStream fos new FileOutputStream(d.txt); fos.write(data); fos.flush(); // 对 FileOutputStream 而言是空操作但保留无害 fos.getFD().sync(); // 等价于 fsync(2)数据和元数据都同步另一个入口是FileChanneltry (FileChannel ch FileChannel.open(Paths.get(d.txt), StandardOpenOption.WRITE, StandardOpenOption.CREATE)) { ch.write(ByteBuffer.wrap(data)); ch.force(true); // true: 等价 fsyncfalse: 等价 fdatasync }这两个 API 和操作系统调用的对应关系是这样的Java API系统调用同步范围适用场景FileDescriptor.sync()fsync(2)文件数据 文件元数据大小、时间戳等要求最严格配置文件、账目类数据FileChannel.force(true)fsync(2)同上同上FileChannel.force(false)fdatasync(2)只保证文件数据元数据按需追加写日志元数据变化不关键OutputStream.flush()无仅清用户态缓冲从不保证持久化force(false)在某些文件系统上能省掉一次元数据写比force(true)快一些代价是如果这次写改变了文件大小典型的追加日志场景元数据也需要更新那省下来的部分就有限了。我的习惯是不确定就用true只有在明确的性能压测数据支撑下才降到false。还有一个容易被忽略的点fsync保证的是文件内容落盘不保证目录项落盘。如果你新建了一个文件然后对文件 fsync 了但目录的元数据还在缓存里断电后可能出现“文件内容在但目录里找不到这个文件”的情况。要严格保证得对文件所在目录也做一次同步Java 里没有直接 API通常只能在创建文件之后短暂等待或依赖文件系统的 journal。绝大多数业务场景不会纠结到这个程度但知道有这么一层能让你在面对“文件莫名其妙消失”的报障时多一条思路。4.2 close 做的事和你以为的不一样很多人对close()有一个美好的误解觉得close会帮你 flush 所有东西。这个说法对包装流成立对FileOutputStream不成立。FileOutputStream.close()的实现是如果还有 channel 就关 channel否则直接close0()关掉 fd。它没有调用 flush因为本来就没有缓冲。而BufferedOutputStream.close()的实现是先判断是不是自己的buf然后flushBuffer()再out.close()。BufferedWriter.close()类似先flushBuffer()再关底层。所以“close 会刷缓冲”这句话只对包装流成立而且必须是你 close 的就是最外层那个包装流。这解释了一类很常见的现场代码里 try-with-resources 只包了内层或者内层被提前关闭了外层残留在那里。看这段// 错误fos 被提前关闭bos 之后无法写出 FileOutputStream fos new FileOutputStream(e.txt); BufferedOutputStream bos new BufferedOutputStream(fos); bos.write(data.getBytes()); fos.close(); // 关掉了底层 bos.flush(); // 抛 IOException: Stream Closed另一个变体是把 flush 写在 close 之后try { bos.write(data); } finally { bos.close(); bos.flush(); // 必然抛异常而且这个异常可能被上层吞掉 }这类顺序问题在异常路径上特别容易出因为正常路径上你可能恰好在 close 前调了 flush测试时一切正常只有走到异常分支时才炸。所以顺序永远是先 flush再 closeclose 放在 finally 或 try-with-resources 里。4.3 一次 fsync 有多贵什么时候该省fsync是同步阻塞的快慢完全取决于底层存储。机械盘上单次几毫秒到十几毫秒很常见SSD 上通常零点几毫秒但如果存储设备带写缓存且没有掉电保护文件系统可能还会做额外的屏障操作。在高频写场景里每写一条就 fsync 一次吞吐会掉一到两个数量级。我做过一个粗略的对比同一块盘上顺序写 10000 条小记录写入方式耗时量级断电后的数据完整性纯FileOutputStream.write最快大量丢失加BufferedOutputStream批量写更快更多丢失每次 write 后force(false)慢 1~2 个数量级基本完整可能丢最后一条的元数据每次 write 后force(true)最慢完整结论很直接不要把 fsync 放在每条记录的路径上。合理的做法是批量提交比如攒够 N 条或者每隔 M 毫秒做一次force把同步的代价摊薄。这个权衡在数据库的 WAL、消息队列的刷盘策略里都是核心设计点你自己写文件时也该有同样的意识。注意force只能保证调用那一刻之前写入的数据落盘调用之后新写的数据依然在页缓存里。所以“批量 定时 force”的策略里最坏情况丢失的是最后一个批次的数据这个窗口大小需要根据业务对数据丢失的容忍度来定。5. 假象与真故障为什么“别人看不到、我看得到”5.1 观察工具自己的缓冲也会骗你有时候代码完全正确flush 打在了对的对象上数据也确实进了内核但你就是“看不到”。这种时候先怀疑观察端。最常见的是编辑器的自动重载机制。很多编辑器打开文件时会把内容读进内存之后依赖文件变更事件来决定是否重新加载如果事件没触发、或者编辑器处于“有未保存修改”的状态它就会拒绝重载你看着还是老内容。判断方法很简单关掉文件再重新打开如果内容对了那就是观察端的问题跟你的代码没关系。tail -f也有类似情况。类 Unix 系统上的tail -f通常基于变更通知机制一般比较及时但一些精简环境里的实现是定时轮询默认间隔可能有一秒甚至更长而且如果文件被替换而不是原地追加比如你每次都new FileOutputStream(file)不带 append 参数等于截断重写tail -f跟的可能还是旧的 inode你看到的内容永远停在某个时刻。这一点我在排查“日志不更新”时踩过不止一次明明每次写都 flush 了tail就是不动最后发现是文件被反复截断重建tail压根没跟到新文件上。还有一层如果你是用 Java 自己写代码去读那个文件验证注意读取端的BufferedReader之类也有自己的缓冲读取位置可能没到你新写的那一段。验证“是否写出去”最可靠的方式是File.length()或者Files.size()它走的是元数据不带任何应用层缓冲。5.2 跨节点、容器挂载与网络文件系统单机本地文件系统上write(2)返回之后同机任何进程都能读到这一点前面说过。但只要你的文件位于网络挂载点上规则就变了。网络文件系统普遍采用“关闭时提交、打开时校验”的一致性模型——写端在close时才把数据和元数据推给服务端读端在open时去校验缓存是否过期。这意味着写端只要不 close另一个节点上的读端就看不到新数据哪怕你 fsync 了也不行因为客户端可能根本没有把数据提交上去或者读端手里的属性缓存还没过期。这类问题的典型症状是同一个进程内自测完全正常一部署到多节点环境就“偶发看不到”。排查时确认两件事就够了这个路径是不是网络挂载看挂载信息以及数据是不是一直被一个长期打开的流持有、从不 close。如果是后者改成“每批数据写完就 close 一次下一批重新 open”通常立刻见效。代价是 open/close 的开销但对低频写入完全可接受。容器场景还有一个变体宿主机上看到的是宿主机的挂载点容器内写的是自己那一层的路径如果挂载配置没对上你在宿主机上cat当然看不到容器里写的文件。这类问题不需要看代码mount或者查一下容器的挂载配置就清楚了。5.3 多线程共用一条流导致的错乱最后要说的这一类症状和前几种不同不是“没写进去”而是“写进去的内容乱了、串了、少了半行”。根因是多线程共用同一个带缓冲的流。带缓冲的流内部维护一个数组和一个游标多个线程同时往里写如果没有正确的同步两个线程可能同时判断“还有空间”然后都往同一个位置写后写的覆盖先写的或者一个线程正在 flush 清空游标另一个线程正在更新游标结果数据被截断。某些版本的 JDK 里BufferedOutputStream的方法带了synchronized看上去是安全的但即使如此“单次 write 是原子的”也不等于“多次 write 组合起来是原子的”你写一行日志的两段内容之间仍然可能被别的线程插进去。我在一个模拟项目里见过这个现象日志文件里偶尔出现半行 A 的记录接着半行 B 的记录。最后的处理方式是把共享的流去掉改成每个写线程持有自己的流配合追加模式打开让操作系统层面来处理并发追加。这样既避免了 Java 层的同步开销也保证了单次写入的完整性。// 每个线程独立打开追加模式 try (FileOutputStream fos new FileOutputStream(file, true)) { fos.write(record); // 单次 write 的数据在追加模式下基本不会被拆开 }需要说明的是追加模式下单次write的原子性取决于文件系统和调用方式超大块写入仍然可能被拆分所以“一次 write 只写一条完整记录、且记录不要太大”是个稳妥的约定。6. 可以直接拿走用的写法与自查表6.1 三种典型场景的模板第一种要求写入后立刻能被其他进程看到写入频率不高public void appendImmediate(Path path, byte[] data) throws IOException { // 不加缓冲write 直接进内核写完即可读 try (FileOutputStream fos new FileOutputStream(path.toFile(), true)) { fos.write(data); fos.getFD().sync(); // 需要扛断电就加上不需要可以去掉 } }第二种高频写入性能优先能接受短暂延迟public class BatchWriter implements Closeable { private final BufferedOutputStream out; private int pending 0; private static final int FLUSH_EVERY 200; public BatchWriter(File file) throws IOException { this.out new BufferedOutputStream(new FileOutputStream(file, true), 64 * 1024); } public void write(byte[] record) throws IOException { out.write(record); if (pending FLUSH_EVERY) { out.flush(); // 刷到最外层 pending 0; } } Override public void close() throws IOException { out.flush(); // 先刷再关顺序不能反 out.close(); } }第三种字符写入且需要精确控制编码public void writeText(Path path, String text) throws IOException { try (BufferedWriter w new BufferedWriter( new OutputStreamWriter( new FileOutputStream(path.toFile()), StandardCharsets.UTF_8))) { w.write(text); w.flush(); // 刷最外层字符缓冲和编码器缓冲一起清 } }这三个模板的共同点是flush 永远打在最外层close 永远在 flush 之后需要持久化时显式调用getFD().sync()或force()。记住这三条能覆盖九成以上的坑。6.2 上线前我会过一遍的检查点下面这份清单是我自己在写文件 IO 代码时的固定动作逐条过一遍基本不会翻车[ ] 从创建流的那一行开始数一共套了几层包装flush 打的是不是最外面那一层[ ] 有没有把内层的流单独存成字段、在别处被误调用[ ] flush 和 close 的顺序是不是先刷后关有没有写在finally里且可能覆盖原异常[ ] 异常路径上有没有保证 flush 执行空指针和流未初始化的情况是否覆盖[ ] 如果用了PrintWriter结尾有没有checkError()判断还是让异常静默消失了[ ] 如果用了OutputStreamWriter或FileWriter编码有没有显式指定是否依赖了平台默认值[ ] 需要扛断电的场景有没有在合适的批次边界调用sync或force而不是每条都调[ ] 追加写用的是new FileOutputStream(f, true)还是先 seek 到末尾再写后者在多进程下不安全[ ] 文件被反复截断重建的场景tail之类的观察工具是否跟得上新的 inode[ ] 多线程写同一个文件时是共用一个流还是各写各的共用的话同步策略是什么最后一条经验来自我反复踩坑之后形成的习惯当你看到“有时不生效”这四个字的时候几乎可以确定背后存在一个阈值或者一个条件分支。缓冲区满没满是阈值包装层套没套是条件观察端刷没刷新也是条件。把“有时”拆成“在什么条件下会、在什么条件下不会”问题基本就解了一半。剩下的一半就是把流对象的真实类型打出来看看你以为的那个调用到底落在了谁身上。