传统 IO
传统 IO 通常指 java.io 包里的流式 API,例如 InputStream、OutputStream、Reader、Writer。它的核心模型是:
数据像水流一样,从输入端一点点读进来,再从输出端一点点写出去。
学习目标
学完本页,你应该能说明:
- 字节流和字符流有什么区别。
- 为什么传统 IO 通常是阻塞的。
- 为什么要使用缓冲流。
- 为什么一定要关闭资源。
- JDK7 的
try-with-resources解决了什么问题。 - 文件复制、文本读取、上传落盘这种商业场景应该怎么写。
IO 类体系
flowchart TD
A["Java 传统 IO"] --> B["字节流"]
A --> C["字符流"]
B --> D["InputStream<br/>读字节"]
B --> E["OutputStream<br/>写字节"]
C --> F["Reader<br/>读字符"]
C --> G["Writer<br/>写字符"]
D --> H["FileInputStream"]
D --> I["BufferedInputStream"]
E --> J["FileOutputStream"]
E --> K["BufferedOutputStream"]
F --> L["InputStreamReader"]
F --> M["BufferedReader"]
G --> N["OutputStreamWriter"]
G --> O["BufferedWriter"]字节流
字节流处理的是原始二进制数据。图片、PDF、压缩包、视频、网络报文,本质都是字节。
常见父类:
| 类 | 作用 |
|---|---|
InputStream | 输入字节流,负责读 |
OutputStream | 输出字节流,负责写 |
字符流
字符流处理的是文本。它会把字节按照某种字符集解码成字符,或者把字符编码成字节。
常见父类:
| 类 | 作用 |
|---|---|
Reader | 输入字符流,负责读字符 |
Writer | 输出字符流,负责写字符 |
字节流和字符流的关系:
flowchart TD
A["磁盘里的字节"] --> B["InputStream"]
B --> C["InputStreamReader<br/>按 UTF-8 解码"]
C --> D["Reader 读到字符"]
E["Writer 写字符"] --> F["OutputStreamWriter<br/>按 UTF-8 编码"]
F --> G["OutputStream"]
G --> H["磁盘里的字节"]为什么传统 IO 会阻塞
阻塞的意思是:线程发起读写后,如果数据还没准备好,线程就停在那里等。
flowchart TD
A["线程调用 read()"] --> B{"数据到了吗"}
B -- "没有" --> C["线程阻塞等待"]
C --> B
B -- "到了" --> D["复制数据到应用缓冲区"]
D --> E["read() 返回"]比如服务端从 Socket 读数据:
int len = inputStream.read(buffer);如果客户端一直不发数据,这个线程就可能一直卡在 read 上。文件读写也可能因为磁盘慢、网络盘慢、系统负载高而等待。
不理解阻塞会带来两个问题:
- 以为线程“没干活”就不占资源,其实线程栈、上下文切换、连接都在占资源。
- 在线程池里大量执行阻塞 IO,可能把工作线程全部占满,导致接口无响应。
装饰器模式
Java IO 的类很多,是因为它大量使用装饰器模式:基础流负责连接数据源,包装流负责增强能力。
flowchart TD
A["FileInputStream<br/>连接文件"] --> B["BufferedInputStream<br/>增加缓冲"]
B --> C["InputStreamReader<br/>字节转字符"]
C --> D["BufferedReader<br/>按行读取"]示例:
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(
new FileInputStream("orders.txt"), "UTF-8"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}这段代码里:
| 层 | 作用 |
|---|---|
FileInputStream | 从文件读字节 |
InputStreamReader | 把字节按 UTF-8 解码成字符 |
BufferedReader | 增加缓冲,并提供 readLine |
JDK7:try-with-resources
JDK7 之前经常手写 finally 关闭资源,代码又长又容易漏。
JDK7 引入 try-with-resources,只要资源实现了 AutoCloseable,就可以自动关闭。
try (InputStream in = new FileInputStream("input.bin");
OutputStream out = new FileOutputStream("output.bin")) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}关闭顺序是后打开的先关闭。
flowchart TD
A["打开 FileInputStream"] --> B["打开 FileOutputStream"]
B --> C["执行复制逻辑"]
C --> D["关闭 FileOutputStream"]
D --> E["关闭 FileInputStream"]为什么必须关闭?
| 不关闭的资源 | 后果 |
|---|---|
| 文件流 | 文件句柄泄漏,Windows 下还可能无法删除文件 |
| Socket | 连接不释放,服务端连接数耗尽 |
| 输出流 | 缓冲区数据可能没刷到磁盘或网络 |
| 压缩流 | ZIP 结构可能不完整 |
Demo:JDK7/8 文件复制
这是最基础、最通用的写法,JDK7 和 JDK8 都能运行。
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class CopyFileDemo {
public static void main(String[] args) throws IOException {
copy("source.dat", "target.dat");
}
public static void copy(String source, String target) throws IOException {
try (InputStream in = new BufferedInputStream(new FileInputStream(source));
OutputStream out = new BufferedOutputStream(new FileOutputStream(target))) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}
}重点不是代码长短,而是理解:
read(buffer)不保证每次读满 8192 字节。- 返回
-1表示读到末尾。 write(buffer, 0, len)必须使用本次真实读取长度。- 缓冲流减少实际访问磁盘的次数。
try-with-resources确保异常时也能关闭。
Demo:JDK7 Files API
JDK7 提供了 NIO.2 的 Path 和 Files,日常文件操作更方便。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class FilesCopyDemo {
public static void main(String[] args) throws IOException {
Path source = Paths.get("source.dat");
Path target = Paths.get("target.dat");
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}Files.copy 适合普通文件复制。大文件、限速下载、断点续传、边读边校验时,仍然需要自己分块处理。
商业场景:文件上传先落临时文件
医疗数据采集、合同上传、批量导入这些场景,不建议边上传边直接覆盖正式文件。更稳妥的流程是:
flowchart TD
A["接收上传流"] --> B["写入临时文件"]
B --> C["计算摘要和大小"]
C --> D{"校验通过"}
D -- "否" --> E["删除临时文件并返回错误"]
D -- "是" --> F["原子移动到正式目录"]
F --> G["写数据库记录"]示例代码:
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class UploadSaveDemo {
public static void save(InputStream uploadStream, String fileName) throws IOException {
Path tempDir = Paths.get("upload-temp");
Path finalDir = Paths.get("upload");
Files.createDirectories(tempDir);
Files.createDirectories(finalDir);
Path tempFile = tempDir.resolve(fileName + ".tmp");
Path finalFile = finalDir.resolve(fileName);
Files.copy(uploadStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
long size = Files.size(tempFile);
if (size <= 0) {
Files.deleteIfExists(tempFile);
throw new IOException("empty file");
}
Files.move(tempFile, finalFile, StandardCopyOption.REPLACE_EXISTING);
}
}这不是“多此一举”。如果直接写正式文件,上传中断时,业务线程可能读到半个文件。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 不关闭流 | 文件句柄泄漏 | 使用 try-with-resources |
复制时忽略 len | 目标文件多出脏数据 | out.write(buffer, 0, len) |
| 文本用字节流直接转 String | 乱码 | 使用 Reader 并指定编码 |
| 大文件一次性读入数组 | OOM | 分块读取 |
| 在锁里做慢 IO | 锁长时间不释放 | 锁内只改内存状态,IO 放锁外 |
| 线程池里无限做阻塞 IO | 线程被占满 | 控制并发、设置超时、使用有界队列 |
面试标准回答
传统 IO 是基于流的同步阻塞模型。字节流处理二进制数据,字符流处理文本并涉及编码解码。Java IO 常用装饰器模式,例如 FileInputStream 负责读文件,BufferedInputStream 增加缓冲,InputStreamReader 做字节到字符的转换。JDK7 的 try-with-resources 可以自动关闭实现 AutoCloseable 的资源,避免文件句柄和连接泄漏。生产中要注意缓冲、编码、大文件分块、超时和资源关闭。
