Netty是 异步的 事件驱动的 网络应用程序框架, 用于快速开发可维护的高性能的面向协议服务器和客户端。
引入:初识Netty
谁在用Netty呢? Netty Adopters
Netty拥有一个充满活力并且不断壮大的社区,其中包含大量大型公司,如阿里,Apple,Twitter,Yahoo,Google,还有流行的开源框架如Dubbo,gRPC等核心代码都用到了Netty强大的网络抽象
反过来,Netty也从这些项目中受益,大型公司对Netty项目的贡献使得Netty进一步扩展其应用范围以及增强稳定性和灵活性
我们之前分享过的Dubbo,默认网络传输层就是Netty,当然也可以使用其他传输方式,通过SPI机制定制
了解Netty的广泛应用后,我们进一步来了解Netty的技术栈,用一个例子来说
假设现在指派你开发一个系统,要求该系统能够支撑15万并发用户,且性能不能有损失,如果不借助现有框架,那么你觉得都需要哪几个方面的能力才能做好这件事?
可以想到开发这个系统至少需要扎实的Java语言基础,Java多线程,JavaIO编程,Java网络编程,还需要了解常用的Java设计模式,常用的数据结构,才能如履薄冰的完成这个系统,但不能保证其优雅并稳定
这些便是Netty相关的技术栈,例子中提到的高性能系统不仅要求一流的编程技巧,还需要各个方面知识的支撑,而Netty优雅的处理了这些领域的知识,使得即使是网络编程新手也可以使用
但是可以使用并不意味着我们仅仅学会使用就可以了,优秀开源框架使得我们可以站立在巨人的肩膀上,可一旦出现问题,只知API,不知所以然,将导致无法迅速定位和解决问题
至此我们粗略的认识到了,Netty是一个广泛应用的网络编程框架
概念详解
序言中我们引用了Netty官方介绍,这里我们解析一下,弄清楚Netty具体做了什么
Netty是异步的
异步编程使得更好的利用线程资源,如Java中的FutureTask,即任务交给子线程执行,进行异步计算
Netty是事件驱动的
Netty定义了非常丰富的事件类型,代表了网络交互的各个阶段。并且当各个阶段发生时,触发相应的事件交给pipeline中定义的handler处理
完全异步的IO操作使我们不用等待一个操作的完成,且会立即返回,等到它完成时,会直接或者在稍后的某个时间点通知用户 选择器可以使我们能够通过较少的线程便可监视许多连接上的事件
面向协议编程
面向网络协议编程,遵循需要的网络协议,优势在于可以扩展支持的协议
IO基础
在开始了解Netty之前,我们先来回顾一下,如果我们需要实现一个client与server通信的程序,使用传统的IO编程,应该如何实现?
Java BIO 编程
模型基本说明
-
I/O
模型简单的理解:用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能。 -
Java
共支持3
种网络编程模型I/O
模式:BIO
、NIO
、AIO
。 -
Java BIO
:同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。 -
Java NIO
:同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O
请求就进行处理。 -
Java AIO(NIO.2)
:异步非阻塞,AIO
引入异步通道的概念,采用了Proactor
模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。
BIO、NIO、AIO 使用场景分析
BIO
方式适用于连接数目比较小且固定的架构,由于线程资源是有限的,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4
以前的唯一选择,但程序简单易理解。NIO
方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4
开始支持。AIO
方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS
参与并发操作,编程比较复杂,JDK7
开始支持。
Java BIO 基本介绍
Java BIO
就是传统的Java I/O
编程,其相关的类和接口在java.io
BIO(BlockingI/O)
:同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)。BIO
方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4
以前的唯一选择,程序简单易理解。
Java BIO 工作机制
- 服务器端启动一个
ServerSocket
。 - 客户端启动
Socket
对服务器进行通信,默认情况下服务器端需要对每个客户建立一个线程与之通讯。 - 客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝。
- 如果有响应,客户端线程会等待请求结束后,在继续执行。
Java BIO 应用实例
实例说明:
- 使用
BIO
模型编写一个服务器端,监听6666
端口,当有客户端连接时,就启动一个线程与之通讯。 - 要求使用线程池机制改善,可以连接多个客户端。
- 服务器端可以接收客户端发送的数据(
telnet
方式即可)。 - 代码演示:
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class BIOServer {
public static void main(String[] args) throws Exception {
//线程池机制
//思路
//1. 创建一个线程池
//2. 如果有客户端连接,就创建一个线程,与之通讯(单独写一个方法)
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
//创建ServerSocket
ServerSocket serverSocket = new ServerSocket(6666);
System.out.println("服务器启动了");
while (true) {
System.out.println("线程信息id = " + Thread.currentThread().getId() + "名字 = " + Thread.currentThread().getName());
//监听,等待客户端连接
System.out.println("等待连接....");
final Socket socket = serverSocket.accept();
System.out.println("连接到一个客户端");
//就创建一个线程,与之通讯(单独写一个方法)
newCachedThreadPool.execute(new Runnable() {
public void run() {//我们重写
//可以和客户端通讯
handler(socket);
}
});
}
}
//编写一个handler方法,和客户端通讯
public static void handler(Socket socket) {
try {
System.out.println("线程信息id = " + Thread.currentThread().getId() + "名字 = " + Thread.currentThread().getName());
byte[] bytes = new byte[1024];
//通过socket获取输入流
InputStream inputStream = socket.getInputStream();
//循环的读取客户端发送的数据
while (true) {
System.out.println("线程信息id = " + Thread.currentThread().getId() + "名字 = " + Thread.currentThread().getName());
System.out.println("read....");
int read = inputStream.read(bytes);
if (read != -1) {
System.out.println(new String(bytes, 0, read));//输出客户端发送的数据
} else {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("关闭和client的连接");
try {
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
Java BIO 问题分析
- 每个请求都需要创建独立的线程,与对应的客户端进行数据
Read
,业务处理,数据Write
。 - 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
- 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在
Read
操作上,造成线程资源浪费。
Java NIO 编程
基本介绍
Java NIO
全称Java non-blocking IO
,是同步非阻塞的。NIO
相关类都被放在java.nio
包及子包下,并且对原java.io
包中的很多类进行改写NIO
有三大核心部分:Channel
(通道)、Buffer
(缓冲区)、Selector
(选择器)。NIO
是面向缓冲区,或者面向块编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络。Java NIO
的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。- 通俗理解:
NIO
是可以做到用一个线程来处理多个操作的。假设有10000
个请求过来,根据实际情况,可以分配50
或者100
个线程来处理。不像之前的阻塞IO
那样,非得分配10000
个。 - 案例说明
NIO
的Buffer
import java.nio.IntBuffer;
public class BasicBuffer {
public static void main(String[] args) {
//举例说明 Buffer 的使用(简单说明)
//创建一个 Buffer,大小为 5,即可以存放 5 个 int
IntBuffer intBuffer = IntBuffer.allocate(5);
//向buffer存放数据
//intBuffer.put(10);
//intBuffer.put(11);
//intBuffer.put(12);
//intBuffer.put(13);
//intBuffer.put(14);
for (int i = 0; i < intBuffer.capacity(); i++) {
intBuffer.put(i * 2);
}
//如何从 buffer 读取数据
//将 buffer 转换,读写切换(!!!)
intBuffer.flip();
while (intBuffer.hasRemaining()) {
System.out.println(intBuffer.get());
}
}
}
NIO 和 BIO 的比较
BIO
以流的方式处理数据,而NIO
以块的方式处理数据,块I/O
的效率比流I/O
高很多。BIO
是阻塞的,NIO
则是非阻塞的。BIO
基于字节流和字符流进行操作,而NIO
基于Channel
(通道)和Buffer
(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector
(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。
NIO 三大核心原理示意图
一张图描述 NIO
的 Selector
、Channel
和 Buffer
的关系。
关系图的说明:
- 每个
Channel
都会对应一个Buffer
。 Selector
对应一个线程,一个线程对应多个Channel
(连接)。- 该图反应了有三个
Channel
注册到该Selector
- 程序切换到哪个
Channel
是由事件决定的 Selector
会根据不同的事件,在各个通道上切换。Buffer
就是一个内存块,底层是一个数组。- 数据的读取写入是通过
Buffer
,而BIO
中要么是输入流,或者是输出流,不能双向,但是NIO
的Buffer
是可以读也可以写,使用flip
方法切换Channel
是双向的,可以返回底层操作系统的情况,比如Linux
,底层的操作系统通道就是双向的。
缓冲区(Buffer)
缓冲区(Buffer
):缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel
提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer
在 NIO
中,Buffer
是一个顶层父类,它是一个抽象类,类的层级关系图:
Buffer
类定义了所有的缓冲区都具有的四个属性来提供关于其所包含的数据元素的信息:
通道(Channel)
NIO
的通道类似于流,但有些区别如下:- 通道可以同时进行读写,而流只能读或者只能写
- 通道可以实现异步读写数据
- 通道可以从缓冲读数据,也可以写数据到缓冲:
BIO
中的Stream
是单向的,例如FileInputStream
对象只能进行读取数据的操作,而NIO
中的通道(Channel
)是双向的,可以读操作,也可以写操作。Channel
在NIO
中是一个接口public interface Channel extends Closeable{}
- 常用的
Channel
类有:**FileChannel
、DatagramChannel
、ServerSocketChannel
和SocketChannel
**。【ServerSocketChanne
类似ServerSocket
、SocketChannel
类似Socket
】 FileChannel
用于文件的数据读写,DatagramChannel
用于UDP
的数据读写,ServerSocketChannel
和SocketChannel
用于TCP
的数据读写。- 图示
FileChannel 类
FileChannel
主要用来对本地文件进行 IO
操作,常见的方法有
public int read(ByteBuffer dst)
,从通道读取数据并放到缓冲区中public int write(ByteBuffer src)
,把缓冲区的数据写到通道中public long transferFrom(ReadableByteChannel src, long position, long count)
,从目标通道中复制数据到当前通道public long transferTo(long position, long count, WritableByteChannel target)
,把数据从当前通道复制给目标通道
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class NIOFileChannel01 {
public static void main(String[] args) throws Exception {
String str = "hello";
//创建一个输出流 -> channel
FileOutputStream fileOutputStream = new FileOutputStream("d:\\file01.txt");
//通过 fileOutputStream 获取对应的 FileChannel
//这个 fileChannel 真实类型是 FileChannelImpl
FileChannel fileChannel = fileOutputStream.getChannel();
//创建一个缓冲区 ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
//将 str 放入 byteBuffer
byteBuffer.put(str.getBytes());
//对 byteBuffer 进行 flip
byteBuffer.flip();
//将 byteBuffer 数据写入到 fileChannel
fileChannel.write(byteBuffer);
fileOutputStream.close();
}
}
import java.io.File;
import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class NIOFileChannel02 {
public static void main(String[] args) throws Exception {
//创建文件的输入流
File file = new File("d:\\file01.txt");
FileInputStream fileInputStream = new FileInputStream(file);
//通过 fileInputStream 获取对应的 FileChannel -> 实际类型 FileChannelImpl
FileChannel fileChannel = fileInputStream.getChannel();
//创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate((int)file.length());
//将通道的数据读入到 Buffer
fileChannel.read(byteBuffer);
//将 byteBuffer 的字节数据转成 String
System.out.println(new String(byteBuffer.array()));
fileInputStream.close();
}
}
选择器/多路复用器(selector)
Java
的NIO
,用非阻塞的IO
方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector
(选择器)。Selector
能够检测多个注册的通道上是否有事件发生(注意:多个Channel
以事件的方式可以注册到同一个Selector
),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。- 只有在连接/通道真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程。
- 避免了多线程之间的上下文切换导致的开销。
说明如下:
Netty
的IO
线程NioEventLoop
聚合了Selector
(选择器,也叫多路复用器),可以同时并发处理成百上千个客户端连接。- 当线程从某客户端
Socket
通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。 - 线程通常将非阻塞
IO
的空闲时间用于在其他通道上执行IO
操作,所以单独的线程可以管理多个输入和输出通道。 - 由于读写操作都是非阻塞的,这就可以充分提升
IO
线程的运行效率,避免由于频繁I/O
阻塞导致的线程挂起。 - 一个
I/O
线程可以并发处理N
个客户端连接和读写操作,这从根本上解决了传统同步阻塞I/O
一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
BIO、NIO、AIO 对比表
BIO | NIO | AIO | |
---|---|---|---|
IO模型 | 同步阻塞 | 同步非阻塞(多路复用) | 异步非阻塞 |
编程难道 | 简单 | 复杂 | 复杂 |
可靠性 | 差 | 好 | 好 |
吞吐量 | 低 | 高 | 高 |
补充:Linux下的五种IO模型
在Java中,主要有三种IO模型,分别是阻塞IO(BIO)、非阻塞IO(NIO)和 异步IO(AIO) Java中提供的IO有关的API,在文件处理时,还是依赖操作系统层面的IO实现的,如Linux2.6之后,Java中的NIO和AIO都是通过epoll来实现的,可以把Java中的BIO,NIO,AIO理解为Java对操作系统各种模型的封装,程序员在使用这些API的时候,不需要关心操作系统层面的知识,也不需要根据不同OS编写不同的代码,只需要使用Java的Api就好了
在Linux(UNIX)操作系统中,共有五种IO模型,分别是:阻塞IO模型、非阻塞IO模型、IO复用模型、信号驱动IO模型以及异步IO模型 那这五种IO模型到底是什么呢?
Linux下IO定义
我们常说的IO,指的是文件的输入和输出,但是在OS层面是如何定义IO的呢?到底什么样的过程可以叫做一次IO呢
拿一次磁盘文件读取为例,我们要读取的文件是存储在磁盘上的,我们的目的是把它读取到内存中,可以把这个步骤简化为把数据从硬盘,经内核空间过渡,最终读取到用户空间中,这就是一次完整的IO
阻塞IO模型
阻塞 I/O 是最简单的 I/O 模型,一般表现为进程或线程等待某个条件,如果条件不满足,则一直等下去。条件满足,则进行下一步操作。
当使用阻塞IO模型,应用进程通过调用
recvfrom
接收数据,但由于内核还未准备好数据报,应用进程就会阻塞住,直到内核准备好数据报,recvfrom
完成数据报复制工作,应用进程才能结束阻塞状态.
就像等快递一样,阻塞模型等快递,就是站小区门口死等(进入阻塞态,交出CPU时间片),啥也干不了.
非阻塞IO模型
非阻塞的IO模型。应用进程与内核交互,目的未达到之前,不再一味的等着,而是直接返回。然后通过轮询的方式,不停的去问内核数据准备有没有准备好。如果某一次轮询发现数据已经准备好了,那就把数据拷贝到用户空间中。
应用进程通过 recvfrom 调用不停的去和内核交互,直到内核准备好数据。如果没有准备好,内核会返回error,应用进程在得到error后,过一段时间再发送recvfrom请求。在两次发送请求的时间段,进程可以先做别的事情,一定不会阻塞于IO操作
从阻塞到非阻塞,是一种进步,通过轮询内核,得到返回值,使应用进程不需要进入阻塞.
等快递时候,一分钟给快递小哥打一个电话(不进入阻塞,不交出CPU时间片,这一分钟内还能做自己的事),一直打到快递小哥说到了,会占用应用和内核一定的资源
信号驱动IO模型
那么为了避免自己一遍遍打电话,我们约定:快递小哥送到快递(内核准备好数据报)之后,给我打个电话,我们收到电话后,去取快递
映射到Linux操作系统中,这就是信号驱动IO。应用进程在读取文件时通知内核,如果某个 socket 的某个事件发生时,请向我发一个信号。在收到信号后,信号对应的处理函数会进行后续处理。
应用进程预先向内核注册一个信号处理函数,然后用户进程返回不阻塞,继续执行进程
当内核数据准备就绪时会发送一个信号给应用进程,此时应用进程调用resvfrom函数,进行内核到用户空间的数据拷贝
IO多路复用模型
多个进程的IO可以注册到同一个管道上,这个管道会统一和内核进行交互。当管道中的某一个请求需要的数据准备好之后,进程再把对应的数据拷贝到用户空间中。多个进程的IO可以注册到同一个select上,当用户进程调用该select,select会监听所有注册好的IO,如果所有被监听的IO需要的数据都没有准备好时,select调用进程会阻塞。当任意一个IO所需的数据准备好之后,select调用就会返回,然后进程在通过recvfrom来进行数据拷贝。
这里的IO复用模型,并没有向内核注册信号处理函数,所以,他并不是非阻塞的。进程在发出select后,要等到select监听的所有IO操作中至少有一个需要的数据准备好,才会有返回,并且也需要再次发送请求去进行文件的拷贝。
上面这四种IO模型,阻塞,非阻塞,信号驱动,IO多路复用哪些是同步的,哪些是异步的?
为什么以上四种都是同步的
我们说阻塞IO模型、非阻塞IO模型、IO复用模型和信号驱动IO模型都是同步的IO模型。原因是因为,无论以上那种模型,真正的数据拷贝过程,都是同步进行的。
信号驱动难道不是异步的么? 信号驱动,内核是在数据准备好之后通知进程,然后进程再通过recvfrom操作进行数据拷贝。我们可以认为数据准备阶段是异步的,但是,数据拷贝操作是同步的。所以,整个IO过程也不能认为是异步的。
异步IO模型
应用进程把IO请求传给内核后,完全由内核去操作文件拷贝。内核完成相关操作后,会发信号告诉应用进程本次IO已经完成
用户进程发起aio_read
操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时,如何通知进程,然后就立刻去做其他事情了。当内核收到aio_read
后,会立刻返回,然后内核开始等待数据准备,数据准备好以后,直接把数据拷贝到用户空间,然后再通知进程本次IO已经完成。
对比这种收快递方式,不用管快递员是否送到货(内核准备好数据),快递员将把货直接放在指定位置(用户空间),之后通知你,货到了,这才是真正的异步IO
IO总结
回到Netty
原生 NIO 存在的问题
NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer等。 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等。4. JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU100%。直到 JDK1.7 版本该问题仍旧存在,没有被根本解决。
Netty 的优点
Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。
设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型-单线程,一个或多个线程池。 使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK5(Netty3.x)或 6(Netty4.x)就足够了。 高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。 安全:完整的 SSL/TLS 和 StartTLS 支持。 社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入。
由基础的网络编程,BIO逐渐演化到NIO,而Netty就是NIO的优化与封装,了解了基础再去看Netty的API,就知其所以然,开发调试不在话下了.