文章目录
一、前言
本系列虽说本意是作为 《Netty4 核心原理》一书的读书笔记,但在实际阅读记录过程中加入了大量个人阅读的理解和内容,因此对书中内容存在大量删改。
本篇涉及内容 :第三章 Netty 与 NIO 之前世今生
本系列内容基于 Netty 4.1.73.Final 版本,如下:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.73.Final</version>
</dependency>
系列文章目录:
【Netty4核心原理】【全系列文章目录】
二、NIO 与 BIO 的区别
-
BIO 时面向字节流(InputStream、OutStream), NIO 是面向缓冲区(ByteBuffer)。BIO 面向流意味着每次从流中读一个字节或多个字节直至读取所有字节,他们没有被缓存在任何地方,此外,不能前后移动流中的数据,如果需要移动,则要先将他缓存到一个缓冲区中。
-
Java BIO 中的各种流是阻塞的。这意味着当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有一些数据被读取,或数据完全写入,在此期间该线程不能干任何事。Java NIO 的非阻塞模式是一个线程从某个通道(Channel)发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有可用数据,就什么都不会获取,而不是保持线程阻塞,直到数据变成可读取之前,该线程可以继续做其他事情。非阻塞写也如此。一个线程请求写入某个通道一些数据,但不需要等待他完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞 IO 的空闲时间用于其他通道上执行 IO 操作,所以个单独的线程可以管理多个 IO通道。
三、Java NIO 三件套
在 NIO 中有三个核心对象:缓冲区(Buffer)、选择器(Selector)和通道(Channel)
1. 缓冲区 (Buffer)
缓冲区可以认为是一个数组,在NIO 库中,所有数据都是用缓冲区处理的。在读取数据和写入数据时都是将其读或写到缓冲区中;任何时候访问 NIO 中的数据,都是将它放到缓冲区中。而在面向流 IO 系统 中,所有数据都是直接写入或直接将数据读取到 Stream 对象中。
在 NIO 中所有的缓冲区类型都继承于抽象类 Buffer,Buffer 的子类如下图:
Buffer 的简单使用 Demo:
public static void main(String[] args) {
// 分配新的 int 缓冲区,缓冲区容量为 8。
// 缓冲区的底层实现是一个数组,其数组偏移量代表新缓冲区当前的位置(刚开始为 0),其界限(限制位置)为其容量(8)
IntBuffer buffer = IntBuffer.allocate(8);
for (int i = 0; i < buffer.capacity(); i++) {
// 写入缓冲区数据
buffer.put(i);
}
// 重设此缓冲区,将限制位置设置为当前位置,然后将当前位置设置为 0
buffer.flip();
while (buffer.hasRemaining()) {
// 输出结果 :0 1 2 3 4 5 6 7
System.out.print(buffer.get() + " ");
}
}
1.1 Buffer 的基本原理
缓冲区本质上是一个特殊的数组,其内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如果我们使用 get() 方法从缓冲区获取数据或使用 put() 方法将数据写入缓冲区,都会引起缓冲区状态的变化。
在缓冲区中最重要的三个属性如下:
- postion :指定下一个将要被写入或者读取的元素索引,他的值由 get put 方法自动更新、在创建一个新的 Buffer 对象时, position 被初始化为 0。
- limit :指定还有多个数据需要取出(在从缓冲区写入通道时),或还有多少空间可以写入数据(在从通道读入缓冲区时)
- capacity :指定了可以存储在缓冲区中的最大数据容量,实际上,他指定了底层数组的大小,或者至少指定了准许我们使用的底层数组的容量。
注 :
- 这三个属性有相对大小关系:0 <= position <= limit <= capacity。capacity 仅在缓冲区创建时赋值,之后不会再变化。
- 除了上述三个属性之外,还存在一个 mark 属性,表示保存当前正在操作的数组下标,可以在需要时将 position 还原为 mark 值。
我们以下面的 Demo 为例(demo.txt 的文件内容为“abcdefg”):
public static void main(String[] args) throws IOException {
File file = new File("D:\\demo.txt");
try (FileInputStream inputStream = new FileInputStream(file)) {
// 打开文件通道
FileChannel channel = inputStream.getChannel();
// 分配读缓冲区,大小为 10 个字节
ByteBuffer buffer = ByteBuffer.allocate(10);
// 也可以通过 wrap 直接将一个数组对象包装成缓冲区对象。
// ByteBuffer.wrap(new byte[10])
print("init", buffer);
// 读取数据放入缓冲区中
channel.read(buffer);
print("read", buffer);
// 反转缓冲区
buffer.flip();
print("flip", buffer);
// 判断是否有可读数据
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
System.out.println();
print("get", buffer);
buffer.clear();
print("clear", buffer);
}
}
public static void print(String step, Buffer buffer) {
System.out.print(step + " : ");
// 容量, 数组大小
System.out.print(" capacity = " + buffer.capacity());
// 游标,当前操作数据所在位置
System.out.print(" position = " + buffer.position());
// 锁定值,flip,数据操作范围索引只能在 postion 和 Limit 之间
System.out.print(" limit = " + buffer.limit());
System.out.println();
}
输出结果如下:
根据以上输出,这里做出图解,如下:
-
init 阶段 :在 Buffer 刚刚 初始化的时候, 三个属性值如下:
-
read 阶段 :当从通道中读出数据时,因为读出了 7 个字符,此时postion 会指向7,即下一个将要被写入的字节索引。(从通道中读取数据,相当于往缓冲区中写入数据,因为从通道读出的数据要写入到缓冲区中。)
-
flip 阶段 :将读取的数据写入输出通道,相当于从缓冲区中读取数据。在此之前,必须要调用 flip 转换读写模式,flip 方法会完成两件事 : 一是将 limit 设置为当前的 position 值,二是把position 设置为 0。由于 position 被设置为 0,所以可以保证在下一步输出时读取的是缓冲区的第一个字节,而 limit 被这是为当前的position,可以保证读取的数据正好是之前写入的缓冲区的数据,如下图:
-
get 阶段 :在调用get 方法从缓冲区中读取数据写入输出通道,会导致 position 增加而 limit 不变,但 position 不会超过limit 的值,所以在读取之前写入缓冲区的7个字节之后,position 和 limit 的值都为 7。如下图:
- clear 阶段 :在从缓冲区中读取数据完毕后, limit 值仍保存在 调用 flip 方法时的值,调用 clear 方法可以将所有状态变化设置为初始化的值,如下图:
1.2 缓冲区分片
在 NIO 中,除了可以分配或者包装一个缓冲区对象,还可以根据现有的缓冲区对象创建一个子缓冲区,即在现有缓冲区上切除一片作为新的缓冲区。但现有缓冲区与子缓冲区在底层数组层面是数据共享的,也就是说,子缓冲区相当于现在缓冲区的一个视图窗口。
如下:调用 slice 方法可以创建一个子缓冲区
// 创建一个缓冲区,并赛入 0-9 的数据
ByteBuffer buffer = ByteBuffer.allocate(10);
for (int i = 0; i < buffer.capacity(); i++) {
buffer.put((byte) i);
}
// 创建子缓冲区
buffer.position(3);
buffer.limit(7);
ByteBuffer subBuffer = buffer.slice();
for (int i = 0; i < subBuffer.capacity(); i++) {
subBuffer.put((byte) (subBuffer.get(i) * 10));
}
buffer.position(0);
buffer.limit(buffer.capacity());
// 输出 : 0 1 2 30 40 50 60 7 8 9
// 输出结果变化说明 子缓冲区与原始缓冲区的数据区域是共享的
while (buffer.hasRemaining()){
System.out.print(buffer.get() + " ");
}
1.3 只读缓冲区
只读缓冲区顾名思义,可以从读取数据,但是不能向他们写入数据,可以通过缓冲区的 asReadOnlyBuffer 方法将任何常规缓冲区转换为只读缓冲区,这个方法会换回一个与元缓冲区完全相同的缓冲区,并且与原缓冲区共享数据,只不过他是只读的。如下:
ByteBuffer readOnlyBuffer = buffer.asReadOnlyBuffer();
1.4 直接缓冲区
直接缓冲区时为了加快 IO 速度,使用一种特殊方式为其分配内存的缓冲区,JDK 文档描述为 :给定一个直接字节缓冲区,Java虚拟机将尽最大努力直接对它执行本机 IO 操作。也就是说他会在每一次调用底层操作系统的本机IO操作之前(之后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区或者从一个中间缓冲区拷贝数据。要分配直接缓冲区,需要调用 allocateDirect 方法来创建缓冲区,在使用方式上,直接缓冲区则与普通缓冲区没有区别。如下:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(10);
1.4.1 直接缓冲区和普通缓冲区的区别
直接缓冲区和普通缓冲区的区别如下:
- 内存分配位置
- 直接缓冲区(Direct Buffer):
- 直接缓冲区的内存分配在 Java 堆外,也就是操作系统的本地内存中。这意味着它不受 Java 堆大小的限制,能够利用操作系统的内存管理机制。
- 使用 ByteBuffer.allocateDirect(capacity) 方法创建,例如 ByteBuffer buffer = ByteBuffer.allocateDirect(10);。
- 普通缓冲区(堆缓冲区,Heap Buffer):
- 普通缓冲区的内存分配在 Java 堆内,是 Java 虚拟机管理的内存区域。它的生命周期受到 Java 垃圾回收机制的影响。
- 使用 ByteBuffer.allocate(capacity) 方法创建,例如 ByteBuffer buffer = ByteBuffer.allocate(10);。
- 直接缓冲区(Direct Buffer):
- 性能表现
- 直接缓冲区(Direct Buffer):
- 优点:在进行 I/O 操作(如网络通信、文件读写)时,直接缓冲区通常具有更好的性能。因为它避免了在 Java 堆和本地内存之间的数据复制,数据可以直接从操作系统的本地内存传输到外部设备,减少了数据的拷贝次数,提高了 I/O 效率。
- 缺点:直接缓冲区的创建和销毁开销相对较大,因为涉及到操作系统的内存分配和释放操作。
- 普通缓冲区(堆缓冲区,Heap Buffer):
- 优点:创建和销毁速度较快,因为它的内存管理由 Java 虚拟机负责,不需要与操作系统进行频繁的交互。
- 缺点:在进行 I/O 操作时,需要将数据从 Java 堆复制到本地内存,然后再进行传输,增加了数据拷贝的开销,性能相对较低。
- 直接缓冲区(Direct Buffer):
- 垃圾回收
- 直接缓冲区(Direct Buffer):
- 直接缓冲区的垃圾回收机制相对复杂。由于其内存位于 Java 堆外,Java 虚拟机无法直接对其进行垃圾回收。当直接缓冲区不再被引用时,需要通过 Cleaner 机制来触发操作系统的内存释放操作。如果大量创建直接缓冲区而不及时释放,可能会导致本地内存泄漏。
- 普通缓冲区(堆缓冲区,Heap Buffer):
- 普通缓冲区的垃圾回收由 Java 虚拟机的垃圾回收器负责。当缓冲区对象不再被引用时,垃圾回收器会自动回收其占用的内存。
- 直接缓冲区(Direct Buffer):
- 使用场景
- 直接缓冲区(Direct Buffer):
- 适用于需要频繁进行 I/O 操作的场景,如网络编程中的数据读写、文件的大规模读写等。例如,在使用 NIO 进行网络编程时,直接缓冲区可以提高数据传输的效率。
- 当需要处理大量数据且对性能要求较高时,可以考虑使用直接缓冲区。
- 普通缓冲区(堆缓冲区,Heap Buffer):
- 适用于对性能要求不是特别高,且数据量较小的场景。例如,在一些简单的内存数据处理中,使用普通缓冲区可以更方便地进行操作。
- 当需要快速创建和销毁缓冲区时,普通缓冲区是更好的选择。
- 直接缓冲区(Direct Buffer):
综上所述,直接缓冲区和普通缓冲区各有优缺点,应根据具体的应用场景选择合适的缓冲区类型。
1.5 内存映射
内存映射时一种读写文件数据的方法,可以比常规的基于流或者基于通道的 IO 快,内存映射文件 IO通过使文件中的数据表现为内存数据的内容来完成。这并不是将整个文件读到内存中,而是一般来说只有文件实际读取或写入的部分才会映射到内存中。如下:
public class MappedByteBufferExample {
public static void main(String[] args) {
try (FileInputStream fis = new FileInputStream(new File("test.txt"));
FileChannel channel = fis.getChannel()) {
// 创建 MappedByteBuffer,以只读模式映射文件的全部内容
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
// 处理缓冲区数据
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
内存映射的使用场景如下:
- 大文件处理:当处理大文件时,使用 MapperByteBuffer 可以避免将整个文件一次性读入内存,而是按需映射文件的部分内容,减少内存占用。
- 提高 I/O 性能:对于频繁访问的文件,内存映射文件可以显著提高数据访问速度,因为它减少了数据在磁盘和内存之间的拷贝次数。
- 跨进程共享数据:在一些需要跨进程共享数据的场景中,可以使用内存映射文件来实现数据的共享。
需要注意的是:
- 内存管理:虽然 MapperByteBuffer 可以提高性能,但如果映射的文件过大,可能会导致内存不足。因此,需要合理控制映射的文件大小。
- 线程安全:MapperByteBuffer 本身不是线程安全的,如果在多线程环境下使用,需要进行适当的同步处理。
- 文件锁:在 READ_WRITE 模式下,对文件的映射可能会导致文件被锁定,其他进程可能无法同时访问该文件。因此,需要注意文件锁的管理。
2. 选择器 (Selector)
传统的 C/S 模式会基于 TPR (Thread per Request) ,服务器会为每一个客户端请求创建一个线程,由该线程单独负责处理一个客户请求。
NIO 中非阻塞 IO 采用了基于 Reactor 模式的工作方式, IO调用不会被阻塞,而是注册感兴趣的特定 IO 事件,如可读数据到达,新的套接字连接等。在发生特定事件时,系统再通知我们。NIO中发生非阻塞 IO 的核心对象是 Selector,Selector 时注册各种 IO 事件的地方,而当那些事件发生时,就是 Selector 告诉我们所发生的事件。
使用 NIO中的非阻塞 IO 编写服务器处理程序,大体分为一下三个步骤:
- 向 Selector 对象注册感兴趣的事件。
- 从 Selector 中获取感兴趣的事件。
- 根据不同的事件进行相应的处理。
选择器(Selector )允许一个线程监视多个输入通道,可以注册多个通道使用一个选择器,然后用一个线程或多个线程来选择管理通道。特别注意的是 :select/poll 的优势并不是对于单个链接能处理的更快,而是能处理更多的连接(关于 IO 多路复用的内容,在 【Netty4核心原理②】【Java I/O 演进之路】 中有过介绍,这里不再赘述)
3. NIO 演示Demo
以下面Demo为例:实现了客户端发送消息以及接收服务端响应消息的功能。
@Slf4j
public class SelectorDemo {
/**
* 该段代码输出如下:
* 【服务端】开始监听 :8089
* 【服务端】收到 OP_ACCEPT 事件
* 【客户端】收到 OP_CONNECT 事件
* 【服务端】收到 OP_READ 事件
* 【服务端】收到客户端消息:hello, world
* 【客户端】收到 OP_READ 事件
* 【客户端】收到服务端消息:服务端收到
*/
public static void main(String[] args) throws InterruptedException {
ThreadUtil.execute(() -> {
Server server = new Server(8089);
server.start();
});
Thread.sleep(1000);
Client client = new Client("127.0.0.1", 8089);
client.start();
// 等待连接完成 :因为 start 内部是异步执行的,可能 OP_CONNECT 事件还没处理完就开始发送数据
Thread.sleep(1000);
client.send("hello, world");
Thread.sleep(100000);
}
/**
* 客户端
*/
static class Client {
private Selector selector;
private SocketChannel socketChannel;
@SneakyThrows
public Client(String host, int port) {
this.selector = Selector.open();
// 直连会导致 OP_CONNECT 还没监听就已经连接完成
//this.socketChannel = SocketChannel.open(new InetSocketAddress(host, port));
this.socketChannel = SocketChannel.open();
this.socketChannel.configureBlocking(false);
// 注册连接事件
this.socketChannel.register(this.selector, SelectionKey.OP_CONNECT);
// 连接
this.socketChannel.connect(new InetSocketAddress(host, port));
}
@SneakyThrows
public void start() {
ThreadUtil.execute(() -> listen(selector));
}
/**
* 发送消息
*
* @param message
* @throws IOException
*/
@SneakyThrows
public void send(String message) {
socketChannel.write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8)));
}
@SneakyThrows
public void listen(Selector selector) {
while (true) {
// 该调用会阻塞,直至至少有一个就绪事件发生 或等待1s后被唤醒
selector.select(1000);
// 获取所有就绪事件并调用 process 方法处理
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
// 移除事件,否则会一直触发
process(selector, iterator.next());
iterator.remove();
}
}
}
/**
* 处理事件
*
* @param selector
* @param key
* @throws IOException
*/
private void process(Selector selector, SelectionKey key) throws IOException {
// 验证操作 判断管道是否有效 true 有效,false 无效
if (!key.isValid()) {
return;
}
SocketChannel channel = (SocketChannel) key.channel();
if (key.isConnectable()) { // OP_CONNECT 事件
System.out.println("【客户端】收到 OP_CONNECT 事件");
if (channel.finishConnect()) { //客户端连接成功
//注册到selector为 可读状态
channel.register(selector, SelectionKey.OP_READ);
}
return;
}
if (key.isReadable()) {
System.out.println("【客户端】收到 OP_READ 事件");
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int bytes = channel.read(byteBuffer);
if (bytes > 0) {
byteBuffer.flip();
byte[] byteArray = new byte[byteBuffer.remaining()];
byteBuffer.get(byteArray);
String resopnseMessage = new String(byteArray, StandardCharsets.UTF_8);
System.out.println("【客户端】收到服务端消息:" + resopnseMessage);
} else if (bytes < 0) {
key.cancel();
channel.close();
}
return;
}
}
}
/**
* 服务端
*/
static class Server {
private int port;
public Server(int port) {
this.port = port;
}
/**
* 服务端启动
*
* @throws IOException
*/
@SneakyThrows
public void start() {
listen(getSelector());
}
/**
* 注册事件
*
* @return
* @throws IOException
*/
private Selector getSelector() throws IOException {
// 创建 Selector 对象
Selector selector = Selector.open();
// 创建可选通道,并配置为非阻塞模式
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
// 绑定通道到指定端口
ServerSocket socket = server.socket();
socket.bind(new InetSocketAddress(port));
// 向 Selector 注册感兴趣事件 : ON_ACCEPT 事件
server.register(selector, SelectionKey.OP_ACCEPT);
// 返回 Selector 对象
return selector;
}
/**
* 监听指定端口
*
* @param selector
* @throws IOException
*/
private void listen(Selector selector) throws IOException {
System.out.println("【服务端】开始监听 :" + port);
while (true) {
// 该调用会阻塞,直至至少有一个就绪事件发生 或 等待 1s 会唤醒
selector.select(1000);
// 获取所有就绪事件并调用 process 方法处理
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
process(selector, iterator.next());
// 移除事件,否则事件会一直重复触发
iterator.remove();
}
}
}
/**
* OP_ACCEPT 是服务器端SocketChannel的有效事件,表示服务器监听到了客户端的连接请求。
* OP_CONNECT 是客户端SocketChannel的有效事件,表示客户端与服务器的连接已经建立成功。
*
* @param selector
* @param key
*/
@SneakyThrows
private void process(Selector selector, SelectionKey key) {
if (key.isAcceptable()) { // OP_ACCEPT 事件
System.out.println("【服务端】收到 OP_ACCEPT 事件");
// 触发 OP_ACCEPT 事件的一定是 ServerSocketChannel
// 每来一个新连接,不需要创建一个线程,而是直接注册到 selector, 并告知 selector 要监听这个 Channel 的 ON_READ 事件
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel channel = server.accept();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ);
return;
}
// 如果是 OP_READ 事件,则表示客户端有数据发送过来
if (key.isReadable()) { // OP_READ 事件
System.out.println("【服务端】收到 OP_READ 事件");
// 触发 OP_READ 事件的一定是 SocketChannel
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer readBuffer = ByteBuffer.allocate(100);
// len 表示读取了多少数据到 Buffer 中,如果返回 -1,则表示已经读到了流的末尾
int len = socketChannel.read(readBuffer);
// 如果 客户端请求 “断开连接”,则关闭当前socketChannel
if (len == -1) {
key.channel().close();
//取消key
key.cancel();
return;
}
// 如果客户端消息有数据则打印,
// 由于 socketChannel 通道里的数据流入到 readBuffer 容器中,所以 readBuffer position一定发生了变化, 必须进行复位
readBuffer.flip();
byte[] bytes = new byte[readBuffer.remaining()];
readBuffer.get(bytes);
System.out.println("【服务端】收到客户端消息:" + new String(bytes));
// 响应客户端消息
socketChannel.write(ByteBuffer.wrap("服务端收到".getBytes(StandardCharsets.UTF_8)));
return;
}
}
}
}
这里注意(摘抄自 selector 为什么无限触发就绪事件):
- 为什么不调用ServerSocketChannel.accept() 就会一直触发 OP_ACCEPT 事件?
因为java NIO 事件触发属于水平触发 ,所以如果我们不清理掉"accept"内容,就会一直触发 accpet ready 事件水平触发 LT (Level-triggered) :如果事件没处理完,后续还会触发该事件。
边缘触发 ET (Edge-triggered) :事件只会触发一次,即使没有处理完。 - 为什么不调用 SocketChannel.read() 就会一直触发 OP_READ 事件?
因为java NIO 事件触发属于水平触发,所以只要内核缓冲内容不为空,就会一直触发 OP_READ 事件 - 为什么注册了 OP_WRITE,就会一直触发写就绪事件?
因为java NIO 事件触发属于水平触发,所以只要内核缓冲区还不满,就一直是写就绪状态,也就会一直触发 OP_WRITE 事件 - 如果 bytebuffer 的大小不足以处理完整个消息,消息会循环发送,直接消息发送结束,如服务端 OP_READ 事件的 readBuffer 改成 5 ,如下:
程序执行的结果如下,可以看到客户端的消息被拆成多次发送
4. 通道 (Channel)
通道是一个对象,通过他可以读写数据,不过需要注意,所有的数据都是通过 Buffer 对象来处理的,我们永远不会将字节直接写入通道。而是将数据写入一个或多个字节的缓冲区。同样也不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
在 NIO 中所有的通道类型都继承于 Channel,Channel 的子类如下图:
通常来说通道分为两大类,一类是网络读写的(SelectableChannel),一类用户文件操作(FileChannel),我们使用的是SocketChannel和ServerSocketChannel都是SelectableChannel的子类。
在上面的Demo 中我们已经演示过用 SocketChannel 和 ServerSocketChannel 的网络读写了,下面我们简单给一个 文件读写的例子,如下:
public static void main(String[] args) throws IOException {
// 写入
try (FileOutputStream outputStream = new FileOutputStream("D://demo.txt")) {
FileChannel channel = outputStream.getChannel();
ByteBuffer writeBuffer = ByteBuffer.wrap("abcdefg".getBytes(StandardCharsets.UTF_8));
channel.write(writeBuffer);
}
// 读取
try (FileInputStream inputStream = new FileInputStream("D://demo.txt")) {
FileChannel channel = inputStream.getChannel();
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
channel.read(readBuffer);
readBuffer.flip();
byte[] data = new byte[readBuffer.remaining()];
readBuffer.get(data);
System.out.println(new String(data));
}
}
目前流行的多路复用IO的实现主要包括四种 :select、poll、epoll、kqueue,具体介绍如下表:
复用模型 | 相对性能 | 关键思路 | 操作系统 | 缺点 | Java支持 |
---|---|---|---|---|---|
select | 高 | Reactor | Win/Linux | 1.单进程监听FD 限制 1024;2. 遍历才能得知哪个FD 就绪;3.每次调用 FD需要从用户态拷贝到内核态; 4. 入参三个 fd_set 每次调用都需要重置 | 支持, Reactor 模式 。Linux Kernel 2.4 之前默认使用 select;当前win下对同步IO的支持都是 select 模型 |
poll | 较高 | Reactor | Linux | 1. 遍历才能得知哪个FD 就绪;2. 每次调用 FD需要从用户态拷贝到内核态; | Linux 下的 Java NIO 框架,Linux Kernel 2.6 之前使用 poll 进行支持,也是使用 Reactor 模式 |
epoll | 高 | Reactor/Proactor | Linux | 1. 相较于 epoll, select 更轻量可移植性更强;2. 在监听连接数和事件较少时 select 可能更优 | Linux Kernel 2.6 及之后使用 epoll 支持,之前则使用 poll 支持;需要注意 Linux 下没有 win 下的 IOCP 技术提供真正的异步 IO 支持,所以 Linux 下使用 epoll 模拟异步IO |
kqueue | 高 | Proactor | Linux | 不支持 |
四、其他
-
Netty 是一个基于 Java NIO 的高性能网络编程框架,它的核心是事件驱动和异步非阻塞 I/O 模型。因此无论是实现类似 Tomcat 的容器功能,还是进行 WebSocket 通信,都可以基于 Netty 实现。
-
Netty 采用 NIO 而非 AIO 的理由。
- Netty 不看重 win 上的使用,而在 Linux 系统上, AIO 底层仍使用 epoll,没有很好地实现 AIO,因此在性能上没有明显优势,而且被 JDK 封装了一层,不容易深度优化。
- Netty 整体框架采用 Reactor 模型,而 AIO 使用 Proactor 模型,混在一起较为混流,把 AIO 改造成 Reactor 模型,看起来是把 epoll 绕个弯又绕回来。
- AIO 存在一个缺点:接收数据需要预先分配缓存,而 NIO 时需要接受时才分配缓存,所以对连接数量非常大但流量小的情况 AIO 浪费很多内存。
- Linux 上的 AIO 不够成熟,处理回调结果的速度跟不上处理需求,造成处理速度瓶颈。
-
Socket、NIO、Netty 的关系:Socket 是网络通信的基础,NIO 在 Socket 基础上进行改进,提供了更高效的非阻塞 I/O 方式。而 Netty 则是对 NIO 的进一步封装和优化,它利用 NIO 的特性,简化了网络编程的复杂度,提升了开发效率,使得开发者能更轻松地构建高性能、高并发的网络应用程序。在实际开发中,开发者通常借助 Netty 来使用 NIO 的能力,而 NIO 底层又依赖 Socket 进行网络通信 。