java中的IO可以分为BIO(blocking io,阻塞IO)、NIO(non-blocking io,非阻塞IO)、AIO(Asynchronous IO,异步IO)
IO中的三大组件
Channel与Buffer
channel有一点类似于stream,它就是读写数据的双向通道,可以从 channel 将数据读入 buffer,也可以将 buffer 的数据写入 channel,而之前的 stream 要么是输入,要么是输出,channel 比 stream 更为底层
graph LR channel --> buffer buffer --> channel
|
常见的Channel有
- FileChannel:用于对文件进行读写操作的通道,支持随机访问和映射文件
- DatagramChannel:用于基于UDP进行无连接数据发送和接收的通道
- SocketChannel:用于基于TCP的客户端网络通信,支持非阻塞连接与数据传输
- ServerSocketChannel用于监听TCP连接请求的服务端通道,生成SocketChannel
buffer则用来缓冲读写数据,常见的buffer有
- ByteBuffer:存储字节数据的基本缓冲区类型,是所有IO操作的核心
- MappedByteBuffer:可将文件内容直接映射到内存中进行读写
- DirectByteBuffer:分配在堆外内存,用于高性能I/O传输
- HeapByteBuffer:分配在Java堆上,速度较慢但创建成本低
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
- CharBuffer
Selector
Selector是一个可以同时监听多个Channel的I/O就绪事件的组件,实现用一个线程处理多个连接
不用Selector时,如果我们使用多线程管理各个socket:
graph TD subgraph 多线程版 t1(thread) --> s1(socket1) t2(thread) --> s2(socket2) t3(thread) --> s3(socket3) end
|
这种方式虽然可以同时管理多个socket,但是:
- 内存占用高
- 线程上下文切换成本高
- 只适合连接数少的场景
优化一下,使用线程池:
graph TD subgraph 线程池版 t4(thread) --> s4(socket1) t5(thread) --> s5(socket2) t4(thread) -.-> s6(socket3) t5(thread) -.-> s7(socket4) end
|
在这种设计下,我们使用线程池中的每个线程管理多个socket,但是这种方式也有缺点:
- 阻塞模式下,线程仅能处理一个socket连接
- 仅适合短连接场景,如果是长连接,会造成socket饥饿现象
现在引入Selector,用一个线程来管理多个channel,获取这些channel上发生的事件,这些channel工作在非阻塞模式下,不会让线程吊死在一个channel上。适合连接数特别多,但流量低的场景。
graph TD subgraph selector 版 thread --> selector selector --> c1(channel) selector --> c2(channel) selector --> c3(channel) end
|
调用selector的select()会阻塞直到channel发生了读写就绪事件,这些事件发生,select方法就会返回这些事件交给thread来处理
ByteBuffer
使用方式
- 向buffer写入数据,例如调用 channel.read(buffer)
- 调用flip()切换至读模式
- 从buffer读取数据,例如调用buffer.get()
- 调用clear()或compact()切换至写模式
- 重复1~4步骤
使用示例
有一普通文本文件data.txt,内容为
使用FileChannel来读取文件内容
@Slf4j public class ChannelDemo1 { public static void main(String[] args) { try (RandomAccessFile file = new RandomAccessFile("helloword/data.txt", "rw")) { FileChannel channel = file.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(10); do { int len = channel.read(buffer); log.debug("读到字节数:{}", len); if (len == -1) { break; } buffer.flip(); while(buffer.hasRemaining()) { log.debug("{}", (char)buffer.get()); } buffer.clear(); } while (true); } catch (IOException e) { e.printStackTrace(); } } }
|
输出
10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 读到字节数:10 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 1 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 2 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 3 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 4 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 5 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 6 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 7 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 8 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 9 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 0 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 读到字节数:4 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - a 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - b 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - c 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - d 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 读到字节数:-1
|
结构
ByteBuffer 有以下重要属性
初始状态:

写模式下,position是写入位置,limit等于容量,下图表示写入了4个字节后的状态

flip动作发生后,position切换为读取位置,limit切换为读取限制

读取4个字节后,状态

compact方法,是把未读完的部分向前压缩,然后切换至写模式。

