# JAVA IO 模型

# BIO

Blocking IO,同步并阻塞方式,应用程序向 OS 请求网络 IO 操作,然后会等待 IO 操作完成。客户端与服务器端连接,一个连接创建一个线程。

适用于连接数目比较小且固定的架构,对服务器要求比较高

简单流程:

  1. 服务端启动一个 ServerSocket。
  2. 客户端启动 Socket 对服务端通讯,默认情况下服务端对每个客户建立一个线程。
  3. 客户端发送请求后,先检测服务端是否有线程响应,没有则等待或被拒绝。
  4. 有响应,客户端线程等待请求结束返回响应,再继续执行。

# NIO

Non-Blocking IO,同步非阻塞,一个线程处理多个请求,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到 IO 请求会处理

适用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯。

# Selector、Channel 和 Buffer 的关系

  • 每个 Channel 都会对应一个 Buffer
  • Selector 对应一个线程,一个线程对应多个 Channel
  • 程序切换到哪个 channel 是由事件决定的。
  • Selector 会根据不同的事件在各个通道切换
  • Buffer 就是一个内存块,底层有个数组
  • 数据的读取写入是通过 Buffer,可以双向读写,需要 filp 方法切换
  • channel 是双向的,可以返回底层操作系统的情况,比如 Linux,底层操作系统的通道就是双向的

image-20221114201053624

# Buffer

Buffer 是一个抽象类,子类有:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

在子类中有一个数组 hb 用于缓冲的实现。

1
2
3
4
5
6
7
8
9
// Invariants: mark <= position <= limit <= capacity
// 标记,调用mark()可以将position的值赋给mark,reset()恢复position
private int mark = -1;
// 位置,下一个要读或写的偏移量
private int position = 0;
// 缓冲区当前的终点,不能对大于limit的位置修改,limit位置可以修改
private int limit;
// 容量,可容纳最大数据量,不可修改
private int capacity;

# Channel

  • 通道可以同时进行读写
  • 通道可以实现异步读写数据
  • 通道可以从缓冲读,可以写到缓冲

Channel 是一个接口

1
2
3
4
5
6
7
public interface Channel extends Closeable {

public boolean isOpen();

public void close() throws IOException;

}

常用的 Channel 类有 FileChannel(文件读写)、DatagramChannel(UDP 数据读写)、ServerSocketChannel 和 SocketChannel(这俩 TCP 数据读写)

在各种 channel 所依托的流如果关闭其 read() 方法会返回 - 1,其余返回 0

# Selector

用一个线程处理多个客户端连接,就会用到选择器,Selector 可以检测多个注册的通道上是否有事件发生(多个 Channel 以事件的方式可以注册到同一个 Selector)

只有在 连接 / 通道 真正有读写事件发生时,才会进行读写,大大减少了系统开销,避免了多线程上下文切换导致的开销。

Selector 是一个抽象类,常用方法有

  • open( ) :得到一个选择器对象
  • selectedKeys( ) :获得内部集合所有等待 IO 操作的 selectionKey
  • select(long timeout) :监控所有注册通道,当有 IO 操作发生时将对应的 SelectionKey 加入到内部集合并且返回,参数超时时间,超时返回有事件发生的 SelectionKey
  • select() :阻塞直到注册的通道有事件到达,返回有事件发生的 SelectionKey
  • selcetNow() :不阻塞,立马返回
  • wakeuo() :唤醒 selector
  • keys() :返回当前所有注册在 selector 中 channel 的 selectionKey

一个 SelectionKey 表示了一个特定的 channel 通道对象和一个特定的 selector 选择器对象之间的注册关系。

🛑SelectionKey 在被轮询后需要 remove (),selector 不会自己删除 selectedKeys () 集合中的 selectionKey,如果不人工 remove (),将导致下次 select () 的时候 selectedKeys () 中仍有上次轮询留下来的信息,这样必然会出现错误。

🛑注册过的 channel 信息会以 SelectionKey 的形式存储在 selector.keys () 中。keys () 中的成员是不需要被删除的 (以此来记录 channel 信息)。

