Skip to content

IO 与 NIO 全过程原理 ​

很多同学学 IO 时会停在“会用 read、write、Buffer、Channel”这一层,但面试和线上排查真正问的是过程:

  1. Java 调一次 read(),数据到底从哪里到哪里。
  2. 为什么传统 IO 会阻塞线程。
  3. 为什么缓冲流能快。
  4. 为什么大文件不能 readAllBytes()。
  5. NIO 的 Buffer 为什么要 flip()。
  6. Selector 为什么能让一个线程管理多个连接。
  7. 零拷贝到底少复制了哪一步。
  8. 直接内存 OOM、文件句柄泄漏、Selector CPU 飙高怎么排查。

这一页按“从用户代码到操作系统”的视角,把 IO/NIO 每个关键过程讲清楚。你不需要先懂操作系统内核,但要知道 Java IO 不只是 Java API,它最终一定会落到文件、网卡、内核缓冲区和系统调用。

学习目标 ​

学完本页,你应该能回答:

问题必须说清楚的点
IO 的本质是什么程序和外部设备交换字节,通常经过内核缓冲区和用户态缓冲区
BIO 为什么阻塞线程发起 read/write 后,如果数据没准备好或写不出去,会挂起等待
缓冲为什么快合并小 IO,减少系统调用次数和用户态/内核态切换
字符流和字节流区别字符流多了编码/解码,乱码本质是字节解释方式错了
NIO 为什么需要 BufferChannel 不直接给业务对象读写,数据先进入 Buffer,业务再处理
Selector 为什么省线程Channel 非阻塞注册到 Selector,一个线程等待多个连接事件
零拷贝是不是完全没有复制不是,主要是减少用户态和内核态之间不必要的数据复制
生产怎么选普通文件用缓冲流/Files,大文件分块,高并发网络用 Netty

IO 的本质:数据不是直接进 Java 对象 ​

先看最重要的过程。Java 读取文件或网络数据,一般不是磁盘、网卡直接把数据塞进你的对象,而是先经过操作系统。

mermaid
flowchart TD
    A["磁盘或网卡"] --> B["操作系统内核"]
    B --> C["内核缓冲区"]
    C --> D["JVM 用户态缓冲区"]
    D --> E["Java 对象或 byte[]"]

一次普通读操作可以理解为:

步骤发生了什么为什么重要
1Java 调用 read()业务线程进入 IO 调用
2JVM 通过本地方法进入操作系统从用户态进入内核态,有切换成本
3内核检查数据是否准备好文件可能等磁盘,网络可能等对端发数据
4数据先到内核缓冲区操作系统管理设备 IO
5数据复制到用户态缓冲区Java 代码才能访问
6Java 代码从 byte[] 或 Buffer 读取业务开始解析数据

这解释了为什么 IO 通常比纯内存计算慢:它涉及外部设备、系统调用、状态切换、数据复制和等待。

BIO 文件读取全过程 ​

最常见的传统 IO 代码:

java
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);
        }
    }
}

这段代码的执行过程:

mermaid
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已经到文件末尾

所以正确读法必须循环:

java
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 慢”,而是线程等待外部条件。

mermaid
flowchart TD
    A["线程调用 read"] --> B{"数据到达了吗"}
    B -- "没有" --> C["线程挂起"]
    C --> D["不占用 CPU 继续执行"]
    D --> B
    B -- "到了" --> E["复制数据并返回"]

网络 BIO 更容易体现这个问题:

java
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()客户端没发完整一行工作线程等待数据

如果一个连接一个线程,连接数很多但大多数连接都不活跃,线程会大量浪费在等待上。线程本身需要栈内存和调度成本,数量上去后会造成上下文切换、内存上涨、线程池耗尽。

为什么缓冲流能提升性能 ​

没有缓冲时,程序可能每读一个字节都触发底层读取。缓冲流会先从底层批量读一块到内存,然后业务代码从内存缓冲区慢慢取。

mermaid
flowchart TD
    A["业务多次 read"] --> B["BufferedInputStream 缓冲区"]
    B --> C{"缓冲区有数据吗"}
    C -- "有" --> D["直接从内存返回"]
    C -- "没有" --> E["一次性从底层读取一批"]
    E --> B

对比:

写法底层访问特点问题
每次读 1 字节系统调用可能非常频繁慢
使用 byte[] 分块一次读一批常用
使用 BufferedInputStream内部维护缓冲区适合包装底层流

Demo:缓冲复制文件。

java
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()。