clear后,bytebuffer复原为初始状态。
常用方法
使用allocate方法为 ByteBuffer 分配空间,其它 buffer 类也有该方法
Bytebuffer buf = ByteBuffer.allocate(16);
|
向buffer写入数据
调用channel的read方法
调用buffer的put方法
int readBytes = channel.read(buf);
buf.put((byte)127);
|
从buffer读取数据
调用channel的write方法
调用buffer的get方法
int writeBytes = channel.write(buf);
byte b = buf.get();
|
get方法会让position读指针向后走,如果想重复读取数据
- 可以调用 rewind 方法将 position 重新置为 0
- 或者调用 get(int i) 方法获取索引 i 的内容,它不会移动读指针
mark 是在读取时,做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置
rewind 和 flip 都会清除 mark 位置
注意:
⚠️ Buffer是非线程安全的
FileChannel
FileChannel 只能工作在阻塞模式下
获取
不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法
- 通过 FileInputStream 获取的 channel 只能读
- 通过 FileOutputStream 获取的 channel 只能写
- 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定
读取
会从channel读取数据填充 ByteBuffer,返回值表示读到了多少字节,-1表示到达了文件的末尾
int readBytes = channel.read(buffer);
|
写入
在while中调用channel.write是因为write方法并不能保证一次将buffer中的内容全部写入channel
ByteBuffer buffer = ...; buffer.put(...); buffer.flip();
while(buffer.hasRemaining()) { channel.write(buffer); }
|
关闭
channel必须关闭,调用了FileInputStream、FileOutputStream或者RandomAccessFile的close方法会间接地调用channel的close方法
强制写入
操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘。可以调用force(true)方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘
网络编程
阻塞
- 阻塞模式下,相关方法都会导致线程暂停
- ServerSocketChannel.accept 会在没有连接建立时让线程暂停
- SocketChannel.read 会在没有数据可读时让线程暂停
- 阻塞的表现为线程暂停,暂停期间不会占用cpu,但线程相当于闲置
- 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
- 但多线程下,有新的问题,体现在以下方面
- 32 位jvm一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
- 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接
ByteBuffer buffer = ByteBuffer.allocate(16);
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
List<SocketChannel> channels = new ArrayList<>(); while (true) { log.debug("connecting..."); SocketChannel sc = ssc.accept(); log.debug("connected... {}", sc); channels.add(sc); for (SocketChannel channel : channels) { log.debug("before read... {}", channel); channel.read(buffer); buffer.flip(); debugRead(buffer); buffer.clear(); log.debug("after read...{}", channel); } }
|
非阻塞
- 非阻塞模式下,相关方法都会不会让线程暂停
- 在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行
- SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
- 写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
- 但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,浪费cpu
- 数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)
ByteBuffer buffer = ByteBuffer.allocate(16);
ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
List<SocketChannel> channels = new ArrayList<>(); while (true) { SocketChannel sc = ssc.accept(); if (sc != null) { log.debug("connected... {}", sc); sc.configureBlocking(false); channels.add(sc); } for (SocketChannel channel : channels) { int read = channel.read(buffer); if (read > 0) { buffer.flip(); debugRead(buffer); buffer.clear(); log.debug("after read...{}", channel); } } }
|
多路复用
单线程可以配合Selector完成对多个Channel可读写事件的监控,这称之为多路复用
- 多路复用仅针对网络IO、普通文件IO没法利用多路复用
- 如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证
- 有可连接事件时才去连接
- 有可读事件才去读取
- 有可写事件才去写入
- 限于网络传输能力,Channel未必时时可写,一旦Channel可写,会触发Selector的可写事件
Selector
创建
Selector selector = Selector.open();
|
绑定Channel事件
也称之为注册事件,绑定的事件selector才会关心
channel.configureBlocking(false); SelectionKey key = channel.register(selector, 绑定事件);
|
- channel 必须工作在非阻塞模式
- FileChannel 没有非阻塞模式,因此不能配合 selector 一起使用
- 绑定的事件类型可以有
- connect - 客户端连接成功时触发
- accept - 服务器端成功接受连接时触发
- read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
- write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
监听Channel事件
可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少channel发生了事件
方法1,阻塞直到绑定事件发生
int count = selector.select();
|
方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms)
int count = selector.select(long timeout);
|
方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件
int count = selector.selectNow();
|
select何时不阻塞
- 事件发生时
- 客户端发起连接请求,会触发 accept 事件
- 客户端发送数据过来,客户端正常、异常关闭时,都会触发 read 事件,另外如果发送的数据大于 buffer 缓冲区,会触发多次读取事件
- channel 可写,会触发 write 事件
- 在 linux 下 nio bug 发生时
- 调用selector.wakeup()
- 调用selector.close()
- selector所在线程interrupt
处理 accept 事件
@Slf4j public class ChannelDemo6 { public static void main(String[] args) { try (ServerSocketChannel channel = ServerSocketChannel.open()) { channel.bind(new InetSocketAddress(8080)); System.out.println(channel); Selector selector = Selector.open(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) { int count = selector.select(); log.debug("select count: {}", count);
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); if (key.isAcceptable()) { ServerSocketChannel c = (ServerSocketChannel) key.channel(); SocketChannel sc = c.accept(); log.debug("{}", sc); } iter.remove(); } } } catch (IOException e) { e.printStackTrace(); } } }
|
注意:
事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发
处理 read 事件
@Slf4j public class ChannelDemo6 { public static void main(String[] args) { try (ServerSocketChannel channel = ServerSocketChannel.open()) { channel.bind(new InetSocketAddress(8080)); System.out.println(channel); Selector selector = Selector.open(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) { int count = selector.select(); log.debug("select count: {}", count);
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); if (key.isAcceptable()) { ServerSocketChannel c = (ServerSocketChannel) key.channel(); SocketChannel sc = c.accept(); sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_READ); log.debug("连接已建立: {}", sc); } else if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(128); int read = sc.read(buffer); if(read == -1) { key.cancel(); sc.close(); } else { buffer.flip(); debug(buffer); } } iter.remove(); } } } catch (IOException e) { e.printStackTrace(); } } }
|
为何要iter.remove()
因为select在事件发生后,就会将相关的 key 放入 selectedKeys 集合,但不会在处理完后从 selectedKeys 集合中移除,需要我们自己编码删除。例如
- 第一次触发了 ssckey 上的 accept 事件,没有移除 ssckey
- 第二次触发了 sckey 上的 read 事件,但这时 selectedKeys 中还有上次的 ssckey ,在处理时因为没有真正的 serverSocket 连上了,就会导致空指针异常
cancel的作用
cancel 会取消注册在 selector 上的 channel,并从 keys 集合中删除 key 后续不会再监听事件
处理消息的边界

