1. 为什么读文件要套那么多层?从最常见的困惑说起
1.1 一个"跑得通但说不清"的新手发问
前几天有个新同事拦住我,拿着IDE里的代码问:"读一个文本文件,为什么要new BufferedReader(new FileReader(...))?直接new FileReader不行吗?"
他的代码确实能跑,所以我明白他不是来抬杠的,是真的想搞懂这层包装的意义。这个问题的背后,恰好就是很多 Java 开发者在本地 I/O 上的真实状态——会写,但说不清为什么;能读能写,但遇到乱码、性能慢、文件删不掉就彻底懵住。
我先给他拆了一下。"FileReader 内部其实带着一个字节流到字符流的转换器,它自己不维护缓冲区,你每次调用 read(),都直接打到操作系统层面,就好比每次喝水都跑一趟水龙头。而 BufferedReader 在它外面加了一口大缸,readLine() 更是按行切分文本,这才是日常处理文本该有的姿势。倒不是说 FileReader 不能直接用,而是直接用它做业务级读写,属于典型的把简单问题做难。"
1.2 本地 I/O 的两条主线:流式读写与文件系统操作
借这个例子,我想把"本地 I/O"这个词拆清楚。在 Java 语境里,它其实包含两条不太一样的主线。
第一条是流式读写。从 InputStream / OutputStream 到 Reader / Writer,再到 Buffered、Data、Object 这些包装流,解决的是"数据怎么从一个文件流进程序、怎么从程序写回文件"的问题。这是二进制还是文本、按字节还是按字符、要不要缓冲、要不要序列化,全看你怎么组合。
第二条是文件系统操作。从老牌的 File 类到 NIO.2 的 Path、Paths、Files,解决的是"路径怎么表达、目录怎么建、文件怎么移动复制删除、属性怎么查"这类问题,它不直接读写内容,却是所有读写的前提。
很多面试题和大纲把这两条线混在一起讲,导致开发者背了一堆 API 名字,真上手时依然无从下手。这也是我写这篇文章的原因:把传统 IO 流、NIO.2 文件 API、编码、性能、资源管理、踩坑排障这六块串起来,形成一张可以反复对照的知识地图。
1.3 这篇文章的阅读建议
如果你是刚接触 Java 的同学,我建议你至少把前四章完整看一遍,把代码示例亲手跑一遍;如果你已经写过两年的业务代码,可以直接跳到第三章之后的"编码陷阱"和"真实踩坑复盘",那里面有大量常规文档不会讲但现实里天天发生的细节。
这篇文章不会把所有 API 罗列一遍,那没有意义。我会把选型逻辑、底层原因和坑讲透,再配一个能直接放进项目里的思路和代码片段。毕竟本地 I/O 这种东西,真正让你加班到深夜的,往往不是某个方法不会用,而是"为什么按我写的代码做出来的结果和预期不一样"。
2. 传统IO流体系:四大基类、装饰器与选型判断
2.1 字节流与字符流:先弄清 What 与 Why
Java 的 IO 流设计从 JDK 1.0 开始,到 1.4 引入 NIO,再到 1.7 完善 NIO.2,底层类库已经非常丰富,但老一代的流式 API 至今没有退出历史舞台,原因很简单:它仍然是最广为人知、最稳定的读写模型。
字节流以 InputStream 和 OutputStream 为抽象基类,单位是 byte。它不关心内容到底是什么,图片、压缩包、序列化对象、视频分片,通通可以处理。字符流以 Reader 和 Writer 为抽象基类,单位是 char,专门为文本而设计。字符流底层一定有个桥梁叫 InputStreamReader / OutputStreamWriter,它负责把字节转换成字符或反向转换。你直接持有的 Reader 或 Writer,最终都能找到一个字节流在下面垫着。
这里有个经典的误区:很多人觉得 Reader/Writer 比 InputStream/OutputStream "高级",所以读文本就该用字符流,读二进制就该用字节流。这句话是对的,但必须补充一句——字符流本身不读字节,真正在读文件字节的还是 FileInputStream,字符流只是多了"编码转换"这一步。明白了这一点,后面所有乱码问题你都能自己推出来。
2.2 节点流与处理流:装饰器模式的最佳教案
IO 流体系里最常见的分类维度,是节点流与处理流。
节点流直接对接数据源,比如 FileInputStream 直接打开文件,FileOutputStream 直接写文件。它处于链条的最底层。处理流则包装在节点流外面,不直接触碰数据源,负责增加能力。BufferedInputStream 加缓冲,DataInputStream 加基本类型读取,ObjectInputStream 加对象反序列化,InputStreamReader 加字节转字符的编码能力。
这个设计是装饰器模式在 JDK 里最经典的实践。你可以把节点流想象成一根裸露的水管,它能把水从水厂送到你家,但是水压不稳、水质没处理。处理流就好比在水管末端加净水器、加增压泵、加流量计,每一层只做一件事,还能自由组合。你不需要一个叫"带缓冲的对象序列化文件输入流"的巨无霸类,而是用三个小类套起来,灵活得多。
理解这个关系对排查问题特别关键。比如你读文件乱码,你要检查的是编码转换层选没选对字符集;你读大文件慢,要检查的是缓冲层加没加;你读到的对象强转报错,要检查的是对象流所在层级的位置对不对。一层一层拆开看,比盯着整个异常栈瞎猜要高效得多。
2.3 实用的流组合清单与典型配置
我按日常场景整理了一张选型表,基本覆盖了本地文件读写的大多数情况:
| 场景 | 推荐组合 | 关键点 |
|---|---|---|
| 小文本按行读 | Files.newBufferedReader(path, UTF_8) | NIO.2 自带缓冲与字符集参数 |
| 手动控制的大文本按行读 | new BufferedReader(new InputStreamReader(new FileInputStream(file), UTF_8)) | 明确字符集,别用 FileReader |
| 二进制文件拷贝 | BufferedInputStream / BufferedOutputStream | 8KB 以上缓冲区比较合适 |
| 读写基本类型数据 | DataInputStream / DataOutputStream,包在缓冲流外层 | 读取顺序必须与写入顺序一致 |
| 对象持久化 | ObjectInputStream / ObjectOutputStream | 类要有 serialVersionUID |
| 网络 IO | Socket 的 getInputStream / getOutputStream | 大量数据记得包缓冲流 |
这里必须特别强调一个组合顺序问题:如果你既想要对象读取,又想要缓冲,顺序应该是ObjectInputStream包在BufferedInputStream外层,而不是反过来。因为 ObjectInputStream 需要底层能够持续稳定地按字节读取,缓冲流在它下面才能提供这种支持;反过来写,数据流会绕过缓冲直接落到文件,性能优势全丢。
另外一个小经验:不要在同一个流上包两层同类缓冲,比如new BufferedInputStream(new BufferedInputStream(...)),那只是白白多占内存,并不会叠加缓冲效果。
3. NIO.2文件系统编程:用Paths和Files重建文件操作习惯
3.1 File类为什么该退休了
如果你还在用 File 类做 all things 文件操作,你可能已经把坑踩遍而不自知。
new File("config/app.yml").exists()返回 false 不一定文件不存在,可能是你传的是相对路径,当前工作目录却不是你想象的那个目录。file.delete()失败不会抛异常,而是静默返回 false,你连为什么失败都不知道。file.renameTo(dest)在跨目录、跨磁盘时也经常返回 false,因为 renameTo 的实现高度依赖于底层操作系统和文件系统的支持。再加上 File 类想在目录里递归找文件,只能自己写递归,代码又丑又容易漏。
这些东西在 JDK 7 推出的 NIO.2 里都被重新设计了。核心类只有两个:Path 表示路径和文件位置,Files 提供一组静态工具方法完成各种文件操作。如果你的项目还没用 Java 8 以上,我强烈建议往上升;如果你已经在用,那就别再把 File 当主力了。
3.2 Path、Paths.get 与 Files 工具类的关键操作
Path 的使用比 File 简单,路径拼接不再依赖字符串拼"/"或"\\",而是用Paths.get("config", "app.yml")或basePath.resolve("app.yml")。这套 API 天然跨平台,Windows 下会用\,Linux 下会用/,你不用再关心分隔符问题。
Files 工具类是我在项目里最常用的类之一,下面这些方法值得你记住:
Files.exists(path)/Files.notExists(path):判定路径是否存在。注意 notExists 跟"不存在"并不完全等价,文件系统可能无法判定时两者都返回 false,所以别写成 if-else 互斥逻辑。Files.createDirectories(path):一次性创建多级目录,比mkdirs()更符合新习惯。Files.deleteIfExists(path):文件存在就删除,不存在也不报错。Files.move(path, target, StandardCopyOption.ATOMIC_MOVE):原子移动,写配置、写临时文件换正文时非常实用。Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING):复制文件,可带是否覆盖选项。Files.readAttributes(path, BasicFileAttributes.class):一次性拿到大小、创建时间、修改时间、是否目录等一堆属性。
读写方面,Files 也提供了带字符集参数的方法。Files.readAllLines(path, StandardCharsets.UTF_8)适合小文件,一次全读进内存;Files.newBufferedReader(path, charset)适合大文件流式读取;Files.newBufferedWriter则是对应的写方法。
3.3 目录遍历和文件树处理的正确姿势
遍历目录是 File 类时代最让人头疼的事情之一。NIO.2 给了两个选择。
第一个是Files.list(path),返回当前目录下一层的 StreamFiles.walk(path),递归返回整棵目录树下的所有路径,适合找特定后缀、统计文件大小、批量清理临时文件的场景。
这里有一个非常重要的实操细节:Files.list 和 Files.walk 返回的 Stream 持有底层目录流的句柄,用完必须关闭,否则在 Windows 上会出现目录句柄泄漏,最后发展成"文件或目录被另一个进程使用,无法删除"。哪怕你只是 foreach 了一下,也记得放在 try-with-resources 里:
try (Stream<Path> paths = Files.walk(Paths.get("/data/logs"))) { long totalBytes = paths .filter(Files::isRegularFile) .mapToLong(p -> { try { return Files.size(p); } catch (IOException e) { return 0L; } }) .sum(); System.out.println(totalBytes); }如果你需要更精细的控制,比如遍历时跳过异常目录、遍历过程中就做文件删除,可以用Files.walkFileTree(path, new SimpleFileVisitor<Path>() {...}),重写visitFile和preVisitDirectory即可。它不依赖 Stream,也就不存在手动关闭的问题,适合做"遍历并清理"这类任务。
4. 编码是本地I/O的头号陷阱:字符集、BOM与乱码现场
4.1 编码与解码:乱码产生的底层原因
乱码不是玄学,它有一套非常朴素的因果关系。文件在磁盘上只是字节序列;你用某个字符集把字符串映射成字节写进去(编码),读取时再用另一个字符集把字节映射回字符串(解码)。映射表对不上,就会出现乱码。
比如中文字符。在 UTF-8 编码下,一个常见汉字通常占 3 个字节;在 GBK 编码下,占 2 个字节。如果文件是 GBK 的,你偏要用 UTF-8 去解码,字节数对不上,读出来的字符自然就是一堆"锟斤拷"或者问号。反过来也一样。
理解这个机制之后,遇到乱码就不要只想着"换成 BufferedReader 试试"。你要先搞清楚三个问题:文件本身是什么编码?你的读写代码指定了什么编码?你的控制台或展示页面是什么编码?其中有一个环节不一致,乱码就会出现。
4.2 全链路的 Charset 控制
Java 的"默认编码"是个坑。老版本的 FileReader、FileWriter 不提供字符集参数,直接用系统默认字符集。如果应用部署在 Windows 中文环境,默认字符集是 GBK,而你的代码和文件都是 UTF-8,读出来必乱。
所以我在项目里立了一条规矩:凡是涉及文本与字节转换的地方,一律显式指定字符集。能不用 FileReader/FileWriter 就不用,或者确认你用的 JDK 版本支持带 Charset 参数的构造器。Java 11 开始 FileReader 才算补齐了FileReader(File, Charset)这个构造器,但这种"事后补救"对老代码没有帮助,干脆统一用 Files API:
// 推荐 BufferedReader reader = Files.newBufferedReader(Path.of("data.txt"), StandardCharsets.UTF_8); // 不推荐,隐式依赖系统默认字符集 BufferedReader reader = new BufferedReader(new FileReader("data.txt")); // 字符串与字节数组互转也要显式指定 byte[] bytes = text.getBytes(StandardCharsets.UTF_8); String text = new String(bytes, StandardCharsets.UTF_8);还有一个容易被忽视的地方:编译器、Maven/Gradle 构建插件的编码配置。如果 IDE 里是 UTF-8,构建时却强制用 GBK,编译出的 class 文件里的中文字符串都是错的,运行时读文件自然跟着乱。这块难度不高,但影响面巨大,项目创建时定好,后面能省掉无数诡异的 bug。
4.3 UTF-8 BOM:一个能坑一整天的细节
BOM,全称 Byte Order Mark,UTF-8 的 BOM 是三个字节EF BB BF,通常写在文件最开头。Windows 系的编辑器(记事本另存为 UTF-8 就有 BOM)经常给文件加上这个东西,Linux 和 macOS 下很多文本文件没有。
BOM 最坑的地方在于:用 BufferedReader.readLine() 读第一行时,\uFEFF会被当作普通字符带出来。第一行如果恰好是 CSV 的表头或 JSON 字符串开头,解析逻辑直接就崩了。你盯着输出看半天,怎么都想不明白为什么第一个字段前面多了个看不见的符号。
处理方式有两种。第一种是读文件前检查前三个字节:
try (InputStream in = Files.newInputStream(path)) { PushbackInputStream pushback = new PushbackInputStream(in, 3); byte[] bom = new byte[3]; int len = pushback.read(bom, 0, 3); if (len == 3 && (bom[0] & 0xFF) == 0xEF && (bom[1] & 0xFF) == 0xBB && (bom[2] & 0xFF) == 0xBF) { // 有 BOM,跳过它,字节流已经被 pushback 装回,无需处理 } else { pushback.unread(bom, 0, len); } // 继续用 pushback 作为数据源 }第二种更省事:第一行读出来后,判断字符串以\uFEFF开头就去掉。这种方法好写,但需要你有"第一行可能带 BOM"的敏感。
反过来,写文件时也要想清楚消费方。如果你导出的 CSV 是给 Excel 打开的,UTF-8 无 BOM 反而会让 Excel 直接乱码,这时你需要故意写三个字节的 BOM 开头。这个需求看起来反直觉,但真实业务里经常出现,最终判断标准永远是"文件给谁用、对方的工具认什么编码"。
5. 性能相关的读写策略:缓冲区、FileChannel与文件映射的边界
5.1 缓冲区的意义:一次系统调用有多慢
本地 I/O 慢,通常不是磁盘硬件本身慢,而是频繁切换用户态和内核态太慢。一个不带缓冲的 FileOutputStream,调用一次write(int b)写一个字节,就要触发一次系统调用。写 1MB 数据等于触发一百万次系统调用,哪怕每次只消耗几微秒,加起来也扛不住。
缓冲流的本质就是把小的、零散的写入请求攒在一个大数组里(BufferedOutputStream 默认 8192 字节),攒够一批再真正落到内核和磁盘。你写的还是一行一行文本,但底层系统调用次数减少了几个数量级。
因此,读写大文件第一原则是:永远不要用不带缓冲的节点流做逐字节操作。这不是高级优化技巧,是基本素养。
5.2 FileChannel 的基本玩法与 transferTo
NIO 引入的 FileChannel 是通往操作系统底层能力的通道。它配合 ByteBuffer 使用,能实现"显式缓冲 + 批量传输"。
ByteBuffer 有三个容易绕晕的状态变量:position(当前读写位置)、limit(可读写边界)、capacity(容量)。写完数据切换到读模式必须调用 flip(),否则你会从错误的位置开始读;下一次写之前要 clear()(重置换写模式)或 compact()(只清理已读部分)。这个状态机是 NIO 新手最常见的翻车点。
一个标准的文件复制片段长这样:
try (FileChannel in = FileChannel.open(Path.of("source.bin"), StandardOpenOption.READ); FileChannel out = FileChannel.open(Path.of("target.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { ByteBuffer buffer = ByteBuffer.allocate(8192); while (in.read(buffer) != -1) { buffer.flip(); out.write(buffer); buffer.clear(); } }如果你只是做文件复制,还有更偷懒也更高性能的方式:in.transferTo(0, in.size(), out)。这个方法底层会尝试利用操作系统的零拷贝能力,把数据直接从文件发到另一个文件,减少一次用户态拷贝。它并非在所有系统上都能完全零拷贝,但绝大多数时候都比手动搬 ByteBuffer 快且省事。
5.3 MappedByteBuffer 的适用场景与坑
MappedByteBuffer 是 FileChannel.map 的返回结果,原理是内存映射,把文件区域映射到进程的虚拟地址空间,读写文件就像操作内存数组一样,具体缺页由操作系统决定什么时候真正读盘。
它的优势在大文件随机访问场景特别明显。比如你要反复修改一个几百 MB 二进制文件中的某几段,常规做法是 read 到内存、改完再 write 回去,中间还有大量偏移计算;用 MappedByteBuffer 可以直接定位到偏移量,像操作 ByteBuffer 一样修改,操作系统会负责脏页面最终写回文件。
但必须清醒地看到它的边界和坑:
- 单次映射的长度受 int 索引限制,超过 2GB 的文件需要分段映射,Java 5 之后 MappedByteBuffer 的索引依然基于 int。
- 写模式下数据不会立刻进磁盘,要主动调用
mappedBuffer.force(),否则进程崩溃或断电可能丢数据。 - Windows 下的文件语义更复杂,映射的文件在映射解除之前可能无法删除,这在实际部署时很让人崩溃。
- 映射文件在外部被截断或增长,继续访问可能抛出 IOException。
我的建议很务实:默认读写一律用 Buffered 流或 Files API,等真遇到"大文件 + 随机访问 + 性能瓶颈"时再考虑 MappedByteBuffer。它不是银弹,只是特定场景的利器。
6. 资源生命周期管理:try-with-resources、flush与文件锁
6.1 try-with-resources 的关闭顺序与异常抑制
Java 7 提供了 try-with-resources,终于把"finally 里关流"这种繁琐且容易遗漏的写法替代掉了。凡是实现了 AutoCloseable 的对象都可以写在 try 括号里。
try (BufferedReader reader = Files.newBufferedReader(Path.of("a.txt"), UTF_8); BufferedWriter writer = Files.newBufferedWriter(Path.of("b.txt"), UTF_8)) { String line; while ((line = reader.readLine()) != null) { writer.write(line); writer.newLine(); } }要注意两个细节。第一,多个资源的关闭顺序与创建顺序相反,也就是后创建的先关闭。这在装饰器链里显得很合理:外层流先关,再关内层节点流。第二,如果 try 块本体抛了一个异常,关闭资源时又抛了另一个异常,后者不会覆盖前者,而是被挂到前者的 suppressed 列表里,通过exception.getSuppressed()能看到完整链路。如果你不 catch 关闭异常,原始异常不至于丢失,排查时栈顶信息才是主线。
还有一类细节很多人忽略:从 Stream 底层封装过来的资源也要关。比如 Files.lines 返回的 Stream、Files.list 返回的 Stream,它们不关,对应文件句柄就泄漏。所以前面才反复强调用 try-with-resources 包 Stream。
6.2 flush 与 close:数据真正落盘的时刻
很多人在"数据没写进去"这件事上栽过跟头,尤其是程序运行完,文件内容却是旧的。这里的主角就是缓冲区。
当你通过 BufferedWriter 或 BufferedOutputStream 写数据时,数据先进内存缓冲,缓冲区没满就不会真正写到文件。如果程序在缓冲区积攒数据后突然崩溃,这些数据就全丢了。close 会自动调用 flush,把剩余数据推出去,所以"关流之前数据一定落盘"这个说法基本成立。
但有一种常见情况:你的程序是一个长时间运行的批处理任务,每写几行就 sleep 一段时间,中间不 close,只在最后关。如果任务执行到一半进程被杀,最近几秒乃至几分钟的数据就丢了。解决方案是在业务节点主动调用 flush,比如每处理 1000 条记录就writer.flush(),让数据及时从用户态缓冲进入内核。
另外注意一个反直觉的点:OutputStream.flush()默认是空实现,真正有缓冲的类是 BufferedOutputStream、BufferedWriter、PrintStream 这些。FileOutputStream.flush() 本身没有缓冲可刷,但装饰链中任何一个环节调 flush,整条链都会向下传播。所以别纠结"FileOutputStream 要不要调用 flush",你只要保证使用缓冲流时按需 flush 即可。
6.3 多线程/多进程写同一文件:文件锁
单线程写文件不存在并发问题,但真实项目里总会出现多线程、多进程往同一个文件追加日志或导出的场景。多人同时写,文件会出现交叉、覆盖、行内容混在一起。
FileChannel 提供了跨进程文件锁 FileLock。基本用法如下:
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { FileLock lock = channel.lock(); // 拿不到锁会阻塞 // 写文件 lock.release(); }tryLock()是非阻塞版本,拿不到锁直接返回 null。需要提醒的是,锁是针对进程级别的文件区域锁,不是 JVM 内部针对线程的同步锁。同一个 JVM 里多个线程通过同一个 FileChannel 对象请求重叠区域的锁,会抛 OverlappingFileLockException;你要是想给线程级互斥,靠 synchronized 或锁对象更合适。
synchronized只能管住单 JVM 内的线程,FileLock 能跨进程协调。两者的边界要分清:管线程用 synchronized / ReentrantLock,管进程用 FileLock。
7. 踩坑复盘:五个我真实遇到过的本地I/O现场
7.1 文件存在但 exists() 返回 false:相对路径的幻觉
有次一个服务在测试环境跑得好好的,部署到生产环境却说配置文件找不到。定位半天,发现代码里写的是new File("config/application.yml"),服务在本地 IDE 里时工作目录是项目根目录,能读对;部署后启动脚本把工作目录切到了安装目录,这个相对路径就完全失效了。
相对路径永远依赖进程的当前工作目录,这是个杀手级坑。排查思路不是一遍遍改路径,而是先打印System.getProperty("user.dir")确认进程实际目录。解决方式基本三种:用绝对路径(如果你能确定部署位置)、把路径转换成Path后基于固定基础目录 resolve、或者干脆把配置文件放到 classpath 里用 getResourceAsStream 读取。
7.2 删不掉的临时文件:Windows 上的句柄占用
Windows 用户应该都对"另一个程序正在使用此文件,进程无法访问"这句话不陌生。Java 程序里最常见的场景是:某段代码创建了一个临时文件,后面明明调用了 Files.delete,却抛出 FileSystemException。原因多半是这个文件还被某个未关闭的流占着句柄。
最气人的是代码看起来已经关了流,实际上因为某个分支提前 return 或者异常跳转,close 压根没执行。解决方式很朴素:从手写 finally 升级到 try-with-resources。Windows 上排查时用资源监视器或者 Process Explorer 看哪些进程持有该文件句柄;Linux/macOS 上可以用lsof | grep 文件名快速定位。
7.3 日志写入后中文乱码:FileWriter 的默认字符集
曾经有个项目在开发环境一切正常,部署到客户的 Windows Server 后,日志文件里的中文全部变成乱码。原因就是代码用了new FileWriter(logFile),开发机默认字符集是 UTF-8,Windows Server 默认字符集是 GBK,写出去的中文自然错乱。
这起事故本质上不是"中文编码特殊",而是"默认字符集不可信"。那之后我在代码里做了三件事:文本文件读写全部显式指定 StandardCharsets.UTF_8;日志框架单独配置编码;新代码禁止使用不带字符集参数的 FileReader / FileWriter。如果你要接手旧代码,先全局搜索这两个类,清理完能消掉一大批玄学问题。
7.4 换一个平台就崩的路径拼接
早期有个模块用字符串拼路径:String dir = baseDir + "/" + fileName;。Linux 下没问题,Windows 下部分环节也凑合能跑,直到文件名里出现以点开头的隐藏文件或带特殊字符的文件名,问题才开始暴露。
正确的姿势是用 Path API:
Path fullPath = Paths.get(baseDir).resolve(fileName); Path base = Paths.get("/data"); Path nested = base.resolve("2025").resolve("logs").resolve("app.log");它能自动处理分隔符、能处理..越界问题、还能简洁地拿到父目录和文件名称。真需要在代码里判断路径分隔符时,用FileSystems.getDefault().getSeparator()而不是写死"/"。跨平台项目里,字符串拼接路径的方法直接打入代码审查红线。
7.5 CSV 按行读取时的"假想结构:为什么不能直接 readLine + split
CSV 看起来像按行分割的文本,结构似乎很简单。但真实 CSV 里字段可以包含逗号、双引号、甚至换行符。一个带引号包裹的多行字段,用 BufferedReader.readLine() 读就完全错位了,split(",") 也会把引号内逗号当分隔符切碎。
我见过一个系统就是拿 readLine + split 解析业务方给的大 CSV,遇到一个字段内部换行的文件直接崩掉整个解析流程。处理 CSV 的正确办法是引入专门解析库,比如 Apache Commons CSV 或 OpenCSV,它们能正确处理引号、转义和嵌入换行。如果你的文件格式允许极度简单,那你至少要约定清楚:字段不允许包含逗号、双引号、换行,否则这条自由格式路线迟早出事。
回头梳理这些现场,它们没有一个是 API 不会用,全部是"对底层机制理解不够"或"对使用方预期判断不准"造成的。本地 I/O 看着基础,但正是这些基础,决定了一个系统在真实环境里能不能撑得住。
我自己的习惯是:每写完一段涉及文件读写的代码,先对着三个问题自查一遍——字符集是不是显式指定了?资源是否在 try-with-resources 里管理?数据在什么时候真正落盘、由谁触发?这三件事想清楚,绝大多数本地 I/O 线上事故都能提前避免。