字节流与字符流
Java IO 的类很多,先抓住一条主线:InputStream / OutputStream 处理字节,Reader / Writer 处理字符。图片、压缩包、Excel 这类二进制内容用字节流;txt、csv、json 这类文本用字符流,并且一定要指定编码。
字符流的底层其实还是字节流,中间靠 InputStreamReader / OutputStreamWriter 做编码转换。JDK 7 之后多了 Files、Paths 这套 NIO 工具类,日常读写文件比以前短很多,新手可以直接从这里入手。
常见组合可以对照着记:
| 场景 | 字节流 | 字符流 |
|---|---|---|
| 文件 | FileInputStream / FileOutputStream |
FileReader / FileWriter |
| 缓冲 | BufferedInputStream |
BufferedReader / BufferedWriter |
| 内存 | ByteArrayInputStream |
StringReader / StringWriter |
| 编码转换 | — | InputStreamReader / OutputStreamWriter |
直接读字节流时,建议再套一层 BufferedInputStream 或 BufferedOutputStream,否则每次 read 都可能触发一次系统调用。小文件感觉不出来,大文件能慢一个数量级。字符流同理,BufferedReader 的缓冲能显著减少 IO 次数。
Files 还提供了 copy、move、deleteIfExists、createTempFile 这些方法,比手写流简单。移动文件优先用 Files.move,它底层可能直接改目录项,不用真的把数据复制一遍。
用 Files 读写小文件
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
public class FilesDemo {
public static void main(String[] args) throws IOException {
Path path = Paths.get("data", "note.txt");
Files.createDirectories(path.getParent());
Files.write(path, "第一行\n第二行".getBytes(StandardCharsets.UTF_8));
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
lines.forEach(System.out::println);
Files.write(path, "追加一行\n".getBytes(StandardCharsets.UTF_8),
StandardOpenOption.APPEND);
System.out.println(Files.size(path));
}
}
Files.write 默认是覆盖,想追加要显式传 StandardOpenOption.APPEND。JDK 11 之后还有更简洁的 Files.readString / Files.writeString,写法和上面差不多。
大文件要逐行读
Files.readAllLines 会把整个文件读进内存,几十 MB 的日志还能扛,几百 MB 就可能直接 OOM。大文件用 BufferedReader 逐行处理,配合 try-with-resources 自动关闭:
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
// 处理每一行,读完即丢
}
}
readLine() 返回 null 表示读到文件末尾,注意判断的是 null,不是空字符串,空行是合法的内容。
编码和路径两个必知点
编码:FileReader、FileWriter、new String(bytes) 这些旧 API 不传编码时用的是平台默认编码,Windows 上通常是 GBK,Linux 上通常是 UTF-8。同一份代码本地正常、服务器乱码,多半就是这里出的问题。统一显式传 StandardCharsets.UTF_8。
路径:相对路径的基准是 JVM 启动目录(user.dir),不是 class 文件所在目录。IDE 里跑和打成 jar 之后跑,基准可能完全不同。生产环境建议用配置项给出绝对路径,不要依赖“当前目录”。拼接路径用 Paths.get("data", "note.txt"),不要手写 "data/" + name,省得跨平台踩分隔符的坑。
读 classpath 下的文件不能用 Paths.get,因为打成 jar 之后文件不在文件系统里,而是压缩包内的一个条目。用 getClass().getResourceAsStream("/config/app.properties") 拿到流,再交给 Properties 或 BufferedReader 读取,单元测试里读 src/test/resources 下的文件也是同一个道理。
临时文件用 Files.createTempFile("prefix", ".tmp"),系统会自动放到临时目录,程序退出后不保证还在,别拿它当持久化存储。需要目录遍历时用 Files.walk(path) 配合 try-with-resources,它返回一个 Stream<Path>,用完要关闭。
文件读写还要考虑并发:多个线程同时写同一个文件,内容会交错甚至损坏。需要并发写时按线程分文件,或者用单线程串行写;加锁只能解决同一进程内的问题,多实例部署时得靠外部存储。
常见坑
1. 忘记关闭流
Windows 上文件没关闭,删除时会提示“文件被另一个程序占用”;连接没关闭则会耗尽句柄。一律用 try-with-resources,不要手写 finally 里一堆 close()。
2. 编码不一致
写的时候 UTF-8,读的时候用默认编码,中文就变成问号或乱码。排查时先确认两端的编码,再看文件本身是不是 GBK 存的。
3. read 的返回值判断错
InputStream.read(buffer) 返回读到的字节数,读到末尾返回 -1。写成 while (in.read(buffer) > 0) 在长度为 0 时会提前退出,标准写法是 != -1。
4. 大文件一次读进内存
readAllBytes()、readAllLines() 都很方便,但只适合小文件。处理日志、导出数据时老老实实流式读,边读边写。
5. 换行符不一致
Windows 的换行是 \r\n,Linux 是 \n。用 readLine() 读会去掉换行,但如果自己按字节读,行尾可能带 \r,导致 equals("ok") 永远为 false。
排查步骤
NoSuchFileException:打印path.toAbsolutePath(),确认程序实际找的是哪个目录;AccessDeniedException:检查文件权限和是否被其他进程占用;- 中文乱码:确认写入、读取两端的编码,统一成 UTF-8;
MalformedInputException:文件不是 UTF-8 编码,用file -i(Linux)或记事本另存确认;- 内存持续上涨:检查有没有把大文件整体读进集合里。
评论