- 固定消息长度,数据包大小一样,服务器按预定长度读取,缺点是浪费带宽
- 按分隔符拆分,缺点是效率低
- TLV 格式,即 Type 类型、Length 长度、Value 数据,类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
- Http 1.1 是 TLV 格式
- Http 2.0 是 LTV 格式
sequenceDiagram participant c1 as 客户端1 participant s as 服务器 participant b1 as ByteBuffer1 participant b2 as ByteBuffer2 c1 ->> s: 发送 01234567890abcdef3333\r s ->> b1: 第一次 read 存入 01234567890abcdef s ->> b2: 扩容 b1 ->> b2: 拷贝 01234567890abcdef s ->> b2: 第二次 read 存入 3333\r b2 ->> b2: 01234567890abcdef3333\r
|
private static void split(ByteBuffer source) { source.flip(); for (int i = 0; i < source.limit(); i++) { if (source.get(i) == '\n') { int length = i + 1 - source.position(); ByteBuffer target = ByteBuffer.allocate(length); for (int j = 0; j < length; j++) { target.put(source.get()); } debugAll(target); } } source.compact(); }
public static void main(String[] args) throws IOException { Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); SelectionKey sscKey = ssc.register(selector, 0, null); sscKey.interestOps(SelectionKey.OP_ACCEPT); log.debug("sscKey:{}", sscKey); ssc.bind(new InetSocketAddress(8080)); while (true) { selector.select(); Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); log.debug("key: {}", key); if (key.isAcceptable()) { ServerSocketChannel channel = (ServerSocketChannel) key.channel(); SocketChannel sc = channel.accept(); sc.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(16); SelectionKey scKey = sc.register(selector, 0, buffer); scKey.interestOps(SelectionKey.OP_READ); log.debug("{}", sc); log.debug("scKey:{}", scKey); } else if (key.isReadable()) { try { SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); int read = channel.read(buffer); if(read == -1) { key.cancel(); } else { split(buffer); if (buffer.position() == buffer.limit()) { ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2); buffer.flip(); newBuffer.put(buffer); key.attach(newBuffer); } }
} catch (IOException e) { e.printStackTrace(); key.cancel(); } } } } }
|
ByteBuffer大小分配
- 每个channel都需要记录可能被切分的消息,因为ByteBuffer不能被多个channel共同使用,因此需要为每个channel维护一个独立的ByteBuffer
- ByteBuffer不能太大,比如一个ByteBuffer 1Mb的话,要支持百万连接就要1Tb内存,因此需要设计大小可变的ByteBuffer
- 一种思路是首先分配一个较小的buffer,例如4k,如果发现数据不够,再分配8k的buffer,将4k buffer内容拷贝至8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能
- 另一种思路是用多个数组组成buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗
处理 write 事件
一次无法写完例子
- 非阻塞模式下,无法保证把buffer中所有数据都写入channel,因此需要追踪write方法的返回值(代表实际写入字节数)
- 用selector监听所有channel的可写事件,每个channel都需要一个key来跟踪buffer,但这样又会导致占用内存过多,就有两阶段策略
- 当消息处理器第一次写入消息时,才将channel注册到selector上
- selector检查channel上的可写事件,如果所有的数据写完了,就取消channel的注册
- 如果不取消,会每次可写均会触发write事件
public class WriteServer {
public static void main(String[] args) throws IOException { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open(); ssc.register(selector, SelectionKey.OP_ACCEPT);
while(true) { selector.select();
Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { SocketChannel sc = ssc.accept(); sc.configureBlocking(false); SelectionKey sckey = sc.register(selector, SelectionKey.OP_READ); StringBuilder sb = new StringBuilder(); for (int i = 0; i < 3000000; i++) { sb.append("a"); } ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString()); int write = sc.write(buffer); System.out.println("实际写入字节:" + write); if (buffer.hasRemaining()) { sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE); sckey.attach(buffer); } } else if (key.isWritable()) { ByteBuffer buffer = (ByteBuffer) key.attachment(); SocketChannel sc = (SocketChannel) key.channel(); int write = sc.write(buffer); System.out.println("实际写入字节:" + write); if (!buffer.hasRemaining()) { key.interestOps(key.interestOps() - SelectionKey.OP_WRITE); key.attach(null); } } } } } }
|
write为何要取消
只要向channel发送数据时,socket缓冲可写,这个事件会频繁触发,因此应当只在socket缓冲区写不下时再关注可写事件,数据写完之后再取消关注
利用多线程优化
前面的代码只有一个选择器,没有充分利用多核cpu,如何改进呢?
分两组选择器
- 单线程配一个选择器,专门处理 accept 事件
- 创建 cpu 核心数的线程,每个线程配一个选择器,轮流处理 read 事件
public class ChannelDemo7 { public static void main(String[] args) throws IOException { new BossEventLoop().register(); }
@Slf4j static class BossEventLoop implements Runnable { private Selector boss; private WorkerEventLoop[] workers; private volatile boolean start = false; AtomicInteger index = new AtomicInteger();
public void register() throws IOException { if (!start) { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8080)); ssc.configureBlocking(false); boss = Selector.open(); SelectionKey ssckey = ssc.register(boss, 0, null); ssckey.interestOps(SelectionKey.OP_ACCEPT); workers = initEventLoops(); new Thread(this, "boss").start(); log.debug("boss start..."); start = true; } }
public WorkerEventLoop[] initEventLoops() { WorkerEventLoop[] workerEventLoops = new WorkerEventLoop[2]; for (int i = 0; i < workerEventLoops.length; i++) { workerEventLoops[i] = new WorkerEventLoop(i); } return workerEventLoops; }
@Override public void run() { while (true) { try { boss.select(); Iterator<SelectionKey> iter = boss.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { ServerSocketChannel c = (ServerSocketChannel) key.channel(); SocketChannel sc = c.accept(); sc.configureBlocking(false); log.debug("{} connected", sc.getRemoteAddress()); workers[index.getAndIncrement() % workers.length].register(sc); } } } catch (IOException e) { e.printStackTrace(); } } } }
@Slf4j static class WorkerEventLoop implements Runnable { private Selector worker; private volatile boolean start = false; private int index;
private final ConcurrentLinkedQueue<Runnable> tasks = new ConcurrentLinkedQueue<>();
public WorkerEventLoop(int index) { this.index = index; }
public void register(SocketChannel sc) throws IOException { if (!start) { worker = Selector.open(); new Thread(this, "worker-" + index).start(); start = true; } tasks.add(() -> { try { SelectionKey sckey = sc.register(worker, 0, null); sckey.interestOps(SelectionKey.OP_READ); worker.selectNow(); } catch (IOException e) { e.printStackTrace(); } }); worker.wakeup(); }
@Override public void run() { while (true) { try { worker.select(); Runnable task = tasks.poll(); if (task != null) { task.run(); } Set<SelectionKey> keys = worker.selectedKeys(); Iterator<SelectionKey> iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(128); try { int read = sc.read(buffer); if (read == -1) { key.cancel(); sc.close(); } else { buffer.flip(); log.debug("{} message:", sc.getRemoteAddress()); debugAll(buffer); } } catch (IOException e) { e.printStackTrace(); key.cancel(); sc.close(); } } iter.remove(); } } catch (IOException e) { e.printStackTrace(); } } } } }
|
拿到 cpu 个数
- Runtime.getRuntime().availableProcessors() 如果工作在 docker 容器下,因为容器不是物理隔离的,会拿到物理 cpu 个数,而不是容器申请时的个数
- 这个问题直到 jdk 10 才修复,使用 jvm 参数 UseContainerSupport 配置, 默认开启
零拷贝
传统IO问题
传统的IO将一个文件通过socket写出
File f = new File("helloword/data.txt"); RandomAccessFile file = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.length()]; file.read(buf);
Socket socket = ...; socket.getOutputStream().write(buf);
|
内部工作流程是这样的:

