Skip to content

IO 与 NIO 全过程原理

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

  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 已经提供 PathFilesStandardCharsets 这些现代文件 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,要同时考虑内存、线程、文件句柄、编码、超时、下游速度和线上排查。