Java IO 与 NIO
Java 提供了丰富的 I/O(输入/输出)操作 API。从 Java 1.4 开始,引入了 NIO(New I/O),提供了更高效的 I/O 操作方式。Java 7 又引入了 NIO.2,进一步完善了文件操作 API。
IO 与 NIO 的核心区别
理解 IO 和 NIO 的本质区别,是选择合适技术方案的基础。
IO:面向流的阻塞式模型
传统 IO 采用**面向流(Stream-Oriented)**的设计,数据像水流一样单向流动:
输入流:数据源 ──────▶ 程序
输出流:程序 ──────▶ 数据目标
特点:
- 阻塞式:调用
read()或write()时,线程会阻塞直到操作完成 - 单向流动:输入流只能读,输出流只能写,需要分别创建
- 无缓冲:数据直接从流中读取或写入,每次操作都可能涉及系统调用
适用场景:连接数较少、每个连接数据量较大的场景
NIO:面向缓冲区的非阻塞模型
NIO 采用**面向缓冲区(Buffer-Oriented)**的设计:
程序 ◀──▶ 缓冲区(Buffer) ◀──▶ 通道(Channel) ◀──▶ 数据源/目标
特点:
- 非阻塞式:线程可以发起请求后立即返回,通过轮询或事件通知获取结果
- 双向通道:Channel 可以同时进行读写操作
- 缓冲区:数据先读入缓冲区,可以灵活地在缓冲区中移动、处理
- 选择器:一个线程可以管理多个 Channel,实现多路复用
适用场景:连接数多、连接时间短、高并发的网络应用
架构对比
| 特性 | 传统 IO | NIO |
|---|---|---|
| 模型 | 面向流 | 面向缓冲区 |
| 阻塞 | 阻塞式 | 非阻塞式 |
| 方向 | 单向(输入/输出分离) | 双向(Channel 可读写) |
| 选择器 | 无 | Selector 支持多路复用 |
| 性能 | 适合少量连接 | 适合大量连接 |
| 编程复杂度 | 简单 | 较高 |
传统 IO 详解
IO 类层次结构
Java IO 按数据类型分为字节流和字符流,按方向分为输入流和输出流:
字节流(处理二进制数据)
├── 输入流 InputStream
│ ├── FileInputStream ─────── 文件输入
│ ├── ByteArrayInputStream ── 内存输入
│ ├── PipedInputStream ────── 管道输入
│ └── FilterInputStream ───── 过滤流(装饰器基类)
│ ├── BufferedInputStream ─ 缓冲输入
│ ├── DataInputStream ───── 基本类型读取
│ └── PushbackInputStream ─ 回退输入
│
└── 输出流 OutputStream
├── FileOutputStream ────── 文件输出
├── ByteArrayOutputStream ─ 内存输出
├── PipedOutputStream ───── 管道输出
└── FilterOutputStream ──── 过滤流
├── BufferedOutputStream ─ 缓冲输出
├── DataOutputStream ───── 基本类型写入
└── PrintStream ────────── 格式化打印
字符流(处理文本数据)
├── 输入流 Reader
│ ├── FileReader ──────────── 文件字符输入
│ ├── CharArrayReader ─────── 字符数组输入
│ ├── PipedReader ─────────── 管道字符输入
│ ├── BufferedReader ──────── 缓冲字符输入
│ ├── InputStreamReader ───── 字节流转字符流
│ └── StringReader ────────── 字符串输入
│
└── 输出流 Writer
├── FileWriter ──────────── 文件字符输出
├── CharArrayWriter ─────── 字符数组输出
├── PipedWriter ─────────── 管道字符输出
├── BufferedWriter ──────── 缓冲字符输出
├── OutputStreamWriter ──── 字节流转字符流
└── PrintWriter ─────────── 格式化打印
装饰器模式
Java IO 大量使用了装饰器模式(Decorator Pattern),通过包装基础流来增强功能:
import java.io.*;
// 基础流:文件流
FileInputStream fis = new FileInputStream("data.txt");
// 装饰:添加缓冲功能
BufferedInputStream bis = new BufferedInputStream(fis);
// 再装饰:添加基本类型读取功能
DataInputStream dis = new DataInputStream(bis);
// 读取基本类型
int value = dis.readInt();
为什么使用装饰器模式?
- 灵活组合:可以根据需要自由组合各种功能
- 单一职责:每个流类只负责一个功能
- 开闭原则:新增功能不需要修改现有代码
字节流详解
FileInputStream / FileOutputStream
用于读写二进制文件(图片、视频、音频等):
import java.io.*;
// 写入文件
public static void writeFile(String filename) throws IOException {
// try-with-resources 自动关闭资源
try (FileOutputStream fos = new FileOutputStream(filename)) {
// 写入单个字节
fos.write(65); // 写入 'A'
// 写入字节数组
byte[] data = "Hello, 世界!".getBytes("UTF-8");
fos.write(data);
// 写入部分字节
fos.write(data, 0, 5); // 写入 "Hello"
}
}
// 读取文件
public static void readFile(String filename) throws IOException {
try (FileInputStream fis = new FileInputStream(filename)) {
// 方式一:逐字节读取(效率低,不推荐)
int b;
while ((b = fis.read()) != -1) {
System.out.print((char) b);
}
// 方式二:读取到缓冲区(推荐)
byte[] buffer = new byte[8192]; // 8KB 缓冲区
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
// 只处理实际读取的字节
System.out.write(buffer, 0, bytesRead);
}
// 方式三:读取全部(Java 9+,适合小文件)
// byte[] allBytes = fis.readAllBytes();
}
}
关于缓冲区大小的选择:
- 太小(如 1 字节):系统调用频繁,效率低
- 太大(如 1MB):占用内存多,可能浪费
- 推荐:4KB 到 64KB 之间,8KB 是常用值
BufferedInputStream / BufferedOutputStream
使用缓冲区减少实际的 I/O 操作次数:
import java.io.*;
// 使用缓冲流复制文件
public static void copyFile(String src, String dest) throws IOException {
try (InputStream in = new BufferedInputStream(new FileInputStream(src));
OutputStream out = new BufferedOutputStream(new FileOutputStream(dest))) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
// 不需要手动 flush(),try-with-resources 会自动调用 close()
// close() 内部会调用 flush()
}
}
缓冲流的工作原理:
无缓冲:
程序 ──write()──▶ 磁盘(每次 write 都触发系统调用)
有缓冲:
程序 ──write()──▶ 缓冲区(8KB)──满──▶ 磁盘(减少系统调用次数)
DataInputStream / DataOutputStream
读写基本数据类型,保证跨平台一致性:
import java.io.*;
public static void writeData(String filename) throws IOException {
try (DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(filename)))) {
dos.writeInt(123456);
dos.writeDouble(3.14159);
dos.writeBoolean(true);
dos.writeUTF("你好,世界"); // 使用 UTF-8 修改版编码
dos.writeLong(System.currentTimeMillis());
}
}
public static void readData(String filename) throws IOException {
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(filename)))) {
// 必须按写入顺序读取!
int i = dis.readInt(); // 123456
double d = dis.readDouble(); // 3.14159
boolean b = dis.readBoolean(); // true
String s = dis.readUTF(); // "你好,世界"
long time = dis.readLong(); // 时间戳
System.out.printf("int=%d, double=%f, bool=%b, str=%s%n", i, d, b, s);
}
}
注意事项:
writeUTF()使用的是 Java 修改版的 UTF-8 编码,与标准 UTF-8 略有不同- 读取顺序必须与写入顺序一致
- 适合存储结构化的二进制数据
字符流详解
为什么需要字符流?
字节流直接操作字节,但文本文件涉及编码问题:
// 错误示例:用字节流读取 UTF-8 文本可能出错
try (FileInputStream fis = new FileInputStream("text.txt")) {
byte[] bytes = fis.readAllBytes();
// 如果文本包含中文,直接转字符串可能乱码
String content = new String(bytes); // 使用默认编码(可能错误)
}
// 正确方式:指定编码
String content = new String(bytes, "UTF-8");
// 更好的方式:使用字符流
try (FileReader reader = new FileReader("text.txt", StandardCharsets.UTF_8)) {
// 字符流自动处理编码转换
}
InputStreamReader / OutputStreamWriter
字节流与字符流的桥梁,可以指定编码:
import java.io.*;
import java.nio.charset.StandardCharsets;
// 写入时指定编码
public static void writeWithEncoding(String filename, String content) throws IOException {
try (OutputStreamWriter osw = new OutputStreamWriter(
new FileOutputStream(filename), StandardCharsets.UTF_8)) {
osw.write(content);
}
}
// 读取时指定编码
public static String readWithEncoding(String filename) throws IOException {
try (InputStreamReader isr = new InputStreamReader(
new FileInputStream(filename), StandardCharsets.UTF_8)) {
char[] buffer = new char[1024];
StringBuilder sb = new StringBuilder();
int charsRead;
while ((charsRead = isr.read(buffer)) != -1) {
sb.append(buffer, 0, charsRead);
}
return sb.toString();
}
}
BufferedReader / BufferedWriter
提供缓冲和按行操作:
import java.io.*;
import java.nio.charset.StandardCharsets;
// 读取文本文件
public static void readTextFile(String filename) throws IOException {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream(filename), StandardCharsets.UTF_8))) {
// 方式一:逐行读取
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
// 方式二:Java 8+ Stream API
// br.lines().forEach(System.out::println);
}
}
// 写入文本文件
public static void writeTextFile(String filename, String[] lines) throws IOException {
try (BufferedWriter bw = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(filename), StandardCharsets.UTF_8))) {
for (String line : lines) {
bw.write(line);
bw.newLine(); // 跨平台的换行符
}
}
}
PrintWriter
提供格式化输出功能,不会抛出 IOException:
import java.io.*;
import java.nio.charset.StandardCharsets;
public static void printDemo(String filename) throws IOException {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(new FileOutputStream(filename), StandardCharsets.UTF_8))) {
// println 自动换行
pw.println("Hello, World!");
// 格式化输出
pw.printf("姓名: %s, 年龄: %d%n", "张三", 25);
pw.format("价格: %.2f 元%n", 123.456);
// 检查错误(不抛异常)
if (pw.checkError()) {
System.err.println("写入出错");
}
}
}
标准输入输出
import java.io.*;
import java.util.Scanner;
public class StandardIODemo {
public static void main(String[] args) throws IOException {
// 标准输出
System.out.println("标准输出");
System.err.println("标准错误");
// 标准输入方式一:BufferedReader
try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
System.out.print("请输入姓名: ");
String name = br.readLine();
System.out.println("你好," + name);
}
// 标准输入方式二:Scanner(推荐)
try (Scanner scanner = new Scanner(System.in)) {
System.out.print("请输入年龄: ");
if (scanner.hasNextInt()) {
int age = scanner.nextInt();
System.out.println("年龄: " + age);
}
}
// 重定向标准流
System.setIn(new FileInputStream("input.txt"));
System.setOut(new PrintStream(new FileOutputStream("output.txt")));
System.setErr(new PrintStream(new FileOutputStream("error.txt")));
}
}
对象序列化
将 Java 对象转换为字节流,便于存储或传输:
import java.io.*;
// 可序列化的类
public class Person implements Serializable {
// 序列化版本号,用于版本兼容
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password; // transient 字段不参与序列化
public Person(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
@Override
public String toString() {
return "Person{name=" + name + ", age=" + age + ", password=" + password + "}";
}
}
// 序列化对象
public static void serialize(Object obj, String filename) throws IOException {
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(filename)))) {
oos.writeObject(obj);
}
}
// 反序列化对象
@SuppressWarnings("unchecked")
public static <T> T deserialize(String filename) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(filename)))) {
return (T) ois.readObject();
}
}
// 使用
Person person = new Person("张三", 25, "123456");
serialize(person, "person.ser");
Person restored = deserialize("person.ser");
System.out.println(restored); // Person{name=张三, age=25, password=null}
序列化注意事项:
- 实现 Serializable 接口:标记接口,无方法需要实现
- serialVersionUID:建议显式定义,否则编译器自动生成,修改类后可能不兼容
- transient:标记不需要序列化的字段(如密码、缓存)
- 引用类型:被引用的对象也必须可序列化
- 静态字段:不参与序列化,属于类而非对象
NIO 核心组件
NIO 的三大核心组件:Buffer(缓冲区)、Channel(通道)、Selector(选择器)。
Buffer 缓冲区
Buffer 是一个容器,用于存储基本数据类型。它是 NIO 数据操作的基础。
Buffer 的核心属性
根据 Oracle 官方文档,Buffer 有四个核心属性:
0 <= mark <= position <= limit <= capacity
| 属性 | 说明 |
|---|---|
| capacity | 容量,缓冲区的最大数据量,创建后不可变 |
| limit | 限制,第一个不应读/写的元素索引 |
| position | 位置,下一个要读/写的元素索引 |
| mark | 标记,调用 reset() 时 position 会回到此位置 |
Buffer 的状态变化
理解 Buffer 的状态变化是正确使用 NIO 的关键:
1. 新创建的 Buffer(容量为 8)
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
▲
position = 0
limit = 8
capacity = 8
2. 写入 5 个字节后
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
▲
position = 5
limit = 8
capacity = 8
3. 调用 flip() 切换到读模式
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
▲ ▲
position = 0 limit = 5
capacity = 8
4. 读取 3 个字节后
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
▲ ▲
position = 3 limit = 5
capacity = 8
5. 调用 clear() 重置(数据实际未清除)
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
▲
position = 0
limit = 8
capacity = 8
Buffer 的类型
每种基本类型都有对应的 Buffer:
| Buffer 类型 | 说明 |
|---|---|
ByteBuffer | 字节缓冲区,最常用 |
CharBuffer | 字符缓冲区 |
ShortBuffer | 短整型缓冲区 |
IntBuffer | 整型缓冲区 |
LongBuffer | 长整型缓冲区 |
FloatBuffer | 浮点缓冲区 |
DoubleBuffer | 双精度缓冲区 |
MappedByteBuffer | 文件映射缓冲区 |
ByteBuffer 详解
ByteBuffer 是最常用的 Buffer,有两种创建方式:
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
// 方式一:堆内存(Heap Buffer)
// 优点:JVM 堆内,GC 管理
// 缺点:IO 操作需要复制到堆外内存
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
// 方式二:直接内存(Direct Buffer)
// 优点:IO 操作效率高,减少复制
// 缺点:分配和释放成本高,不受 GC 直接管理
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
// 写入数据
heapBuffer.put((byte) 65); // 写入单个字节
heapBuffer.put(new byte[]{66, 67, 68}); // 写入字节数组
heapBuffer.put("Hello".getBytes(StandardCharsets.UTF_8)); // 写入字符串
// 切换到读模式(必须!)
heapBuffer.flip();
// 读取数据
byte b = heapBuffer.get(); // 读取单个字节
byte[] arr = new byte[3];
heapBuffer.get(arr); // 读取到数组
// 常用方法
int capacity = heapBuffer.capacity(); // 容量
int position = heapBuffer.position(); // 当前位置
int limit = heapBuffer.limit(); // 限制
int remaining = heapBuffer.remaining(); // 剩余可读元素
boolean hasRemaining = heapBuffer.hasRemaining(); // 是否有剩余
// 标记和重置
heapBuffer.mark(); // 标记当前位置
heapBuffer.get(); // 移动位置
heapBuffer.reset(); // 回到标记位置
// 重绕(不改变 limit,position 归零)
heapBuffer.rewind();
// 清空(position 归零,limit 设为 capacity,数据未清除)
heapBuffer.clear();
// 压缩(丢弃已读数据,未读数据移到前面)
heapBuffer.compact();
Buffer 的相对操作与绝对操作
ByteBuffer buffer = ByteBuffer.allocate(10);
// 相对操作:从当前位置开始,完成后 position 递增
buffer.put((byte) 65); // 相对 put
byte b = buffer.get(); // 相对 get
// 绝对操作:指定索引,不影响 position
buffer.put(0, (byte) 66); // 绝对 put,写入索引 0
byte b2 = buffer.get(1); // 绝对 get,读取索引 1
Channel 通道
Channel 是双向的,可以同时进行读写操作。与 Stream 的单向性不同,Channel 更加灵活。
Channel 类型
| Channel 类型 | 说明 |
|---|---|
FileChannel | 文件通道,用于文件读写 |
SocketChannel | Socket 客户端通道 |
ServerSocketChannel | Socket 服务端通道 |
DatagramChannel | UDP 通道 |
Pipe.SinkChannel | 管道写入端 |
Pipe.SourceChannel | 管道读取端 |
FileChannel 详解
FileChannel 用于文件操作,支持随机访问:
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
public class FileChannelDemo {
// 读取文件
public static void readFile(String filename) throws Exception {
try (FileChannel channel = FileChannel.open(
Paths.get(filename), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// read() 返回读取的字节数,-1 表示到达文件末尾
while (channel.read(buffer) != -1) {
buffer.flip(); // 切换到读模式
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // 清空,准备下次读取
}
}
}
// 写入文件
public static void writeFile(String filename, String content) throws Exception {
try (FileChannel channel = FileChannel.open(
Paths.get(filename),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE)) {
ByteBuffer buffer = ByteBuffer.wrap(content.getBytes());
channel.write(buffer);
}
}
// 随机访问(可读可写)
public static void randomAccess(String filename) throws Exception {
try (RandomAccessFile file = new RandomAccessFile(filename, "rw");
FileChannel channel = file.getChannel()) {
// 移动到文件末尾
channel.position(channel.size());
// 写入数据
ByteBuffer buffer = ByteBuffer.wrap("追加内容".getBytes());
channel.write(buffer);
// 移动到文件开头
channel.position(0);
// 读取数据
buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
// 获取文件大小
long size = channel.size();
// 截断文件
channel.truncate(1024);
}
}
// 文件复制(零拷贝,效率最高)
public static void copyFile(String src, String dest) throws Exception {
try (FileChannel srcChannel = FileChannel.open(Paths.get(src), StandardOpenOption.READ);
FileChannel destChannel = FileChannel.open(
Paths.get(dest),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE)) {
// transferTo:从当前通道传输到目标通道
srcChannel.transferTo(0, srcChannel.size(), destChannel);
// 或者使用 transferFrom
// destChannel.transferFrom(srcChannel, 0, srcChannel.size());
}
}
// 内存映射文件(适合大文件)
public static void memoryMappedFile(String filename) throws Exception {
try (FileChannel channel = FileChannel.open(
Paths.get(filename),
StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
// 将文件映射到内存
MappedByteBuffer mappedBuffer = channel.map(
FileChannel.MapMode.READ_WRITE, // 读写模式
0, // 起始位置
channel.size() // 映射大小
);
// 修改内存中的数据会自动同步到文件
mappedBuffer.put(0, (byte) 'X');
// 强制将修改写入磁盘
mappedBuffer.force();
}
}
}
FileChannel 与传统 IO 的性能对比:
| 操作方式 | 适用场景 | 特点 |
|---|---|---|
| FileInputStream + BufferedInputStream | 小文件、简单读取 | 简单易用 |
| FileChannel + ByteBuffer | 大文件、需要随机访问 | 高效、灵活 |
| transferTo/transferFrom | 文件复制 | 零拷贝,效率最高 |
| MappedByteBuffer | 超大文件、频繁修改 | 内存映射,减少复制 |
Selector 选择器
Selector 是 Java NIO 实现多路复用的核心组件,允许单线程管理多个 Channel。
为什么需要 Selector?
在传统阻塞 IO 模型中,每个连接需要一个线程:
传统模型:
连接1 ──▶ 线程1 ──▶ 阻塞等待数据
连接2 ──▶ 线程2 ──▶ 阻塞等待数据
连接3 ──▶ 线程3 ──▶ 阻塞等待数据
...(1000 个连接 = 1000 个线程)
问题:
- 线程是昂贵资源(每个线程约 1MB 栈空间)
- 线程上下文切换开销大
- 大量空闲线程浪费资源
使用 Selector 的多路复用模型:
NIO 模型:
连接1 ──┐
连接2 ──┼──▶ Selector ──▶ 单个线程处理就绪的 Channel
连接3 ──┤
... ──┘
(1000 个连接 = 1 个线程)
SelectionKey 事件类型
Selector 监听四种事件:
| 事件 | 常量 | 说明 |
|---|---|---|
| 连接就绪 | SelectionKey.OP_CONNECT | 客户端连接成功 |
| 接受就绪 | SelectionKey.OP_ACCEPT | 服务端收到新连接 |
| 读就绪 | SelectionKey.OP_READ | 通道有数据可读 |
| 写就绪 | SelectionKey.OP_WRITE | 通道可以写入数据 |
Selector 使用示例
下面是一个完整的 NIO 服务器示例:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Set;
public class NioServer {
public static void main(String[] args) throws IOException {
// 1. 创建 Selector
Selector selector = Selector.open();
// 2. 创建 ServerSocketChannel
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // 必须设置为非阻塞模式
// 3. 将 ServerSocketChannel 注册到 Selector,监听 ACCEPT 事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器启动,监听端口 8080...");
// 4. 事件循环
while (true) {
// 阻塞等待至少一个 Channel 就绪
int readyChannels = selector.select();
if (readyChannels == 0) {
continue;
}
// 获取就绪的 SelectionKey 集合
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 从集合中移除,避免重复处理
keyIterator.remove();
if (!key.isValid()) {
continue;
}
// 处理 ACCEPT 事件(新连接)
if (key.isAcceptable()) {
handleAccept(selector, key);
}
// 处理 READ 事件(有数据可读)
if (key.isReadable()) {
handleRead(key);
}
}
}
}
// 处理新连接
private static void handleAccept(Selector selector, SelectionKey key) throws IOException {
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
// 接受新连接
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false);
// 将客户端 Channel 注册到 Selector,监听 READ 事件
// 可以附加一个 Buffer 或其他对象
clientChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
System.out.println("新连接: " + clientChannel.getRemoteAddress());
}
// 处理读事件
private static void handleRead(SelectionKey key) throws IOException {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int bytesRead = clientChannel.read(buffer);
if (bytesRead == -1) {
// 客户端关闭连接
System.out.println("连接关闭: " + clientChannel.getRemoteAddress());
clientChannel.close();
key.cancel();
return;
}
if (bytesRead > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data, StandardCharsets.UTF_8);
System.out.println("收到消息: " + message);
// 回显消息
buffer.flip();
clientChannel.write(buffer);
}
}
}
NIO 客户端示例
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
public class NioClient {
public static void main(String[] args) throws IOException {
// 创建 SocketChannel
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false); // 非阻塞模式
// 发起连接
boolean connected = socketChannel.connect(new InetSocketAddress("localhost", 8080));
// 如果连接未立即建立,需要等待
if (!connected) {
while (!socketChannel.finishConnect()) {
System.out.println("连接中...");
}
}
System.out.println("连接成功");
// 发送消息
String message = "Hello, NIO Server!";
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
socketChannel.write(buffer);
// 读取响应
buffer = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buffer);
if (bytesRead > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String response = new String(data, StandardCharsets.UTF_8);
System.out.println("服务器响应: " + response);
}
socketChannel.close();
}
}
Selector 的三种 select 方法
Selector selector = Selector.open();
// 1. select() - 阻塞直到至少一个 Channel 就绪
int readyCount = selector.select();
// 2. select(long timeout) - 阻塞最多 timeout 毫秒
int readyCount = selector.select(1000); // 最多阻塞 1 秒
// 3. selectNow() - 非阻塞,立即返回
int readyCount = selector.selectNow();
// wakeup() - 唤醒正在阻塞的 select() 调用
selector.wakeup();
NIO.2 文件操作(Java 7+)
Java 7 引入了 NIO.2(JSR 203),提供了更强大的文件操作 API。
Path 接口
Path 是 NIO.2 中表示文件路径的核心接口:
import java.nio.file.*;
// 创建 Path
Path path1 = Path.of("test.txt"); // Java 11+
Path path2 = Paths.get("test.txt"); // Java 7+
Path path3 = FileSystems.getDefault().getPath("test.txt");
Path absPath = Path.of("/home/user/docs/file.txt");
Path relPath = Path.of("docs/file.txt");
// Path 信息
System.out.println("文件名: " + path1.getFileName());
System.out.println("父路径: " + path1.getParent());
System.out.println("根路径: " + path1.getRoot());
System.out.println("绝对路径: " + path1.toAbsolutePath());
System.out.println("规范化: " + Path.of("/home/user/../docs/./file.txt").normalize());
// 路径组合
Path combined = Path.of("/home", "user", "docs"); // /home/user/docs
Path resolved = path1.resolve("subdir/file.txt"); // 拼接路径
Path sibling = path1.resolveSibling("other.txt"); // 替换文件名
// 路径比较
Path p1 = Path.of("/home/user");
Path p2 = Path.of("/home/user");
System.out.println(p1.equals(p2)); // true
System.out.println(p1.startsWith(p2)); // true
Files 工具类
Files 提供了丰富的文件操作方法:
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.util.List;
public class FilesDemo {
public static void main(String[] args) throws Exception {
Path file = Path.of("test.txt");
// === 创建 ===
Files.createFile(file);
Files.createDirectory(Path.of("mydir"));
Files.createDirectories(Path.of("a/b/c/d")); // 创建多级目录
Files.createTempFile("prefix", ".tmp"); // 临时文件
Files.createTempDirectory("temp"); // 临时目录
// === 写入 ===
Files.write(file, "Hello, World!".getBytes());
Files.writeString(file, "你好,世界!"); // Java 11+
Files.write(file, List.of("Line1", "Line2")); // 写入行列表
// 追加内容
Files.write(file, "追加内容".getBytes(), StandardOpenOption.APPEND);
// === 读取 ===
byte[] bytes = Files.readAllBytes(file);
String content = Files.readString(file); // Java 11+
List<String> lines = Files.readAllLines(file);
// === 复制、移动、删除 ===
Files.copy(Path.of("src.txt"), Path.of("dest.txt"),
StandardCopyOption.REPLACE_EXISTING);
Files.move(Path.of("old.txt"), Path.of("new.txt"),
StandardCopyOption.REPLACE_EXISTING);
Files.delete(file); // 不存在会抛异常
Files.deleteIfExists(file); // 不存在返回 false
// === 文件信息 ===
System.out.println("大小: " + Files.size(file));
System.out.println("是否存在: " + Files.exists(file));
System.out.println("是否文件: " + Files.isRegularFile(file));
System.out.println("是否目录: " + Files.isDirectory(file));
System.out.println("是否隐藏: " + Files.isHidden(file));
System.out.println("最后修改: " + Files.getLastModifiedTime(file));
// 文件属性
BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
System.out.println("创建时间: " + attrs.creationTime());
System.out.println("最后访问: " + attrs.lastAccessTime());
System.out.println("最后修改: " + attrs.lastModifiedTime());
System.out.println("大小: " + attrs.size());
System.out.println("是否目录: " + attrs.isDirectory());
// === 遍历目录 ===
// 方式一:DirectoryStream
try (DirectoryStream<Path> stream = Files.newDirectoryStream(Path.of("."))) {
for (Path entry : stream) {
System.out.println(entry.getFileName());
}
}
// 带过滤
try (DirectoryStream<Path> stream = Files.newDirectoryStream(
Path.of("."), "*.java")) {
for (Path entry : stream) {
System.out.println(entry.getFileName());
}
}
// 方式二:Files.list(Java 8+)
Files.list(Path.of(".")).forEach(System.out::println);
// 方式三:深度遍历
Files.walk(Path.of(".")).forEach(System.out::println);
// 方式四:查找文件
Files.find(Path.of("."), Integer.MAX_VALUE,
(path, attrs) -> path.toString().endsWith(".java"))
.forEach(System.out::println);
// 方式五:递归遍历(可控制行为)
Files.walkFileTree(Path.of("."), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
System.out.println("文件: " + file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
System.out.println("目录: " + dir);
return FileVisitResult.CONTINUE;
}
});
// === 文件读写流 ===
try (BufferedReader reader = Files.newBufferedReader(file);
BufferedWriter writer = Files.newBufferedWriter(file)) {
// ...
}
// === 文件通道 ===
try (SeekableByteChannel channel = Files.newByteChannel(file,
StandardOpenOption.READ, StandardOpenOption.WRITE)) {
// ...
}
}
}
WatchService 文件监控
监控文件系统的变化:
import java.nio.file.*;
import java.nio.file.WatchEvent.Kind;
public class WatchServiceDemo {
public static void main(String[] args) throws Exception {
WatchService watchService = FileSystems.getDefault().newWatchService();
Path dir = Path.of(".");
// 注册监控事件
dir.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE, // 创建
StandardWatchEventKinds.ENTRY_DELETE, // 删除
StandardWatchEventKinds.ENTRY_MODIFY); // 修改
System.out.println("监控目录: " + dir.toAbsolutePath());
while (true) {
WatchKey key = watchService.take(); // 阻塞等待事件
for (WatchEvent<?> event : key.pollEvents()) {
Kind<?> kind = event.kind();
Path filename = (Path) event.context();
System.out.println(kind.name() + ": " + filename);
// 处理事件
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
System.out.println("新建文件: " + filename);
} else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
System.out.println("删除文件: " + filename);
} else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
System.out.println("修改文件: " + filename);
}
}
// 重置 WatchKey,继续监听
boolean valid = key.reset();
if (!valid) {
break;
}
}
watchService.close();
}
}
最佳实践
选择正确的 IO 方式
| 场景 | 推荐方式 |
|---|---|
| 小文件读写 | Files.readAllBytes/Files.writeString |
| 大文件复制 | FileChannel.transferTo/transferFrom |
| 文本文件处理 | BufferedReader/BufferedWriter |
| 高并发网络服务 | NIO + Selector |
| 简单网络客户端 | 传统 IO 或 NIO |
| 随机访问文件 | FileChannel 或 RandomAccessFile |
| 文件监控 | WatchService |
资源管理
始终使用 try-with-resources 确保资源关闭:
// 推荐
try (InputStream in = new FileInputStream("file.txt");
OutputStream out = new FileOutputStream("copy.txt")) {
// 操作
}
// 不推荐(需要手动关闭)
InputStream in = null;
try {
in = new FileInputStream("file.txt");
// 操作
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
// 忽略
}
}
}
异常处理
IO 操作可能抛出多种异常:
try {
// IO 操作
} catch (FileNotFoundException e) {
// 文件不存在
} catch (IOException e) {
// 其他 IO 错误
} catch (SecurityException e) {
// 权限问题
}
编码处理
始终显式指定编码,避免平台依赖:
// 推荐:显式指定编码
InputStreamReader reader = new InputStreamReader(
new FileInputStream("file.txt"), StandardCharsets.UTF_8);
// 不推荐:使用默认编码
// FileReader reader = new FileReader("file.txt");
小结
本章我们学习了:
- IO 与 NIO 的区别:面向流 vs 面向缓冲区、阻塞 vs 非阻塞
- 传统 IO:字节流、字符流、装饰器模式、对象序列化
- NIO 核心:Buffer(缓冲区)、Channel(通道)、Selector(选择器)
- Buffer 操作:capacity、limit、position、flip、clear、compact
- Channel 使用:FileChannel 文件操作、SocketChannel 网络通信
- Selector 多路复用:单线程管理多连接的高效模型
- NIO.2 文件 API:Path、Files、WatchService
- 最佳实践:资源管理、异常处理、编码处理
练习
- 使用 FileChannel 实现大文件的高效复制
- 使用 NIO Selector 实现一个简单的聊天服务器
- 使用 Files API 递归统计目录下各类文件的数量
- 使用 WatchService 实现配置文件热更新
- 比较传统 IO 和 NIO 复制大文件的性能差异