java本身并不具备IO读写能力,因此 read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用DMA(Direct Memory Access)来实现文件读,其间也不会使用 cpu
DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO
从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间cpu会参与拷贝,无法利用DMA
调用write方法,这时将数据从用户缓冲区(byte[] buf)写入socket 缓冲区,cpu会参与拷贝
接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到中间环节较多,java的IO实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的
- 用户态与内核态的切换发生了 3 次
- 数据拷贝了共 4 次
NIO 优化
通过 DirectByteBuf
- ByteBuffer.allocate(10) HeapByteBuffer 使用的是java堆内存
- ByteBuffer.allocateDirect(10) DirectByteBuffer使用的是操作系统直接内存

这种优化中,java可以使用 DirectByteBuf将堆外内存映射到jvm内存中来直接访问使用
- 这块内存不受jvm垃圾回收的影响,因此内存地址固定,有助于IO读写
- java中的DirectByteBuf对象仅维护了此内存的虚引用,内存回收分成两步
- DirectByteBuf对象被垃圾回收,将虚引用加入引用队列
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
- 减少了一次数据拷贝,用户态与内核态的切换次数没有减少
进一步优化(底层采用了linux 2.1后提供的sendFile方法),java中对应着两个channel调用transferTo/transferFrom方法拷贝数据

- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用cpu
- 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到
- 只发生了一次用户态与内核态的切换
- 数据拷贝了 3 次
进一步优化(linux 2.4)

- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用DMA将数据读入内核缓冲区,不会使用 cpu
- 只会将一些 offset 和 length 信息拷入socket 缓冲区,几乎无消耗
- 使用 DMA 将内核缓冲区的数据写入网卡,不会使用cpu
整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了2次。所谓零拷贝,并不是真正无拷贝,而是在不会拷贝重复数据到jvm内存中,零拷贝的优点有
- 更少的用户态与内核态的切换
- 不利用 cpu 计算,减少 cpu 缓存伪共享
- 零拷贝适合小文件传输