文件 IO 入门
这一课你将能做到
- 用 JDK 11+ 的
Files.readString/writeString 完成文本读写,用 BufferedReader 逐行处理大文件
- 理解字节流与字符流的区别,知道什么时候用哪一个、编码为什么必须显式指定 UTF-8
- 用 try-with-resources 安全操作文件——把上一课的异常知识落到资源管理实战
为什么要学
后端离不开文件 IO:本站场景里,日志落盘、配置文件读取(application.yml)、用户上传头像存储、Excel 导入导出、生成导出报表——底层全是文件操作。
Java 的 IO 类库历史悠久(1996 年的 java.io、2002 年的 NIO、2007 年的 NIO.2),新手最容易被 File、FileInputStream、Reader、Path、Files 十几个类绕晕。本课直接采用现代写法:JDK 7+ 的 Path/Files + JDK 11+ 的便捷读写方法,覆盖日常 90% 场景,老 API 只需认识(以后读别人代码会碰到)。
先建立图像
数据的外壳 数据的内容
Path(路径) ──▶ Files(操作) ──▶ 内容
"demo.txt" readString / "你好\nJava"
writeString /
copy / walk
字节流 vs 字符流:
字节流 InputStream/OutputStream → 处理二进制(图片、zip、视频),按 8 位字节
字符流 Reader/Writer → 处理文本,按「字符」读,自动解码
字符流必须知道编码(UTF-8/GBK),否则中文乱码
跟着做
步骤 1:三行读写一个文件
新建 QuickIo.java:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class QuickIo {
public static void main(String[] args) throws IOException {
Path file = Path.of("demo.txt"); // Path.of 是 JDK 11+ 的写法
// 写入:整个字符串一次写完(默认 UTF-8;不存在则创建,存在则覆盖)
Files.writeString(file, "你好, CodeRoadMap\n第二行: Spring Boot + MySQL + Redis");
System.out.println("文件已生成: " + file.toAbsolutePath());
// 读取:整个文件一次读进内存
String content = Files.readString(file);
System.out.println("---- 文件内容 ----");
System.out.println(content);
}
}
适用边界:readString 一次载入全量内容,适合配置、小文件(几 MB 内)。几百 MB 的日志用它会把堆撑爆——逐行读见步骤 3。
步骤 2:常用 Files 操作集
import java.io.IOException;
import java.nio.file.*;
public class FilesOps {
public static void main(String[] args) throws IOException {
Path dir = Path.of("data");
Path src = dir.resolve("a.txt"); // resolve:拼子路径(跨平台分隔符)
Path dst = Path.of("data", "b.txt"); // 多段拼接
Files.createDirectories(dir); // 建多级目录(已存在不报错)
Files.writeString(src, "hello io");
System.out.println(Files.exists(src)); // true
System.out.println(Files.isRegularFile(src)); // true
System.out.println(Files.size(src)); // 8(字节数)
// 复制 / 移动 / 删除
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.move(src, Path.of("data", "renamed.txt"),
StandardCopyOption.REPLACE_EXISTING);
Files.deleteIfExists(dst); // 存在才删,避免 NoSuchFileException
// 遍历目录(一层)
try (var entries = Files.list(dir)) {
entries.forEach(e -> System.out.println("条目: " + e.getFileName()));
}
// 深度递归遍历用 Files.walk(dir)
}
}
注意 Files.list 返回的是需要关闭的流,必须 try-with-resources 包住——这是新手最容易漏的资源泄漏点。
步骤 3:逐行读大文件
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.*;
public class LineReaderDemo {
public static void main(String[] args) throws IOException {
Path log = Path.of("access.log");
// 造一份模拟访问日志
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 3; i++) {
sb.append("2026-10-06 10:0").append(i)
.append(" GET /api/user/").append(1000 + i)
.append(" 200\n");
}
Files.writeString(log, sb.toString());
// 方式 1:BufferedReader 逐行(内存恒定占用,适合任意大文件)
try (BufferedReader reader = Files.newBufferedReader(log)) {
String line;
while ((line = reader.readLine()) != null) { // 读到末尾返回 null
if (line.contains("user/1001")) {
System.out.println("命中: " + line);
}
}
}
// 方式 2:Files.lines 流式处理(函数式风格,同样要关闭)
try (var lines = Files.lines(log)) {
long count = lines.filter(l -> l.contains(" 200")).count();
System.out.println("200 响应条数: " + count);
}
}
}
步骤 4:字符流与编码
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
public class EncodingDemo {
public static void main(String[] args) throws IOException {
Path f = Path.of("utf8.txt");
String text = "中文内容 Chinese";
// 写:显式指定 UTF-8(writeString 默认就是 UTF-8)
Files.writeString(f, text, StandardCharsets.UTF_8);
// 读:编码不一致 → 乱码或 MalformedInputException
String ok = Files.readString(f, StandardCharsets.UTF_8);
System.out.println("UTF-8 读回: " + ok);
try {
Files.readString(f, StandardCharsets.US_ASCII); // 用错编码
} catch (Exception e) {
System.out.println("编码不符: " + e.getClass().getSimpleName());
}
// 字节流侧写:文本 → 字节 → 还原(理解「字符 = 编码后的字节」)
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
System.out.println("UTF-8 字节数: " + bytes.length); // 汉字 3 字节/个
String back = new String(bytes, StandardCharsets.UTF_8);
System.out.println("还原: " + back);
}
}
原则:编码永远显式写 UTF-8,不依赖平台默认值。Windows 老版本默认 GBK,Linux 默认 UTF-8——不指定就是跨平台乱码地雷。
步骤 5:字节流处理二进制
import java.io.*;
import java.nio.file.*;
public class BinaryDemo {
public static void main(String[] args) throws IOException {
Path src = Path.of("demo-src.bin");
Path dst = Path.of("demo-dst.bin");
// 造 1MB 随机二进制数据(模拟图片/zip)
byte[] data = new byte[1024 * 1024];
new java.util.Random(42).nextBytes(data);
Files.write(src, data);
// 字节流复制:InputStream / OutputStream,8KB 缓冲区
try (InputStream in = Files.newInputStream(src);
OutputStream out = Files.newOutputStream(dst)) {
byte[] buffer = new byte[8192];
int n;
while ((n = in.read(buffer)) != -1) { // read 返回 -1 表示读完
out.write(buffer, 0, n);
}
}
System.out.println("复制完成,大小一致: " + (Files.size(src) == Files.size(dst)));
// 小文件也可以一步复制
Files.copy(src, Path.of("demo-dst2.bin"), StandardCopyOption.REPLACE_EXISTING);
}
}
步骤 6:后端场景串烧——简易 KV 存储
import java.io.IOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;
public class FileKvStore {
private final Path file = Path.of("kv-store.txt"); // 模拟持久化(Redis 落盘的极简版)
public void save(Map<String, String> data) throws IOException {
StringBuilder sb = new StringBuilder();
data.forEach((k, v) -> sb.append(k).append("=").append(v).append("\n"));
Files.writeString(file, sb.toString()); // 全量覆盖写
}
public Map<String, String> load() throws IOException {
Map<String, String> data = new HashMap<>();
if (Files.notExists(file)) return data;
try (var lines = Files.lines(file)) {
lines.forEach(l -> {
int i = l.indexOf('=');
if (i > 0) data.put(l.substring(0, i), l.substring(i + 1));
});
}
return data;
}
public static void main(String[] args) throws IOException {
FileKvStore store = new FileKvStore();
Map<String, String> m = new HashMap<>();
m.put("user:1001", "tom");
m.put("user:1002", "jerry");
store.save(m);
System.out.println("重启后读回: " + store.load());
// 生产里这就是「内存缓存 + 定期落盘」的思路,Redis 的 RDB 快照同理
}
}
讲清楚要点
- Path/Files 是现代标准:
Path.of("a/b.txt") 创建路径,Files.xxx 做所有操作;java.io.File 是遗留 API,只在旧代码里见到(file.toPath() 可转现代路径)。
- 读写选型:小文件
readString/writeString;大文件/逐行 BufferedReader/Files.lines;二进制 InputStream/OutputStream + 缓冲区;已知字节全量 Files.readAllBytes。
- 编码必须显式:所有读写字符的方法都有带
Charset 参数的重载,统一传 StandardCharsets.UTF_8。
- 资源必须关闭:
Files.list、Files.walk、Files.lines、各种流都实现 AutoCloseable,全部套进 try-with-resources。文件句柄泄漏在长跑的服务里会拖垮系统。
- 异常模型:IO 异常都是受检的
IOException(呼应上一课),要么 catch 要么 throws——因为「磁盘、权限、文件被删」都是程序外因素,编译器逼你处理。
- 覆盖 vs 追加:
writeString 默认覆盖;追加用 Files.writeString(file, text, StandardOpenOption.CREATE, StandardOpenOption.APPEND)。日志场景就是追加写。
你可能会懵的地方
-
File 和 Path 什么关系?
File 是 JDK 1.0 的遗留类,方法命名混乱(mkdir 失败不报原因)、不支持符号链接、错误只能拿到 false。NIO.2 的 Path 修复了这一切,两者可通过 file.toPath() / path.toFile() 互转。新代码一律 Path + Files。
-
为什么 Files.lines 要 try-with-resources,Files.readString 不用?
readString 返回 String(纯数据,无资源);lines 返回懒加载的 Stream,底层持有打开的文件句柄,不关闭就泄漏。判断标准:返回的是「数据」还是「带句柄的流」。
-
readLine 返回 null 和空串的区别?
null = 文件结束(EOF);空串 = 该行没有内容(连续换行符)。所以循环条件写 != null,不要写成 !line.isEmpty()。
-
为什么二进制文件不能用 readString 读?
图片/zip 的字节序列不一定能解码成合法字符,readString 会抛 MalformedInputException 或产出损坏数据。文本走字符流,二进制走字节流,泾渭分明。
-
写入的数据立刻可见吗?会丢吗?
writeString 默认写入后即对同进程可见;但 OS 有页缓存,机器断电可能丢最后几 KB。要强持久化用 FileChannel.force(true)——生产日志/存储引擎的 fsync 就是这层。学习阶段不必展开,知道「写进 Java ≠ 落盘」即可。
-
大文件逐行读会不会 OOM?
不会。BufferedReader 按行读,内存里同时只有一行;Files.lines 也是流式惰性求值。会 OOM 的是 readString/readAllBytes 这类全量载入。
错法对照
| 错法 | 问题 | 正解 |
|---|
Files.readString(Path.of("big.log")) 读 GB 级文件 | OOM | BufferedReader / Files.lines 逐行 |
| 流没关闭(手写 new BufferedReader 不 close) | 句柄泄漏 | try-with-resources |
| 读文件不指定编码 | Windows 中文乱码 | 统一 StandardCharsets.UTF_8 |
Files.delete(path) 文件不存在 | 抛 NoSuchFileException | Files.deleteIfExists(path) |
用 File 的 mkdir() 判断成功与否 | 失败原因被吞成 false | Files.createDirectories(path),失败抛异常带原因 |
字节流读文本后 new String(bytes) 不给编码 | 平台相关乱码 | new String(bytes, StandardCharsets.UTF_8) |
Windows 路径硬编码 C:\dir\a.txt 单反斜杠 | \a 被当转义符 | Path.of("C:", "dir", "a.txt") 或正斜杠 C:/dir/a.txt |
卡点急救
- NoSuchFileException(读):路径拼错或文件没创建。先
Files.exists(path) 检查,打印 path.toAbsolutePath() 核对实际位置——相对路径基于「启动命令的当前目录」,不是源码目录。
- AccessDeniedException:目标目录无写权限(Linux 常见)、或文件正被其他进程占用(Windows 的 Excel/编辑器锁文件)。换目录或关占用程序。
- 中文乱码:三方对齐——写文件编码、读文件编码、终端显示编码统一 UTF-8;Windows 老终端先
chcp 65001。
- DirectoryNotEmptyException:delete 只删空目录,递归删除要
Files.walk 先删文件再删目录(谨慎,删前打印确认)。
- Files.lines 编译报「未处理的异常」:它抛受检 IOException,try/catch 或方法 throws(上一课的知识现在闭环了)。
面试常问
-
字节流和字符流的区别?
字节流按 8 位字节处理原始二进制(InputStream/OutputStream);字符流按字符处理文本,内部完成字节↔字符的编解码(Reader/Writer),需要指定编码。文本用字符流,图片/音视频用字节流。
-
Java IO 有哪些层次?
装饰器模式:节点流(FileInputStream 直接连数据源)+ 处理流(BufferedInputStream 加缓冲、DataInputStream 读基本类型)。new BufferedReader(new FileReader(f)) 就是典型装饰。
-
BIO、NIO、AIO 的区别?(后端进阶题)
BIO 同步阻塞(一线程一连接,Tomcat 传统模式);NIO 同步非阻塞(多路复用 Selector,Netty 底座);AIO 异步非阻塞(回调,Linux 支持有限,用得少)。本课的 Files/NIO.2 是文件系统 API,与网络 NIO 同属 java.nio 包但不是一回事。
-
如何高效读写大文件?
逐行流式处理(BufferedReader/Files.lines)而非全量载入;复制用带缓冲区的循环 read/write 或 Files.copy;超大规模考虑 FileChannel + MappedByteBuffer(内存映射)。
-
try-with-resources 的原理?
编译器在 try 结束(正常或异常路径)自动生成 close() 调用,并把 close 的异常处理为 suppressed exception 挂到主异常上。要求资源实现 AutoCloseable。
自测三问
- 读一个 2GB 的日志文件统计 ERROR 行数,写出代码骨架(选哪个 API、怎么关资源)。
Files.readString 返回的 String 需要关闭吗?Files.lines 返回的 Stream 呢?为什么?
- 字节流复制文件时
in.read(buffer) 返回 0、-1、n>0 分别代表什么?循环条件怎么写?
小结一句话
现代 Java IO = Path 定位 + Files 操作 + try-with-resources 保安全;小文件 readString、大文件逐行流、二进制字节流,编码永远显式 UTF-8——日志、配置、上传存储全靠这套路子。
延伸