IO 是程序与外部设备之间的数据管道。本篇从流的分类讲起,吃透字节流的读写套路,并弄清缓冲流为什么快——答案是减少系统调用次数。

本篇目标

  • 理解 IO 流的两个分类维度:流向(输入/输出)与数据单位(字节/字符)
  • 掌握 FileInputStream / FileOutputStream 的读写与文件复制
  • 讲清缓冲流提速的真正原因
  • 学会用 try-with-resources 优雅地关闭资源

IO 流的分类

IO 流处理的是设备之间的数据传输,比如内存与硬盘、程序与网络。Java 里所有操作流的类都在 java.io 包下。分类有两个维度:

  • 按流向分:输入流(读进程序)、输出流(从程序写出)。记住方向永远以”程序”为参照物:对程序来说是”拿进来”的就是输入。
  • 按数据单位分:
    • 字节流:以字节(8bit)为单位读写,可以操作任何文件——图片、音频、视频在硬盘上都是字节
    • 字符流:以字符为单位读写,只能操作纯文本,下一篇专门讲

四个抽象父类构成了整个 IO 体系的骨架:InputStream、OutputStream(字节流),Reader、Writer(字符流)。所有具体流都是它们的子类,命名规律是”功能 + 父类名”,比如 FileInputStream 就是操作文件的 InputStream。掌握了父类的 read/write 方法,子类基本拿来就会用。

IO 程序三步走:导包建流 → 读写并处理 IOException → 关闭释放资源。IO 操作的大多方法都抛受检的 IOException,所以代码里总少不了 try。

FileInputStream:读

read() 一次读一个字节,读到末尾返回 -1:

FileInputStream fis = new FileInputStream("demo/a.txt");
int b;
while ((b = fis.read()) != -1) {
System.out.print((char) b);
}
fis.close();

为什么 read() 返回 int 而不是 byte

这是一个经典问题。字节流可以读任何文件,文件中完全可能出现 11111111 这样的字节数据,转成 byte 恰好是 -1——如果 read() 返回 byte,程序读到一半遇到它就会误以为到了文件末尾,后面的数据全丢了。所以 JDK 把读到的那个字节前面补 24 个 0 装进 int 返回:11111111 变成了 255,与真正的结束标记 -1 区分开。同理,写出时 write(int) 会自动砍掉前 24 位,只把最低 8 位写进文件。

FileOutputStream:写

FileOutputStream fos = new FileOutputStream("demo/b.txt");
fos.write(97); // 写入 'a'
fos.write("hello".getBytes()); // 写字符串,先转成字节数组
fos.close();

注意三点:构造时文件不存在会自动创建,但父目录不存在会抛 FileNotFoundException;new FileOutputStream(“b.txt”) 默认清空原文件,想追加内容要用 new FileOutputStream(“b.txt”, true);写文本时换行要写 “\r\n”.getBytes()(Windows 风格),跨平台换行问题留到下一篇字符流再解决。

文件复制:三种写法对比

写法一:一次一个字节

FileInputStream fis = new FileInputStream("demo/a.jpg");
FileOutputStream fos = new FileOutputStream("demo/copy.jpg");
int b;
while ((b = fis.read()) != -1) {
fos.write(b);
}
fis.close();
fos.close();

原理是循环往复:从源文件读一个字节,立刻写到目标文件。能跑,但极慢——复制一个几 MB 的文件要往返硬盘几百万次。

写法二:自定义小数组(必须掌握)

FileInputStream fis = new FileInputStream("demo/a.jpg");
FileOutputStream fos = new FileOutputStream("demo/copy.jpg");
int len;
byte[] arr = new byte[8192]; // 8KB 缓冲区
while ((len = fis.read(arr)) != -1) { // len 是本次实际读到的字节数
fos.write(arr, 0, len); // 只写有效部分
}
fis.close();
fos.close();

write(arr, 0, len) 是关键细节:最后一次读取通常读不满整个数组,如果直接 write(arr) 会把数组里上一次的旧数据也写出去,文件尾部就多出一截垃圾字节,图片会花、程序会崩。

有人会问:能不能用 available() 得到文件大小,一次全读进来?不能。文件很大时 new byte[文件大小] 直接内存溢出,小数组分批读写才是正道。

写法三:缓冲流

