
Java读写文件常见方法:字符流、字节流与缓冲流
Java 文件读写看起来只是“打开文件、读写内容、关闭文件”,实际选择哪种流、使用什么字符集、如何处理资源和异常,都会影响程序的正确性。读取文本时,字节必须按照正确字符集解码;处理图片、压缩包等二进制内容时,则不能经过字符编码转换;文件较大时,还要避免一次性把全部内容装入内存。
本文介绍 Java 常用的文件读写方式,包括字符流、字节流、缓冲流和 Files 工具类,并通过可运行示例说明适用场景、关键边界和常见错误。示例以 Java 8 及以上版本为基础;使用 Path、Files 和明确指定的字符集,通常比依赖默认编码的旧式写法更容易维护。
一、先分清文本和二进制
Java 文件 API 大致分为两类:
- 字节流:以原始字节为单位读写,常见类是
InputStream、OutputStream及其文件实现FileInputStream、FileOutputStream。适用于图片、音频、视频、压缩包、PDF 等二进制数据,也适用于需要自行处理编码的场景。 - 字符流:以字符为单位读写,常见抽象类是
Reader、Writer,文件实现包括FileReader、FileWriter。字符流涉及“字节如何解码为字符”或“字符如何编码为字节”,因此文本文件应明确指定字符集。
字符流并不意味着文件内部直接存放 Java 字符。磁盘上仍然保存字节,字符流只是在读写过程中完成编码转换。例如,UTF-8 文本中的一个汉字通常占多个字节;如果用错误的字符集解码,文件可能没有损坏,但读出的文字会变成乱码。
二、读写文本:指定字符集的字符流
需要逐行处理文本、边读边处理,或处理内容可能较大的文件时,字符流很合适。Java 8 中可用 InputStreamReader、OutputStreamWriter 将字节流包装为指定字符集的字符流,再用缓冲流提高读写效率并方便逐行操作。
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
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;
public class TextFileExample {
public static void main(String[] args) throws IOException {
Path path = Paths.get("notes.txt");
// 新建或覆盖文件;明确使用 UTF-8。
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(
Files.newOutputStream(path),
StandardCharsets.UTF_8))) {
writer.write("第一行");
writer.newLine();
writer.write("第二行:你好,Java");
writer.newLine();
}
// 按 UTF-8 逐行读取。
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(
Files.newInputStream(path),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
}
将代码保存为 TextFileExample.java,在 Java 8 或更高版本中编译运行:
javac TextFileExample.java
java TextFileExample
这里使用 Files.newOutputStream(path),其默认选项是创建文件并截断已存在的文件,也就是覆盖原内容。读取时,Files.newInputStream(path) 打开文件供读取。若文件不存在,读取会抛出 NoSuchFileException;父目录不存在时,写入也可能失败,需先创建目录。
readLine() 返回一行文本,但不包含换行符。它会把不同平台的行结束符作为分隔符处理。若需要保留原始换行符,或需要逐字节检查文件内容,就不应使用逐行读取方式。写入时,newLine() 使用当前系统对应的行分隔符,并不保证所有平台都写出相同的换行字节;需要固定文件格式时,应明确写入约定的分隔符。
三、FileReader 和 FileWriter 的使用边界
FileReader、FileWriter 写法较短,适用于字符集明确且代码运行环境受控的简单文本处理。较新的 Java 版本提供了接收字符集参数的构造方式;但在需要兼容 Java 8 的代码中,常用且清楚的方式仍是通过 InputStreamReader 和 OutputStreamWriter 显式指定字符集。
不指定字符集的构造方式依赖运行时默认字符集。默认字符集可能受 Java 版本和运行环境影响,不适合作为跨机器交换文本的隐含约定。即使某些环境默认使用 UTF-8,也建议在文件格式要求 UTF-8 时显式写出 StandardCharsets.UTF_8。如果文件采用其他编码,例如 UTF-16,应按文件约定选择对应字符集,不能仅凭扩展名推断编码。
还有一个常见边界:字符流读取文本会进行解码。如果输入包含无法按指定字符集解码的字节,解码器可能替换错误字符,也可能按配置报告错误。对要求严格校验的文件格式,应显式配置解码器的错误处理策略,而不是默认假设读取成功就代表文件内容正确。
四、读写二进制:字节流
二进制内容应使用字节流原样复制,不能先转成字符串再写回。用文本字符集转换二进制数据会改变字节序列,可能导致文件无法打开或校验失败。
下面的示例将一个文件复制到另一个文件,使用固定大小的缓冲区逐块处理,不会一次性把整个文件读入内存。示例采用 Java 8 语法:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class ByteCopyExample {
public static void main(String[] args) throws IOException {
Path source = Paths.get("input.bin");
Path target = Paths.get("output.bin");
byte[] buffer = new byte[8192];
try (InputStream in = Files.newInputStream(source);
OutputStream out = Files.newOutputStream(target)) {
int count;
while ((count = in.read(buffer)) != -1) {
out.write(buffer, 0, count);
}
}
}
}
read(buffer) 一次可能只读到部分数据,因此不能假设每次都会填满缓冲区;循环应以返回值 -1 表示输入结束,并只写入实际读取的 count 个字节。将整个 buffer 都写出去,会把上一次或未使用位置的旧数据也写入目标文件,造成文件内容错误。
该示例在目标路径已存在时会按默认选项截断并覆盖目标文件。若不允许覆盖,应使用明确的创建选项,例如 StandardOpenOption.CREATE_NEW,让目标已存在时操作失败。复制到源文件本身或与源文件指向同一实际文件的路径也应避免:输出端可能先截断文件,进而破坏源数据。
简单复制通常优先考虑 Files.copy(source, target, ...),由标准库完成复制过程;若需要逐块检查、统计进度、转换内容或执行额外校验,再使用字节流自行处理。复制行为应结合具体选项判断:未指定覆盖选项时,目标已存在通常会导致失败;使用 REPLACE_EXISTING 则允许替换目标。文件属性、符号链接及跨文件系统行为也可能受平台和选项影响,不能把一次复制等同于对所有元数据的完整保留。
五、缓冲流有什么用
BufferedInputStream、BufferedOutputStream、BufferedReader 和 BufferedWriter 会在内存中维护缓冲区,减少频繁的小块底层读写。缓冲流还提供一些便利操作,例如 BufferedReader.readLine()。它适合大量小块读写或逐行处理,但缓冲并不会让文件操作自动具备事务性,也不会限制程序最终占用的总内存。
对简单的批量复制,显式使用字节数组本身已经实现了分块处理;再套一层缓冲流通常也可用,但不一定能带来明显收益。不要为了“有缓冲”而机械叠加包装层,尤其要看清关闭顺序和实际性能需求。对少量文本,使用 Files.readString 等便捷 API 往往更直接;对大文件,逐行或分块处理更重要。
写入缓冲流时,数据可能暂留在用户态缓冲区。正常关闭流会先刷新缓冲数据再关闭底层资源;也可以显式调用 flush(),要求将缓冲内容交给下层流。但 flush() 不等于数据已经可靠写入物理介质,也不等于操作具备断电安全保证。若应用有严格的持久化要求,需要进一步评估文件系统、同步写入和部署环境提供的保证。
六、用 Files 简化小文件读写
对内容规模确定、适合一次性装入内存的小文本文件,Files.readString 和 Files.writeString 可以减少样板代码。这两个方法从 Java 11 开始提供,使用时仍应指定字符集,并确认文件大小不会造成不合适的内存占用。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class FilesTextExample {
public static void main(String[] args) throws IOException {
Path path = Paths.get("message.txt");
Files.writeString(
path,
"你好,文件\n",
StandardCharsets.UTF_8);
String text = Files.readString(path, StandardCharsets.UTF_8);
System.out.print(text);
}
}
Files.writeString(path, text, charset) 在未指定打开选项时会创建文件或截断已有文件后写入;它不是默认追加。若要追加,应提供诸如 StandardOpenOption.CREATE 和 StandardOpenOption.APPEND 的选项。追加操作也不自动保证多线程或多进程场景下整段文本的原子性。
Files.readAllBytes、Files.readString、Files.readAllLines 等一次性读取方法会将全部内容放入内存。它们适合有明确大小上限的小文件,不适合未经评估地处理任意大小的用户文件或日志。对于大型文件,优先使用流式读取;即使使用逐行读取,也应考虑单行可能异常地长,不能把“逐行”当成绝对内存上限。
七、追加、创建目录和显式选项
写入行为应由选项表达清楚,避免误把覆盖当追加。下面的示例先创建父目录,再向文件末尾追加一行:
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;
public class AppendExample {
public static void main(String[] args) throws IOException {
Path path = Paths.get("logs", "app.log");
Files.createDirectories(path.getParent());
Files.write(
path,
"新的一条记录\n".getBytes(StandardCharsets.UTF_8),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
}
}
Files.createDirectories 会创建尚不存在的父目录;如果目录已存在,通常不报错。示例中的 getBytes(UTF_8) 先把字符串编码成字节,再由 Files.write 写入。对大量文本或持续写日志,更适合使用缓冲字符流,避免反复创建临时字节数组,并根据应用的日志需求设计并发和轮转策略。
文件打开选项有相互约束。例如,APPEND 与 TRUNCATE_EXISTING 不能同时使用;某些选项组合在特定文件系统或提供者上可能不受支持。需要“文件不存在时创建、存在时不覆盖”时,可使用 CREATE_NEW,它适合避免直接覆盖已有目标,但并不能替代对文件内容和路径的安全校验。
八、资源关闭与异常处理
文件流、读写器等资源应及时关闭,否则可能占用文件句柄,导致资源泄漏,或使缓冲中的数据没有正常刷新。推荐使用 try-with-resources,它会在代码块结束时自动关闭资源,即使代码抛出异常也会尝试关闭。
try (BufferedReader reader = Files.newBufferedReader(
path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
// 处理 line
}
}
当包装流关闭时,它会关闭底层流,因此通常只需要把最外层包装对象放入资源声明中。若同一个底层流被多个对象共同使用,或者关闭行为有特殊要求,则应明确资源所有权,避免提前关闭仍需使用的流。
示例通常将 IOException 向上声明,便于短程序运行,也让调用方知道文件操作可能失败。生产代码需要根据业务决定如何处理:向用户返回明确错误、记录上下文后重试,还是中止任务。不要用空的 catch 把读写失败吞掉,也不要仅仅打印异常后继续假装操作成功。
若对外写入一个重要文件,直接覆盖可能留下半成品:程序在写到一半时失败,旧文件可能已经被截断。常见做法是先写入同一文件系统中的临时文件,成功后再用 Files.move 替换目标,并在需要时尝试使用 StandardCopyOption.ATOMIC_MOVE。原子移动是否受支持取决于文件系统和提供者;跨文件系统移动也可能不满足原子性。需要可靠更新时,应捕获相应异常并设计回退策略,不能仅凭指定了原子选项就假定所有环境都能保证原子替换。
九、路径与文件操作的安全边界
如果路径来自用户输入,不能直接拼接到服务器上的目录后就认定它安全。诸如 ../ 的路径片段可能使最终路径越出预期目录。可以先解析并规范化路径,再检查它是否仍位于允许的根目录中:
Path root = Paths.get("/srv/app/uploads").toRealPath();
Path target = root.resolve(userFileName).normalize();
if (!target.startsWith(root)) {
throw new SecurityException("路径越出允许目录");
}
这段检查只是路径遍历防护的基础示意,并不能单独解决所有文件系统安全问题。toRealPath() 要求路径存在;对尚未创建的目标文件,处理方式需要结合父目录和创建时机设计。符号链接可能使路径实际指向预期目录之外;在攻击者能够并发修改目录结构的环境中,还可能出现检查后路径被替换的竞态问题。高风险服务应限制目录权限、控制符号链接策略,并使用适合该环境的安全文件访问机制。
还要注意文件大小和处理资源。不要对不可信输入无条件调用 readAllBytes() 或 readString();应根据业务设定允许的最大大小,必要时在流式读取时累计字节数并超限终止。文件名也不应直接用于构造可执行命令或其他敏感操作。读写成功与否应检查异常和业务校验结果,而不是只根据文件是否存在来判断。
十、常见错误清单
- 用字符流复制图片或压缩包:字符编码转换会改变字节,二进制文件应使用字节流或
Files.copy。 - 依赖默认字符集:读写两端编码不一致会乱码。文本格式有约定时显式指定字符集。
- 认为一次读取就能得到全部内容:
InputStream.read(byte[])允许只返回部分数据,应循环读取直到返回-1。 - 忽略实际读取长度:写缓冲区时只写入本次读取的字节数,不要无条件写整个数组。
- 以为写入默认是追加:常见写入 API 默认可能截断目标文件。追加时显式使用
APPEND。 - 一次性读取大文件:整文件 API 会占用与内容规模相关的内存,处理大文件应采用流式方式并设置合理限制。
- 忘记创建父目录:创建文件不会自动创建缺失的上级目录,可按需使用
Files.createDirectories。 - 没有关闭流:使用
try-with-resources管理文件资源。 - 把 flush 当成持久化保证:刷新缓冲不等于事务提交或断电后必然保留数据。
- 信任用户提供的路径:检查规范化后的路径边界,并评估符号链接、并发修改和文件大小风险。
十一、如何选择
| 需求 | 建议方式 | 注意事项 |
|---|---|---|
| 读取或写入小型文本文件 | Files.readString、Files.writeString(Java 11+) | 明确字符集,评估文件大小与内存 |
| 逐行处理文本或处理大文本 | BufferedReader、BufferedWriter | 明确字符集;留意超长行和换行约定 |
| 复制图片、压缩包等二进制文件 | Files.copy 或字节流 | 确认是否允许覆盖及失败后的清理策略 |
| 按块处理大型二进制文件 | InputStream、OutputStream | 循环处理部分读取,设置合理缓冲区和大小限制 |
| 向文本文件追加内容 | 带 APPEND 选项的写入流或 Files.write | 确认文件编码;并发追加不自动具备业务级原子性 |
最重要的判断顺序是:先确认数据是文本还是二进制,再确定字符集和写入模式,然后选择一次性 API 或流式处理,最后通过资源管理、异常处理和路径校验守住边界。把这些约定写清楚,文件读写代码通常不需要复杂封装,也能避免乱码、意外覆盖、内存溢出和资源泄漏等常见问题。