# SelectionKey

SelectionKey 表示 Selector 与网络通道是注册关系

# NIO 非阻塞网络编程

实现:

  1. 客户端连接时,会通过 ServerSocketChannel 得到 SocketChannel。

  2. 将 SocketChannel 注册到 Selector 上, register(Selector sel, int ops) ,一个 Selector 可以注册多个 SocketChannel。

  3. 注册后返回一个 SelectionKey。

    1
    2
    3
    4
    5
    6
    7
    8
    // 读事件
    public static final int OP_READ = 1 << 0;
    // 写事件
    public static final int OP_WRITE = 1 << 2;
    // 连接事件
    public static final int OP_CONNECT = 1 << 3;
    // 接受连接事件
    public static final int OP_ACCEPT = 1 << 4;

  4. Selector 进行监听, select( ) 返回有事件发生的通道个数。

  5. 进一步得到各个 SelectionKey。

  6. 在通过 SelectionKey 反向获取 SocketChannel。

  7. 通过得到的 Channel 完成处理。

服务端实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Slf4j(topic = "Server")
public class NIOServer {
public static void main(String[] args) throws IOException {
//创建选择器(open是一个工厂方法)
Selector selector = Selector.open();
// 创建ServerSocketChannel绑定套接字
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
// 设置非阻塞
ssChannel.configureBlocking(false);
// 将通道注册到选择器上,接受连接事件操作
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

// 循环监听
while (true) {
//监听事件,会阻塞直到有至少一个事件到达
selector.select();

//获取到达的事件
Set<SelectionKey> keys = selector.selectedKeys();
//使用迭代器遍历
Iterator<SelectionKey> keyIterator = keys.iterator();

while (keyIterator.hasNext()) {

SelectionKey key = keyIterator.next();

//对应OP_ACCEPT通道事件,客户端连接先执行这个代码
if (key.isAcceptable()) {

ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();

// 服务器会为每个新连接创建一个 SocketChannel
SocketChannel sChannel = ssChannel1.accept();
sChannel.configureBlocking(false);
log.info("socketChannel HashCode: {}", sChannel.hashCode());

// 这个新连接主要用于从客户端读取数据
sChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));

} else if (key.isReadable()) {
// key反向获取对应Channel
SocketChannel sChannel = (SocketChannel) key.channel();
// 拿到之前放进去的ByteBuffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
sChannel.read(buffer);
// 读写反转limit=position,position = 0
buffer.flip();
log.info(new String(buffer.array(), buffer.position(), buffer.limit()));
sChannel.close();

}
// 从SectionKeys集合中移除当前已处理的SelectionKey,重复操作
keyIterator.remove();
}
}
}

客户端实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j(topic = "Client")
public class NiOClient {
public static void main(String[] args) throws IOException, IOException {
// Socket socket = new Socket("127.0.0.1", 8888);
// OutputStream out = socket.getOutputStream();
// String s = "hello world";
// out.write(s.getBytes());
// out.close();

SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1",8888);
if(!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()){
}
}
String str = "你好,啊";
// 无需指定大小,直接就是你传进的byte数组大小
ByteBuffer buffer = ByteBuffer.wrap(str.getBytes(StandardCharsets.UTF_8));
socketChannel.write(buffer);
}
}

# NIO 与零拷贝

# 零拷贝

这要从 Linux 说起:Linux 系统中一切皆文件,很多活动本质上都是读写操作。

一般的数据拷贝过程:

  1. 当应用程序读取磁盘数据时,调用 read ( ) 从用户态到内核态,该过程由 cpu 完成
  2. 之后 CPU 发送 I/O 请求,磁盘收到请求后开始准备数据
  3. 磁盘将数据传送到磁盘的缓冲区中,然后发送 I/O 中断
  4. CPU 收到中断后开始拷贝数据,然后由 read () 返回,再从内核态转换成用户态