字节流、字符流和乱码全过程 ​

字节流处理原始字节,字符流处理字符。字符要落盘或网络传输,必须先编码成字节;字节要变成字符,必须解码。

mermaid
flowchart TD
    A["Java 字符串"] --> B["按 UTF-8 编码"]
    B --> C["字节数组"]
    C --> D["写入文件或网络"]
    D --> E["读取字节数组"]
    E --> F["按 UTF-8 解码"]
    F --> G["Java 字符串"]

乱码发生的典型过程:

mermaid
flowchart TD
    A["字符串:患者"] --> B["用 UTF-8 编码成字节"]
    B --> C["文件保存"]
    C --> D["用 GBK 解码"]
    D --> E["字符解释错误"]

正确做法:不要依赖平台默认编码。

java
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。

大文件为什么不能一次读进内存 ​

错误示例:

java
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 时间。

正确思路是分块或按行:

mermaid
flowchart TD
    A["打开文件"] --> B["读取一块"]
    B --> C["解析一批记录"]
    C --> D["批量入库或处理"]
    D --> E{"还有数据吗"}
    E -- "有" --> B
    E -- "没有" --> F["关闭资源"]

CSV 导入 Demo:

java
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 背后的生产原则:

  1. 文件按行读,不一次性进堆。
  2. 数据分批入库,避免单事务过大。
  3. 批次集合复用,减少临时对象。
  4. 失败时记录文件名、行号、批次号,方便重跑。

NIO 的核心:Buffer 和 Channel ​

传统 IO 面向流,NIO 面向 Buffer 和 Channel。

mermaid
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 的难点是它有“写模式”和“读模式”。

mermaid
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:观察指针变化。

java
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() 的本质是:

  1. 把 limit 设置为刚才写到的位置。
  2. 把 position 重置为 0。
  3. 让程序从头读到刚才写入的末尾。

clear() 和 compact() 怎么选 ​

方法作用场景
clear()不清数据,只重置指针,准备重新写数据已经处理完
compact()保留未读数据,把它挪到开头,再准备写网络半包、数据没处理完

FileChannel 读写全过程 ​

FileChannel 不是直接返回数据,而是把数据读进 Buffer。

mermaid
flowchart TD
    A["FileChannel.read(buffer)"] --> B["内核读取文件"]
    B --> C["数据进入 Buffer"]
    C --> D["flip"]
    D --> E["业务从 Buffer 读取"]
    E --> F["clear"]
    F --> G["继续下一轮"]

文件复制 Demo:

java
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 的做法是:

  1. 把 Channel 设置为非阻塞。
  2. 把 Channel 注册到 Selector。
  3. 一个线程调用 select() 等待多个 Channel 的事件。
  4. 哪个 Channel 有事件,就处理哪个。
mermaid
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 可能被打满。

正确做法:

  1. 平时只关注读事件。
  2. 只有有待发送数据且一次没写完,才临时关注写事件。
  3. 写完后取消写事件关注。

最小 Selector Demo ​

这个 Demo 用来理解模型,不建议生产手写。生产高并发网络服务优先用 Netty。

java
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 的执行过程:

  1. 服务端 Channel 设置为非阻塞。
  2. 服务端 Channel 注册 OP_ACCEPT。
  3. selector.select() 阻塞等待事件。
  4. 有新连接时 accept,得到客户端 Channel。
  5. 客户端 Channel 设置非阻塞并注册 OP_READ。
  6. 客户端发数据后,Selector 返回读事件。
  7. 服务端读取 Buffer,处理后写回。

为什么生产不用这个 Demo 写法:

问题生产后果
没有处理半包粘包TCP 数据边界错误
没有连接心跳死连接占资源
没有背压和写队列写慢客户端会拖垮服务
没有线程模型隔离IO 线程容易被业务阻塞
没有内存池Buffer 频繁分配和回收

这就是 Netty 的价值:它把 Selector、Reactor、Pipeline、ByteBuf、编解码、内存池、连接管理这些复杂问题封装好了。

零拷贝全过程 ​

普通文件下载可能经历多次复制。

mermaid
flowchart TD
    A["磁盘"] --> B["内核缓冲区"]
    B --> C["用户态 byte[]"]
    C --> D["Socket 缓冲区"]
    D --> E["网卡"]

FileChannel.transferTo 目标是减少用户态参与。

mermaid
flowchart TD
    A["磁盘"] --> B["内核缓冲区"]
    B --> C["Socket 缓冲区"]
    C --> D["网卡"]

Demo:

