IO流

IO是什么?

IO = Input/Output = 输入/输出 = 数据的进和出

比喻:你的程序就像一个房间,IO就是门——数据从门外进来(Input),从门内出去(Output)

读文件 = 数据从硬盘输入到程序(Input)

写文件 = 数据从程序输出到硬盘(Output)

网络请求 = 数据从网络输入,响应从程序输出

方向参考系永远是程序本身:站在程序的角度看,进来的是Input,出去的是Output

一、流的分类

“流”(Stream)这个比喻非常形象:数据像水一样,从一端流到另一端,你可以一点一点地接

按方向分

方向 说明
输入流 InputStream / Reader 数据流进程序(读)
输出流 OutputStream / Writer 数据流出程序(写)

按数据类型分

类型 基类 单位 适合场景
字节流 InputStream / OutputStream byte(1字节) 图片、视频、压缩包等二进制文件
字符流 Reader / Writer char(2字节) 文本文件(.txt, .java, .html)

口诀:给人看的用字符流,给机器看的用字节流(来自 进制与编码 里的口诀)

完整分类对比表

字节流 字符流
输入(读) InputStream Reader
输出(写) OutputStream Writer
文件读写 FileInputStream / FileOutputStream FileReader / FileWriter
缓冲 BufferedInputStream / BufferedOutputStream BufferedReader / BufferedWriter
桥接(字节↔字符) InputStreamReader / OutputStreamWriter
操作单位 1 byte 1 char(2 bytes)
是否处理编码 ❌ 不管编码 ✅ 自动处理编码

二、字节流:InputStream / OutputStream

字节流是最底层的流,一次搬运一个字节(或一个字节数组),不关心编码,什么文件都能读写

FileInputStream:从文件读字节

1
2
3
4
5
6
7
8
9
10
11
@Test
public void testFileInputStream() throws Exception {
// 一个字节一个字节地读(效率低,仅用于理解原理)
try (FileInputStream fis = new FileInputStream("hello.txt")) {
int b;
while ((b = fis.read()) != -1) { // read()返回-1表示读完了
System.out.print((char) b); // 强转成字符看看
}
}
// 注意:中文用这种方式会乱码!因为中文是多字节的,一个一个字节读会被拆开
}

FileOutputStream:向文件写字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testFileOutputStream() throws Exception {
// 写字节到文件(文件不存在会自动创建,存在则覆盖)
try (FileOutputStream fos = new FileOutputStream("output.txt")) {
fos.write(72); // 写入字节72 → 字符'H'
fos.write(105); // 写入字节105 → 字符'i'
fos.write("Hello".getBytes()); // 写入字符串对应的字节数组
}

// 追加模式:第二个参数传true,不会覆盖原内容
try (FileOutputStream fos = new FileOutputStream("output.txt", true)) {
fos.write(" World!".getBytes());
}
}

实战:用字节流复制文件

这是IO流最经典的操作——把一个文件的内容搬到另一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testCopyFile() throws Exception {
String src = "photo.jpg";
String dest = "photo_copy.jpg";

try (FileInputStream fis = new FileInputStream(src);
FileOutputStream fos = new FileOutputStream(dest)) {

byte[] buffer = new byte[1024]; // 缓冲区:每次搬1024字节(1KB)
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len); // 读了多少就写多少,不能多写
}
}
// 为什么用 buffer?
// 一个字节一个字节搬 = 用勺子舀水,太慢
// 一次搬1024字节 = 用桶装水,快得多
}

⚠️ 常见坑

fos.write(buffer) 而不是 fos.write(buffer, 0, len) → 最后一次读取可能不满1024字节,多写了垃圾数据

忘记 close() → 数据可能还在缓冲区没写入文件,用 try-with-resources 就不会忘

三、字符流:Reader / Writer

字符流专门处理文本,会自动帮你处理编码问题(一个中文字符不管占几个字节,读出来就是一个 char)

FileReader / FileWriter:读写文本文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void testFileReaderWriter() throws Exception {
// 写文本文件
try (FileWriter fw = new FileWriter("note.txt")) {
fw.write("你好,世界!\n");
fw.write("Hello, World!\n");
}

// 读文本文件
try (FileReader fr = new FileReader("note.txt")) {
int ch;
while ((ch = fr.read()) != -1) {
System.out.print((char) ch); // 中文也能正确显示
}
}
}

InputStreamReader / OutputStreamWriter:桥接流(字节↔字符)

这两个类是字节流和字符流之间的桥梁,最大的价值是可以指定编码