直接内存访问(Direct Memory Access)方式是一种硬件直接访问内存的一种方式:

  • 读数据;

    1. 调用 read () 函数,用户态切换内核态,状态切换一次;
    2. DMA 控制器将数据从磁盘拷贝到内核缓冲区,1 次 DMA 拷贝;
    3. CPU 将数据从内核缓冲区复制到用户缓冲区,1 次 CPU 拷贝;
    4. read () 函数返回,用户态切换回用户态,2 次状态切换;
  • 写数据;

    1. 调用 write () 函数,用户态切换内核态,1 次切换;
    2. CPU 将用户缓冲区数据拷贝到内核缓冲区,1 次 CPU 拷贝;
    3. DMA 将数据从内核缓冲区复制到套接字的缓冲区,1 次 DMA 拷贝;
    4. write () 函数返回,内核态切换回用户态,2 次切换;

零拷贝是网络编程的关键,很多性能优化都离不开零拷贝

在 java 程序中,常用的零拷贝有 mmap(内存映射)和 sendFile

# mmap 内存映射

内存映射文件是 一种读写数据的方法,比常规的流或者通道读写要快,但是会有一些安全问题。

内存映射文件是一个文件到一块内存的映射。使用内存映射文件处理存储于磁盘的文件时,不比对文件执行 IO 操作。在 Linux 中,mmap 实现了内核中读缓冲区域用户空间缓冲区的映射,从而实现二者的缓冲区共享。这样就减少了一次 cpu 拷贝。

  1. 用户进程通过 mmap() 向操作系统内核发起 IO 调用,用户态切换为内核态
  2. CPU 利用 DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. 内核态切换回用户态write() 返回。
  4. 用户进程通过 write() 向操作系统内核发起 IO 调用,用户态切换为内核态
  5. CPU 将内核缓冲区的数据拷贝到的 socket 缓冲区。
  6. CPU 利用 DMA 控制器,把数据从 socket 缓冲区拷贝到网卡,内核态切换回用户态,write 调用返回。

一次读 + 写 4 次上下文切换,3 次数据拷贝

MappedByteBuffer 类继承自 ByteBuffer,子类 DirectByteBuffer 内部维护了一个缓存数组偏移量 arrayBaseOffset

FileChannel 提供了 map() 方法把文件映射到虚拟内存,可以整个文件映射,也可以分段映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
public void mappedByteBufferTest() throws IOException {
/*
* MappedByteBuffer 可让文件直接在内存(堆外内存)修改,操作系统不需要拷贝一次
*/
RandomAccessFile randomAccessFile = new RandomAccessFile("h1.txt", "rw");
FileChannel fileChannel = randomAccessFile.getChannel();
// FileChannel.MapMode.READ_WRITE 读写模式
// 0 可以修改的起始位置
// 映射到内存的大小,即将文件多少个字节映射到内存
// 可直接修改的范围是0-5
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

mappedByteBuffer.put(0, (byte) 'H');
mappedByteBuffer.put(1, (byte) 'H');
mappedByteBuffer.put(2, (byte) 'H');
mappedByteBuffer.put(3, (byte) 'H');
mappedByteBuffer.put(4, (byte) 'H');

randomAccessFile.close();
}

# sendFile 系统调用

建立两个文件之间的传输通道

  1. 用户进程发起 sendfile系统调用用户态到内核态
  2. DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. CPU 将读缓冲区中数据拷贝到 socket 缓冲区
  4. DMA 控制器,异步把数据从 socket 缓冲区拷贝到网卡,
  5. 内核态到回用户态sendfile调用 返回。

2 次上下文切换,最少 3 次数据拷贝

# AIO

Asynchronous I/O,异步非阻塞,无论是客户端的连接请求还是读写请求都会异步执行, 由操作系统完成后回调通知服务端程序启动线程去处理

适用于连接数目多且连接比较长的架构,相册服务器

BIONIOAIO
IO 模型同步阻塞同步非阻塞(多路复用)异步非阻塞
编程难度简单复杂复杂
可靠性
吞吐量
更新于 阅读次数 本文阅读量:

请我喝[茶]~( ̄▽ ̄)~*

Windlinxy 微信支付

微信支付

Windlinxy 支付宝

支付宝