java
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);
            }
        }
    }
}

注意:

  1. 零拷贝不是完全没有复制,而是减少用户态和内核态之间的数据搬运。
  2. 底层效果依赖操作系统。
  3. 文件到网络的传输更典型,文件到文件也可以使用 Channel 优化。
  4. 超大文件要循环调用,不能假设一次 transferTo 就完成全部传输。

直接内存和 DirectBuffer ​

ByteBuffer.allocate() 分配堆内 Buffer,ByteBuffer.allocateDirect() 分配直接内存。

mermaid
flowchart TD
    A["Java 堆"] --> B["DirectByteBuffer 对象"]
    B --> C["引用堆外直接内存"]
    C --> D["本地 IO 读写"]
类型位置优点风险
Heap BufferJava 堆创建回收简单本地 IO 可能多一次复制
Direct Buffer堆外直接内存适合高频 IO分配成本高,释放不及时会 OOM

直接内存 OOM 常见报错:

text
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 文件,平台需要导入患者、就诊、检验报告数据。

正确链路:

mermaid
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
不指定编码中文乱码、数据错误
一条一条入库导入极慢
一个大事务入库锁时间长、回滚成本高
不关闭流文件句柄泄漏

线上排查流程 ​

文件句柄泄漏 ​

现象:

text
Too many open files

排查:

步骤看什么
1是否所有流都用 try-with-resources
2是否上传下载异常分支没关闭
3Linux 用 lsof -p <pid> 看打开文件
4检查连接池、Socket、日志文件是否泄漏

IO 阻塞导致接口慢 ​

mermaid
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 状态处理错误

排查顺序:

  1. top 找到高 CPU 线程。
  2. 转十六进制线程 ID。
  3. jstack 找到对应线程栈。
  4. 看是否在 Selector、业务循环、编解码、日志输出。
  5. 如果是 Netty,看 EventLoop 是否被业务阻塞。

面试标准回答 ​

IO 一次 read 的过程 ​

text
Java 调用 read 后,JVM 会通过本地方法进入操作系统内核。数据通常先从磁盘或网卡进入内核缓冲区,再复制到用户态缓冲区,最后 Java 代码从 byte[] 或 Buffer 中读取。如果数据没有准备好,传统阻塞 IO 的线程会挂起等待,所以连接数多时一个连接一个线程成本很高。

为什么缓冲流更快 ​

text
缓冲流不是让磁盘变快,而是减少系统调用和底层 IO 次数。它先一次性从底层读一批数据到内存缓冲区,业务多次 read 可以直接从内存拿;输出时也可以先写入缓冲区,攒一批再写到底层。生产中还要注意 flush 和 close,close 通常会先 flush 并释放资源。

BIO、NIO、AIO 区别 ​

text
BIO 是同步阻塞模型,线程发起 IO 后会等待结果,常见做法是一个连接一个线程,简单但连接多时线程成本高。Java NIO 提供 Buffer、Channel、Selector,网络 Channel 可以设置为非阻塞并注册到 Selector,一个线程监听多个连接事件,适合高并发连接。AIO 是异步 IO,操作系统完成后通知回调。Java 后端生产高并发网络服务通常使用 Netty,而不是手写复杂 NIO。

Selector 为什么能处理多个连接 ​

text
Selector 的前提是 Channel 设置为非阻塞。多个 Channel 注册到同一个 Selector 后,线程阻塞在 select 上等待事件。哪个连接有 accept、read、write 事件,Selector 就返回对应 SelectionKey,线程再处理这些就绪连接。这样线程不是为每个连接阻塞等待,而是为一批连接等待事件,所以能减少线程数量。

零拷贝怎么理解 ​

text
零拷贝不是完全没有复制,而是减少用户态和内核态之间不必要的数据复制。普通文件传输可能从磁盘到内核缓冲区,再复制到用户态 byte[],再复制到 Socket 缓冲区。FileChannel.transferTo 等方式可以让数据更多在内核态完成传输,减少用户态参与,提高大文件传输效率。实际效果依赖操作系统支持。

关联知识点 ​

本章小结 ​

IO 的核心不是 API 名字,而是数据流动过程。传统 IO 简单但阻塞,缓冲流通过减少系统调用提升性能,字符流必须处理编码,NIO 通过 Buffer、Channel、Selector 支撑多路复用,零拷贝和直接内存围绕减少复制和提高本地 IO 效率展开。真正能在项目里用好 IO,要同时考虑内存、线程、文件句柄、编码、超时、下游速度和线上排查。