摘要: 本文深入讲解Java I/O流的核心概念与实战应用,涵盖字节流(InputStream/OutputStream)和字符流(Reader/Writer)两大体系。通过具体代码示例,详细解析文件读写、缓冲流优化、字符编码处理等关键技术,帮助开发者掌握高效、安全的文件操作方式。文章还提供了字节流与字符流的选择指南、常见陷阱规避方法以及一个完整的文本文件复制工具实现,为Java文件处理打下坚实基础。
关键词: Java I/O流, 字节流, 字符流, 文件读写, 缓冲流
在之前的所有文章里,我们所有的数据都存在于“内存”中——变量、数组、集合、对象。一旦程序结束,这些数据就消失了,就像一场梦醒来什么都记不住。
但你写程序,很多时候是为了处理“真实世界”的数据:从硬盘读取一个文件、把用户输入保存到磁盘、从网络下载一张图片、上传一份日志……这些数据是持久化的,它们不随程序的结束而消失。
那么,Java 是如何与外部世界“对话”的呢?
答案就是 I/O 流(Input/Output Stream)。
I/O 是 Input/Output 的缩写。你可以把流想象成一根“管道”:数据从一端流进来(输入),或者从一端流出去(输出)。这根管道连接着你的程序和外部的数据源(文件、网络、键盘、显示器等)。
今天,我们先来认识 I/O 流的两大家族:字节流和字符流。
1. 什么是流?—— 数据就像水一样流动
在 Java 中,“流”是一个抽象概念,它代表了一个有序的、连续的数据序列。
- 输入流(Input Stream):数据从外部“流进”程序。比如,你从文件读取数据,数据从文件流向程序。
- 输出流(Output Stream):数据从程序“流出”到外部。比如,你把数据写入文件,数据从程序流向文件。
流的本质是一连串的字节(或字符)。你可以把它想象成水管里的水——水从源头流向目的地,你可以在中途“接”一部分水(读取),也可以往里面“加”水(写入)。
Java 的 I/O 流分为两大类:
它们各自有四个抽象基类:
| 字节流 | InputStream | OutputStream |
| 字符流 | Reader | Writer |
所有具体的流类都直接或间接继承自这四个基类。
2. 字节流 —— InputStream 和 OutputStream
字节流是 I/O 的“通用语言”。无论什么数据(文本、图片、视频),在计算机中最终都是以字节序列存在的。所以字节流可以处理任何类型的文件。
2.1 InputStream —— 字节输入流的鼻祖
InputStream 是所有字节输入流的父类。它定义了读取字节的基本方法:
| int read() | 读取一个字节(0~255),如果到达末尾返回 -1 |
| int read(byte[] b) | 读取多个字节到数组,返回实际读取的字节数 |
| int read(byte[] b, int off, int len) | 读取指定长度的字节到数组的指定位置 |
| void close() | 关闭流,释放系统资源(非常重要!) |
最常用的子类是 FileInputStream,用来从文件读取字节。
// 示例:用 FileInputStream 读取一个文件
import java.io.FileInputStream;
import java.io.IOException;
public class FileInputStreamDemo {
public static void main(String[] args) {
FileInputStream fis = null;
try {
// 创建 FileInputStream,指定文件路径
fis = new FileInputStream("test.txt");
int data;
// 一次读一个字节,直到返回 -1(文件末尾)
while ((data = fis.read()) != –1) {
// 这里把字节转成字符显示(仅适用于纯文本)
System.out.print((char) data);
}
} catch (IOException e) {
System.out.println("读取文件出错:" + e.getMessage());
} finally {
// 确保关闭流
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
2.2 OutputStream —— 字节输出流的鼻祖
OutputStream 是所有字节输出流的父类。它定义了写入字节的基本方法:
| void write(int b) | 写入一个字节 |
| void write(byte[] b) | 写入整个字节数组 |
| void write(byte[] b, int off, int len) | 写入数组的一部分 |
| void flush() | 强制将缓冲区的数据写入底层设备 |
| void close() | 关闭流 |
最常用的子类是 FileOutputStream,用来向文件写入字节。
// 示例:用 FileOutputStream 写入文件
import java.io.FileOutputStream;
import java.io.IOException;
public class FileOutputStreamDemo {
public static void main(String[] args) {
String content = "Hello, I/O World!\\n这是第二行。";
try (FileOutputStream fos = new FileOutputStream("output.txt")) {
// 把字符串转成字节数组
byte[] bytes = content.getBytes();
fos.write(bytes);
System.out.println("写入成功!");
} catch (IOException e) {
System.out.println("写入出错:" + e.getMessage());
}
// 使用 try-with-resources,自动关闭流
}
}
2.3 缓冲字节流 —— BufferedInputStream / BufferedOutputStream
直接使用 FileInputStream 或 FileOutputStream 时,每次读写都会直接操作硬盘,效率较低。为了提升性能,Java 提供了缓冲流。
缓冲流内部维护了一个缓冲区(默认 8192 字节)。读写数据时,会先操作缓冲区,满了之后才真正读写硬盘,大幅减少了 I/O 次数。
// 用缓冲流复制文件
import java.io.*;
public class BufferedCopyDemo {
public static void main(String[] args) {
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("source.jpg"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("copy.jpg"))) {
byte[] buffer = new byte[1024]; // 自定义缓冲区
int bytesRead;
while ((bytesRead = bis.read(buffer)) != –1) {
bos.write(buffer, 0, bytesRead);
}
System.out.println("文件复制完成!");
} catch (IOException e) {
System.out.println("复制出错:" + e.getMessage());
}
}
}
小贴士:使用缓冲流时,记得调用 flush() 确保所有数据都被写出(close() 也会自动调用 flush())。
3. 字符流 —— Reader 和 Writer
字节流以字节为单位,但人类阅读的是字符。如果你用字节流读取一个文本文件,然后把它转成字符显示,很可能出现乱码,因为不同的编码(GBK、UTF-8)下,一个字符可能占用 1~4 个字节。
字符流就是为了解决这个问题而生的。它在字节流的基础上,增加了字符编码转换的能力。你只需要指定字符集,字符流就会自动完成字节 ↔ 字符的转换。
3.1 Reader —— 字符输入流的鼻祖
Reader 是所有字符输入流的父类。它的核心方法是 read(),但操作的是 char(字符)而不是 byte。
最常用的子类是 FileReader,用来读取文本文件。
// 用 FileReader 读取文本文件
import java.io.FileReader;
import java.io.IOException;
public class FileReaderDemo {
public static void main(String[] args) {
try (FileReader fr = new FileReader("test.txt")) {
int ch;
while ((ch = fr.read()) != –1) {
System.out.print((char) ch);
}
} catch (IOException e) {
System.out.println("读取出错:" + e.getMessage());
}
}
}
3.2 Writer —— 字符输出流的鼻祖
Writer 是所有字符输出流的父类。它提供了写入字符的方法。
最常用的子类是 FileWriter,用来写入文本文件。
// 用 FileWriter 写入文本
import java.io.FileWriter;
import java.io.IOException;
public class FileWriterDemo {
public static void main(String[] args) {
try (FileWriter fw = new FileWriter("output.txt", true)) { // true 表示追加模式
fw.write("这是追加的内容\\n");
fw.write("另一行内容");
System.out.println("写入成功!");
} catch (IOException e) {
System.out.println("写入出错:" + e.getMessage());
}
}
}
FileWriter 默认使用系统默认编码。如果你需要指定编码(比如 UTF-8),建议使用 OutputStreamWriter 或 Files.newBufferedWriter()。
3.3 缓冲字符流 —— BufferedReader / BufferedWriter
和字节流一样,字符流也有缓冲版本,而且还有额外的好处:BufferedReader 提供了 readLine() 方法,可以一次读取一整行文本,非常方便。
// 用 BufferedReader 逐行读取
import java.io.*;
public class BufferedReaderDemo {
public static void main(String[] args) {
try (BufferedReader br = new BufferedReader(new FileReader("test.txt"))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println("行内容:" + line);
}
} catch (IOException e) {
System.out.println("读取出错:" + e.getMessage());
}
}
}
// 用 BufferedWriter 写入
import java.io.*;
public class BufferedWriterDemo {
public static void main(String[] args) {
try (BufferedWriter bw = new BufferedWriter(new FileWriter("output.txt"))) {
bw.write("第一行");
bw.newLine(); // 写入换行符(跨平台)
bw.write("第二行");
bw.newLine();
bw.write("第三行");
} catch (IOException e) {
System.out.println("写入出错:" + e.getMessage());
}
}
}
4. 字节流 vs 字符流 —— 怎么选?
| 读写任何类型的文件(图片、音频、视频、二进制文件) | 字节流(FileInputStream / FileOutputStream) |
| 读写纯文本文件,并且关心字符编码 | 字符流(FileReader / FileWriter 或 InputStreamReader / OutputStreamWriter) |
| 需要逐行读取文本 | BufferedReader(支持 readLine()) |
| 需要高性能、大数据量 | 使用缓冲流包装基础流 |
重点:不要用字节流去读取文本文件,除非你手动处理编码。字符流会帮你自动处理编码,让你的代码在不同操作系统上都能正常工作。
5. 指定字符编码 —— 避免乱码
在不同的操作系统和环境中,默认字符编码可能不同(Windows 是 GBK,Linux 是 UTF-8)。如果你用默认编码读写文件,换一个环境就可能出现乱码。
解决方案:显式指定字符编码。
使用 InputStreamReader 和 OutputStreamWriter 这两个“转换流”,它们在字节流和字符流之间搭了一座桥,允许你指定字符集。
// 指定 UTF-8 编码读取文件
import java.io.*;
public class EncodingDemo {
public static void main(String[] args) {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(
new FileInputStream("test.txt"),
"UTF-8" // 指定编码
)
)) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("读取出错:" + e.getMessage());
}
}
}
从 JDK 11 开始,Files 工具类提供了更简洁的方法:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
public class FilesDemo {
public static void main(String[] args) throws IOException {
// 一次性读取所有行(适用于小文件)
List<String> lines = Files.readAllLines(Paths.get("test.txt"));
lines.forEach(System.out::println);
// 一次性写入所有行
List<String> content = List.of("第一行", "第二行", "第三行");
Files.write(Paths.get("output.txt"), content);
}
}
Files 类默认使用 UTF-8,大大简化了代码。我们下一篇会深入介绍 NIO 和 Files 工具类的更多用法。
6. 常见陷阱与注意事项
① 忘记关闭流
不关闭流会导致资源泄漏(文件句柄、网络连接等)。始终用 try-with-resources 来自动关闭。
② 在字符流中用了字节流,或者反过来
不要混淆——字符流处理文本,字节流处理二进制。用错了会导致乱码或数据损坏。
③ 缓冲流忘记 flush()
缓冲流在写入时会先把数据存在内存缓冲区,如果没有 flush() 或 close(),数据可能没有真正写入硬盘。close() 会自动调用 flush(),所以只要正常关闭就没问题。
④ 路径问题
文件路径可以是绝对路径(C:/data/test.txt)或相对路径(data/test.txt)。相对路径是相对于当前工作目录(通常是项目的根目录)。在 IDE 中运行,路径可能会因为运行配置的不同而变化。建议使用绝对路径或把文件放在 src 目录下用类加载器读取。
⑤ 编码不一致
读取时指定的编码与文件实际的编码不一致,会出现乱码。保持读写编码一致。
7. 综合示例 —— 一个简单的文本文件复制工具
我们用今天学的知识,写一个可以复制文本文件的工具,支持指定编码和追加模式:
import java.io.*;
import java.nio.charset.Charset;
public class TextFileUtil {
/**
* 复制文本文件
* @param srcPath 源文件路径
* @param destPath 目标文件路径
* @param encoding 字符编码(如 "UTF-8")
* @param append 是否追加到目标文件末尾
*/
public static void copyTextFile(String srcPath, String destPath,
String encoding, boolean append) {
// 使用 try-with-resources 自动关闭流
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(
new FileInputStream(srcPath),
Charset.forName(encoding)
));
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream(destPath, append),
Charset.forName(encoding)
)
)) {
String line;
int lineCount = 0;
while ((line = reader.readLine()) != null) {
writer.write(line);
writer.newLine();
lineCount++;
}
System.out.printf("✅ 复制完成,共 %d 行。%n", lineCount);
} catch (FileNotFoundException e) {
System.out.println("❌ 文件不存在:" + srcPath);
} catch (IOException e) {
System.out.println("❌ IO 错误:" + e.getMessage());
}
}
public static void main(String[] args) {
// 测试:复制文件,使用 UTF-8,覆盖目标
copyTextFile("source.txt", "destination.txt", "UTF-8", false);
}
}
8. 今天的总结
今天我们进入了 I/O 流的世界:
- 字节流(InputStream / OutputStream):以字节为单位,可处理任何类型的数据。
- 字符流(Reader / Writer):以字符为单位,专为文本设计,支持编码转换。
- 缓冲流(BufferedInputStream / BufferedOutputStream / BufferedReader / BufferedWriter):提升性能,并提供了便捷方法(如 readLine())。
- 转换流(InputStreamReader / OutputStreamWriter):在字节流和字符流之间搭桥,支持指定编码。
- try-with-resources:自动关闭资源,防止资源泄漏。
- 选择建议:文本用字符流,二进制用字节流;指定编码避免乱码。
今天的内容是 I/O 的基础。你学会了如何从文件读取和写入数据。在下一篇(I/O 流下)中,我们将学习更现代的 NIO(New I/O)——它提供了更高效的文件操作(Path、Files 工具类、文件遍历等),是我们目前最推荐的现代 I/O 方式。
动手试试
写一个程序,用 FileInputStream 和 FileOutputStream 复制一张图片(二进制文件),体会字节流可以处理任何文件。
用 BufferedReader 和 BufferedWriter 把一段文本按行读取,然后把每行前加上行号(如“1: 内容”),写入新文件。
修改上面的 copyTextFile 方法,增加统计字符数的功能,并在复制完成后输出总字符数。
(挑战)写一个 FileSearch 方法,接收一个目录路径和一个关键字,遍历目录下的所有 .txt 文件,搜索包含该关键字的文件并打印文件名和匹配的行号(提示:可以用 File 类和递归)。
有了 I/O 流,你的程序终于可以和外部世界“对话”了。下一篇文章,我们会走进 NIO 的世界,体验更现代、更强大的文件操作方式。
我们下一篇见。😃
📌 获取本系列示例代码请访问 GitCode。



