Appearance
Java NIO - 04 IO模型
本文介绍IO模型。
1. 从操作系统说起:内核态与用户态
操作系统为了保证系统的稳定性和安全性,将运行状态划分为内核态(Kernel Mode)和用户态(User Mode),也称为系统态(System Mode)和用户态。 这两种状态在权限级别和可以执行的操作上有着根本的区别。
1.1 内核态 (Kernel Mode)
内核态,也称为系统态或特权态,是操作系统运行的最高权限级别。当处理器处于内核态时,代码可以执行任何指令集中的任何指令,并可以直接访问计算机的所有硬件资源,包括内存、I/O 设备等等。
核心特点:
- 最高权限: 在内核态下运行的代码拥有不受限制的访问权限。
- 直接访问硬件: 可以直接控制和操作硬件设备。
- 运行关键系统代码: 操作系统的内核、设备驱动程序以及一些关键的系统服务通常运行在内核态。
- 稳定性至关重要: 由于内核态代码的错误可能导致整个系统崩溃,因此内核代码的稳定性和可靠性至关重要。
运行在内核态的典型程序:
- 操作系统内核 (Operating System Kernel): 这是操作系统的核心部分,负责进程管理、内存管理、文件系统、设备驱动等核心功能。
- 设备驱动程序 (Device Drivers): 用于控制和管理硬件设备的程序,必须直接与硬件交互,因此通常运行在内核态。
- 某些系统服务 (System Services): 一些需要直接访问硬件或执行特权操作的系统服务,例如进程调度、内存管理服务等。
可以形象地比喻为: 内核态就像一个国家的中央政府,拥有最高的权力,可以管理和控制国家的任何资源(硬件),负责国家(系统)的正常运转。
1.2 什么是用户态 (User Mode)
用户态,也称为普通态或非特权态,是操作系统为普通应用程序提供的运行状态。当处理器处于用户态时,代码的执行权限受到限制,不能直接访问某些硬件资源,也不能执行某些特权指令。用户态程序必须通过系统调用(System Call)来请求操作系统内核提供服务,才能间接地访问硬件资源或执行特权操作。
核心特点:
- 权限受限: 在用户态下运行的代码受到操作系统设定的权限限制。
- 间接访问硬件: 不能直接访问硬件,必须通过系统调用请求内核服务才能访问。
- 运行用户应用程序: 我们平时使用的各种应用程序,例如浏览器、文字处理器、游戏等,通常运行在用户态。
- 安全性更高: 用户态程序的错误通常不会导致整个系统崩溃,因为其权限受限,不会直接影响到系统核心。
运行在用户态的典型程序:
- 用户应用程序 (User Applications): 例如,网页浏览器、办公软件、游戏、音视频播放器等等,我们日常使用的绝大多数应用程序都运行在用户态。
可以形象地比喻为: 用户态就像国家的普通公民,受到法律(操作系统)的约束,不能随意访问和使用国家的资源(硬件),需要通过合法的途径(系统调用)向政府(内核)申请才能获得需要的服务。
1.3 内核态和用户态的切换
操作系统在内核态和用户态之间切换通常发生在以下几种情况:
系统调用 (System Call): 用户态程序需要请求操作系统内核提供的服务(例如,读取文件、创建进程、网络通信等)时,会发起系统调用。系统调用是一种特殊的中断,会触发处理器从用户态切换到内核态,由内核代码处理系统调用请求,完成后再切换回用户态。
例如: 当用户程序需要打开一个文件时,它会调用 open() 函数,这个函数最终会触发一个系统调用,将控制权交给内核,内核在内核态下执行文件打开操作,然后将结果返回给用户程序,控制权再回到用户态。
中断 (Interrupt) 和异常 (Exception): 硬件设备发出的中断信号(例如,键盘输入、网卡接收到数据)或者程序执行过程中发生的异常(例如,除零错误、访问非法内存)都可能导致处理器从用户态切换到内核态,由内核中的中断处理程序或异常处理程序进行处理。处理完成后,通常会切换回用户态继续执行用户程序。
例如: 当用户按下键盘上的一个键时,键盘控制器会向 CPU 发送一个中断信号,CPU 接收到中断信号后,会从用户态切换到内核态,执行键盘中断处理程序,读取键盘输入,并将输入信息传递给相应的应用程序。
进程切换 (Process Switching): 当操作系统进行进程切换时,也可能涉及到内核态和用户态的切换。进程切换通常由操作系统内核中的调度器完成,调度器运行在内核态。
2. IO模型
IO模型有两组关键词:同步与异步、阻塞与非阻塞。我们需要理解这几组关键词来理解IO模型。
2.1 同步与异步、阻塞与非阻塞
在上面的内核态与用户态的介绍中,如果用户态程序请求操作系统内核提供的服务,我们把用户态程序称为调用方,把操作系统内核称为被调用方。
同步与异步:调用方是否主动获取调用结果,同步是主动获取结果,异步是被动接受结果(被调用者通知调用者)。
阻塞与非阻塞:是指调用方在等待结果的过程中,是否会暂停活动,阻塞是暂停活动,非阻塞是不暂停活动。
根据同步与异步、阻塞与非阻塞的组合,可以分为以下四种IO模型:
| 同步 (Synchronous) | 异步 (Asynchronous) | |
|---|---|---|
| 阻塞(Blocking) | 同步阻塞 | 异步阻塞 |
| 非阻塞 (Non-blocking) | 同步非阻塞 | 异步非阻塞 |
2.3 IO模型详解
以餐厅点餐为例子,小明去烧鹅坊餐厅吃饭,服务员小红接待。
2.3.1 同步阻塞
同步的意思是服务员小红态度很不好,烧鹅好了也不通知小明。小明点完餐后就眼巴巴地望着出餐口,一直等待不干其他事。当烧鹅做好后,小明就拿过烧鹅开始吃饭。
程序发起 I/O 请求后,线程会被阻塞,等待 I/O 操作完成并返回结果,才能继续执行。 程序 主动等待结果,并且等待期间 线程被挂起。
例如,在Java中我们可以通过java.io.FileInputStream同步阻塞读取文件内容:
java
public static void main(String[] args) {
try (
FileInputStream fileInputStream = new FileInputStream("jwtdemo/src/main/resources/a.txt");
){
byte[] buffer = new byte[1024];
// 同步阻塞读取文件内容
int len = fileInputStream.read(buffer);
System.out.println(new String(buffer, 0, len));
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}2.3.2 同步非阻塞
同步的意思是服务员小红态度很不好,烧鹅好了也不通知小明。小明点完餐后就去找位置坐下,玩会儿手机就去出餐口看看烧鹅好了吗,没好又去窗边看看风景,再去出餐口看看烧鹅好了吗,没好又去便利店买瓶水,再回来出餐口看看烧鹅好了吗。当小明去出餐口发现烧鹅做好后,就拿过烧鹅开始吃饭了。
程序发起 I/O 请求后,系统调用不会阻塞线程,会立即返回 (可能返回错误码表示操作未完成)。 程序需要 不断地轮询 (polling) 检查 I/O 操作是否完成。 程序 主动等待结果 (轮询),但是等待期间 线程不会被挂起,可以执行其他任务 (例如轮询检查其他文件描述符)。 select、poll、epoll 等多路复用 I/O 技术就属于同步非阻塞 I/O 模型。
例如,在Java中,我们可以通过线程池模拟同步非阻塞读取文件内容:
java
public static void main(String[] args) throws IOException, InterruptedException {
// 创建共享缓冲区
ByteBuffer sharedBuffer = ByteBuffer.allocate(1024);
// 标志文件读取是否完成
AtomicBoolean fileReadCompleted = new AtomicBoolean(false);
// 创建线程池,用于执行文件读取任务
ExecutorService executor = Executors.newSingleThreadExecutor();
// 提交文件读取任务到线程池
executor.submit(() -> {
try (FileChannel fileChannel = FileChannel.open(
Paths.get("jwtdemo/src/main/resources/a.txt"),
StandardOpenOption.READ)) {
fileChannel.read(sharedBuffer);
fileReadCompleted.compareAndSet(false, true);
} catch (IOException e) {
e.printStackTrace();
}
});
// 同步:不断轮询判断读取文件是否完成
while (true) {
if(!fileReadCompleted.get())
continue;
sharedBuffer.flip();
while (sharedBuffer.hasRemaining()){
System.out.print((char) sharedBuffer.get());
}
sharedBuffer.clear();
break;
}
}与真正的异步非阻塞 I/O 的区别:
- 实现方式不同:
- 真正的异步非阻塞 I/O (例如 AIO,
AsynchronousFileChannel): 依赖于操作系统内核的异步 I/O 支持,程序发起异步操作后,I/O 操作完全在内核态异步执行,内核在操作完成后主动通知程序。 程序无需自己创建线程,也无需进行轮询检查。 - 线程池 + 原子变量模拟的 "非阻塞": 通过 手动创建线程池 和 原子变量 来实现 "非阻塞" 效果。 文件 I/O 操作仍然是 阻塞的,只是阻塞发生在独立的线程中,主线程通过轮询检查原子变量来同步等待结果。 本质上仍然是同步阻塞模型,只是将阻塞操作转移到了后台线程,在主线程层面模拟了非阻塞的效果。
- 真正的异步非阻塞 I/O (例如 AIO,
- 性能和效率:
- 真正的异步非阻塞 I/O: 通常具有 更高的性能和效率,因为完全依赖于操作系统内核的异步机制,可以更充分地利用操作系统资源,减少线程切换开销。
- 线程池 + 原子变量模拟的 "非阻塞": 性能和效率相对较低,仍然存在线程创建、线程切换、线程同步的开销,并且轮询检查原子变量也会占用一定的 CPU 资源。
2.3.3 异步阻塞
异步的意思是服务员小红很称职,烧鹅好了后会通知小明。但是小明不放心,点完餐后还是望着出餐口,一直等待不干其他事。当烧鹅做好后,小红告诉小明,这是你的烧鹅,小明就拿过烧鹅去吃饭了。你说小明是不是很傻!
程序发起异步 I/O 请求后,线程会被阻塞,等待 操作系统内核完成 I/O 操作后,再唤醒线程并返回结果。 程序 被动等待结果 (等待内核通知),但是等待期间 线程被挂起。
"异步" 的目的是为了不阻塞线程,但 "阻塞" 又导致线程被挂起,因此这种模型比较矛盾,实际应用价值不高。
2.3.4 异步非阻塞
异步的意思是服务员小红很称职,烧鹅好了后会通知小明。小明就去做其他事了,比如找位置,玩手机,看风景,买水,当烧鹅好了后,小红通知小明,小明起身去拿烧鹅吃饭。 有两种通知方式:
- 在点餐时,小明告诉小红:“烧鹅好了之后打电话给我。”
- 在点餐时,小明给了小红一包辣椒粉,并说:“烧鹅好了之后,帮我把辣椒粉撒上去,然后帮我送到桌上。”
程序发起异步 I/O 请求后,线程不会被阻塞,可以立即返回,继续执行其他任务。 操作系统内核会在后台完成 I/O 操作,并将结果 (例如通过信号、回调函数等) 异步通知给程序。 程序 被动等待结果 (等待内核通知),并且等待期间 线程不会被挂起。 Linux AIO (Asynchronous I/O) 就属于异步非阻塞 I/O 模型。
这是 最理想的 I/O 模型。
操作系统内核通过信号通知程序:
java
public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
AsynchronousFileChannel asynchronousFileChannel = AsynchronousFileChannel.open(
Paths.get("src/main/resources/a.txt"),
StandardOpenOption.READ
);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 异步非阻塞读取,返回的 Future<Integer> 表示读取完成信号量
Future<Integer> future = asynchronousFileChannel.read(byteBuffer, 0);
// 线程可以做其他事
doSomething();
while (true){
// future.isDone()返回读取事件是否完成
if(!future.isDone())
continue;
// future.get() 表示读取的数据量
System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 读取文件完成,总共读取" + future.get() + "字节数据,结果如下:");
byteBuffer.flip();
while (byteBuffer.hasRemaining()){
System.out.print((char)byteBuffer.get());
}
byteBuffer.clear();
break;
}
}
private static void doSomething() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 开始做其他事...");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 结束做其他事...");
}结果如下:
txt
main 15:17:19.754 开始做其他事...
main 15:17:20.761 结束做其他事...
main 15:17:20.761 读取文件完成,总共读取3字节数据,结果如下:
xyz操作系统通过回调函数通知程序:
java
public static void main(String[] args) throws IOException, InterruptedException {
AsynchronousFileChannel asynchronousFileChannel = AsynchronousFileChannel.open(
Paths.get("src/main/resources/a.txt"),
StandardOpenOption.READ
);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 通过回调的方式异步非阻塞IO
asynchronousFileChannel.read(byteBuffer, 0, byteBuffer, new CompletionHandler<Integer, ByteBuffer>(){
@Override
public void completed(Integer result, ByteBuffer attachment) {
System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 读取文件完成,总共读取" + result + "字节数据,结果如下:");
attachment.flip();
while (attachment.hasRemaining()){
System.out.print((char)attachment.get());
}
System.out.println(); // 换行
attachment.clear();
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
System.out.println(LocalTime.now() + " 读取文件失败,异常信息:" + exc.getMessage());
}
});
// 做其他事
doSomething();
}
private static void doSomething() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " " + LocalTime.now() + " 开始做其他事...");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " " +LocalTime.now() + " 结束做其他事...");
}结果如下:
txt
Thread-8 15:19:10.163 读取文件完成,总共读取3字节数据,结果如下:
main 15:19:10.163 开始做其他事...
xyz
main 15:19:11.178 结束做其他事...可以看到回调函数是在另一个线程调用的。注意,由于回调函数是在守护线程中调用的,如果主线程结束了,则回调线程也会结束,有可能造成未打印文件内容,我们可以阻塞主线程解决该问题。
2.3.5 多路复用
多路复用是基于同步非阻塞的。
我们仍然以小明点餐为例子,加入小明到烧鹅坊点餐,由于是非阻塞的,所以小明可以继续做其他事,此时他可以去奶茶店点杯奶茶,仍然是同步非阻塞的,然后去甜品店点份甜品,仍然是同步非阻塞的。之后,他就在手机上关注三家店的状态,当某一家店出餐后,会更新手机上的状态,小明看到后就去该店取餐。
在Java中,可以使用NIO中的Selector实现多路复用:
java
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true){
// 阻塞等待事件就绪
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
if(selectionKey.isAcceptable()){
ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();
SocketChannel socketChannel = channel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
if(selectionKey.isReadable()){
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int len = channel.read(byteBuffer);
if(len == -1){
channel.close();
selectionKey.cancel();
}else if(len > 0){
byteBuffer.flip();
while (byteBuffer.hasRemaining()){
System.out.print((char) byteBuffer.get());
}
byteBuffer.clear();
}
}
iterator.remove();
}
}
}这里有个难以理解的点:是阻塞等待事件发生的,即是内核通知应用程序某个事件发生了,就是异步的,那为什么说Selector是同步非阻塞的?
- 为什么是同步的?虽然是内核通知应用程序某个事件发生了,但是只是数据就绪标志,而不是数据本身。同步是指应用程序主动获取结果,这里的结果是数据,后续应用程序需要通过管道Channel主动获取数据本身,所以说是同步的。
- 为什么是非阻塞的?虽然
Selector.select()是阻塞的,但这是为了高效地等待多个事件就绪,而不是阻塞在某一个IO事件上,即非阻塞体现在Selector可以监控多个事件是否就绪(对应这多个管道),并且每个管道(ServerSocketChannel和SocketChannel)是非阻塞的。
3. 零拷贝
3.1 传统方法
首先我们先看一个典型的数据传输案例,首先从文件中读取内容,并将文件内容通过TCP传输出去:
java
File file = new File("src/main/resources/data.txt");
FileInputStream fileInputStream = new FileInputStream(file);
// 读取文件内容
byte[] buffer = new byte[1024];
int len = fileInputStream.read(buffer);
// 发送文件内容
Socket socket = new Socket("127.0.0.1",8080);
socket.getOutputStream().write(buffer,0,len);在上面的例子中,整个过程如下:

- 首先因为调用了
read()方法,所以要从用户态切换到内核态,调用操作系统的读能力,将数据从磁盘读入内核缓冲区。这期间用户线程(Java程序线程阻塞),操作系统使用DMA(Direct Memory Access)来实现文件读,期间不会使用CPU。 - 然后在内核态下,操作系统将内核缓冲区中的数据复制到用户缓冲区(即byte[] buffer数组),这期间CPU会参与拷贝,无法利用DMA。
- 然后从内核态切换到用户态,继续执行Java代码;
- Java代码调用了
write()写方法,所以又从用户态切换到内核态,调用操作系统的写能力,首先将数据从用户缓冲区复制到socket缓冲区,这期间CPU会参与拷贝,无法使用DMA。 - 然后使用DMA将socket缓冲区中的数据写入网卡,通过网络发送出去。
- 最后从内核态切换到用户态,继续执行Java代码。
可以看到,在整个过程中,存在四次数据复制,四次内核态与用户态切换。
3.2 transferTo()或transferFrom()
我们可以使用NIO中Channel提供的transferTo()或transferFrom()方法传输数据:
java
try(
FileChannel fileChannel = FileChannel.open(Path.of("src/main/resources/b.txt"), StandardOpenOption.READ);
SocketChannel socketChannel = SocketChannel.open();
){
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
}catch (Exception e){
e.printStackTrace();
}在上面的例子中,过程如下:

- Java程序调用
transferTo(),从用户态切换到内核态; - 在内核态下,首先利用DMA将数据从磁盘拷贝到内核缓冲区中,此过程不需要CPU参与;
- 然后将数据从内核缓冲区拷贝到socket缓冲区,此过程需要CPU参与;
- 然后将数据从socket缓冲区写入网卡,并最终发送出去;
- 最后从内核态切换到用户态;
可以看到,在整个过程中,存在着三次数据复制,两次内核态与用户态切换。相较于传统方法有了效率上的提升。
3.3 进一步提升
从Linux 2.4及以后,有了进一步提升,流程如下:

- Java程序调用
transferTo(),从用户态切换到内核态; - 在内核态下,首先利用DMA将数据从磁盘拷贝到内核缓冲区中,此过程不需要CPU参与;
- 然后将一些文件描述信息从内核缓冲区复制到socket缓冲区,由于文件描述信息很少,所以此过程基本不耗费时间;
- 然后从内核缓冲区中的数据写入网卡发送出去;
- 最后从内核态切换回用户态;
可以看到,Linux 2.4的改进是的CPU拷贝数据彻底消失了。
3.4 mmap
mmap 是一种内存映射文件的方法,允许程序将文件或设备直接映射到内存中,从而通过内存地址访问文件内容,避免了频繁的读写操作。在Java中,我们可以通过FileChannel.map()方法实现:
java
public static void main(String[] args) throws IOException {
// 打开文件并获取 FileChannel
RandomAccessFile file = new RandomAccessFile("src/main/resources/b.txt", "rw");
FileChannel channel = file.getChannel();
// 获取 MappedByteBuffer,即文件内存映射
MappedByteBuffer byteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
// 此时MappedByteBuffer是可读状态,直接读取
while (byteBuffer.hasRemaining()){
System.out.print((char) byteBuffer.get());
}
// 向MappedByteBuffer随机写入字符,会反映到文件中
byteBuffer.clear();
for (int i = 0; i < byteBuffer.capacity(); i++)
{
int nextInt = new Random().nextInt('a', 'z');
byteBuffer.put((byte) nextInt);
}
// 关闭资源
channel.close();
file.close();
}上面的例子演示了MappedByteBuffer的使用,这是文件在Java应用程序内存空间的映射,我们直接读取MappedByteBuffer就可以获取文件内容,直接向MappedByteBuffer写入内容就可以向文件写入。
如果我们使用mmap技术将文件的内容通过网络传输出去,可以如下实现:
java
public static void main(String[] args) {
try (
FileChannel fileChannel = new RandomAccessFile("src/main/resources/b.txt", "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open();
){
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());
socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));
socketChannel.write(mappedByteBuffer);
} catch (IOException e) {
throw new RuntimeException(e);
}
}相较于传统的方法,mmap省略了一次数据拷贝:从内核缓冲区拷贝到用户缓冲区。

既然有了transferTo()或transferFrom()方法,mmap还有什么优势呢?
有,mmap允许我们修改数据!现有一个需求,有一个文件,需要将里面的字母转换为大写字母,然后通过网络传输出去,此时使用mmap就可以实现:
java
public static void main(String[] args) {
try (
FileChannel fileChannel = new RandomAccessFile("src/main/resources/b.txt", "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open();
){
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());
// 小写字母转换为大写字母
for (int i = 0; i < mappedByteBuffer.capacity(); i++) {
if('a' <= mappedByteBuffer.get(i) && mappedByteBuffer.get(i) <= 'z'){
mappedByteBuffer.put(i, (byte) (mappedByteBuffer.get(i) - 32));
}
}
socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));
socketChannel.write(mappedByteBuffer);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
参考资料
[1] https://www.bilibili.com/video/BV1py4y1E7oA
[2] Gemini、deepseek
[3] https://shawn-xu.medium.com/its-all-about-buffers-zero-copy-mmap-and-java-nio-50f2a1bfc05c