IO 的过程
- 首先在网络的网卡上或本地存储设备中准备数据,然后调用 read() 函数。
- 调用 read() 函数厚,由内核将网络/本地数据读取到内核缓冲区中。
- 读取完成后向 CPU 发送一个中断信号,通知 CPU 对数据进行后续处理。
- CPU 将内核中的数据写入到对应的程序缓冲区或网络 Socket 接收缓冲区中。
- 数据全部写入到缓冲区后,应用程序开始对数据开始实际的处理。
原文链接:blog.csdn.net/JHIII/artic…
当调用一次 channel.read 或 stream.read 后,会切换至操作系统内核来完成真正的数据读取,而读取又分为两个阶段,分别为:
- 等待数据阶段:指数据从网络网卡或本地存储器读取到内核的过程
- 复制数据阶段:数据准备好到内核缓冲区,需要从内核缓冲区拷贝数据到用户空间
同步阻塞、同步非阻塞、多路复用、信号驱动模型、异步非阻塞 IO 模型详解
- 同步阻塞:如果内核的数据还没准备好的话,那么用户程序就一直阻塞等待
- 同步非阻塞:通过轮训的方式来处理,如果轮训的时候发现来请求了才会进行内核空间到用户空间的复制
- 多路复用:一个线程通过 select 函数同时监控多个 fd,在 select 函数监控的 fd 中 有任何一个数据状态准备就绪了,select 函数就会告诉用户线程,用户线程然后再去读取数据。多路是指网络连接,复用一个线程。
- select 只是返回一个可读状态,用户线程只知道有事件发生了,但是需要循环判断哪些流有事件,然后进行处理
- epoll 是返回可读事件,用户线程会知道哪个流有事件,并且知道是什么事件,然后用户线程根据指定事件来进行处理
- select、poll、epoll之间的区别(搜狗面试),系统层面的 select 的实现其实是更底层的实现了刚才我们写的循环遍历查找思路,只是直接在系统层面性能更高。
-
信号驱动模型:Java 对信号驱动模型并没有原生的支持,这是因为 Java 运行时环境在 Java SE 8 之前使用的是单线程模型,直到 Java SE 8 才增加了对并发的大量改进。信号驱动模型需要操作系统内核支持,而不同的操作系统对信号驱动模型的实现各不相同,需要在不同的系统上进行定制和配置,这就会导致 Java 跨平台移植的困难性增加。另外,Java 的线程模型相对来说更容易被 Java 开发人员理解和使用,也能够满足大部分的编程需求,因此 Java 没有特别强调对信号驱动模型的支持
-
异步非阻塞
- 同步:线程是自己获取结果
- 异步:线程自己不去获取结果,而是其他线程送结果,有的说是内核做复制数据操作,然后通知用户线程,用户线程直接使用数据
- linux 系统异步 IO 在 2.6 版本引入,但是底层实现还是用多路复用模拟了异步 IO,性能没有优势
- windows 系统通过 IOCP 实现了真正的异步 IO
BIO
同步阻塞 IO ,数据的写入和读取都必须阻塞在一个线程中执行,在写入和读取完成之前线程阻塞额
- 特点:程序简单,在阻塞等待数据期间,用户线程挂起,用户线程基本上不会占用 CPU 资源
- 缺点:一般情况下,服务端会为每个客户端连接配套一条独立线程用来读写,在并发大的情况下,开销会很大
NIO
提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的同步非阻塞 IO 程序,同时提供了更接近操作系统高性能的数据操作方式
Java中 的 NIO 主要是通过 ServerSocketChannel 和 SocketChannel 实现的。当我们使用 NIO 的方式向ServerSocketChannel 注册一个 I/O 操作时,调用者一般是通过 ServerSocketChannel.register 方法,该方法实际上是调用了 Selector.register 方法来注册事件。
NIO epoll 存在空轮询导致 CPU 100% BUG
- 特点:每次发起 IO 系统调用,在内核的等待数据过程中可以立即返回,用户线程不会阻塞,可以去干其他活。
- 缺点:需要不断间隔一定时间重复发起 IO 系统调用,不断轮询,占用大量 CPU,任务响应延迟增大,因为任务可能在两次轮询之间的任意时间发生,Java NIO 并不是只能实现 IO 模型中的 NIO,还能实现 IO 多路复用
AIO
是 jdk7 引入的包,提供了异步非阻塞的 IO 操作方式,所以叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作
NIO 基础
非阻塞 IO
三大组件
Channel & Buffer
就是读写数据的双向通道,而之前的 stream 要么是输入,要么是输出, channel 比 stream 更底层。
常见的 Channel
- 文件传输通道
- FileChannel
- UDP 时需要的传输通道
- DatagramChannel
- TCP 传输通道
- SocketChannel
- ServerSocketChannel
FileChannel 常用方法
- channel.read(buffer):读满当前 buffer position 以后的数据
// 读满当前 buffer position 以后的数据
channel.read(buffer)
// 比如文本: 1234567890abc
ByteBuffer buffer = ByteBuffer.allocate(10);
int readLen = channel.read(buffer);
// read 之后 position 是 10,
buffer.position(8);
// 这里再读会读两个字节到第 9 和 10 两个位置,如果输出会打印
// 12345678ab而不是1234567890
readLen = channel.read(buffer);
Buffer
用来缓冲读写数据 常见的 buffer 有
- ByteBuffer
- MappedByteBuffer
- DirectByteBuffer
- HeapByteBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
- CharBuffer
Selector
多线程版设计
一个客户端建立连接,代码的角度就是创建一个 socket,读写操作就是通过 socket 来完成,所以一个客户端来了就是给他开一个线程,如果多个客户端就是要开多个线程,每个线程管一个客户端连接
缺点:
- 内存占用率高
- 线程上下文切换成本高
- 只适合连接数少的场景
线程池版设计
用线程池合理控制线程总数,一个线程连接建立后,期间所有的 read、write 等处理完之后再处理下一个 socket 请求,依次循环处理,所以只适用于短连接场景。
缺点:
- 处理第一个客户端时占用了其中一个线程,哪怕什么都不做,那这个线程也会一直被占用,比如客人就餐的话可能有很多的步骤,他要翻菜单、点菜、吃饭,要最后买单结账,但是在他执行所有操作的这个同时,这个服务员都必须跟着陪着。就比如说他在这儿光翻菜单不点菜,都在这个线程内。直到最后这个客人结账完了,他把这个连接断开了,这个服务员才得到自由,才能去服务下一个
selector 版设计
可以被称为多路复用器(可以对多个 Channe 可读写事件监控),可以把多个 channel 注册到一个 selector 上,每个线程可以通过 selector 管理多个 channel,当其中的一个 channel 发生了读写事件,selector 就会通知线程去处理这个事件,避免了一个客户端连接占用一个线程的情况出现,线程的利用率得到提升。
适合连接数特别多、事件处理快的场景。
如果一个 channel 发送了很大的事件,那么 thread 就一直在处理这个事件,其他的 channel 就只能等待了。
- 仅针对网络 IO、普通文件 IO 没法利用多路复用
- 如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 可以保证
- 有可连接事件时才去连接
- 有可读事件才去读取
- 有可写事件才去写入
- 限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件
SelectionKey
表示 SelectableChannel 在 selector 中注册的标记/句柄,会记录你关心的 Channel,可以告知 SelectionKey 只关注哪些事件。
SocketChannel.register 方法会返回一个 SelectionKey
selector.selectedKeys() 用完需要 remove。
原因、原因
selector.selectedKeys()与selecot.keys区别
- selectorImpl 在内部存放 selection key 时,用 keys、selectedKeys、publicKeys、publicSelectedKeys 四个字段,前两个是实际创建的 HashSet,后两者是 unmodifiableSet、ungrowableSet,前两者不能暴露给用户,这样防止用户对其错误修改
- selectedKeys 会在 select() 有事件发生时进行 add,是不会进行 remove 的,所以用完不 remove ,用完的 key 还是在集合里,那么再有别的 Channel 来的话,会出现以下情况
- accept 是会报错的,因为非阻塞模式下 accept() 返回的是 Null
- read 会读到空数据,read 方法返回的是 0
服务端就算用完后 remove ,客户端异常断开链接后还是需要 catch 并 cancel
- 服务端 remove 后只是从 publicSelectedKeys 里删除了,但是
- sun.nio.ch.SelectorImpl#processDeregisterQueue 会判断 cancelledKeys 如果有的话会从 sun.nio.ch.WindowsSelectorImpl#fdMap、selectedKeys 和 cancelledKeys 删除数据,那么再取的时候,是从 fdMap 中获取的 SelectorImpl,那么就会获取不到了
客户端正常 close 断开,怎么判断是正常断开还是正常读数据,read 返回的数据是 -1
处理消息边界
- 固定长度边界:每次发送消息时指定每个消息的固定长度,不满固定长度时,用固定字符串填充,比如空格
- 缺点:需要提前得知消息的范围,设置的小的话接收大消息会有问题,如果设置大的话,小消息又需要用大量的数据填充
- 分隔符:以固定符号表示结尾
- 优点:简单,不会浪费空间
- 缺点:
- 需要对内容本身做处理,防止内容出现分隔符,所以需扫描一遍传输的数据将其转义
- 需要每个字节比较,比较耗时
- 固定长度 + 内容:比如协议规定固定 4 位存放内容长度
- 优点:可以根据固定长度精准定位,也不用扫描转义字符
- 缺点:设计比较困难,大了浪费空间,毕竟每个报文都需要带长度,小了可能不够用
- TLV 格式,即 Type 类型、Length 长度、Value 数据,类型和长度已知的情况下,就可以方便分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
- Http1.1 是 TLV 模式
- Http2.0 是 LTV 模式
分隔符读数据简单 util
@Slf4j
public class SocketUtils {
public static void readData(SelectionKey key) {
try {
SocketChannel clientChannel = (SocketChannel)key.channel();
// 获取附件
ByteBuffer buffer = (ByteBuffer)key.attachment();
// 正常断开返回值是 -1
final int readSize = clientChannel.read(buffer);
if (readSize == -1) {
key.cancel();
log.info("客户端正常断开:{}", clientChannel);
} else {
split(buffer);
// 还相同说明一个字节都没被读,需要被扩容
if (buffer.position() == buffer.limit()) {
ByteBuffer newByteBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
buffer.flip();
newByteBuffer.put(buffer);
// 替换附件
key.attach(newByteBuffer);
}
}
} catch (Exception e) {
// 因为客户端断开了,这里需要取消连接
key.cancel();
e.printStackTrace();
}
}
private static void split(ByteBuffer source) {
// 读
source.flip();
// 循环到最后需要读取的一个位置
for (int i = 0; i < source.limit(); i++) {
// 找到分隔符,用get(i)是因为不会改变position,一次性初始化够ByteBuffer的空间
if (source.get(i) == '\n') {
// 完整的一条信息的长度,+1是因为 \n 也要取到
int length = i + 1 - source.position();
// -1 是因为\n不读出来,直接略过
ByteBuffer target = ByteBuffer.allocate(length - 1);
for (int j = 0; j < length; j++) {
final byte b = source.get();
// 不取 \n
if (j != length - 1) {
target.put(b);
}
}
printString(target);
}
}
// 从未读的地方开始重新写
source.compact();
}
public static void printString(ByteBuffer byteBuffer) {
System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit(), StandardCharsets.UTF_8));;
}
}
单线程 selector 实现
@Slf4j
public class SelectorReadServerDemo {
public static void main(String[] args) throws IOException {
// 1. selector,管理多个channel
final Selector selector = Selector.open();
// 2. 创建服务器
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 非阻塞模式
serverSocketChannel.configureBlocking(false);
// 3. 建立 selector 和 channel 的联系(注册),0表示不关注任何事件
// 就是将来发送事件后,通过它可以知道事件和哪个 channel 的事件
final SelectionKey selectionKey = serverSocketChannel.register(selector, 0, null);
// 告诉 selectionKey 关注哪种事件
selectionKey.interestOps(SelectionKey.OP_ACCEPT);
// 绑定监听端口号
serverSocketChannel.bind(new InetSocketAddress(8080));
// accept
while (true) {
// 4. 等待事件
// 没有事件发生则会阻塞,有事件会恢复运行
// 如果下面取到任务未处理,select 方法会一直能取到任务,必须处理 accept 获取取消 cancel
selector.select();
// 5. 处理事件,selectionKeys 内部包含了所有发生的事件
final Set<SelectionKey> selectionKeys = selector.selectedKeys();
final Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
log.info("key:{}", key);
// 6. 区分事件类型
if (key.isAcceptable()) {
// 获取发生事件的 serverChannel
final ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
log.info("accept....{}", serverChannel);
final SocketChannel client = serverChannel.accept();
client.configureBlocking(false);
// 设置一个 bytebuffer 作为附件关联到 selectionKey上
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
SelectionKey clientSectionKey = client.register(selector, 0, byteBuffer);
clientSectionKey.interestOps(SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketUtils.readData(key);ackTrace();
}
}
// 必须要删除掉 https://blog.csdn.net/weixin_65349299/article/details/122301441
iterator.remove();
}
}
}
public static void printString(ByteBuffer byteBuffer) {
System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit(), StandardCharsets.UTF_8));;
}
private static void split(ByteBuffer source) {
// 读
source.flip();
// 循环到最后需要读取的一个位置
for (int i = 0; i < source.limit(); i++) {
// 找到分隔符,用get(i)是因为不会改变position,一次性初始化够ByteBuffer的空间
if (source.get(i) == '\n') {
// 完整的一条信息的长度,+1是因为 \n 也要取到
int length = i + 1 - source.position();
// -1 是因为\n不读出来,直接略过
ByteBuffer target = ByteBuffer.allocate(length - 1);
for (int j = 0; j < length; j++) {
final byte b = source.get();
// 不取 \n
if (j != length - 1) {
target.put(b);
}
}
printString(target);
}
}
// 从未读的地方开始重新写
source.compact();
}
}
多线程 selector 实现
@Slf4j
public class ThreadServerDemo {
public static void main(String[] args) throws IOException {
// 1. selector,管理多个channel
final Selector selector = Selector.open();
// 2. 创建服务器
ServerSocketChannel server = ServerSocketChannel.open();
// 非阻塞模式
server.configureBlocking(false);
// 绑定监听端口号
server.bind(new InetSocketAddress(8080));
// 3. 建立 selector 和 channel 的联系(注册),0表示不关注任何事件
// 只关注 accept 事件
final SelectionKey acceptSelectionKey = server.register(selector, 0, null);
// 告诉 selectionKey 关注哪种事件
acceptSelectionKey.interestOps(SelectionKey.OP_ACCEPT);
// 创建固定数量的 worker,并初始化
Worker[] workers = new Worker[2];
for (int i = 0; i < workers.length; i++) {
workers[i] = new Worker("worker-0");
}
AtomicInteger index = new AtomicInteger();
while (true) {
// 等待事件
selector.select();
// 5、处理事件
final Set<SelectionKey> acceptSelectionKeys = selector.selectedKeys();
final Iterator<SelectionKey> iterator = acceptSelectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey acceptKey = iterator.next();
if (acceptKey.isAcceptable()) {
final SocketChannel clientChannel = server.accept();
clientChannel.configureBlocking(false);
// 注册 channel 到 worker 的 selector
workers[index.getAndIncrement() % workers.length].register(clientChannel);
}
iterator.remove();
}
}
}
static class Worker implements Runnable {
private Thread thread;
private Selector selector;
private String name;
// 是否初始化 selector
private volatile boolean start = false;
private ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>();
public Worker(String name) {
this.name = name;
}
/**
* 注册 channel 到 worker 的 selector
* 初始化线程和 selector
*/
public void register(SocketChannel clientChannel) throws IOException {
log.info("register====={}", this);
if (!start) {
thread = new Thread(this);
selector = Selector.open();
// 先执行 select 方法,再关联 SocketChannel 则会一直阻塞
thread.start();
start = true;
}
// 这块如果关注事件比 run 方法里的 select 方法晚,那么就会一直阻塞
// Thread.sleep(100);
// selector.wakeup();
log.info("wakeup===============");
// 设置一个 bytebuffer 作为附件关联到 selectionKey上
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
clientChannel.register(this.selector, SelectionKey.OP_READ, byteBuffer);
// 唤醒 selector 类似于 LockSupport 先拿到和后拿到无所谓,不过一定要放在注册事件后面
selector.wakeup();
}
@Override
public void run() {
// 先执行 select 方法,再关联 SocketChannel 则会一直阻塞,睡眠一会,最 low 的解决方式
// Thread.sleep(5000);
log.info("工作线程:{}初始化了", name);
while (true) {
try {
selector.select();
final Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
if (key.isReadable()) {
log.info("read==========:{}", ((SocketChannel)key.channel()).getRemoteAddress());
SocketUtils.readData(key);
}
iterator.remove();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
零拷贝
传统 IO
传统 IO 将一个文件通过 socket 写出流程如下:
- Java 本身不具备 IO 能力,因此 read 方法调用后,要从 Java 程序的用户态切换到内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,期间也不会使用 CPU
DMA 也可以理解为硬件单元,用来解放 CPU 完成文件 IO
- 从内核态切换到用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 CPU 会参与拷贝,无法利用 DMA
- 调用 write 方法,这时数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,CPU 会参与拷贝
- 接下来向网卡写数据,这项能力 Java 又不具备,因此又需要从用户态切换到内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU
可以看到中间环节较多,Java 的 IO 实际并不是物理设备级别的读写,而是缓存的复制,底层真正读写是操作系统来完成的
- 用户态与内核态切换发生了三次(没有算上最后切换回用户态的一次),操作比较重量级
- 数据拷贝供 4 次
NIO 优化
通过 DirectByteBuffer,大部分步骤与优化前相同,只有一点区别:Java 可以使用 DirectByteBuffer(MappedByteBuffer 子类) 将堆外内存映射到 jvm 内存中来直接访问,因为已经将文件映射到内存,所以就减少了一次 cpu 拷贝
- 这块内存不受 jvm 垃圾回收影响,因此内存地址固定,有助于 IO 读写
- Java 中的 DirectByteBuffer 对象仅维护了此内存的虚引用,内存回收分为两步
- DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
- 减少了一次数据拷贝,用户态与内核态切换的次数没有减少
进一步优化 linux2.1
底层采用了 linux2.1 后提供的 sendFile 方法,Java 中对应两个 channel 调用 transferTo/transfrFrom 方法拷贝数据
- Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 CPU
- 数据从内核缓冲区传输到 socket 缓冲区,CPU 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU
可以看到
- 只发生了一次用户态到内核态的切换
- 数据拷贝 3 次
进一步优化 linux2.4
- Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态, 使用 DMA 将数据读入内核缓冲区,不会使用 CPU
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
- 使用 DMA 将内核缓冲区的数据写入网卡,不会使用 CPU
整个过程仅发生了一次用户态与内核态的切换,数据拷贝了 2 次,所谓的【零拷贝】并不是真正无拷贝,而是不会拷贝重复数据到 jvm 内存中,零拷贝的优点有
- 更少的用户态与内核态的切换
- 不利用 CPU 计算,减少 CPU 缓存伪共享
- 零拷贝适合小文件传输
mmap 和 sendFile
MappedByteBuffer 便是 MMAP 的操作类。