Netty学习基础:BIO、NIO、AIO
其实我的重点呢,是来和大家一起学习接下来的Netty篇
然而嘞,这个Netty又不太合适直接讲,为啥呢,我们学习一门技术必须知道这门技术的由来的初衷是啥,对吧
先来给大家简单的介绍一下Netty是什么
Netty是一个提供异步事件驱动的网络应用程序框架,用以快速开发高性能、高可靠的网络服务器和客户端程序
Netty简化了网络程序的开发,属于BIO、NIO、AIO的演变中的产物,属于一种NIO框架
在我们平时使用的很多中间件中,很多底层通信都是采用的Netty,比如rocketmq、dubbo,这些我们最常见的底层通信都是用的netty,足以可见这个的性能是多么的优秀了
ok,接下来再来理解一下同步、异步、阻塞、非阻塞这四个概念
从简单的开始,我们以经典的读取文件的模型举例。(对操作系统而言,所有的输入输出设备都被抽象成文件。)
在发起读取文件的请求时,应用层会调用系统内核的I/O接口。
阻塞和非阻塞
如果应用层调用的是阻塞型I/O,那么在调用之后,应用层即刻被挂起,一处于等待数据返回的状态,直到系统内核从磁盘读取完数据并返回给应用层,应用层才用获得的数据进行接下来的其他操作。
如果应用层调用的是非阻塞I/O,那么调用后,系统内核会立即返回(虽然还没有文件内容的数据),应用层并不会被挂起,它可以做其他任意它想做的操作。(至于文件内容数据如何返回给应用层,这已经超出了阻塞和非阻塞的辨别范畴。)
这便是(脱离同步和异步来说之后)阻塞和非阻塞的区别。总结来说,是否是阻塞还是非阻塞,关注的是接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的,可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。
同步和异步
阻塞和非阻塞解决了应用层等待数据返回时的状态问题,那系统内核获取到的数据到底如何返回给应用层呢?这里不同类型的操作便体现的是同步和异步的区别。
对于同步型的调用,应用层需要自己去向系统内核问询,如果数据还未读取完毕,那此时读取文件的任务还未完成,应用层根据其阻塞和非阻塞的划分,或挂起或去做其他事情(所以同步和异步并不决定其等待数据返回时的状态);如果数据已经读取完毕,那此时系统内核将数据返回给应用层,应用层即可以用取得的数据做其他相关的事情。
而对于异步型的调用,应用层无需主动向系统内核问询,在系统内核读取完文件数据之后,会主动通知应用层数据已经读取完毕,此时应用层即可以接收系统内核返回过来的数据,再做其他事情。
这便是(脱离阻塞和非阻塞来说之后)同步和异步的区别。也就是说,是否是同步还是异步,关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用。
上面这几个概念大家一定要搞懂,这是基础,必须好好理解上面这些,才能真正理解netty的出处,这也是面试常被问到的点之一
总结一下
阻塞和非阻塞,关注的是发起请求之后等待数据返回时的状态,被挂起无法执行其他操作的是阻塞型的,可以立即去进行其他作业的是非阻塞型的
同步和异步,关注的是任务完成时的消息通知的方式,由调用方主动去询问的方式属于同步调用,而被调用方主动通知调用方该任务已完成的方式属于异步调用
这个在网上最常见的一个例子就是烧水的例子了,我也继续给大家啰嗦一下咯
老王烧水,老王把水放在炉子上,在这里干等着,啥也没有去做,并且需要随时看着水是否开了,这叫阻塞同步,阻塞是因为老王啥也不能去做,同步是因为水开他得自己看着
老王后来学精了,不在这里傻等着了,把水放在炉子上之后,然后就去开了一把紧张又刺激的lol手游,这叫非阻塞同步,非阻塞是因为老王在等水期间自己打游戏了,同步是因为水开他还是得自己看着
后来,老王觉得自己看着水太麻烦了,于是买了个升级版的水壶,牛了啊,这个水壶把水煮开了之后,会吹哨,哎
老王不需要每隔几分钟就去看一眼水是否开了,只需要听这个哨声即可,做水期间可以打游戏,并且水开了还会主动通知老王,这就是异步非阻塞,非阻塞就是因为老王可以去玩游戏,异步就是水壶的那个哨子
这下大家应该很好理解了吧!
接下来继续看BIO、NIO、AIO
Socket 网络通信过程简单来说分为下面 4 步:
建立服务端并且监听客户端请求
客户端请求,服务端和客户端建立连接
两端之间可以传递数据
关闭资源
传统的阻塞式通信BIO流程
BIO就是属于最传统的一种阻塞同步的通信方式,也是属于最简单的一种,使用起来比较方便,但是处理并发能力低,通信比较耗时
服务器会通过一个线程负责监听客户端请求和为每一个客户端创建一个新的线程进行链路的处理,属于一种典型的请求应答模式,若客户端数量增加,则需要频繁的创建和销毁线程,会给服务器增加很大的压力
服务器提供IP地址和监听的端口,客户端通过TCP的三次握手和服务器建立连接通信,连接成功之后,双方进行通过,之后通过四次挥手进行断开连接
即使用线程池的方式来改进新增加线程,这也是属于一种伪异步IO,这样实现能够为少数的客户端提供服务,如果客户端并发量足够多,还是会因为线程池满导致OOM的问题
给大家看一个简单的Demon
public class SocketServer {
public static void main(String[] args) throws IOException {
SocketServer socketServer = new SocketServer();
socketServer.start(9000);
}
public void start(int port) {
//1.创建 ServerSocket 对象并且绑定一个端口
try (ServerSocket server = new ServerSocket(port);) {
System.out.println("server start");
Socket socket;
//2.通过 accept()方法监听客户端请求, 这个方法会一直阻塞到有一个连接建立
while ((socket = server.accept()) != null) {
System.out.println("client connected");
try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) {
//3.通过输入流读取客户端发送的请求信息
String message = (String) objectInputStream.readObject();
System.out.println("server receive message:" + message);
//4.通过输出流向客户端发送响应信息
objectOutputStream.writeObject(message);
objectOutputStream.flush();
} catch (IOException | ClassNotFoundException e) {
System.out.println("occur exception:");
}
}
} catch (IOException e) {
System.out.println("occur IOException:");
}
}
}
这是服务端的代码
public class Client {
public Object send(String message, String host, int port) {
//1. 创建Socket对象并且指定服务器的地址和端口号
try (Socket socket = new Socket(host, port)) {
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
//2.通过输出流向服务器端发送请求信息
objectOutputStream.writeObject(message);
//3.通过输入流获取服务器响应的信息
ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
return objectInputStream.readObject();
} catch (ClassNotFoundException | IOException e) {
System.out.println("occur exception:");
}
return null;
}
public static void main(String[] args) {
Client helloClient = new Client();
helloClient.send("content from client", "127.0.0.1", 9000);
System.out.println("发送数据成功");
}
}
这是客户端的代码,我们接下来先运行服务器,再运行客户端,看效果
服务器启动之后,便会一直阻塞在这里,等待客户端的连接处理
接着我们启动客户端,然后看到发送数据成功,此时我们再切换到服务器的控制台,看下效果
我们也可以通过命令行直接执行telnet localhost 9000去连接服务端,效果如下
从上面例子看出的问题
我们看到服务器和客户端成功的进行通信了,也就是这段服务器的代码只能同时为一个客户端服务,当然有改进方法,我们监听到连接之后,就立刻new Thread().start()创建一个线程用于这个客户端接下来的处理
这也就意味着,每一个客户端都要建立一个线程为其处理,如果客户端数量很多,或者说客户端处理很慢,那就很糟糕了
我们从线程文章中也介绍过线程是一个很宝贵的资源,我们需要合理的利用这些资源,需要根据机器的性能去合理的控制线程的数量
即使线程池可以优化上面的例子,让线程创建和销毁的成本降低,我们也可以执行线程池的最大数量,控制线程资源的使用,但是,即使如何改进,我们并没有从根本上解决这个问题,根本上还是属于BIO,也就是同步阻塞IO的模式
NIO
同步非阻塞模型,在JDK1.4中引入了NIO的框架,NIO 中的 N 可以理解为 Non-blocking,NIO是面向缓冲Buffer的,基于通道Channel的操作
NIO提供了和传统BIO模型中的ServerSocket和Socket相对应的ServerSocketChannel和SocketChannel两种不同的套接字通道,对应服务端和客户端
两种通道都支持阻塞和非阻塞的模式
阻塞模式一般不会被使用,既然使用了阻塞,那就意味着使用起来就像上面的BIO一样了,性能和可靠性都不是很好
非阻塞模式,对于高负载和高并发的网络应用是很友好的,后续我们要说的Netty就是基于这个改进的
NIO 相对于BIO来说一大进步。客户端和服务器之间通过Channel通信。NIO可以在Channel进行读写操作。这些Channel都会被注册在Selector多路复用器上。Selector通过一个线程不停的轮询这些Channel。找出已经准备就绪的Channel执行IO操作。
NIO 通过一个线程轮询,实现千万个客户端的请求,这就是非阻塞NIO的特点。
NIO核心组件
Channel:和流不同,通道是双向的。NIO可以通过Channel进行数据的读,写和同时读写操作。通道分为两大类:一类是网络读写(SelectableChannel),一类是用于文件操作(FileChannel),我们使用的SocketChannel和ServerSocketChannel都是SelectableChannel的子类
Buffer:它是NIO与BIO的一个重要区别。BIO是将数据直接写入或读取到Stream对象中。而NIO的数据操作都是在缓冲区中进行的。缓冲区实际上是一个数组
Selector和Selection Key:多路复用器提供选择已经就绪的任务的能力。就是Selector会不断地轮询注册在其上的通道(Channel),如果某个通道处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以取得就绪的Channel集合,从而进行后续的IO操作。服务器端只要提供一个线程负责Selector的轮询,就可以接入成千上万个客户端
接下来我们看使用的例子
public class NioServer {
static List
channelList = new ArrayList<>();
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9000));
//设置serverSocketChannel为非阻塞
serverSocketChannel.configureBlocking(false);
System.out.println("服务器启动成功");
while (true){
//非阻塞模式的accept不会阻塞,否则会阻塞
//NIO的非阻塞是由操作系统实现的,底层调用了Linux内核的accept函数
SocketChannel socketChannel = serverSocketChannel.accept();
if(socketChannel != null){ //此时有客户端连接
System.out.println("有客户端连接");
socketChannel.configureBlocking(false);
channelList.add(socketChannel);
}
//遍历
Iterator
iterator = channelList.iterator(); while (iterator.hasNext()){
SocketChannel channel = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
int read = channel.read(byteBuffer);
if(read > 0){
System.out.println("接收到消息:" + new String(byteBuffer.array()));
}else if(read == -1){
iterator.remove();
System.out.println("客户端断开连接");
}
}
}
}
}
这里我们只写了服务端的代码,客户端就通过telnet来模拟就行了
我们用debug的模式看下服务端
启动成功之后,发现NIO模式下竟然没有在accept函数这里阻塞,而是直接执行过去了
NIO优点
NIO最大的优点,就是引入了IO多路复用机制,使得一个服务器可以同时为大量的客户端提供服务的同时,效率也不会低,而这个IO多路复用这里,经常遇到的一个面试题就是select、poll、epoll的区别,这个我会单独开一篇给大家说清楚,这一篇放不下了
NIO存在的问题
NIO跨平台和兼容性问题
使用NIO的时候需要考虑Linux平台和Windows平台的兼容性问题,如果该程序运行在多个平台,则需要考虑测试多个平台
NIO2看起来很理想,但是NIO2只支持Jdk1.7+,若你的程序在Java1.6上运行,则无法使用NIO2。另外,Java7的NIO2中没有提供DatagramSocket的支持,所以NIO2只支持TCP程序,不支持UDP程序
NIO对缓冲区的聚合和分散操作可能会导致内存泄露
很多Channel的实现支持Gather和Scatter。这个功能允许从从多个ByteBuffer中读入或写入,这样做可以有更好的性能。
例如,你可能希望header在一个ByteBuffer中,而body在另外的ByteBuffer中;
下图显示的是Scatter(分散),将ScatteringByteBuffer中的数据分散读取到多个ByteBuffer中:
下图显示的是Gather(聚合),将多个ByteBuffer的数据写入到GatheringByteChannel:
可惜Gather/Scatter功能会导致内存泄露,知道Java7才解决内存泄露问题。使用这个功能必须小心编码和Java版本
Squashing the famous epoll bug(压碎著名的epoll bug)
著名的epoll-bug也可能会导致无效的状态选择和100%的CPU利用率。要解决epoll-bug的唯一方法是回收旧的选择器,将先前注册的通道实例转移到新创建的选择器上。
不是十分的清楚这里,感兴趣的可以去更深的了解下这里
还有一个很真实贴切的问题,就是这个对于开发者来说太不友好了,开发成本和维护成本都比较高
AIO
AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
AIO 是异步IO的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。
AIO 并没有采用NIO的多路复用器,而是使用异步通道的概念。其read,write方法的返回类型都是Future对象
而Future模型是异步的,其核心思想是:去主函数等待时间。AIO模型中通过AsynchronousSocketChannel和AsynchronousServerSocketChannel完成套接字通道的实现。非阻塞,异步
结束语
感谢大家能够做我最初的读者和传播者,请大家相信,只要你给我一份爱,我终究会还你们一页情的。
我会持续更新技术文章,和生活中的暴躁文章,欢迎大家关注【左耳君】,我们一起乘千里风、破万里浪
哦对了,后续所有的文章都会更新到这里
https://github.com/DayuMM2021/Java