1. 先把 flush 在 FileOutputStream 上到底做了什么事说清楚
1.1 它其实是个空方法,什么都没做
很多人对FileOutputStream调flush()的心理预期是"把数据推出去",但如果你翻开 JDK 源码,FileOutputStream压根就没有重写flush(),它继承的是OutputStream里的实现,方法体是空的:
// java.io.OutputStream public void flush() throws IOException { }是的,空的。一行都没有。所以你写fos.flush()的时候,编译器不报错、IDE 不提示、运行时也不抛异常,但它真的什么都没干。这不是 bug,是设计使然——FileOutputStream底层就没有用户态缓冲区,每次write都是一次真实的系统调用,把字节交给内核。
我见过不少同事在这上面卡半天,调试器断点打在flush()那一行,单步进去看到空方法体,愣了两秒然后骂了一句。更有意思的是,如果FileOutputStream外面套了一层BufferedOutputStream,有些人会想"我内层 flush 一下先让数据下去,外层慢慢攒",结果内层flush()是空操作,数据全卡在BufferedOutputStream那个 8KB 的byte[]里,文件大小纹丝不动。
注意:
flush()的语义是"把这一层缓冲区里的数据推给下一层"。如果这一层没有缓冲区,那它就没什么可推的;如果下一层还有缓冲区,推过去也没用。理解这句话,标题里 80% 的"不生效"就已经破案了。
1.2 一次 write 到落盘,中间其实有四层缓冲
要判断 flush 到底"生效"到什么程度,得先建立一张全链路图。从你的write()调用到数据真正躺在盘片上,中间穿过了四层:
| 层级 | 谁在负责缓冲 | 怎么才能把数据推到下一层 | 掉电会不会丢 |
|---|---|---|---|
| 应用层包装流缓冲 | BufferedOutputStream、OutputStreamWriter内部的StreamEncoder、GZIPOutputStream的Deflater | 调用最外层的flush()或close() | 会丢 |
| JDK 原生 fd 写入 | FileOutputStream自身,无缓冲 | 直接触发write(2)系统调用 | 不涉及 |
| 内核页缓存 page cache | 操作系统,脏页由回写线程周期性刷 | fsync(2)/fdatasync(2)/sync(2)/ 打开时带O_SYNC | 会丢 |
| 存储设备写缓存 | 机械盘/SSD 固件里的小缓存 | 收到SYNC CACHE命令或 FUA 标志 | 无掉电保护的设备仍可能丢 |
看清楚这张表,标题里那句"flush 操作有时不生效"就有了解释空间——因为"生效"这个词本身是含糊的。你是希望同机器上的另一个进程能立刻读到?还是希望进程被 kill -9 后数据还在?还是希望机器突然断电后数据还在?三种诉求对应的动作完全不同,而FileOutputStream.flush()对这三种诉求的贡献都是零。
我一般会先问提问的人一句:你说的"不生效",具体是怎么观察到的?这一问,往往对方自己就发现问题在哪了。
1.3 "不生效"的三种典型语义,对应三套完全不同的解法
第一种语义是可见性:写完之后另一个进程读不到、tail -f看不到。这种情况在单机上几乎不可能发生,因为write(2)返回时数据已经进了 page cache,同一台机器上任何进程读同一个文件都会命中同一份页缓存。真正读不到,基本都是因为数据压根没到内核——卡在应用层缓冲区了。
第二种语义是进程存活:JVM 被kill -9、或者 OOM Killer 干掉了,重启后发现文件末尾缺了一截。这就是 page cache 的问题,write返回不等于落盘,脏页还没到回写周期,进程一死数据就跟着内存一起没了。这种情况必须fsync。
第三种语义是设备掉电:机房断电、容器所在宿主机宕机。理论上fsync之后数据应该安全,但如果磁盘自己的写缓存没有掉电保护,且驱动没有下发SYNC CACHE,仍有可能丢最后一批数据。这个层级已经超出 Java 能控制的范围了,属于硬件和文件系统挂载参数的范畴。
三件事混在一起谈,就会得出"flush 有时生效有时不生效"这种玄学结论。实际上它一次都没生效过,只是你观察的维度变了。
2. 四种真实世界里的失效场景,逐个拆开看
2.1 包装流只 flush 了内层,外层还在憋着
这是出现频率最高的一种,没有之一。典型代码长这样:
File file = new File("/tmp/demo.log"); FileOutputStream fos = new FileOutputStream(file, true); BufferedOutputStream bos = new BufferedOutputStream(fos, 8192); bos.write("hello\n".getBytes(StandardCharsets.UTF_8)); fos.flush(); // 以为这样就把数据推下去了 System.out.println(file.length()); // 打印 0 bos.flush(); // 这才是关键 System.out.println(file.length()); // 打印 6跑一遍,第一行打印0,第二行打印6。原因前面说了,fos.flush()是空方法,bos里那 6 个字节根本没动。
比BufferedOutputStream更隐蔽的是OutputStreamWriter/FileWriter。FileWriter继承自OutputStreamWriter,内部持有一个StreamEncoder,默认缓冲区是 8192 字节。你用new FileWriter(f)写了一点东西,看着像写进文件了,其实全在StreamEncoder的ByteBuffer里,必须flush或close才会编码成字节并下发。
再往上一层,PrintWriter、ObjectOutputStream、DataOutputStream都有各自的缓冲。一个链条套三层是常有的事:
PrintWriter pw = new PrintWriter( new BufferedWriter( new OutputStreamWriter( new FileOutputStream(file, true), StandardCharsets.UTF_8)));这种写法不是炫技,是真实项目里为了控制编码和控制换行符搞出来的。此时你只flush最里面那一层,等于什么都没做。规则很简单:flush 永远从最外层调,一层都不能漏。
2.2 flush 做了它该做的,但它本来就不负责落盘
第二种场景是"人没问题,预期错了"。看下面这段:
try (FileOutputStream fos = new FileOutputStream(path, true)) { fos.write(payload); fos.flush(); // 空操作,但即使有作用也只是到内核 } // 进程正常退出,数据在 page cache 里,还没落盘try-with-resources结束时close()会关闭 fd,但关闭文件描述符并不会触发 fsync。数据安安静静躺在 page cache 里,等着内核回写线程来处理。Linux 上回写有两组参数控制:/proc/sys/vm/dirty_writeback_centisecs决定回写线程多久醒一次,默认 500(也就是 5 秒);/proc/sys/vm/dirty_expire_centisecs决定脏页多久算"过期",默认 3000(30 秒)。也就是说,一批数据正常情况下几秒到几十秒内会被刷下去,但这个时间窗口内断电就是丢。
很多人在本地开发时从来没遇到过,因为写完就看文件、机器也没断过电。一上生产环境,容器被调度走、节点被重启、OOM 被杀,日志尾部就少了一段。然后回头查代码,看到flush()调了,就断定"flush 不生效"。
真正要落盘只有一条路:FileDescriptor.sync()或者FileChannel.force()。这两个最终都会走到fsync(2):
try (FileOutputStream fos = new FileOutputStream(path, true)) { fos.write(payload); fos.getFD().sync(); // 真正的 fsync }sync()会抛SyncFailedException,说明底层fsync返回了错误,通常是磁盘满了或者 IO 出错,这个异常不能吞。
2.3 PrintWriter 的 autoFlush 只认 println,不认 print
这个坑我踩过一次,印象很深。当时写一个按行输出的小工具,用了:
PrintWriter pw = new PrintWriter(new FileWriter(file), true); // autoFlush = true pw.print("no newline here"); // 这里没有 println,autoFlush 不触发PrintWriter的autoFlush语义在 Javadoc 里写得很清楚:只有在调用println、printf、format这几类方法时才会触发 flush,普通的print不会。更坑的是,autoFlush触发的 flush 只推到PrintWriter这一层,底下FileWriter的StreamEncoder还是要靠flush才能把字节交给FileOutputStream。不过PrintWriter.flush()内部会级联调用out.flush(),所以这条链路是通的,只要触发了就没问题。
类似的还有ObjectOutputStream。它内部有一个BlockDataOutputStream,缓冲 1024 字节,还有一层HandleTable做对象引用记录。如果你在两个 JVM 之间传对象,写完一个对象没 flush,对面的readObject就会一直阻塞等数据。这种情况必须显式flush(),必要时还要reset()清掉句柄表,否则同一个对象被复用引用,对方可能反序列化出意料之外的结果。
还有一个更隐蔽的:GZIPOutputStream。它的flush()默认行为是把底层outflush 一下,但Deflater内部攒着的压缩数据不会输出,因为默认构造的syncFlush是false,需要累积到一个完整的 deflate block 才吐出来。如果你做的是流式压缩传输,发送方flush之后等着接收方解压,接收方会一直卡着没数据。正确做法是用带syncFlush参数的构造函数:
GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(file), true); // 或者在网络流场景 GZIPOutputStream gz = new GZIPOutputStream(socket.getOutputStream(), true);打开syncFlush后,flush()会用Deflater.SYNC_FLUSH把当前可压缩的数据强行收尾成一个块吐出来,接收方就能立刻解压出已经发送的部分。代价是压缩率会下降,因为每个 SYNC_FLUSH 都会插入一个空的存储块。这个取舍要看场景:实时性优先就开,纯文件归档就别开。
2.4 跨进程、跨机器、跨文件系统的可见性差异
单机单文件系统上,页缓存是共享的,write返回后其他进程就能读到。但一旦跨出这个边界,情况就变了。
NFS 是最典型的例子。NFSv3 有所谓的 close-to-open 一致性:客户端 A 写完不close,客户端 B 打开文件读,可能读到旧内容。因为 A 的写入还缓存在客户端本地的页缓存里,没有提交到服务端。这种情况下flush()更没用,你需要的是close()或者fsync()(NFS 上fsync会触发 COMMIT 操作把数据推给服务端)。
容器场景也有类似情况。如果两个容器共享一个 volume,但挂载方式不同(比如一个用 bind mount,一个是 volume),理论上还是同一个内核页缓存,可见性没问题。但如果其中一个用了O_DIRECT打开文件,就会绕过页缓存直接读磁盘,这时候对方写的数据还在缓存里没落盘,O_DIRECT的读者是看不到的。Java 本身不直接暴露O_DIRECT,但某些数据库、某些 native 库会用到。
还有一种容易忽略的:如果你写的是/dev/shm下的文件(tmpfs),fsync在 tmpfs 上是空操作,返回成功但什么都没做。数据就在内存里,可见性没问题,但机器一重启全没了。用 tmpfs 存中间文件很常见,但要清楚它的边界。
3. 一次完整的排查实操:从现象到定位到修复
3.1 第一步永远是确定现象,不是翻代码
我现在的习惯是,遇到"数据没写进去",先不看代码,先做三件事:确认文件路径对不对、确认文件大小变化、确认系统调用有没有发生。
路径这件事听起来很傻,但我真的遇到过大小写不一致导致写到另一个文件的情况。快速验证:
# 看进程打开了哪些文件 ls -l /proc/<pid>/fd/ # 看某个 fd 当前的读写偏移 cat /proc/<pid>/fdinfo/<fd>fdinfo里的pos字段是当前文件偏移量,如果它是 0,说明这个 fd 上确实没写过东西;如果它一直在涨而文件大小不涨,那问题就在别处(比如写的是另一个路径,或者是稀疏文件)。这个信息比打断点直观得多。
第二步看系统调用。只要怀疑"写没写下去",strace是最高效的工具:
strace -f -p <pid> -e trace=write,writev,fsync,fdatasync,openat,close -s 200如果write系统调用压根没出现,那 100% 是应用层缓冲区没刷;如果write出现了但fsync没出现,那就是落盘时机的问题;如果write返回-1 EIO,那是磁盘层面的错误,得去看dmesg。
再说一个我常用的土办法:用一个独立的小脚本每秒stat一次文件大小,把变化过程记录下来:
while true; do printf '%s %s\n' "$(date +%H:%M:%S)" "$(stat -c %s /tmp/demo.log)" sleep 0.5 done跑起来之后再看业务日志,两条时间线一对齐,什么时候数据真正进了文件一目了然。
3.2 复现:把"偶发"变成"必现"
偶发问题最怕的就是"复现不了"。我的做法是把变量一个个固定住,构造一个最小可复现程序。下面这个例子模拟"写入后 kill -9"的场景:
public class CrashWriter { public static void main(String[] args) throws Exception { String path = args[0]; boolean doSync = args.length > 1 && "sync".equals(args[1]); FileOutputStream fos = new FileOutputStream(path, true); for (int i = 0; i < 100000; i++) { String line = "line-" + i + "\n"; fos.write(line.getBytes(StandardCharsets.UTF_8)); if (doSync && i % 10 == 0) { fos.getFD().sync(); } } } }一种跑法不带sync,一种带。跑到一半kill -9,然后数文件行数。不带sync的那个,最后几十上百行会消失;带sync的,最多丢 9 行(因为每 10 行才 sync 一次)。
这个实验做完,基本就能说服所有人:flush在这件事上毫无贡献,sync才是答案。同时它也直观地告诉你同步的代价——每 10 行 sync 一次,行数丢得少但吞吐掉下来了;每 1000 行 sync 一次,吞吐上去了但可能丢 1000 行。这个平衡点要根据业务对数据完整性的要求来定,没有标准答案。
顺带说一个观察手段:/proc/meminfo里的Dirty字段能直接看到当前系统有多少脏页等着回写。写一批数据后立刻看一眼,Dirty涨了,说明数据确实进了页缓存但还没落盘。这个数字比任何日志都有说服力。
3.3 修复方案:三层配套才能真的稳
定位清楚之后,修复不能只改一处,要按层补齐。
第一层,把包装流的 flush 链理顺。要么统一用try-with-resources让close()兜底,要么在最外层显式 flush。我倾向于前者,因为close()一定能触发 flush,而且异常路径也覆盖了:
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { writer.write(payload); writer.flush(); // 想立刻可见就先 flush } // 离开作用域自动 close第二层,落盘时机按业务需要加sync。不是所有写入都要 sync,那样性能扛不住。我的判断标准是:这批数据如果丢了,业务会不会出问题?日志丢了能接受就不 sync,账务流水丢了就是事故就必须 sync。
第三层,考虑清楚批量策略。逐条 sync 是最蠢的做法,fsync一次通常要几毫秒到几十毫秒,QPS 直接被打到个位数。合理的方式是攒批:
public class SyncBatchWriter implements Closeable { private final FileOutputStream fos; private final int batchSize; private int pending = 0; public SyncBatchWriter(File file, int batchSize) throws IOException { this.fos = new FileOutputStream(file, true); this.batchSize = batchSize; } public void write(byte[] data) throws IOException { fos.write(data); if (++pending >= batchSize) { fos.getFD().sync(); pending = 0; } } @Override public void close() throws IOException { try { if (pending > 0) { fos.getFD().sync(); } } finally { fos.close(); } } }batchSize怎么定?我的经验是先测出单次fsync的耗时,再用1000ms / fsync耗时估算每秒最多能做多少次 sync。比如一次 5ms,那理论上限是 200 次/秒。如果你的写入速率是 2000 条/秒,那batchSize至少得是 10 才能跟上,再留点余量取 20 到 50 比较稳妥。
注意:
close()里先sync再close,顺序不能反。close之后 fd 就失效了,再调sync会抛异常。而且close本身不会触发 fsync,别指望它。
3.4 性能代价实测与替代方案
fsync有多贵,取决于存储介质。机械盘上一次fsync常常要 5 到 20 毫秒,因为要等磁头真正写完并刷新缓存;SATA SSD 走SYNC CACHE大概 0.5 到 2 毫秒;NVMe 盘在 0.1 毫秒量级,启用了掉电保护的企业级盘更快。这个数字直接影响你的吞吐上限。
我在一套日志采集的场景里做过对比:不加 sync 时单线程能写到 40 万条/秒,改成每条 sync 直接掉到 80 条/秒,差了三个数量级。最后采用的是"每秒 sync 一次 + 关闭时兜底 sync"的策略,吞吐保住 30 万条/秒,最坏情况丢 1 秒数据,业务方接受了这个折中。
如果确实需要高频落盘又扛不住 fsync,有几个思路可以试:
- 换成预分配的大文件,配合
FileChannel的write+ 定期force,减少元数据更新。 - 用
fdatasync而不是完整fsync。Java 里FileChannel.force(false)在 Linux 上对应fdatasync,只同步数据不同步元数据(比如 mtime),能省一点。但注意,如果文件大小变了,fdatasync也需要同步这部分元数据,省不了太多。 - 把写入改成追加式的日志结构,避免随机写导致的额外元数据开销。
- 关键数据不要只依赖落盘,加一层内存队列 + 独立的强一致存储,把持久化的责任交出去。
4. 症状对照表与六条踩坑经验
4.1 常见症状速查
| 症状 | 最可能的原因 | 快速验证手段 | 处理方式 |
|---|---|---|---|
文件大小为 0,tail没内容 | 包装流没 flush/close | strace看不到write | 调最外层 flush 或补 close |
| 同机其他进程读不到 | 同上,数据没进内核 | /proc/<pid>/fdinfo看pos | 同上 |
| 进程崩溃后末尾数据缺失 | 数据在 page cache 没落盘 | 对比Dirty字段变化 | fd.sync()或force(true) |
| 断电后数据缺失 | 磁盘写缓存未刷新 | 查阅设备是否带掉电保护 | 换设备或调整挂载参数 |
| 跨机器读不到 | NFS/网络文件系统缓存 | 在写入端close后再读 | close()或fsync()触发 COMMIT |
| 流式压缩对端解不出 | Deflater缓冲未 SYNC_FLUSH | 对端持续阻塞无数据 | 用syncFlush=true构造 |
PrintWriter输出不完整 | print不触发 autoFlush | 换成println试试 | 显式flush() |
4.2 我在实际项目里攒下的六条经验
第一条,先别怀疑 flush,怀疑代码路径。我经手的问题里,八成以上的"flush 不生效"最后查出来是写到了另一个路径、另一个文件、或者是被logback的异步 appender 吞了。花五分钟确认路径和文件大小,比在代码里翻半小时有用。
第二条,try-with-resources能救命,但它不等于落盘。它能保证close,close能保证 flush 链走完,但它保证不了fsync。这两件事必须分开记。
第三条,FileWriter尽量别直接用。它的编码依赖平台默认值,缓冲区大小也不可控。老老实实用Files.newBufferedWriter(path, StandardCharsets.UTF_8),编码明确,还能配合OpenOption控制行为。
第四条,日志框架的immediateFlush是个双刃剑。很多日志框架默认开着立即 flush,看起来"日志一条不少",实际上每次写日志都伴随一次系统调用,高并发下这就是瓶颈。把它关掉、改成按大小或时间刷,再配合定期sync,性能能涨一大截。但要接受"崩溃时可能丢最后几条日志"这个事实,这本来就不是日志框架该负责的事。
第五条,不要在循环里逐条 sync。这个错误新手犯得最多。哪怕你只是写个小脚本,把 sync 提到循环外面,速度也能差几十倍。养成"批量攒、统一下发"的习惯。
第六条,测试环境一定要模拟真实故障。本地跑通不代表线上稳。我现在的做法是每个涉及持久化的模块都写一个"kill -9 测试":一个进程狂写,另一个脚本随机时刻kill -9,然后校验数据完整性。这个测试写起来不到二十行,但能提前发现绝大多数落盘逻辑的坑。
5. Fork 一下思路:什么时候该换 FileChannel 或者 mmap
5.1 FileChannel.force 的参数不是随便填的
FileChannel.force(boolean metaData)两个取值在 Linux 上的语义不一样:force(false)对应fdatasync(2),只保证文件数据落盘,元数据可能不刷;force(true)对应fsync(2),数据和元数据都刷。
什么时候需要元数据?如果你的写入改变了文件大小(绝大多数追加写都会改变),那么文件长度属于元数据的一部分,fdatasync也得把它刷下去,否则重启后文件长度不对,数据就成了"孤儿"字节。所以只有当你在一个预分配好、长度不变的区域内做覆盖写时,force(false)才真的能省事。
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { ByteBuffer buf = ByteBuffer.wrap(payload); while (buf.hasRemaining()) { ch.write(buf); // 注意 write 可能只写一部分 } ch.force(true); }上面那个while (buf.hasRemaining())不是多余的。FileChannel.write不保证一次写完所有字节,返回值是实际写入量,剩下的要靠循环补。直接ch.write(buf)一行了事的写法,在数据量大或者磁盘繁忙时是会丢尾巴的。这一点和FileOutputStream.write不同——后者接受byte[]参数时,方法签名声明了会写完全部或者抛异常,语义更强。
5.2 mmap 的 force 也没有想象中那么硬
内存映射文件看着很爽,写起来像操作数组一样,没有系统调用开销。但它的持久化语义非常模糊。MappedByteBuffer.force()在 Linux 上走的是msync(MS_SYNC),只保证把脏页写回页缓存并等待 IO 完成,但它并不能保证数据真的到了盘片上——某些实现下它的行为和fsync不完全等价。
更要命的是,munmap的时候如果还有脏页,操作系统会在后台慢慢刷,进程退出那一刻并不会阻塞等待。所以你会看到"我 force 了,但断电后数据还是丢了"这种情况。真要强持久化,mmap 不是个好选择。
另外一个坑是MappedByteBuffer的释放。JDK 里没有提供公开的unmap方法,映射的文件在 GC 回收DirectByteBuffer之前不会解除映射。在 Windows 上这会导致文件删不掉、改不了名。想主动释放只能靠反射调用sun.misc.Unsafe.invokeCleaner,而这个 API 在新版本 JDK 里被限制得越来越严。所以除非是大文件随机读、或者进程间共享内存这类场景,我一般不推荐用 mmap 做写入。
普通的追加写场景,FileOutputStream+ 定期sync,或者FileChannel+force,都是更稳妥的选择。简单、语义清晰、不容易踩坑。花哨的东西往往意味着你得对底层有更深的了解才能驾驭。
5.3 一个容易被忽略的方向:直接写原始 fd
最后提一个偏门但很有用的手段。如果你的场景对性能极度敏感,可以绕开 Java 的流包装,直接用FileDescriptor加FileOutputStream(fd)构造。这种方式下没有额外的缓冲层,每次write都是直接系统调用,配合fd.sync()就能精确控制落盘时机。
但代价是你得自己管理缓冲区。没有BufferedOutputStream帮你合并小写入,每条日志一个write系统调用,上下文切换的开销反而更大。所以这个方案只适合"写入块本来就很大"的场景,比如每次写 64KB 以上的数据块。
我的经验是,先把测量做起来:用strace -c统计一下write的调用次数和耗时占比。如果write次数极少、单次数据量大,那说明缓冲策略是对的;如果write次数是写入条数的好几倍,那说明缓冲区根本没起作用,得回头检查包装流是不是被漏掉了。这个数据比任何猜测都靠得住。