进制与编码 中我们学过,乱码 = 编码和解码方案不一致。桥接流就是让你显式告诉Java用什么编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
public void testBridgeStream() throws Exception {
// 用GBK编码写一个文件
try (OutputStreamWriter osw = new OutputStreamWriter(
new FileOutputStream("gbk.txt"), "GBK")) {
osw.write("你好GBK");
}

// 用GBK编码读回来 → 正确
try (InputStreamReader isr = new InputStreamReader(
new FileInputStream("gbk.txt"), "GBK")) {
char[] buf = new char[100];
int len = isr.read(buf);
System.out.println(new String(buf, 0, len)); // 你好GBK
}

// 用UTF-8去读GBK文件 → 乱码!
try (InputStreamReader isr = new InputStreamReader(
new FileInputStream("gbk.txt"), "UTF-8")) {
char[] buf = new char[100];
int len = isr.read(buf);
System.out.println(new String(buf, 0, len)); // 乱码
}
}

FileReader 和 InputStreamReader 的关系

FileReader 其实就是 InputStreamReader 的简化版,用系统默认编码

new FileReader("a.txt") 等价于 new InputStreamReader(new FileInputStream("a.txt"), 系统默认编码)

需要指定编码时,必须用 InputStreamReader

四、缓冲流:性能提升利器

为什么要缓冲?

比喻:你要从井里打100桶水

没缓冲 = 每次只端一杯水,跑100趟 → 慢死

有缓冲 = 每次装满一桶再搬,跑几趟就够了 → 快得多

每次调用 read() / write() 都要和操作系统交互(系统调用),这很慢

缓冲流在内部维护一个 缓冲区(默认8KB),攒够一批再统一读/写,大大减少系统调用次数

