现代文件读写救急锦囊: Files.readString / writeString
Java 11+ 读写小文件一把梭:不用 FileReader/FileWriter 那套老写法。
一句话定调
读写小配置文件,用 Files.readString() / Files.writeString() 一把梭,默认 UTF-8,自动关闭资源。
为什么老写法该退休
new FileReader("a.txt") 这套 IO 流写法有三宗罪:要手写 try-finally 关闭、默认平台编码(Windows 上 GBK,Linux 上 UTF-8,同一个文件跨平台读出两种结果)、五六行样板才能读出一个字符串。java.nio.file.Files 的静态方法一次解决:内部自己开关资源、readString 默认 UTF-8、一行读全文件。记住一个心智模型——老 API 是”自来水管”(一边流一边用),新 API 是”矿泉水瓶”(整瓶搬运):小文件直接拿瓶,大文件(百 MB 以上)老老实实用流。
极简代码(看懂这 20 行就够了)
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class FileQuickstart {
public static void main(String[] args) throws IOException {
Path path = Path.of("note.txt");
// 写入:不存在则创建,已存在则覆盖
Files.writeString(path, "第一行内容\n第二行内容");
// 读取:一次性读回整个文件
String text = Files.readString(path);
System.out.println(text);
// 预期输出:
// 第一行内容
// 第二行内容
// 追加一行:需要显式声明 APPEND
Files.writeString(path, "追加行\n", StandardOpenOption.APPEND);
// 按行读:小文件直接用 readAllLines
for (String line : Files.readAllLines(path)) {
System.out.println("行: " + line);
}
// 预期输出:
// 行: 第一行内容
// 行: 第二行内容
// 行: 追加行
}
}注意:方法签名带 throws IOException,要么在方法上声明,要么用 try-catch(结合 017 try-with-resources 与 016 异常处理)。
完整示例:一行不用流读完 + 常用文件操作全家福
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FilesKitchen {
public static void main(String[] args) throws IOException {
Path src = Path.of("demo.txt");
// 按行处理大一点的文件(惰性读取,不一次性装进内存)
Files.writeString(src, "alpha\nbeta\ngamma");
long count = Files.lines(src).filter(l -> !l.isEmpty()).count();
System.out.println("非空行数: " + count); // 预期输出:非空行数: 3
// 判断 / 元信息
System.out.println(Files.exists(src)); // true
System.out.println(Files.size(src)); // 16(UTF-8 下每个英文字母 1 字节,3 个换行符)
// 复制 / 移动 / 删除
Files.copy(src, Path.of("demo-copy.txt"));
Files.move(Path.of("demo-copy.txt"), Path.of("demo-renamed.txt"));
Files.delete(Path.of("demo-renamed.txt"));
// 临时文件:自动起名,用完即删
Path tmp = Files.createTempFile("log-", ".txt");
System.out.println("临时文件: " + tmp.getFileName()); // 类似 log-8371492.txt
Files.delete(tmp);
Files.delete(src);
}
}Files.lines 返回 Stream<String>,记得用 try-with-resources 包住(流持有文件句柄),或像上例一样当场消费干净。
指定编码:跨平台别赌默认值
readString/writeString 默认 UTF-8(JDK 18 起整个 JVM 默认编码也是 UTF-8)。读写历史遗留的 GBK 文件时显式指定,避免乱码:
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
// 读 GBK 编码的老文件
String gbkText = java.nio.file.Files.readString(
java.nio.file.Path.of("old.txt"), Charset.forName("GBK"));
// 写 UTF-8(显式写出意图)
java.nio.file.Files.writeString(
java.nio.file.Path.of("new.txt"), "内容", StandardCharsets.UTF_8);如果报这个错,看这里
报错:java.nio.file.NoSuchFileException: a.txt
原因:路径不存在或相对路径的工作目录和你以为的不一样。
对策:先用 Files.exists(path) 判断;相对路径是相对程序启动目录,不确定时用绝对路径或 Paths.get("src", "main", "resources", "a.txt")。
报错:FileSystemException: 另一个程序正在使用此文件,进程无法访问(Windows)
原因:文件被编辑器/Excel 占用。
对策:关闭占用程序;写入用 StandardOpenOption.TRUNCATE_EXISTING 配合重试,或先写临时文件再 Files.move 原子替换。
报错:java.nio.file.FileAlreadyExistsException
原因:Files.copy/move/createFile 遇到同名目标,默认不覆盖。
对策:加 StandardOpenOption.REPLACE_EXISTING(copy/move 均支持),或先判断再决定覆盖策略。
现象:读回来全是乱码
原因:文件实际编码与解码编码不一致(最常见是 GBK 文件被按 UTF-8 解)。
对策:显式传 Charset 参数;无法确定编码时,先看文件头几个字节(BOM)或与文件生产方确认,Java 无法”自动识别编码”。
现象:大文件用 readString 后内存暴涨或 OOM
原因:readString/readAllLines 把整个文件装进内存。
对策:超过几十 MB 改用 Files.lines(按行流式)或 028 的 BufferedReader 流式处理。
进阶配方:目录遍历与流式统计
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class DirWalkDemo {
public static void main(String[] args) throws IOException {
Path dir = Path.of("src");
// 递归列出目录下所有 .java 文件(惰性遍历,目录大也稳)
try (Stream<Path> entries = Files.walk(dir)) {
Map<String, Long> byExt = entries
.filter(Files::isRegularFile)
.collect(Collectors.groupingBy(
p -> extension(p.getFileName().toString()),
Collectors.counting()));
System.out.println(byExt);
// 预期输出(按实际目录内容):{java=12, md=3}
}
}
static String extension(String name) {
int dot = name.lastIndexOf('.');
return dot < 0 ? "无扩展名" : name.substring(dot + 1);
}
}Files.walk 返回的流持有目录句柄,务必 try-with-resources;只要文件名列表不需要递归,用 Files.list(仅当前目录)即可。这类”walk + 过滤 + 收集”的写法比手写 File 递归少一半代码,也少一半出错机会。
动手练习(先遮住参考实现)
预计 30 分钟,围绕一条主线:给你的命令行工具做一个「随手记」存储层。三道题层层递进,每道先写自测再写实现。
练习一(10 分钟):追加与回读
写一个 NotesStore 类,提供两个方法:append(Path file, String note) 往文件末尾追加一行(文件不存在则创建),readAll(Path file) 返回 List<String>,空文件返回空列表。自测断言:追加三条后回读得到三条;对不存在的文件回读得到空列表而不是异常。
(提示:Files.writeString 的第四个参数是 StandardOpenOption——追加用 CREATE, APPEND;回读先 Files.exists 判断,或直接 catch NoSuchFileException,两种都算对,想想哪种更符合「异常即流程」的口味。)
练习二(10 分钟):配置 round-trip
把一个 Map<String, String>(如 {theme=dark, fontSize=14})按 key=value 每行一项写进 config.txt,再写一个 load(Path) 读回来还原成 Map。自测断言:写入再读出,两个 Map 相等。做这题时你会撞上本篇第 4 节的坑——回读时用 split("=", 2) 还是 split("=")?值里带 = 时两者差别就显形了。
练习三(10 分钟):统计一个目录的代码行数
写 countLines(Path dir):用 Files.walk 找出目录下所有 .java 文件,用 Files.lines 流式统计总行数。自测:对本文示例项目跑一遍,行数与你肉眼估的同数量级即可。做错几乎一定是两处之一:忘了 try-with-resources(编译器或 IDE 会提醒第一处,第二处靠运行才知道)。
参考实现(先别看)
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
public class NotesStore {
static void append(Path file, String note) throws IOException {
Files.writeString(file, note + System.lineSeparator(),
StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
static List<String> readAll(Path file) throws IOException {
if (Files.notExists(file)) return List.of();
return Files.readAllLines(file, StandardCharsets.UTF_8);
}
static Map<String, String> load(Path file) throws IOException {
Map<String, String> result = new LinkedHashMap<>();
for (String line : readAll(file)) {
if (line.isBlank()) continue;
int eq = line.indexOf('=');
result.put(line.substring(0, eq), line.substring(eq + 1)); // indexOf 只切第一刀
}
return result;
}
static long countLines(Path dir) throws IOException {
try (Stream<Path> entries = Files.walk(dir)) {
return entries
.filter(p -> p.toString().endsWith(".java"))
.filter(Files::isRegularFile)
.mapToLong(NotesStore::lineCount)
.sum();
}
}
private static long lineCount(Path file) {
try (Stream<String> lines = Files.lines(file)) {
return lines.count();
} catch (IOException e) {
return 0; // 生产代码应记录日志,练习从简
}
}
}预测题答案(先答再看):color=#fff 用两种切法都对(split("=") 得 ["color", "#fff"]);差别出在值本身带等号时——url=https://a=b 用 split("=") 会切成三段,取 parts[1] 只剩 https://a,静默丢数据不报错;indexOf('=') 只切第一刀,剩下的整体都是值。I/O 题的坑常不在 I/O 本身,而在字符串处理。
记住
初学者记住三点:
- 小文件:
Files.writeString写、Files.readString读、Files.readAllLines按行读。- 默认 UTF-8;非 UTF-8 文件显式传
Charset,别赌平台默认编码。- 方法都抛受检异常
IOException,先throws跑通再细化处理。
进阶者还需注意:
Files.lines是惰性流且持有文件句柄,务必 try-with-resources 或当场消费。- 原子写模式:写临时文件 +
Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING),避免读方读到写了一半的内容。 - 目录树遍历用
Files.walk/Files.walkFileTree;删除整个目录要自己递归,Files.delete不删非空目录。