字节流
小崔端着笔记本:"老大,我需要把HR系统导出的员工照片复制到公共目录,照片是jpg格式的,用什么流?"
白歌:"图片是二进制文件,必须用字节流。字符流会把字节按编码转成字符,图片的二进制数据一转就毁了。记住——图片、视频、PDF,统统用字节流。"
一、字节流定位
字节流是Java IO体系的基石——所有其他流在底层最终都通过字节流与物理设备交互。字符流内部也使用字节流,只是加了一层编码/解码的桥梁。
| 特性 | 说明 |
|---|---|
| 处理单位 | 1 字节(8 bit) |
| 适用范围 | 所有文件类型(文本、图片、视频、音频、二进制) |
| 文本处理能力 | 可处理,但需自行管理编码(容易乱码) |
| 核心基类 | InputStream(输入)和 OutputStream(输出) |
| JDK 8 中最常用的节点流 | FileInputStream、FileOutputStream |
二、核心类定义与构造方法
2.1 FileInputStream
| 构造方法 | 说明 |
|---|---|
FileInputStream(String name) | 通过路径字符串打开文件 |
FileInputStream(File file) | 通过File对象打开文件 |
| 抛出的异常 | FileNotFoundException(文件不存在或无权限) |
2.2 FileOutputStream
| 构造方法 | 说明 |
|---|---|
FileOutputStream(String name) | 通过路径字符串打开文件(覆盖模式) |
FileOutputStream(String name, boolean append) | append=true 时追加写入 |
FileOutputStream(File file) | 通过File对象打开文件(覆盖模式) |
FileOutputStream(File file, boolean append) | 带追加模式的构造器 |
| 行为 | 若文件不存在则自动创建;若父目录不存在则抛异常 |
三、核心方法详解
3.1 read() 方法的三重奏
3.2 write() 方法的三重奏
| 方法 | 说明 |
|---|---|
void write(int b) | 写出int的低8位(即一个字节),高24位被忽略 |
void write(byte[] b) | 写出整个字节数组 |
void write(byte[] b, int off, int len) | 写出数组的一部分(off起始,len长度) |
关键:
write(int b)接受 int,但只写低8位。也就是说write(257)等效于write(1),因为 257 的低8位等于 1。
四、文件复制完整示例(小崔的员工照片复制任务)
场景简述
飞翔科技HR系统需要将员工入职照片从临时目录复制到正式存储目录。小崔负责实现这个功能——照片是 jpg 二进制格式,必须使用字节流。
示例一:基本复制(小缓冲区)
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* 飞翔科技 - 员工照片复制工具(基础版)
* 使用字节流逐字节复制,性能较差但逻辑最直观
*/
public class PhotoCopyBasic {
public static void main(String[] args) {
String src = "C:/hr/temp/employee_photo.jpg";
String dest = "C:/hr/storage/employee_photo.jpg";
try (FileInputStream fis = new FileInputStream(src);
FileOutputStream fos = new FileOutputStream(dest)) {
long startTime = System.currentTimeMillis();
int b;
while ((b = fis.read()) != -1) {
fos.write(b); // 逐字节复制
}
long endTime = System.currentTimeMillis();
System.out.println("照片复制完成,耗时:" + (endTime - startTime) + " ms");
} catch (IOException e) {
System.err.println("照片复制失败:" + e.getMessage());
}
}
}
运行输出:
照片复制完成,耗时:342 ms
小崔:"2MB的照片花了342ms,能不能再快点?"
李眉:"你每次只读一个字节就写一次,系统调用开销太大。用个大点的缓冲区,批量读写。"
示例二:带字节数组缓冲区的复制(高效版)
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* 飞翔科技 - 员工照片复制工具(高效版)
* 使用字节数组缓冲区批量读写,大幅减少系统调用次数
*/
public class PhotoCopyEfficient {
public static void main(String[] args) {
String src = "C:/hr/temp/employee_photo.jpg";
String dest = "C:/hr/storage/employee_photo.jpg";
try (FileInputStream fis = new FileInputStream(src);
FileOutputStream fos = new FileOutputStream(dest)) {
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[8192]; // 8KB 缓冲区
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead); // 只写实际读取的字节数
}
long endTime = System.currentTimeMillis();
System.out.println("照片复制完成,耗时:" + (endTime - startTime) + " ms");
System.out.println("缓冲区大小:" + 8192 + " bytes");
} catch (IOException e) {
System.err.println("照片复制失败:" + e.getMessage());
}
}
}
运行输出:
照片复制完成,耗时:8 ms
性能对比:
| 方案 | 缓冲区 | 耗时 | 系统调用次数(2MB文件) |
|---|---|---|---|
| 逐字节读写 | 无 | 342 ms | ~2,000,000 次 |
| 8KB 批量读写 | 8KB | 8 ms | ~256 次 |
性能提升约43倍。缓冲区越大,系统调用越少,但过大也会浪费内存。8KB~64KB是常用经验值。
五、深度原理:字节流的数据流转
关键结论:IO操作的性能瓶颈不在于Java代码本身,而在于系统调用的次数。每次 read() 都可能触发一次OS级别的系统调用(从用户态切换到内核态),开销巨大。使用缓冲区本质上是用空间换时间,将多次小读写合并为少量大读写。
六、try-with-resources 详解
字节流是操作系统资源的封装,用完后必须关闭。JDK 7 引入的 try-with-resources 是关闭资源的最佳实践。
工作原理
多资源声明
// ✅ try-with-resources 按逆序关闭:fos 先关,fis 后关
try (FileInputStream fis = new FileInputStream("src.bin");
FileOutputStream fos = new FileOutputStream("dest.bin")) {
// ... 复制操作
}
// 关闭顺序:fos.close() → fis.close()
// 即使 fis 的 close() 抛异常,fos 的 close() 也会执行
传统 try-finally 的陷阱
// ❌ 传统方式:代码冗长,异常可能被吞掉
FileInputStream fis = null;
FileOutputStream fos = null;
try {
fis = new FileInputStream("src.bin");
fos = new FileOutputStream("dest.bin");
// ... 复制操作
} finally {
if (fis != null) {
try {
fis.close(); // 如果这里抛异常...
} catch (IOException e) { /* 吞掉 */ }
}
if (fos != null) {
try {
fos.close(); // 这里就不会被执行
} catch (IOException e) { /* 吞掉 */ }
}
}
七、易错场景
反例一:write(buffer) 而非 write(buffer, 0, len)
// ❌ 错误:最后一次可能写出上次残留的旧数据
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer); // 错误!总是写出1024字节
// 如果最后一次只读了300字节,后面724字节是上次的旧数据!
}
纠正:
// ✅ 正确:只写出实际读取的字节数
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len); // 只写出前 len 个字节
}
反例二:用字节流读取中文文本而不指定编码
// ❌ 错误:中文字符被拆散后产生乱码
try (FileInputStream fis = new FileInputStream("员工.txt")) {
byte[] buffer = new byte[3]; // 故意用小于UTF-8中文编码长度的缓冲区
int len = fis.read(buffer);
// UTF-8 中一个中文占3字节,"大" 的编码是 0xE5 0xA4 0xA7
// 如果缓冲区边界恰好切在中间,字符串构造将出错
System.out.println(new String(buffer, 0, len));
}
输出可能是:大�(因为3字节缓冲区恰好看似够,但文件偏移时容易切分错误)
纠正:对于文本文件,应使用字符流或保证足够的缓冲区并正确处理编码。
反例三:使用完毕后未关闭输出流导致文件为空
// ❌ 错误:程序正常退出但文件为空
FileOutputStream fos = new FileOutputStream("output.bin");
fos.write(new byte[]{1, 2, 3, 4, 5});
// 没有 fos.close() 或 fos.flush()
// Java程序优雅退出时JVM会尝试关闭,但数据可能还在OS缓冲区中丢失!
纠正:始终使用 try-with-resources 或确保 close()(close() 内部会调用 flush())。
八、面试考点
Q1:read() 返回 int 为什么不返回 byte?
byte范围是-128 ~ 127,而read()需要返回0~255的有效字节值和表示流结束的-1。如果返回byte,无法区分"读到字节值255(byte表示为-1)"和"流结束"。因此返回int,0~255为有效数据,-1为流结束。
Q2:FileOutputStream 的 write(int b) 写入的是什么?
写入的是 int 参数的低8位(一个字节),高24位被忽略。例如
write(0x12345678)实际上写出的是0x78(即十进制的120)。
Q3:为什么批量读写比逐字节读写快几十倍?
每次
read()/write()调用都可能触发系统调用(用户态→内核态切换,一次约几微秒到十几微秒)。2MB文件逐字节读写需要约200万次系统调用,而8KB批量读写只需约256次。性能差异主要源于系统调用次数,而非Java执行速度。
Q4:try-with-resources 的关闭顺序是怎样的?
按资源声明的逆序关闭。
try (A a = ...; B b = ...)中,先关闭 b,再关闭 a。如果 try 块已抛出异常,而 close() 也抛异常,则 close() 异常会被抑制(suppressed),可以通过Throwable.getSuppressed()获取。
Q5:字节流能否操作文本文件?什么情况下会出问题?
可以,字节流可以操作任意文件。但操作文本文件时,如果缓冲区边界恰好切在多字节字符的中间(如UTF-8中文字符占3字节),会导致该字符被拆分,最终显示为乱码。因此文本文件建议使用字符流。
李眉:"字节流是底层基础,有了它什么文件都能操作。但对于文本,字符流更贴心——它帮我们处理好了编码问题。下一节我们看看字符流是怎么简化文本处理的。"