IO 与 NIO 全过程原理
很多同学学 IO 时会停在“会用 read、write、Buffer、Channel”这一层,但面试和线上排查真正问的是过程:
- Java 调一次
read(),数据到底从哪里到哪里。 - 为什么传统 IO 会阻塞线程。
- 为什么缓冲流能快。
- 为什么大文件不能
readAllBytes()。 - NIO 的 Buffer 为什么要
flip()。 - Selector 为什么能让一个线程管理多个连接。
- 零拷贝到底少复制了哪一步。
- 直接内存 OOM、文件句柄泄漏、Selector CPU 飙高怎么排查。
这一页按“从用户代码到操作系统”的视角,把 IO/NIO 每个关键过程讲清楚。你不需要先懂操作系统内核,但要知道 Java IO 不只是 Java API,它最终一定会落到文件、网卡、内核缓冲区和系统调用。
学习目标
学完本页,你应该能回答:
| 问题 | 必须说清楚的点 |
|---|---|
| IO 的本质是什么 | 程序和外部设备交换字节,通常经过内核缓冲区和用户态缓冲区 |
| BIO 为什么阻塞 | 线程发起 read/write 后,如果数据没准备好或写不出去,会挂起等待 |
| 缓冲为什么快 | 合并小 IO,减少系统调用次数和用户态/内核态切换 |
| 字符流和字节流区别 | 字符流多了编码/解码,乱码本质是字节解释方式错了 |
| NIO 为什么需要 Buffer | Channel 不直接给业务对象读写,数据先进入 Buffer,业务再处理 |
| Selector 为什么省线程 | Channel 非阻塞注册到 Selector,一个线程等待多个连接事件 |
| 零拷贝是不是完全没有复制 | 不是,主要是减少用户态和内核态之间不必要的数据复制 |
| 生产怎么选 | 普通文件用缓冲流/Files,大文件分块,高并发网络用 Netty |
IO 的本质:数据不是直接进 Java 对象
先看最重要的过程。Java 读取文件或网络数据,一般不是磁盘、网卡直接把数据塞进你的对象,而是先经过操作系统。
flowchart TD
A["磁盘或网卡"] --> B["操作系统内核"]
B --> C["内核缓冲区"]
C --> D["JVM 用户态缓冲区"]
D --> E["Java 对象或 byte[]"]一次普通读操作可以理解为:
| 步骤 | 发生了什么 | 为什么重要 |
|---|---|---|
| 1 | Java 调用 read() | 业务线程进入 IO 调用 |
| 2 | JVM 通过本地方法进入操作系统 | 从用户态进入内核态,有切换成本 |
| 3 | 内核检查数据是否准备好 | 文件可能等磁盘,网络可能等对端发数据 |
| 4 | 数据先到内核缓冲区 | 操作系统管理设备 IO |
| 5 | 数据复制到用户态缓冲区 | Java 代码才能访问 |
| 6 | Java 代码从 byte[] 或 Buffer 读取 | 业务开始解析数据 |
这解释了为什么 IO 通常比纯内存计算慢:它涉及外部设备、系统调用、状态切换、数据复制和等待。
BIO 文件读取全过程
最常见的传统 IO 代码:
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class BioReadDemo {
public static void main(String[] args) throws IOException {
try (InputStream in = new FileInputStream("patient.txt")) {
byte[] buffer = new byte[1024];
int len = in.read(buffer);
System.out.println("read bytes = " + len);
}
}
}这段代码的执行过程:
flowchart TD
A["new FileInputStream"] --> B["打开文件句柄"]
B --> C["调用 read(byte[])"]
C --> D["线程进入系统调用"]
D --> E{"内核是否准备好数据"}
E -- "未准备好" --> F["线程阻塞等待"]
E -- "已准备好" --> G["复制数据到 byte[]"]
G --> H["read 返回读取字节数"]
H --> I["close 释放文件句柄"]为什么 read() 返回的是 int
read(byte[]) 返回的是这次实际读到的字节数。
| 返回值 | 含义 |
|---|---|
> 0 | 本次读到了多少字节 |
0 | 某些场景可能没有读到数据,常见文件流里少见 |
-1 | 已经到文件末尾 |
所以正确读法必须循环:
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class ReadLoopDemo {
public static void main(String[] args) throws IOException {
try (InputStream in = new FileInputStream("patient.txt")) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
handle(buffer, len);
}
}
}
private static void handle(byte[] buffer, int len) {
System.out.println("handle bytes = " + len);
}
}如果只读一次,大文件只会处理前面一小段,后面的内容会丢。
为什么传统 IO 会阻塞
阻塞不是“Java 慢”,而是线程等待外部条件。
flowchart TD
A["线程调用 read"] --> B{"数据到达了吗"}
B -- "没有" --> C["线程挂起"]
C --> D["不占用 CPU 继续执行"]
D --> B
B -- "到了" --> E["复制数据并返回"]网络 BIO 更容易体现这个问题:
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class BioSocketServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
Socket socket = serverSocket.accept();
new Thread(() -> handle(socket)).start();
}
}
private static void handle(Socket socket) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("receive: " + line);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}这里有两个阻塞点:
| 阻塞点 | 阻塞原因 | 后果 |
|---|---|---|
accept() | 没有新连接 | 主线程等待连接 |
readLine() | 客户端没发完整一行 | 工作线程等待数据 |
如果一个连接一个线程,连接数很多但大多数连接都不活跃,线程会大量浪费在等待上。线程本身需要栈内存和调度成本,数量上去后会造成上下文切换、内存上涨、线程池耗尽。
为什么缓冲流能提升性能
没有缓冲时,程序可能每读一个字节都触发底层读取。缓冲流会先从底层批量读一块到内存,然后业务代码从内存缓冲区慢慢取。
flowchart TD
A["业务多次 read"] --> B["BufferedInputStream 缓冲区"]
B --> C{"缓冲区有数据吗"}
C -- "有" --> D["直接从内存返回"]
C -- "没有" --> E["一次性从底层读取一批"]
E --> B对比:
| 写法 | 底层访问特点 | 问题 |
|---|---|---|
| 每次读 1 字节 | 系统调用可能非常频繁 | 慢 |
使用 byte[] 分块 | 一次读一批 | 常用 |
使用 BufferedInputStream | 内部维护缓冲区 | 适合包装底层流 |
Demo:缓冲复制文件。
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 BufferedCopyDemo {
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);
}
}
}
}flush() 和 close() 的区别
| 方法 | 作用 |
|---|---|
flush() | 把 Java 输出缓冲区中的数据推给下一层 |
close() | 关闭资源,通常会先 flush,再释放文件句柄或连接 |
注意:flush() 不等于数据已经永久写入物理磁盘。操作系统和磁盘控制器还可能有缓存。如果你需要更强落盘语义,要看 FileChannel.force()。
字节流、字符流和乱码全过程
字节流处理原始字节,字符流处理字符。字符要落盘或网络传输,必须先编码成字节;字节要变成字符,必须解码。
flowchart TD
A["Java 字符串"] --> B["按 UTF-8 编码"]
B --> C["字节数组"]
C --> D["写入文件或网络"]
D --> E["读取字节数组"]
E --> F["按 UTF-8 解码"]
F --> G["Java 字符串"]乱码发生的典型过程:
flowchart TD
A["字符串:患者"] --> B["用 UTF-8 编码成字节"]
B --> C["文件保存"]
C --> D["用 GBK 解码"]
D --> E["字符解释错误"]正确做法:不要依赖平台默认编码。
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class Utf8ReadDemo {
public static void main(String[] args) throws IOException {
Path path = Paths.get("patient.csv");
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
}JDK7 已经提供 Path、Files、StandardCharsets 这些现代文件 API;JDK8 企业项目里也大量使用。不要只记 JDK21 的新 API。
大文件为什么不能一次读进内存
错误示例:
import java.nio.file.Files;
import java.nio.file.Paths;
public class BadLargeFileDemo {
public static void main(String[] args) throws Exception {
byte[] all = Files.readAllBytes(Paths.get("large-export.dat"));
System.out.println(all.length);
}
}如果文件 2GB,而 JVM 堆只有 1GB,这种写法必然有风险。即使堆够大,也会制造巨大的对象,占用 GC 时间。
正确思路是分块或按行:
flowchart TD
A["打开文件"] --> B["读取一块"]
B --> C["解析一批记录"]
C --> D["批量入库或处理"]
D --> E{"还有数据吗"}
E -- "有" --> B
E -- "没有" --> F["关闭资源"]CSV 导入 Demo:
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
public class CsvBatchImportDemo {
private static final int BATCH_SIZE = 1000;
public static void main(String[] args) throws IOException {
importCsv("patient.csv");
}
public static void importCsv(String file) throws IOException {
List<String> batch = new ArrayList<String>(BATCH_SIZE);
try (BufferedReader reader = Files.newBufferedReader(
Paths.get(file), StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
batch.add(line);
if (batch.size() >= BATCH_SIZE) {
saveBatch(batch);
batch.clear();
}
}
if (!batch.isEmpty()) {
saveBatch(batch);
}
}
}
private static void saveBatch(List<String> batch) {
System.out.println("save batch size = " + batch.size());
}
}这个 Demo 背后的生产原则:
- 文件按行读,不一次性进堆。
- 数据分批入库,避免单事务过大。
- 批次集合复用,减少临时对象。
- 失败时记录文件名、行号、批次号,方便重跑。
NIO 的核心:Buffer 和 Channel
传统 IO 面向流,NIO 面向 Buffer 和 Channel。
flowchart TD
A["文件或 Socket"] --> B["Channel"]
B --> C["ByteBuffer"]
C --> D["业务代码读取"]
D --> E["业务代码写入 ByteBuffer"]
E --> F["Channel"]
F --> G["文件或 Socket"]| 组件 | 作用 | 类比 |
|---|---|---|
| Buffer | 存放待读写数据的内存区域 | 托盘 |
| Channel | 连接文件、Socket 的通道 | 运输通道 |
| Selector | 监听多个 Channel 事件 | 调度员 |
Buffer 三个指针
| 属性 | 含义 |
|---|---|
capacity | 容量,最多能放多少数据 |
position | 当前读写位置 |
limit | 当前可读或可写的边界 |
Buffer 的难点是它有“写模式”和“读模式”。
flowchart TD
A["allocate: position=0 limit=capacity"] --> B["put 写入数据"]
B --> C["position 后移"]
C --> D["flip 切换读模式"]
D --> E["limit=旧 position"]
E --> F["position=0"]
F --> G["get 读取数据"]
G --> H["clear 或 compact"]Demo:观察指针变化。
import java.nio.ByteBuffer;
public class BufferPointerDemo {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(8);
print("init", buffer);
buffer.put((byte) 10);
buffer.put((byte) 20);
print("after put", buffer);
buffer.flip();
print("after flip", buffer);
System.out.println(buffer.get());
print("after get", buffer);
buffer.clear();
print("after clear", buffer);
}
private static void print(String step, ByteBuffer buffer) {
System.out.println(step
+ " position=" + buffer.position()
+ ", limit=" + buffer.limit()
+ ", capacity=" + buffer.capacity());
}
}为什么忘记 flip() 会读不到数据
因为写入后 position 在数据末尾。如果不 flip(),读的时候从末尾开始读,当然没有可读数据。flip() 的本质是:
- 把
limit设置为刚才写到的位置。 - 把
position重置为 0。 - 让程序从头读到刚才写入的末尾。
clear() 和 compact() 怎么选
| 方法 | 作用 | 场景 |
|---|---|---|
clear() | 不清数据,只重置指针,准备重新写 | 数据已经处理完 |
compact() | 保留未读数据,把它挪到开头,再准备写 | 网络半包、数据没处理完 |
FileChannel 读写全过程
FileChannel 不是直接返回数据,而是把数据读进 Buffer。
flowchart TD
A["FileChannel.read(buffer)"] --> B["内核读取文件"]
B --> C["数据进入 Buffer"]
C --> D["flip"]
D --> E["业务从 Buffer 读取"]
E --> F["clear"]
F --> G["继续下一轮"]文件复制 Demo:
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
public class FileChannelCopyProcessDemo {
public static void main(String[] args) throws IOException {
copy("source.dat", "target.dat");
}
public static void copy(String source, String target) throws IOException {
Path sourcePath = Paths.get(source);
Path targetPath = Paths.get(target);
try (FileChannel in = FileChannel.open(sourcePath, StandardOpenOption.READ);
FileChannel out = FileChannel.open(targetPath,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.allocate(8192);
while (in.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
out.write(buffer);
}
buffer.clear();
}
}
}
}为什么写的时候要 while (buffer.hasRemaining()):
Channel 的 write 不保证一次写完 Buffer 中所有剩余数据。尤其在非阻塞网络 Channel 中,写一部分就返回很常见。
NIO 网络:Selector 为什么能省线程
BIO 模型里,很多连接会占很多线程。NIO 的做法是:
- 把 Channel 设置为非阻塞。
- 把 Channel 注册到 Selector。
- 一个线程调用
select()等待多个 Channel 的事件。 - 哪个 Channel 有事件,就处理哪个。
flowchart TD
A["多个 SocketChannel"] --> B["注册到 Selector"]
B --> C["一个线程 select"]
C --> D{"哪些连接有事件"}
D --> E["accept 事件"]
D --> F["read 事件"]
D --> G["write 事件"]
E --> H["处理连接"]
F --> I["读取数据"]
G --> J["写出数据"]事件是什么
| 事件 | 含义 | 常见处理 |
|---|---|---|
OP_ACCEPT | 有客户端连接进来 | accept() 得到 SocketChannel |
OP_CONNECT | 客户端连接完成 | 完成连接建立 |
OP_READ | 有数据可读 | channel.read(buffer) |
OP_WRITE | 通道可写 | 写出积压数据 |
为什么不能一直注册 OP_WRITE
大多数时候 Socket 都是“可写”的。如果一直监听 OP_WRITE,Selector 会不停返回写事件,CPU 可能被打满。
正确做法:
- 平时只关注读事件。
- 只有有待发送数据且一次没写完,才临时关注写事件。
- 写完后取消写事件关注。
最小 Selector Demo
这个 Demo 用来理解模型,不建议生产手写。生产高并发网络服务优先用 Netty。
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
public class SelectorEchoServer {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(9000));
server.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
SocketChannel client = serverChannel.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
buffer.clear();
int len = client.read(buffer);
if (len == -1) {
client.close();
continue;
}
buffer.flip();
String msg = StandardCharsets.UTF_8.decode(buffer).toString();
client.write(StandardCharsets.UTF_8.encode("echo: " + msg));
}
}
}
}
}这个 Demo 的执行过程:
- 服务端 Channel 设置为非阻塞。
- 服务端 Channel 注册
OP_ACCEPT。 selector.select()阻塞等待事件。- 有新连接时 accept,得到客户端 Channel。
- 客户端 Channel 设置非阻塞并注册
OP_READ。 - 客户端发数据后,Selector 返回读事件。
- 服务端读取 Buffer,处理后写回。
为什么生产不用这个 Demo 写法:
| 问题 | 生产后果 |
|---|---|
| 没有处理半包粘包 | TCP 数据边界错误 |
| 没有连接心跳 | 死连接占资源 |
| 没有背压和写队列 | 写慢客户端会拖垮服务 |
| 没有线程模型隔离 | IO 线程容易被业务阻塞 |
| 没有内存池 | Buffer 频繁分配和回收 |
这就是 Netty 的价值:它把 Selector、Reactor、Pipeline、ByteBuf、编解码、内存池、连接管理这些复杂问题封装好了。
零拷贝全过程
普通文件下载可能经历多次复制。
flowchart TD
A["磁盘"] --> B["内核缓冲区"]
B --> C["用户态 byte[]"]
C --> D["Socket 缓冲区"]
D --> E["网卡"]FileChannel.transferTo 目标是减少用户态参与。
flowchart TD
A["磁盘"] --> B["内核缓冲区"]
B --> C["Socket 缓冲区"]
C --> D["网卡"]Demo:
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
public class TransferToDemo {
public static void main(String[] args) throws IOException {
try (FileChannel in = FileChannel.open(Paths.get("source.dat"), StandardOpenOption.READ);
FileChannel out = FileChannel.open(Paths.get("target.dat"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
long position = 0;
long size = in.size();
while (position < size) {
position += in.transferTo(position, size - position, out);
}
}
}
}注意:
- 零拷贝不是完全没有复制,而是减少用户态和内核态之间的数据搬运。
- 底层效果依赖操作系统。
- 文件到网络的传输更典型,文件到文件也可以使用 Channel 优化。
- 超大文件要循环调用,不能假设一次
transferTo就完成全部传输。
直接内存和 DirectBuffer
ByteBuffer.allocate() 分配堆内 Buffer,ByteBuffer.allocateDirect() 分配直接内存。
flowchart TD
A["Java 堆"] --> B["DirectByteBuffer 对象"]
B --> C["引用堆外直接内存"]
C --> D["本地 IO 读写"]| 类型 | 位置 | 优点 | 风险 |
|---|---|---|---|
| Heap Buffer | Java 堆 | 创建回收简单 | 本地 IO 可能多一次复制 |
| Direct Buffer | 堆外直接内存 | 适合高频 IO | 分配成本高,释放不及时会 OOM |
直接内存 OOM 常见报错:
java.lang.OutOfMemoryError: Direct buffer memory排查方向:
| 检查项 | 说明 |
|---|---|
是否大量 allocateDirect | 临时 Buffer 不要反复创建直接内存 |
| 是否使用 Netty | 检查 ByteBuf 是否释放 |
MaxDirectMemorySize | 直接内存上限是否过小 |
| 进程 RSS | 堆外内存不会完整体现在 Java 堆里 |
| 是否内存池复用 | 高频 IO 应该复用 Buffer |
BIO、NIO、AIO、Netty 怎么选
| 场景 | 推荐 | 原因 |
|---|---|---|
| 普通配置文件读取 | Files.newBufferedReader | 简单、编码明确 |
| 小文件复制 | 缓冲流或 Files.copy | 代码简单 |
| 大文件导入 | 按行或分块读取 | 避免堆 OOM |
| 大文件下载 | 分块写或 transferTo | 降低内存占用 |
| 少量 Socket 连接 | BIO 可以接受 | 简单直接 |
| 大量长连接 | Netty | 手写 NIO 复杂且容易出错 |
| 网关、IM、采集长连接 | Netty/Reactor | 需要连接管理、编解码、背压 |
| 异步文件操作 | AIO 或异步框架 | Java 后端使用相对少 |
商业场景:医疗数据采集文件导入
需求:医院每天推送大 CSV 文件,平台需要导入患者、就诊、检验报告数据。
正确链路:
flowchart TD
A["接收上传文件"] --> B["写入临时文件"]
B --> C["校验大小和格式"]
C --> D["按行读取 UTF-8"]
D --> E["解析一批记录"]
E --> F["批量校验"]
F --> G["批量入库"]
G --> H{"还有数据吗"}
H -- "有" --> D
H -- "没有" --> I["原子移动为正式文件"]
I --> J["记录导入结果"]为什么这样做:
| 设计 | 原因 |
|---|---|
| 临时文件 | 防止半文件被业务读取 |
| UTF-8 显式解码 | 避免不同服务器默认编码不同 |
| 按行读取 | 避免大文件进入堆 |
| 分批入库 | 控制事务大小和内存 |
| 记录行号 | 方便定位脏数据 |
| 原子移动 | 保证文件状态一致 |
错误做法和后果:
| 错误 | 后果 |
|---|---|
readAllBytes 读大文件 | 堆 OOM 或 Full GC |
| 不指定编码 | 中文乱码、数据错误 |
| 一条一条入库 | 导入极慢 |
| 一个大事务入库 | 锁时间长、回滚成本高 |
| 不关闭流 | 文件句柄泄漏 |
线上排查流程
文件句柄泄漏
现象:
Too many open files排查:
| 步骤 | 看什么 |
|---|---|
| 1 | 是否所有流都用 try-with-resources |
| 2 | 是否上传下载异常分支没关闭 |
| 3 | Linux 用 lsof -p <pid> 看打开文件 |
| 4 | 检查连接池、Socket、日志文件是否泄漏 |
IO 阻塞导致接口慢
flowchart TD
A["接口响应慢"] --> B["看线程栈"]
B --> C{"线程卡在哪里"}
C -- "read/write" --> D["下游网络或磁盘慢"]
C -- "数据库 IO" --> E["查慢 SQL 和连接池"]
C -- "锁等待" --> F["检查锁内是否做 IO"]
D --> G["加超时、限流、异步、降级"]NIO CPU 100%
常见原因:
| 原因 | 说明 |
|---|---|
一直关注 OP_WRITE | 可写事件频繁返回 |
| Selector 空轮询 | JDK 或使用方式导致 select 异常返回 |
| IO 线程做慢业务 | 事件循环被业务代码占住 |
| 死循环处理半包 | Buffer 状态处理错误 |
排查顺序:
top找到高 CPU 线程。- 转十六进制线程 ID。
jstack找到对应线程栈。- 看是否在 Selector、业务循环、编解码、日志输出。
- 如果是 Netty,看 EventLoop 是否被业务阻塞。
面试标准回答
IO 一次 read 的过程
Java 调用 read 后,JVM 会通过本地方法进入操作系统内核。数据通常先从磁盘或网卡进入内核缓冲区,再复制到用户态缓冲区,最后 Java 代码从 byte[] 或 Buffer 中读取。如果数据没有准备好,传统阻塞 IO 的线程会挂起等待,所以连接数多时一个连接一个线程成本很高。为什么缓冲流更快
缓冲流不是让磁盘变快,而是减少系统调用和底层 IO 次数。它先一次性从底层读一批数据到内存缓冲区,业务多次 read 可以直接从内存拿;输出时也可以先写入缓冲区,攒一批再写到底层。生产中还要注意 flush 和 close,close 通常会先 flush 并释放资源。BIO、NIO、AIO 区别
BIO 是同步阻塞模型,线程发起 IO 后会等待结果,常见做法是一个连接一个线程,简单但连接多时线程成本高。Java NIO 提供 Buffer、Channel、Selector,网络 Channel 可以设置为非阻塞并注册到 Selector,一个线程监听多个连接事件,适合高并发连接。AIO 是异步 IO,操作系统完成后通知回调。Java 后端生产高并发网络服务通常使用 Netty,而不是手写复杂 NIO。Selector 为什么能处理多个连接
Selector 的前提是 Channel 设置为非阻塞。多个 Channel 注册到同一个 Selector 后,线程阻塞在 select 上等待事件。哪个连接有 accept、read、write 事件,Selector 就返回对应 SelectionKey,线程再处理这些就绪连接。这样线程不是为每个连接阻塞等待,而是为一批连接等待事件,所以能减少线程数量。零拷贝怎么理解
零拷贝不是完全没有复制,而是减少用户态和内核态之间不必要的数据复制。普通文件传输可能从磁盘到内核缓冲区,再复制到用户态 byte[],再复制到 Socket 缓冲区。FileChannel.transferTo 等方式可以让数据更多在内核态完成传输,减少用户态参与,提高大文件传输效率。实际效果依赖操作系统支持。关联知识点
本章小结
IO 的核心不是 API 名字,而是数据流动过程。传统 IO 简单但阻塞,缓冲流通过减少系统调用提升性能,字符流必须处理编码,NIO 通过 Buffer、Channel、Selector 支撑多路复用,零拷贝和直接内存围绕减少复制和提高本地 IO 效率展开。真正能在项目里用好 IO,要同时考虑内存、线程、文件句柄、编码、超时、下游速度和线上排查。
