Netty指南之IO和NIO的介绍(一)
一、前言
在学习 Netty 框架之前,我们需要先理解 Java 的 IO 和 NIO 模型。这两种模型在网络编程中扮演着重要角色,而 Netty 则是基于 NIO 模型构建的高性能网络应用框架。本文将详细介绍 IO 和 NIO 的概念、特点以及它们之间的区别,为后续学习 Netty 打下坚实基础。
二、IO 和 NIO
下表总结了 Java IO 和 NIO 之间的主要区别:
IO 与 NIO 的对比
特性 | IO | NIO |
---|---|---|
处理方式 | 阻塞 | 非阻塞 |
数据处理 | 面向流 | 面向缓冲 |
传输方式 | 单向 | 双向(通过 Channel) |
并发处理 | 多线程 | 单线程可处理多连接 |
编程难度 | 简单 | 相对复杂 |
适用场景 | 连接数少,流程简单 | 高并发,大量连接 |
2.1. 阻塞与非阻塞
阻塞式操作:当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有数据可读或数据完全写入。
非阻塞操作:线程可以发起 IO 请求后立即返回,做其他事情。 Java IO是属于阻塞式,NIO属于非阻塞式操作。阻塞式最大的缺点就是导致资源利用率不高。
2.2.面向流(Stream)和面向缓冲(Buffer)
面向流:IO 是面向流的,每次从流中读取一个或多个字节,直至读取所有字节。
面向缓冲:NIO 是面向缓冲区的,数据被读取到一个缓冲区中,便于处理。
下表总结了 面向流 和 面向缓冲的主要区别:
特性 | 面向流 | 面向缓冲 |
---|---|---|
数据处理单位 | 字节流 | 缓冲区 |
数据读写方式 | 从流中顺序读取 | 从缓冲区中读取或写入 |
处理灵活性 | 只能顺序读写 | 可以随机访问缓冲区的任何位置 |
数据缓存 | 不直接缓存数据 | 数据缓存在缓冲区中 |
适用场景 | 简单的顺序读写 | 需要灵活处理的数据 |
读写操作 | read()或write()方法 | flip()、clear()等缓冲区操作 |
处理效率 | 相对较低 | 较高,特别是在大量数据处理时 |
内存使用 | 通常较少 | 可能较多,需要分配缓冲区 |
编程复杂度 | 相对简单 | 较复杂,需要管理缓冲区状态 |
数据转换 | 需要为不同数据类型创建不同的流 | 可以使用不同的缓冲区类型(如ByteBuffer、CharBuffer) |
三、同步和异步
在这里提一嘴同步和异步。
同步:在同步操作中,发起调用的线程会等待操作完成后才继续执行。
异步:在异步操作中,发起调用的线程不会等待操作完成,而是继续执行其他任务。操作完成后通过回调、通知等机制来通知调用方。
在这里用我们熟悉的表格来总结一下IO同步异步的差别:
特性 | 同步阻塞 IO | 同步非阻塞 IO | 异步 IO |
---|---|---|---|
阻塞性 | 阻塞 | 非阻塞 | 非阻塞 |
CPU 利用 | 低 | 高(轮询消耗 CPU) | 高 |
编程复杂度 | 简单 | 复杂 | 较复杂 |
适用场景 | 连接数少 | 高并发 | 高并发,长连接 |
四、小试牛刀
光练不说傻把式,又练又说真把式,那么接下来我们搭建一下来试试IO和NIO的差别,在实操的感受过程中,再想一下为什么有了NIO还有Netty的诞生呢?
IO初体验
4.1. 服务端代码
/**
* @Author: Panjiangfeng
* @CreateTime: 2024-10-11 23:23
* @Description: IOClient
* @Version: 1.0
*/
public class IOClient {
public static void main(String[] args) {
new Thread(() -> {
try {
Socket socket = new Socket("127.0.0.1", 8000);
while (true) {
try {
// 发送消息到服务端
socket.getOutputStream().write((new Date() + ": hello world").getBytes());
// 休眠2秒
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace(); // 打印异常
}
}
} catch (IOException e) {
e.printStackTrace(); // 打印异常
}
}).start();
}
}
4.2客户端代码
/**
* @Author: Panjiangfeng
* @CreateTime: 2024-10-11 23:22
* @Description: IOServer
* @Version: 1.0
*/
public class IOServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8000);
// 接收新连接的线程
new Thread(() -> {
while (true) {
try {
// (1) 阻塞方法获取新连接
Socket socket = serverSocket.accept();
// (2) 为每一个新连接都创建一个新线程,负责读取数据
new Thread(() -> {
try {
int len;
byte[] data = new byte[1024];
InputStream inputStream = socket.getInputStream();
// (3) 按字节流方式读取数据
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
} catch (IOException e) {
e.printStackTrace(); // 打印异常
}
}).start();
} catch (IOException e) {
e.printStackTrace(); // 打印异常
}
}
}).start();
}
}
接下来先启动服务端代码,再启动客户端代码就可以每隔2秒给服务端发送消息,这也很简单,看代码大家都能看懂吧。其实Java IO的代码还是很简单的吧 NIO初体验 想必大家其实对NIO可能还是不是很懂,所以这里初体验当然是先初以下再体验咯,大家先学习下相关概念哈,很重要,很重要,很重要
Channel 通常来说, 所有的 NIO 的 I/O 操作都是从 Channel 开始的. 一个 channel 类似于一个 stream。
Channel 类型有:
FileChannel, 文件操作
DatagramChannel, UDP 操作
SocketChannel, TCP 操作
ServerSocketChannel, TCP 操作, 使用在服务器端。
这些通道涵盖了 UDP 和 TCP网络 IO以及文件 IO。
Buffer 当我们需要与 NIO Channel 进行交互时, 我们就需要使用到 NIO Buffer, 即数据从 Buffer读取到 Channel 中, 并且从 Channel 中写入到 Buffer 中. 实际上, 一个 Buffer 其实就是一块内存区域, 我们可以在这个内存区域中进行数据的读写. NIO Buffer 其实是这样的内存块的一个封装, 并提供了一些操作方法让我们能够方便地进行数据的读写。
Buffer 类型有:
ByteBuffer
CharBuffer
DoubleBuffer
FloatBuffer
IntBuffer
LongBuffer
ShortBuffer
这些 Buffer 覆盖了能从 IO 中传输的所有的 Java 基本数据类型。
selector
selector 是 NIO 中才有的概念, 它是 Java NIO 之所以可以非阻塞地进行 IO 操作的关键。
通过 Selector, 一个线程可以监听多个 Channel 的 IO 事件, 当我们向一个 Selector 中注册了 Channel 后, Selector 内部的机制就可以自动地为我们不断地查询(select) 这些注册的 Channel 是否有已就绪的 IO 事件(例如可读, 可写, 网络连接完成等). 通过这样的 Selector 机制, 我们就可以很简单地使用一个线程高效地管理多个 Channel 了。
相信大家看完上面的玩意,脑袋里都是懵逼的,这啥玩意啊,没事,先顺着看下去,咱们把代码先跑起来,下一章会对NIO详细深入了解。
服务端代码:
public class NIOServer {
public static void main(String[] args) throws IOException {
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();
// 服务端监听线程
new Thread(() -> {
try {
// 对应IO编程中的服务端启动
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(8000));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
while (true) {
// 监测是否有新连接,这里的1指阻塞的时间为1ms
if (serverSelector.select(1) > 0) {
Set<SelectionKey> set = serverSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// 每来一个新连接,不需要创建一个线程,而是直接注册到 clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
keyIterator.remove();
}
}
}
}
}
} catch (IOException ignored) {
ignored.printStackTrace();
}
}).start();
// 客户端读写线程
new Thread(() -> {
try {
while (true) {
// 批量轮询哪些连接有数据可读,这里的1指阻塞的时间为1ms
if (clientSelector.select(1) > 0) {
Set<SelectionKey> set = clientSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isReadable()) {
try {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 面向Buffer的读取操作
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer).toString());
} finally {
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
} catch (IOException ignored) {
ignored.printStackTrace();
}
}).start();
}
}
是不是特别复杂呢小伙伴们,这就是为什么引入Netty的原因,先不考虑这个问,我们先来分析一下NIO的代码吧
-
NIO模型一般会绑定2个线程,每个线程都绑定一个轮训器Seletor。
serverSelector:负责轮训是否有新连接。
clientSelector:负责轮训是否有数据能读。
-
服务端监测到新连接之后,不再创建一个新线程,而是直接将新连接绑定到clientSelector上,这样就不用IO模型中的1万个while循环死等。
-
clientSelector被一个while死循环包裹着,如果在某一时刻有多个连接有数据可读,那么通过clientSelector.select(1)方法可以轮询出来,进而批量处理。
结语
如果这一章NIO这一块的代码不太清晰,大家先看下一章对于NIO的详解,然后回过头来复习一下一定会收获满满。
文中: 源代码点我跳转
本文系作者 @Mr.Mk 原创发布在Mk's Blog站点。未经许可,禁止转载。
暂无评论数据