# JAVA IO 模型
# BIO
Blocking IO,同步并阻塞方式,应用程序向 OS 请求网络 IO 操作,然后会等待 IO 操作完成。客户端与服务器端连接,一个连接创建一个线程。
适用于连接数目比较小且固定的架构,对服务器要求比较高
简单流程:
- 服务端启动一个 ServerSocket。
- 客户端启动 Socket 对服务端通讯,默认情况下服务端对每个客户建立一个线程。
- 客户端发送请求后,先检测服务端是否有线程响应,没有则等待或被拒绝。
- 有响应,客户端线程等待请求结束返回响应,再继续执行。
# NIO
Non-Blocking IO,同步非阻塞,一个线程处理多个请求,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到 IO 请求会处理
适用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯。
# Selector、Channel 和 Buffer 的关系
- 每个 Channel 都会对应一个 Buffer
- Selector 对应一个线程,一个线程对应多个 Channel
- 程序切换到哪个 channel 是由事件决定的。
- Selector 会根据不同的事件在各个通道切换
- Buffer 就是一个内存块,底层有个数组
- 数据的读取写入是通过 Buffer,可以双向读写,需要 filp 方法切换
- channel 是双向的,可以返回底层操作系统的情况,比如 Linux,底层操作系统的通道就是双向的
# Buffer
Buffer 是一个抽象类,子类有:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
在子类中有一个数组 hb 用于缓冲的实现。
// 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 是一个接口
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 操作的 selectionKeyselect(long timeout)
:监控所有注册通道,当有 IO 操作发生时将对应的 SelectionKey 加入到内部集合并且返回,参数超时时间,超时返回有事件发生的 SelectionKeyselect()
:阻塞直到注册的通道有事件到达,返回有事件发生的 SelectionKeyselcetNow()
:不阻塞,立马返回wakeuo()
:唤醒 selectorkeys()
:返回当前所有注册在 selector 中 channel 的 selectionKey
一个 SelectionKey 表示了一个特定的 channel 通道对象和一个特定的 selector 选择器对象之间的注册关系。
🛑SelectionKey 在被轮询后需要 remove (),selector 不会自己删除 selectedKeys () 集合中的 selectionKey,如果不人工 remove (),将导致下次 select () 的时候 selectedKeys () 中仍有上次轮询留下来的信息,这样必然会出现错误。
🛑注册过的 channel 信息会以 SelectionKey 的形式存储在 selector.keys () 中。keys () 中的成员是不需要被删除的 (以此来记录 channel 信息)。
# SelectionKey
SelectionKey 表示 Selector 与网络通道是注册关系
# NIO 非阻塞网络编程
实现:
客户端连接时,会通过 ServerSocketChannel 得到 SocketChannel。
将 SocketChannel 注册到 Selector 上,
register(Selector sel, int ops)
,一个 Selector 可以注册多个 SocketChannel。注册后返回一个 SelectionKey。
// 读事件
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;
Selector 进行监听,
select( )
返回有事件发生的通道个数。进一步得到各个 SelectionKey。
在通过 SelectionKey 反向获取 SocketChannel。
通过得到的 Channel 完成处理。
服务端实现
@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(); | |
} | |
} | |
} |
客户端实现
@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 系统中一切皆文件,很多活动本质上都是读写操作。
一般的数据拷贝过程:
- 当应用程序读取磁盘数据时,调用 read ( ) 从用户态到内核态,该过程由 cpu 完成
- 之后 CPU 发送 I/O 请求,磁盘收到请求后开始准备数据
- 磁盘将数据传送到磁盘的缓冲区中,然后发送 I/O 中断
- CPU 收到中断后开始拷贝数据,然后由 read () 返回,再从内核态转换成用户态
直接内存访问(Direct Memory Access)方式是一种硬件直接访问内存的一种方式:
读数据;
- 调用 read () 函数,用户态切换内核态,状态切换一次;
- DMA 控制器将数据从磁盘拷贝到内核缓冲区,1 次 DMA 拷贝;
- CPU 将数据从内核缓冲区复制到用户缓冲区,1 次 CPU 拷贝;
- read () 函数返回,用户态切换回用户态,2 次状态切换;
写数据;
- 调用 write () 函数,用户态切换内核态,1 次切换;
- CPU 将用户缓冲区数据拷贝到内核缓冲区,1 次 CPU 拷贝;
- DMA 将数据从内核缓冲区复制到套接字的缓冲区,1 次 DMA 拷贝;
- write () 函数返回,内核态切换回用户态,2 次切换;
零拷贝是网络编程的关键,很多性能优化都离不开零拷贝
在 java 程序中,常用的零拷贝有 mmap(内存映射)和 sendFile
# mmap 内存映射
内存映射文件是 一种读写数据的方法,比常规的流或者通道读写要快,但是会有一些安全问题。
内存映射文件是一个文件到一块内存的映射。使用内存映射文件处理存储于磁盘的文件时,不比对文件执行 IO 操作。在 Linux 中,mmap 实现了内核中读缓冲区域用户空间缓冲区的映射,从而实现二者的缓冲区共享。这样就减少了一次 cpu 拷贝。
- 用户进程通过
mmap()
向操作系统内核发起 IO 调用,用户态切换为内核态。 - CPU 利用 DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
- 内核态切换回用户态,
write()
返回。 - 用户进程通过
write()
向操作系统内核发起 IO 调用,用户态切换为内核态。 - CPU 将内核缓冲区的数据拷贝到的 socket 缓冲区。
- CPU 利用 DMA 控制器,把数据从 socket 缓冲区拷贝到网卡,内核态切换回用户态,write 调用返回。
一次读 + 写 4 次上下文切换,3 次数据拷贝
MappedByteBuffer 类继承自 ByteBuffer,子类 DirectByteBuffer 内部维护了一个缓存数组偏移量 arrayBaseOffset
FileChannel 提供了 map()
方法把文件映射到虚拟内存,可以整个文件映射,也可以分段映射
@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 系统调用
建立两个文件之间的传输通道
- 用户进程发起
sendfile系统调用
,用户态到内核态 - DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
- CPU 将读缓冲区中数据拷贝到 socket 缓冲区
- DMA 控制器,异步把数据从 socket 缓冲区拷贝到网卡,
- 内核态到回用户态,
sendfile调用
返回。
2 次上下文切换,最少 3 次数据拷贝
# AIO
Asynchronous I/O,异步非阻塞,无论是客户端的连接请求还是读写请求都会异步执行, 由操作系统完成后回调通知服务端程序启动线程去处理
适用于连接数目多且连接比较长的架构,相册服务器
BIO | NIO | AIO | |
---|---|---|---|
IO 模型 | 同步阻塞 | 同步非阻塞(多路复用) | 异步非阻塞 |
编程难度 | 简单 | 复杂 | 复杂 |
可靠性 | 差 | 好 | 好 |
吞吐量 | 低 | 高 | 高 |