几乎所有我们叫得上名字的框架,底层用的都是Netty,包括RocketMQ、Elasticsearch、gRPC、Apache Dubbo、Spring5、HSF、 Zookeeper、Spark、Hadoop,这足以说明Netty有用且好用。
学习Netty之前需要先了解下一些基本概念。
阻塞、非阻塞、异步、同步
IO 中阻塞、非阻塞、异步、同步这几个术语的含义和关系:
- 阻塞:如果线程调用 read/write 过程,但 read/write 过程没有就绪或没有完成,则调用 read/write 过程的线程会一直等待,这个过程叫做阻塞式读写。
- 非阻塞:如果线程调用 read/write 过程,但 read/write 过程没有就绪或没有完成,调用 read/write 过程的线程并不会一直等待,而是去处理其他工作,等到 read/write 过程就绪或完成后再回来处理,这个过程叫做非阻塞式读写。
- 异步:read/write 过程托管给操作系统来完成,完成后操作系统会通知(通过回调或者事件)应用网络 IO 程序(其中的线程)来进行后续的处理。
- 同步:read/write 过程由网络 IO 程序(其中的线程)来完成。
可以看出:异步 IO 一定是非阻塞 IO;同步 IO 既可以是阻塞 IO、也可以是非阻塞 IO。
BIO、NIO、AIO
Java 中的网络 IO 模型有三种:BIO、NIO、AIO。
1)BIO
同步的、阻塞式 IO。在这种模型中,服务器上一个线程处理一次连接,即客户端每发起一个请求,服务端都要开启一个线程专门处理该请求。这种模型对线程量的耗费极大,且线程利用率低,难以承受请求的高并发。BIO 虽然可以使用线程池+等待队列进行优化,避免使用过多的线程,但是依然无法解决线程利用率低的问题。
使用 BIO 构建 C/S 系统的 Java 编程组件是 ServerSocket 和 Socket。服务端示例代码为:
public static void main(String[] args) throws IOException {
ExecutorService threadPool = Executors.newCachedThreadPool();
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket socket = serverSocket.accept();
threadPool.execute(() -> {
handler(socket);
});
}
}
// 处理客户端请求
private static void handler(Socket socket) throws IOException {
byte[] bytes = new byte[1024];
InputStream inputStream = socket.getInputStream();
socket.close();
while (true) {
int read = inputStream.read(bytes);
if (read != -1) {
System.out.println("msg from client: " + new String(bytes, 0, read));
} else {
break;
}
}
}
上面获取流和读取流的过程如果很慢的话,那么服务端这个线程就不能处理别的客户端请求,即阻塞住了。
2)NIO
同步的、非阻塞式 IO。在这种模型中,服务器上一个线程处理多个连接,即多个客户端请求都会被注册到多路复用器(后文要讲的 Selector)上,多路复用器会轮训这些连接,轮训到连接上有 IO 活动就进行处理。NIO 降低了线程的需求量,提高了线程的利用率。Netty 就是基于 NIO 的(这里有一个问题:前文大力宣扬 Netty 是一个异步高性能网络应用框架,为何这里又说 Netty 是基于同步的 NIO 的?请读者跟着文章的描述找寻答案)。
NIO让线程和客户端的关系由BIO的一对一,变成了一对多。
Java NIO 的非阻塞模式,使得一个线程从某个通道读取数据的时候,若当前有可用数据,则该线程进行处理,若当前无可用数据,则该线程不会保持阻塞等待状态,而是可以去处理其他工作(比如处理其他通道的读写);同样,一个线程向某个通道写入数据的时候,一旦开始写入,该线程无需等待写完即可去处理其他工作(比如处理其他通道的读写)。这种特性使得一个线程能够处理多个客户端请求,而不是像 BIO 那样,一个线程只能处理一个请求。
使用 NIO 构建 C/S 系统的 Java 编程组件是 Channel、Buffer、Selector。服务端示例代码为:
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
Selector selector = Selector.open();
// 绑定端口
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
// 设置 serverSocketChannel 为非阻塞模式
serverSocketChannel.configureBlocking(false);
// 注册 serverSocketChannel 到 selector,关注 OP_ACCEPT 事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 没有事件发生
if (selector.select(1000) == 0) {
continue;
}
// 有事件发生,找到发生事件的 Channel 对应的 SelectionKey 的集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
// 发生 OP_ACCEPT 事件,处理连接请求
if (selectionKey.isAcceptable()) {
SocketChannel socketChannel = serverSocketChannel.accept();
// 将 socketChannel 也注册到 selector,关注 OP_READ
// 事件,并给 socketChannel 关联 Buffer
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
// 发生 OP_READ 事件,读客户端数据
if (selectionKey.isReadable()) {
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
channel.read(buffer);
System.out.println("msg form client: " + new String(buffer.array()));
}
// 手动从集合中移除当前的 selectionKey,防止重复处理事件
iterator.remove();
}
}
}
上述服务端的工作流程为:
- 1)当客户端发起连接时,会通过 ServerSocketChannel 创建对应的 SocketChannel。
- 2)调用 SocketChannel 的注册方法将 SocketChannel 注册到 Selector 上,注册方法返回一个 SelectionKey,该 SelectionKey 会被放入 Selector 内部的 SelectionKey 集合中。
- 3) 该 SelectionKey 和 Selector 关联(即通过 SelectionKey 可以找到对应的 Selector),也和 SocketChannel 关联(即通过 SelectionKey 可以找到对应的 SocketChannel)。
- 4)Selector 会调用 select()/select(timeout)/selectNow()方法对内部的 SelectionKey 集合关联的 SocketChannel 集合进行监听,找到有事件发生的 SocketChannel 对应的 SelectionKey。
- 5)通过 SelectionKey 找到有事件发生的 SocketChannel,完成数据处理。
3)AIO
异步非阻塞式 IO。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。
在NIO中获取到可读或可写的事件之后,后续还需要用户线程自己主动去处理的。
在AIO中获取到可读或可写的事件之后,是内核去主动处理,用户线程直接拿结果就行了。
AIO这种模型中,由操作系统完成与客户端之间的 read/write,之后再由操作系统主动通知服务器线程去处理后面的工作,在这个过程中服务器线程不必同步等待 read/write 完成。
使用 Java NIO 构建的 IO 程序,它的工作模式是:主动轮训 IO 事件,IO 事件发生后程序的线程主动处理 IO 工作,这种模式也叫做 Reactor 模式。它将 IO 事件的处理托管给操作系统,操作系统完成 IO 工作之后会通知程序的线程去处理后面的工作,这种模式也叫做 Proactor 模式。
由于不同的操作系统对 AIO 的支持程度不同,AIO 目前未得到广泛应用。
小结:
BIO 以流的方式处理数据,而 NIO 以缓冲区(也被叫做块)的方式处理数据,块 IO 效率比流 IO 效率高很多。BIO 基于字符流或者字节流进行操作,而 NIO 基于 Channel 和 Buffer 进行操作,数据总是从通道读取到缓冲区或者从缓冲区写入到通道。Selector 用于监听多个通道上的事件(比如收到连接请求、数据达到等等),因此使用单个线程就可以监听多个客户端通道。
NIO中的 Buffer、Channel、Selector
NIO 基于 Channel 和 Buffer 进行操作,数据总是从通道读取到缓冲区或者从缓冲区写入到通道。
Selector 用于监听多个通道上的事件(比如收到连接请求、数据达到等等),因此使用单个线程就可以监听多个客户端通道。
关于上图,再进行几点说明:
- 一个 Selector 对应一个处理线程
- 一个 Selector 上可以注册多个 Channel
- 每个 Channel 都会对应一个 Buffer(有时候一个 Channel 可以使用多个 Buffer,这时候程序要进行多个 Buffer 的分散和聚集操作),Buffer 的本质是一个内存块,底层是一个数组
- Selector 会根据不同的事件在各个 Channel 上切换
- Buffer 是双向的,既可以读也可以写,切换读写方向要调用 Buffer 的 flip()方法
- 同样,Channel 也是双向的,数据既可以流入也可以流出
1. 缓冲区(Buffer)
缓冲区(Buffer)本质上是一个可读可写的内存块,可以理解成一个容器对象,Channel 读写文件或者网络都要经由 Buffer。在 Java NIO 中,Buffer 是一个顶层抽象类,它的常用子类有:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- DoubleBuffer
- FloatBuffer
涵盖了 Java 中除 boolean 之外的所有的基本数据类型。其中 ByteBuffer 支持类型化的数据存取,即可以往 ByteBuffer 中放 byte 类型数据、也可以放 char、int、long、double 等类型的数据,但读取的时候要做好类型匹配处理,否则会抛出 BufferUnderflowException。
2. 通道(Channel)
通道(Channel)是双向的,可读可写。
在 Java NIO 中,Channel 是一个顶层接口,它的常用子类有:
- FileChannel:用于文件读写
- DatagramChannel:用于 UDP 数据包收发
- ServerSocketChannel:用于服务端 TCP 数据包收发
- SocketChannel:用于客户端 TCP 数据包收发
3. 选择器(Selector)
选择器(Selector)是实现 IO 多路复用的关键,多个 Channel 注册到某个 Selector 上,当 Channel 上有事件发生时,Selector 就会取得事件然后调用线程去处理事件。也就是说只有当连接上真正有读写等事件发生时,线程才会去进行读写等操作,这就不必为每个连接都创建一个线程,一个线程可以应对多个连接。这就是 IO 多路复用的要义。
在 Java NIO 中,Selector 是一个抽象类,它的常用方法有:
public abstract class Selector implements Closeable {
// 得到一个选择器对象
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
// 返回所有发生事件的 Channel 对应的 SelectionKey 的集合,通过
// SelectionKey 可以找到对应的 Channel
public abstract Set<SelectionKey> selectedKeys();
// 返回所有 Channel 对应的 SelectionKey 的集合,通过 SelectionKey
// 可以找到对应的 Channel
public abstract Set<SelectionKey> keys();
// 监控所有注册的 Channel,当其中的 Channel 有 IO 操作可以进行时,
// 将这些 Channel 对应的 SelectionKey 找到。参数用于设置超时时间
public abstract int select(long timeout) throws IOException;
// 无超时时间的 select 过程,一直等待,直到发现有 Channel 可以进行
// IO 操作
public abstract int select() throws IOException;
// 立即返回的 select 过程
public abstract int selectNow() throws IOException;
// 唤醒 Selector,对无超时时间的 select 过程起作用,终止其等待
public abstract Selector wakeup();
}
Netty 的 IO 线程 NioEventLoop 聚合了 Selector,可以同时并发处理成百上千的客户端连接。
操作系统中的中断
系统中断是指 CPU 对系统发生的某个事件做出的一种反应:CPU 暂停正在执行的程序,保留现场后转去执行相应的处理程序,处理完该事件后再返回断点继续执行被打断的程序。
中断可以避免 CPU 轮询等待某条件成立,减小系统开销。中断是多程序并发执行的前提条件,因为没有中断cpu会一直处理当前的任务,不会停下转而处理别的任务。
当中断发生时,由于操作系统的管理工作需要特权指令,CPU 会立刻进入内核态,使得操作系统获得计算机的控制权。用户态到核心态的转换就是通过中断机制实现的,并且是唯一途径。
中断可以分为硬件中断和软件中断:
- 硬件中断:由外部硬件设备生成,例如键盘输入、鼠标移动等。当硬件设备需要操作系统的注意时,会发送一个中断信号给CPU,CPU会暂停当前任务,保存状态,然后执行中断处理程序(ISR),完成后恢复原任务。
- 软件中断:由运行的程序产生,主要用于实现系统调用。例如,当一个程序需要访问受保护的内存时,会发送一个软件中断请求,操作系统会响应这个请求,完成相应的操作。
中断的处理流程
当中断发生时,CPU会执行以下步骤:
- 保存当前状态:CPU会保存当前执行的任务的状态,包括寄存器的值。
- 查找中断处理程序:根据中断向量,CPU从中断向量表中查找中断处理程序入口地址。
- 执行中断处理程序:执行找到的中断处理程序,处理中断请求。
- 恢复状态:处理完成后,CPU恢复之前保存的状态,继续执行原来的任务。
中断的优缺点
优点:
- 异步处理:中断使得程序之间可以异步通信,不需要一直等待某个任务完成,提高了系统的并发处理能力。
- 资源调度:中断机制使得CPU可以抢占资源进行任务调度,允许多个程序同时运行。