BufferedInputStream bis = new BufferedInputStream(new FileInputStream("demo/a.jpg"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("demo/copy.jpg"));
int b;
while ((b = bis.read()) != -1) {
bos.write(b);
}
bis.close();
bos.close(); // 只关外层即可

即使代码里还是一个字节一个字节地读写,速度也和小数组写法不相上下。

缓冲流为什么快:减少系统调用

从硬盘读数据是一次系统调用,要从用户态切到内核态,开销远大于内存操作。普通 FileInputStream 每 read 一次就发生一次系统调用;BufferedInputStream 内部维护了一个 8192 字节的数组,第一次 read 时一次性从硬盘读满 8KB 放进内存,之后 8191 次 read 都只是从内存数组里取数据,根本不碰硬盘。写方向同理:write 先写进缓冲区,攒满 8KB 才一次性落盘。

一句话:缓冲流不是让硬盘变快,而是把成千上万次系统调用压缩成几次。顺带一提,close() 之前会自动 flush() 把缓冲区剩余数据写出,所以只关外层缓冲流就够了,被包装的内层流会被一并关闭。

那么”8KB 小数组”和”缓冲流”哪个更快?两者都把单次读写的批量提到了 8KB,性能在同一量级;细抠的话小数组略占上风,因为它读写共用同一个数组,而缓冲流内部是读缓冲、写缓冲两个数组,多了一次内存拷贝。实际开发不用纠结这点差距:缓冲流的 API 更省心,是工程首选;小数组写法则必须练熟,因为网络流等场景经常只能这样读。

flush() 与 close() 的区别在于:flush 只把缓冲区刷出去、流还能继续用;close 先刷新再关闭,之后不能再写。忘记 flush 是新手最常见的丢数据原因。

字节流读写中文的问题

字节流不理解”字符”这回事。用 FileInputStream 读含中文的文本时,read() 一次只取一个字节,而一个中文在 UTF-8 下占 3 个字节、GBK 下占 2 个字节,读出的单个字节强转成 char 打印必然是乱码——这就是”读到半个中文”的由来。写方向稍好:可以用 write(“中文”.getBytes()) 先把字符串按平台默认编码转成字节数组再写出,但编码问题依然存在。结论先记住:文本读写请交给下一篇的字符流,字节流只负责”搬运”字节本身。

常见错误清单

  1. 复制文件时 write(arr) 而不是 write(arr, 0, len),导致文件尾部多出垃圾数据。
  2. 用 available() 的大小一次性 new 数组,大文件直接 OutOfMemoryError。
  3. 忘记关流,或者只关了内层流导致缓冲区的数据没刷出去。
  4. 想追加写入却用了默认构造,把原文件清空了。

资源关闭:try-with-resources

JDK 1.6 及以前的写法要在 finally 里判空再 close,两个流还要嵌套 try,啰嗦又容易漏。JDK 7 引入的 try-with-resources 可以自动关闭:

try (FileInputStream fis = new FileInputStream("demo/a.txt");
FileOutputStream fos = new FileOutputStream("demo/b.txt")) {
int b;
while ((b = fis.read()) != -1) {
fos.write(b);
}
} // 出了大括号自动按声明逆序调用 close()

原理:try() 里声明的资源必须实现 AutoCloseable 接口,代码块结束后编译器自动插入 close() 调用,即使中途抛异常也能保证关闭,还顺带处理了 close 本身再抛异常的嵌套问题。今后写 IO 代码一律用这种形式。

经典练习

  1. 从控制台录入一个文件路径,把该文件复制到当前目录下,目标文件名用 file.getName() 获取。
  2. 图片加密:复制图片时把每个字节与同一个数做异或(b ^ 123),得到的文件无法正常打开;再跑一次同样的程序就还原了。通过它体会”字节流可以操作一切文件”和异或运算的对称性(同一个数异或两次等于没异或)。

小结

  • 流按流向分输入/输出,按单位分字节/字符,四个抽象父类是整个体系的骨架
  • read() 返回 int,是为了把字节数据 11111111 与结束标记 -1 区分开
  • 小数组拷贝务必 write(arr, 0, len);缓冲流的本质是减少系统调用次数
  • 资源关闭统一用 try-with-resources

下一篇讲字符流,以及 Java IO 里无处不在的编码问题和装饰器模式。