字节缓冲流:BufferedInputStream / BufferedOutputStream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testBufferedByte() throws Exception {
// 包一层缓冲 → 速度飞升
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("big.dat"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("big_copy.dat"))) {

byte[] buffer = new byte[1024];
int len;
while ((len = bis.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
}
// 套路就是:原始流外面包一层Buffered,其他代码不变
}

字符缓冲流:BufferedReader / BufferedWriter

BufferedReader 多了一个杀手级方法:readLine() —— 一次读一行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void testBufferedReaderWriter() throws Exception {
// 写文件
try (BufferedWriter bw = new BufferedWriter(new FileWriter("lines.txt"))) {
bw.write("第一行");
bw.newLine(); // 跨平台换行(Windows是\r\n,Linux是\n,这个方法自动处理)
bw.write("第二行");
bw.newLine();
bw.write("第三行");
}

// 读文件:逐行读取
try (BufferedReader br = new BufferedReader(new FileReader("lines.txt"))) {
String line;
while ((line = br.readLine()) != null) { // null表示读完了
System.out.println(line);
}
}
}

缓冲流对比表

无缓冲 有缓冲
字节流 FileInputStream / FileOutputStream BufferedInputStream / BufferedOutputStream
字符流 FileReader / FileWriter BufferedReader / BufferedWriter
额外方法 readLine()(BufferedReader)、newLine()(BufferedWriter)
性能 慢(频繁系统调用) 快(批量读写)

⚠️ 常见坑

BufferedWriter 写完后忘记 flush() → 数据还在缓冲区,文件里没内容。不过 close() 会自动 flush(),所以用 try-with-resources 就没问题

readLine() 不包含换行符,如果你要把读到的内容原样写回去,别忘了加 newLine()

五、File类:操作文件和目录

File 类不是用来读写文件内容的,而是操作文件/目录本身——创建、删除、查询信息

就像文件管理器:你可以看到文件的名字、大小、位置,但看不到里面写了什么

常用方法一览

方法 作用 返回值
exists() 文件/目录是否存在 boolean
isFile() 是不是文件 boolean
isDirectory() 是不是目录 boolean
getName() 获取文件名 String
getAbsolutePath() 获取绝对路径 String
length() 文件大小(字节数) long
createNewFile() 创建新文件 boolean
mkdir() 创建单级目录 boolean
mkdirs() 创建多级目录(推荐) boolean
delete() 删除文件或空目录 boolean
list() 列出目录下的文件名 String[]
listFiles() 列出目录下的File对象 File[]

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void testFile() {
// 创建File对象(注意:只是创建对象,不会真的创建文件)
File file = new File("test_dir/hello.txt");

// 判断
System.out.println("存在吗?" + file.exists()); // false
System.out.println("是文件吗?" + file.isFile()); // false(不存在当然不是)
System.out.println("是目录吗?" + file.isDirectory()); // false

// 创建目录(mkdirs可以创建多级,mkdir只能创建一级)
File dir = new File("test_dir/sub_dir");
boolean created = dir.mkdirs();
System.out.println("目录创建成功:" + created); // true

// 列出目录下所有文件
File currentDir = new File(".");
String[] fileNames = currentDir.list();
for (String name : fileNames) {
System.out.println(name);
}

// listFiles() 返回File数组,可以进一步判断
File[] files = currentDir.listFiles();
for (File f : files) {
String type = f.isDirectory() ? "[目录]" : "[文件]";
System.out.println(type + " " + f.getName() + " (" + f.length() + " bytes)");
}
}

递归遍历:列出目录下所有文件(包含子目录)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testListAllFiles() {
listFiles(new File("."), 0);
}

private void listFiles(File dir, int depth) {
if (!dir.isDirectory()) return;

File[] files = dir.listFiles();
if (files == null) return;

for (File f : files) {
// 缩进表示层级
String indent = " ".repeat(depth);
if (f.isDirectory()) {
System.out.println(indent + "📁 " + f.getName());
listFiles(f, depth + 1); // 递归
} else {
System.out.println(indent + "📄 " + f.getName());
}
}
}

⚠️ 常见坑

new File("xxx") 不会创建文件!只是创建了一个路径的引用对象

mkdir() vs mkdirs():前者只能创建一级目录,路径中间的目录不存在就失败;后者能创建整条路径

delete() 只能删空目录,非空目录要先递归删里面的文件

listFiles() 如果路径不存在或不是目录,返回 null 而不是空数组——不判空会 NPE

六、try-with-resources 和流

流用完必须关闭,否则会占用系统资源(文件句柄),严重时导致”Too many open files”错误

try-catch-finally 中我们学过 try-with-resources 语法——这就是专门为IO流设计的

对比:手动关闭 vs 自动关闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ❌ 老写法:手动close,又臭又长,容易忘
@Test
public void testManualClose() {
FileInputStream fis = null;
try {
fis = new FileInputStream("data.txt");
// 读取操作...
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

// ✅ 新写法:try-with-resources,简洁安全
@Test
public void testAutoClose() {
try (FileInputStream fis = new FileInputStream("data.txt")) {
// 读取操作...
} catch (IOException e) {
e.printStackTrace();
}
// 出了try块,fis自动关闭,不管是否发生异常
}

多个流一起关闭

1
2
3
4
5
6
7
// 多个流用分号隔开,都会自动关闭
try (FileInputStream fis = new FileInputStream("a.txt");
FileOutputStream fos = new FileOutputStream("b.txt");
BufferedInputStream bis = new BufferedInputStream(fis)) {
// 操作...
}
// 关闭顺序:bis → fos → fis(后声明的先关闭,像栈一样)

能放进 try() 里的条件

必须实现 AutoCloseable 接口(所有IO流都实现了)

数据库连接(Connection)、网络连接等也实现了,都能用

七、⭐ 安全角度:路径遍历漏洞(Path Traversal)

这是Web开发中非常常见的安全漏洞,学IO流必须知道

攻击原理

如果你的程序根据用户输入的文件名来读取文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 危险代码!
@Test
public void testPathTraversal() throws Exception {
// 假设这是用户输入的文件名
String userInput = "../../etc/passwd"; // 攻击者的输入

// 程序拼接路径后读取
String basePath = "/app/uploads/";
File file = new File(basePath + userInput);
// 实际路径变成了:/app/uploads/../../etc/passwd → /etc/passwd
// 攻击者成功读取了服务器的密码文件!

System.out.println("实际路径:" + file.getCanonicalPath());
// 输出:/etc/passwd
}

.. 是什么?

.. 表示上级目录,../.. 就是往上跳两级

攻击者通过多个 .. 跳出你的应用目录,访问服务器上的任意文件

防御方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Test
public void testPathTraversalDefense() throws Exception {
String userInput = "../../etc/passwd";
String basePath = "/app/uploads/";

// 防御方法1:用 getCanonicalPath() 解析真实路径,然后检查是否在允许的目录内
File file = new File(basePath + userInput);
String canonicalPath = file.getCanonicalPath();
String allowedDir = new File(basePath).getCanonicalPath();

if (!canonicalPath.startsWith(allowedDir)) {
System.out.println("⚠️ 路径遍历攻击!拒绝访问:" + canonicalPath);
return;
}

// 防御方法2:白名单校验文件名(只允许字母、数字、点、横杠)
if (!userInput.matches("[a-zA-Z0-9._-]+")) {
System.out.println("⚠️ 文件名包含非法字符!");
return;
}

// 防御方法3:使用 Java NIO 的 Path.normalize()
java.nio.file.Path resolved = java.nio.file.Paths.get(basePath).resolve(userInput).normalize();
if (!resolved.startsWith(basePath)) {
System.out.println("⚠️ 路径遍历攻击!");
return;
}
}

防御清单

方法 说明
getCanonicalPath() 解析真实路径(去掉 ..),然后判断是否在允许目录内
白名单校验 只允许合法字符(字母、数字、.-_
Path.normalize() NIO方式标准化路径
绝不信任用户输入 永远不要直接拼接用户输入到文件路径

八、NIO基础(了解)

NIO = New IO,Java 1.4 引入,Java 7 又大幅增强(NIO.2)

NIO vs 传统IO对比

传统IO NIO
模型 流(Stream) 通道(Channel)+ 缓冲区(Buffer)
阻塞 阻塞(read时线程卡住等数据) 可以非阻塞
处理方式 面向流:一个字节一个字节地流过 面向缓冲区:先把数据装到Buffer里再处理
适合场景 简单的文件读写 高并发网络编程(如Netty底层就是NIO)
代码复杂度 简单 复杂

Files / Paths 工具类(Java 7+):日常最实用

Java 7 引入的 java.nio.file.Filesjava.nio.file.Paths,比传统IO简洁太多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Test
public void testNIOFiles() throws Exception {
java.nio.file.Path path = java.nio.file.Paths.get("nio_test.txt");

// 写文件:一行代码搞定
java.nio.file.Files.write(path, "你好NIO\n第二行\n第三行".getBytes());

// 读文件:一行代码读所有行
java.util.List<String> lines = java.nio.file.Files.readAllLines(path);
for (String line : lines) {
System.out.println(line);
}

// 读成字符串(Java 11+)
String content = java.nio.file.Files.readString(path);
System.out.println("全部内容:\n" + content);

// 复制文件:一行代码
java.nio.file.Path copy = java.nio.file.Paths.get("nio_copy.txt");
java.nio.file.Files.copy(path, copy, java.nio.file.StandardCopyOption.REPLACE_EXISTING);

// 删除文件
java.nio.file.Files.deleteIfExists(copy);

// 判断
System.out.println("存在:" + java.nio.file.Files.exists(path));
System.out.println("大小:" + java.nio.file.Files.size(path) + " bytes");
}

Files 常用方法速查

方法 作用 对应传统方式
Files.readAllLines(path) 读所有行到List BufferedReader + readLine循环
Files.readString(path) 读成字符串(Java 11+) 手动拼StringBuilder
Files.write(path, bytes) 写字节数组 FileOutputStream
Files.writeString(path, str) 写字符串(Java 11+) FileWriter
Files.copy(src, dest) 复制文件 手写输入输出流循环
Files.move(src, dest) 移动/重命名 File.renameTo()
Files.delete(path) 删除 File.delete()
Files.exists(path) 是否存在 File.exists()
Files.size(path) 文件大小 File.length()

日常建议

简单的文件读写,优先用 Files 工具类,代码少、可读性好

需要逐行处理大文件、需要指定编码时,还是用 BufferedReader

高并发网络编程才需要深入 Channel/Buffer/Selector 那一套

九、IO流选择指南总结

给人看的用字符流,给机器看的用字节流(再强调一遍,来自 进制与编码

需求 推荐方案
读写文本文件 BufferedReader / BufferedWriter
读写二进制文件(图片、视频) BufferedInputStream / BufferedOutputStream
需要指定编码 InputStreamReader / OutputStreamWriter(桥接流)
简单读写小文件 Files.readAllLines() / Files.write()(NIO)
复制文件 Files.copy()(NIO)或字节流 + buffer
操作文件属性(创建/删除/列表) File 类或 Files / Paths(NIO)

十、常见坑汇总

现象 原因 解决
字节流读中文乱码 输出乱码 中文是多字节,逐字节读会拆开 用字符流或指定编码
文件写入不完整 文件比预期小 flush() 或没 close() 用 try-with-resources
write(buffer) 多写数据 文件末尾多了垃圾 最后一次读取不满buffer write(buffer, 0, len)
listFiles() NPE 空指针异常 路径不存在返回null 先判空
mkdir() 失败 返回false 中间目录不存在 mkdirs()
路径遍历漏洞 被读取任意文件 直接拼接用户输入 校验canonicalPath
编码不匹配 读出乱码 写GBK读UTF-8 全链路统一UTF-8(参考 进制与编码

十一、练习题

练习1:统计文件中某个字符出现的次数

读取一个文本文件,统计字符 e 出现了多少次

提示:用 FileReaderBufferedReader,逐字符读取判断

练习2:文件复制工具

写一个方法 copyFile(String src, String dest),能复制任意类型的文件

要求:使用缓冲字节流,输出复制耗时

练习3:目录统计

给定一个目录路径,递归统计:总共多少个文件、多少个子目录、总大小多少字节

练习4:逐行读取并编号

读取一个Java源文件,每行前面加上行号后写到新文件中

例如:1: public class Hello {

练习5(安全):实现一个安全的文件读取方法

方法签名:String safeReadFile(String basePath, String userInput)

要求:防御路径遍历攻击,只允许读取basePath目录内的文件


上一章 目录 下一章
序列化与Serializable java基础 Lambda表达式