Java 从入门到精通(十二):File 与 IO 流基础,为什么程序"读写文件"时总是容易出问题?
前一篇我们把异常处理与自定义异常讲清楚了。
但很多人学完异常之后,真正第一次强烈感受到"异常不是语法题,而是工程题"的场景,往往不是算术运算,也不是集合操作,而是:
文件读写。
比如你很快就会遇到这些问题:
- 为什么同样一段代码,昨天能读文件,今天就报错?
- 为什么文件明明存在,程序却说找不到?
- 为什么写进去的中文打开后变成乱码?
- 为什么复制文件时,一不小心就把内容写坏了?
- 为什么代码里已经
close()了,资源问题还是层出不穷?
这些问题表面看起来零散,背后其实都指向同一件事:
程序和外部世界打交道时,事情就不再像内存里的变量那样"理所当然"。
内存里的数据是瞬时的,程序一停就没了;但文件是落在磁盘上的,是持久化的,是要跨时间、跨程序、甚至跨机器被读取的。
所以学 File 与 IO,不是为了背几个类名,而是为了建立一套很重要的理解:
-
Java 里"文件"到底怎么表示
-
程序是如何把数据写出去、再读回来的
-
为什么字符、字节、路径、缓冲区这些概念必须分清
-
怎样写出不容易出错的基础读写代码
这一篇不追求一次把所有 IO API 背完,而是先把最核心的骨架搭起来。
一、先搞清楚:什么是 File?什么是 IO?
很多初学者会把这两个词混在一起。
其实它们不是一回事。
1)File:表示"文件或目录"这个对象
在 Java 里,File 更像是:
对磁盘路径的一种抽象表示。
比如:
java
File file = new File("demo.txt");
这并不等于"文件内容已经读进来了"。
它只是告诉 Java:
- 这里有个路径
- 这个路径可能指向一个文件
- 也可能指向一个目录
- 你可以基于它去判断是否存在、是否可读、是否可写、是否创建
所以你要先记住:
File 主要负责"描述文件",不负责真正"读写内容"。
2)IO:Input / Output
IO 就是输入输出。
站在程序角度:
- Input:把外部数据读进程序
- Output:把程序中的数据写到外部
例如:
- 从磁盘读取文本文件
- 把日志写入文件
- 从网络读取数据
- 向控制台输出内容
它们本质上都属于 IO。
所以这两个概念的关系可以简单理解成:
File:告诉你"目标是谁"IO:告诉你"数据怎么进出"
二、为什么文件操作总比普通变量更容易出问题?
因为变量大多活在内存里,环境相对可控。
例如:
java
int age = 18;
String name = "Tom";
这类代码如果报错,通常原因很直接。
但文件操作会额外受很多外部因素影响:
- 文件是否真的存在
- 路径写得对不对
- 程序有没有权限访问
- 当前工作目录是不是你以为的那个目录
- 读写时编码是否一致
- 资源有没有及时关闭
所以 IO 之所以让初学者头疼,不是因为 API 难,而是因为它天然带着不确定性。
这也是为什么文件读写和异常处理经常是连在一起学的。
三、File 类最常用的能力有哪些?
先看一个最基础的例子:
java
import java.io.File;
public class Demo {
public static void main(String[] args) {
File file = new File("test.txt");
System.out.println("文件是否存在:" + file.exists());
System.out.println("是不是文件:" + file.isFile());
System.out.println("是不是目录:" + file.isDirectory());
System.out.println("文件名:" + file.getName());
System.out.println("绝对路径:" + file.getAbsolutePath());
}
}
这些方法非常适合做"前置判断"。
比如你要读一个文件,最好不要上来就直接读,而是先确认:
- 它存在吗?
- 它真的是文件吗?
- 路径是不是你想要的那个?
很多"找不到文件"的问题,本质上不是文件丢了,而是:
你以为程序在 A 目录运行,实际上它在 B 目录运行。
所以 getAbsolutePath() 这个方法,调试时非常有用。
四、创建文件和目录时要注意什么?
1)创建文件
java
import java.io.File;
import java.io.IOException;
public class Demo {
public static void main(String[] args) throws IOException {
File file = new File("note.txt");
if (!file.exists()) {
file.createNewFile();
}
}
}
这里的 createNewFile() 会抛 IOException,因为创建文件这件事并不是百分之百能成功。
可能失败的原因包括:
- 路径不合法
- 没有权限
- 磁盘或目录状态异常
2)创建目录
java
File dir = new File("data");
if (!dir.exists()) {
dir.mkdir();
}
如果要创建多级目录,更常用的是:
java
dir.mkdirs();
区别是:
mkdir():只能创建单级目录mkdirs():可以连父目录一起创建
这个细节很常见,也很容易写错。
五、为什么 IO 要分"字节流"和"字符流"?
这是 File 与 IO 入门里最重要的一个分界线。
1)字节流:适合处理一切二进制数据
字节流按 byte 来读写。
典型类:
InputStreamOutputStreamFileInputStreamFileOutputStream
它适合处理:
- 图片
- 视频
- 音频
- 压缩包
- 以及任何"你不想擅自按字符解释"的数据
2)字符流:适合处理文本
字符流按 char 来读写。
典型类:
ReaderWriterFileReaderFileWriter
它更适合处理:
- 普通文本
- 配置文件
- 日志内容
- 用户可读字符串
为什么一定要区分?
因为"文本"最终在底层也是字节。
字符流做的事情,本质上是:
帮你处理"字节 ↔ 字符"之间的转换。
如果你处理的是纯文本,用字符流通常更自然。
如果你处理的是图片、压缩包这类二进制内容,用字符流就可能直接把数据搞坏。
所以不要死记,先记原则:
- 文本优先考虑字符流
- 二进制优先考虑字节流
六、先看最基础的字节输出:把内容写进文件
java
import java.io.FileOutputStream;
import java.io.IOException;
public class Demo {
public static void main(String[] args) {
try (FileOutputStream fos = new FileOutputStream("a.txt")) {
fos.write(65);
fos.write(66);
fos.write(67);
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行后,文件里会出现:
text
ABC
因为:
- 65 对应字符 A
- 66 对应字符 B
- 67 对应字符 C
但你也会发现,这种写法并不直观。
实际开发里更常见的是写字节数组:
java
try (FileOutputStream fos = new FileOutputStream("a.txt")) {
String str = "hello";
fos.write(str.getBytes());
} catch (IOException e) {
e.printStackTrace();
}
这里的 getBytes() 本质上是把字符串转成字节数组再写出去。
七、读取文件时在做什么?
java
import java.io.FileInputStream;
import java.io.IOException;
public class Demo {
public static void main(String[] args) {
try (FileInputStream fis = new FileInputStream("a.txt")) {
int data;
while ((data = fis.read()) != -1) {
System.out.print((char) data);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里要理解两个关键点。
1)read() 每次读一个字节
它返回的是一个 int,不是 byte。
原因是 Java 需要用 -1 表示"读到文件末尾了"。
2)为什么还能强转成 char?
因为这个例子里文件内容刚好是普通英文字符。
但如果内容是中文,或者编码不一致,这种写法就容易出现乱码。
所以它更适合作为"理解 IO 原理"的示例,而不是直接照搬到所有文本读取场景。
八、字符流为什么更适合读文本?
如果你读取的是文本,字符流代码通常更顺手。
例如:
java
import java.io.FileReader;
import java.io.IOException;
public class Demo {
public static void main(String[] args) {
try (FileReader fr = new FileReader("a.txt")) {
int ch;
while ((ch = fr.read()) != -1) {
System.out.print((char) ch);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
它和字节流看起来很像,但思路上已经更偏向"文本字符"而不是"原始字节"。
对应地,写文本也可以用:
java
import java.io.FileWriter;
import java.io.IOException;
public class Demo {
public static void main(String[] args) {
try (FileWriter fw = new FileWriter("b.txt")) {
fw.write("Java IO 入门");
fw.write("\n学会读写文件很重要");
} catch (IOException e) {
e.printStackTrace();
}
}
}
这类写法更适合初学者理解"写文本文件"这件事。
九、为什么现在更推荐 try-with-resources?
很多旧教程会这样写:
java
FileInputStream fis = null;
try {
fis = new FileInputStream("a.txt");
// 读取逻辑
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
这当然没错,但太啰嗦,而且很容易漏。
现在更推荐:
java
try (FileInputStream fis = new FileInputStream("a.txt")) {
// 读取逻辑
} catch (IOException e) {
e.printStackTrace();
}
因为它会自动帮你关闭资源。
这非常重要。
文件流、网络流、数据库连接,本质上都属于"资源"。
如果你总忘记关,轻则浪费资源,重则导致句柄耗尽、文件被占用、程序异常。
所以从工程习惯上说:
能用 try-with-resources,就尽量别手写 finally 关闭。
十、初学 IO 最容易踩的 5 个坑
1)把相对路径想当然
比如你写:
java
new File("data.txt")
你以为它在项目根目录,实际上它可能相对于当前运行目录。
所以一旦出问题,先打印:
java
System.out.println(file.getAbsolutePath());
2)文本和二进制不分
- 文本:优先字符流
- 图片、压缩包、音视频:优先字节流
别混着来。
3)读写完不关流
这会带来各种隐蔽问题。
现在直接养成 try-with-resources 的习惯。
4)忽略编码问题
最典型的表现就是:
- 写进去是中文
- 打开之后变乱码
这通常不是"Java 坏了",而是编码没处理一致。
5)一次只读一个字符/字节还觉得没问题
教学示例可以这样写,但真实开发里,通常会结合缓冲数组、缓冲流来提升效率。
这也是为什么下一篇继续讲 IO 时,通常就会进入:
- 缓冲流
- 高效复制
- 编码转换
- 更现代的 NIO
十一、你现在应该建立的不是 API 记忆,而是 IO 思维
学到这里,真正重要的不是你能不能一口气背出所有流类名,而是你有没有形成下面这套判断:
当你要操作文件时,先问自己 4 个问题:
-
目标是什么?
- 文件还是目录?
- 路径是否正确?
-
处理的是什么数据?
- 文本还是二进制?
-
应该用什么流?
- 字节流还是字符流?
-
资源怎么安全关闭?
- 是否使用 try-with-resources?
如果你形成了这套思路,后面学缓冲流、转换流、NIO、序列化时就不会乱。
十二、最后总结
这一篇你要真正带走的,不是"File 和 IO 有哪些类",而是这几个核心认识:
1)File 主要负责描述路径和文件信息
它不负责真正的内容读写。
2)IO 的本质是数据在程序和外部世界之间流动
输入是读进来,输出是写出去。
3)字节流和字符流必须分清
- 文本:优先字符流
- 二进制:优先字节流
4)文件操作天然带着不确定性
所以异常处理和资源关闭非常重要。
5)try-with-resources 是现代 Java 基础习惯
能自动关流,就别手动把代码写复杂。
如果前面的面向对象、集合、泛型是在帮你把"程序内部的数据结构"搭起来,那么 File 与 IO 做的事情,就是把程序真正接到外部世界上。
从这一篇开始,你写的代码就不再只是"在控制台跑一下",而是会开始:
- 读配置
- 写日志
- 保存数据
- 处理文件
这一步很关键。
因为很多真正像"软件"的程序,都是从这里开始有了现实感。