Java面试——Netty
一、BIO、NIO 和 AIO
【1】阻塞 IO(Blocking I/O):同步阻塞I/O模式,当一条线程执行 read() 或者 write() 方法时,这条线程会一直阻塞直到读取一些数据或者写出去的数据已经全部写出,在这期间这条线程不能做任何其他的事情。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。但是,当面对十万甚至百万级连接的时候,传统的 BIO模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
【2】非阻塞 NIO(New I/O):NIO是一种同步非阻塞的 I/O模型,NIO 与原有的 IO 有同样的作用和目的,但是使用的方式完全不同,NIO 支持面向缓冲区、基于通道的操作。NIO 将以更加高效的方式进行文件读写操作。JAVA NIO的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开 IO 设备(例如:文件、套接字)的连接。若需要使用 NIO系统,需要获取用于连接 IO设备的通道以及用于容纳数据的缓冲区。对数据进行处理。阻塞IO 会一直等待,所以非阻塞IO 是用来解决 IO线程与 Socket 之间的解耦问题【通过引入事件机制】,如果 Socket 发送缓冲区可写的话会通知 IO线程进行 write,如果 Socket 的接收缓冲区可读的话会通知 IO线程进行 read。NIO 提供了与传统 BIO 模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel。对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发。
【3】I/O 复用模型:非阻塞 IO中引入的机制就是 IO多路复用模型,Linux 提供了 epoll系统调用,epoll使用基于事件驱动方式代替顺序扫描(select/poll),因此性能更高。当有fd 就绪时,立即回调函数 rollback。IO多路复用技术底层就是通过 epoll 函数实现的。
【4】AIO (Asynchronous I/O):AIO 也就是 NIO2。在 Java 7 中引入了 NIO 的改进版 NIO2,它是异步非阻塞的IO模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。
二、Netty 的各大组件
【1】Channel:Channel 是 Netty 网络操作抽象类,它除了包括基本的 I/O 操作,如 bind、connect、read、write 之外,还包括了 Netty 框架相关的一些功能,如获取该 Channel 的 EventLoop。在传统的网络编程中,作为核心类的 Socket ,它对程序员来说并不是那么友好,直接使用其成本还是稍微高了点。而 Netty 的 Channel 则提供的一系列的 API ,它大大降低了直接与 Socket 进行操作的复杂性。而相对于原生 NIO 的 Channel,Netty 的 Channel 具有如下优势: ①、在 Channel 接口层,采用 Facade 模式进行统一封装,将网络 I/O 操作、网络 I/O 相关联的其他操作封装起来,统一对外提供。②、Channel 接口的定义尽量大而全,为 SocketChannel 和 ServerSocketChannel 提供统一的视图,由不同子类实现不同的功能,公共功能在抽象父类中实现,最大程度地实现功能和接口的重用。③、具体实现采用聚合而非包含的方式,将相关的功能类聚合在 Channel 中,有 Channel 统一负责和调度,功能实现更加灵活。
【2】EventLoop:Netty 基于事件驱动模型,使用不同的事件来通知我们状态的改变或者操作状态的改变。它定义了在整个连接的生命周期里当有事件发生的时候处理的核心抽象。Channel 为 Netty 网络操作抽象类,EventLoop 主要是为 Channel 处理 I/O 操作,两者配合参与 I/O 操作。Channel、EventLoop、Thread、EventLoopGroup之间的关系:
①、一个 EventLoopGroup 包含一个或多个 EventLoop。
②、一个 EventLoop 在它的生命周期内只能与一个 Thread绑定。
③、所有 EnventLoop 处理的 I/O事件都将在它专有的 Thread 上被处理。
④、一个 Channel 在它的生命周期内只能注册于一个 EventLoop。
⑤、一个 EventLoop 可被分配至一个或多个 Channel 。
当一个连接到达时,Netty 就会注册一个 Channel,然后从 EventLoopGroup 中分配一个 EventLoop 绑定到这个Channel上,在该 Channel的整个生命周期中都是有这个绑定的 EventLoop 来服务的。
【3】ChannelFuture:Netty 为异步非阻塞,即所有的 I/O 操作都为异步的,因此,我们不能立刻得知消息是否已经被处理了。Netty 提供了 ChannelFuture 接口,通过该接口的 addListener() 方法注册一个 ChannelFutureListener,当操作执行成功或者失败时,监听就会自动触发返回结果。
【4】ChannelHandler:ChannelHandler 为 Netty 中最核心的组件,它充当了所有处理入站和出站数据的应用程序逻辑的容器。ChannelHandler 主要用来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。ChannelHandler 有两个核心子类 ChannelInboundHandler 和 ChannelOutboundHandler,其中 ChannelInboundHandler 用于接收、处理入站数据和事件,而 ChannelOutboundHandler 则相反。
【5】ChannelPipeline:ChannelPipeline 为 ChannelHandler 链提供了一个容器并定义了用于沿着链传播入站和出站事件流的 API。一个数据或者事件可能会被多个 Handler 处理,在这个过程中,数据或者事件经流 ChannelPipeline,由 ChannelHandler 处理。在这个处理过程中,一个 ChannelHandler 接收数据后处理完成后交给下一个 ChannelHandler,或者什么都不做直接交给下一个 ChannelHandler。当一个数据流进入 ChannlePipeline 时,它会从 ChannelPipeline 头部开始传给第一个 ChannelInboundHandler ,当第一个处理完后再传给下一个,一直传递到管道的尾部。与之相对应的是,当数据被写出时,它会从管道的尾部开始,先经过管道尾部的 “最后” 一个ChannelOutboundHandler,当它处理完成后会传递给前一个 ChannelOutboundHandler 。当 ChannelHandler 被添加到 ChannelPipeline 时,它将会被分配一个 ChannelHandlerContext,它代表了 ChannelHandler 和 ChannelPipeline 之间的绑定。其中 ChannelHandler 添加到 ChannelPipeline 过程如下:
①、一个 ChannelInitializer 的实现被注册到了 ServerBootStrap 中;
②、当 ChannelInitializer.initChannel() 方法被调用时,ChannelInitializer 将在 ChannelPipeline 中安装一组自定义的ChannelHandler;
③、ChannelInitializer 将它自己从 ChannelPipeline 中移除;
三、Netty 的线程模型
【博客连接】:链接
四、TCP 粘包/拆包的原因及解决方法
【博客连接】:链接
五、了解哪几种序列化协议?包括使用场景和如何去选择
【博客连接:序列化的缺点】:链接
【博客连接:编辑码框架 Protobuf】:链接
【博客连接:编辑码框架 Thrift】:链接
六、Netty 的零拷贝实现
【博客连接】:链接
七、Netty 的高性能分析
【1】传输:IO 模型在很大程度上决定了框架的性能,相比于BIO,Netty建议采用异步通信模式,因为 NIO一个线程可以并发处理N个客户端连接和读写操作,这从根本上解决了传统同步阻塞IO "一连接一线程模型",架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。正如代码中所示,使用的是 NioEventLoopGroup 和 NioSocketChannel 来提升传输效率。
【2】协议:采用什么样的通信协议,对系统的性能极其重要,Netty 默认提供了对 Google Protobuf 的支持,也可以通过扩展Netty 的编解码接口,用户可以实现其它的高性能序列化框架。
【3】线程:Netty 使用了Reactor线程模型,但 Reactor 模型不同,对性能的影响也非常大,上面线程模型中就介绍常用的 Reactor 模型的三种情况。
八、基于BIO实现的Server端,当建立了100个连接时,会有多少个线程?如果基于NIO,又会是多少个线程? 为什么?
BIO由于不是 NIO那样的事件机制,在连接的 IO读取上,无论是否真的有读/写发生,都需要阻塞住当前的线程,对于基于 BIO实现的 Server端,通常的实现方法都是用一个线程去 accept连接,当连接建立后,将这个连接的 IO读写放到一个专门的处理线程,所以当建立100个连接时,通常会产生1个 Accept线程 + 100个处理线程。
NIO通过事件来触发,这样就可以实现在有需要读/写的时候才处理,不用阻塞当前线程,NIO在处理 IO的读写时,当从网卡缓冲区读或写入缓冲区时,这个过程是串行的,所以用太多线程处理 IO事件其实也没什么意义,连接事件由于通常处理比较快,用1个线程去处理就可以,IO事件呢,通常会采用 cpu core数+1或 cpu core数 * 2,这个的原因是 IO线程通常除了从缓冲区读写外,还会做些比较轻量的例如解析协议头等,这些是可以并发的,为什么不只用1个线程处理,是因为当并发的 IO事件非常多时,1个线程的效率不足以发挥出多核CPU 的能力,从而导致这个地方成为瓶颈,这种在分布式场景中比较明显,按照这个,也就更容易理解为什么在基于 Netty等写程序时,不要在 IO线程里直接做过多动作,而应该把这些动作转移到另外的线程池里去处理,就是为了能保持好 IO事件能被高效处理。从上面可以看出,对于大多数需要建立大量连接,但并发读写并不会同时产生的场景而言,NIO的优势是非常明显的。这种关于BIO、NIO的问法的变化空间是非常大的,还可以进一步拓展问问AIO和BIO、NIO的根本不同。
九、Netty 的心跳检测及重连机制怎么实现的
【博客连接】:链接
十、ChannelInboundHandlerAdapter 与 SimpleChannelInboundHandler 的区别
如下就是两个类的声明,SimpleChannelInboundHandler是继承 ChannelInboundHandlerAdapter`的。也就是说`SimpleChannelInboundHandler 也拥有 ChannelInboundHandlerAdapter的方法。
继承关系图:
SimpleChannelInboundHandler的channelRead相比ChannelInboundHandlerAdapter而言,主要做了类型匹配以及用完之后释放指向保存该消息的ByteBuf的内存引用。这里提供了一个模板,作用是把处理逻辑不变的内容写好在channelRead(ctx,msg)中,并且在里面调用channelRead0,这样变化的内容通过抽象方法实现传递到子类中去了(在Netty5中channelRead0已被重命名为 messageReceived)。
相比之下,ChannelInboundHandlerAdapter好像一无是处,毕竟他要自己处理资源的释放,例如如下的调用:buf.release();如果说 channelRead都是同步操作的话,SimpleChannelInboundHandler是不错的选择,如果操作是异步的话,那他的逻辑就有点麻烦了,例如你把数据交给另外的线程处理了,还没处理就会释放了 。这里必须说明一个问题,他的回收和 jvm的垃圾回收还不完全是一回事。netty是自己做了引用计数的操作。buf.refCnt(); 通过上面的 api就可以获取到计数的个数。具体的引用计数的部分,不知道也不影响netty的学习,这个点后面具体再说。ChannelInboundHandlerAdapter处理自由的优点也就提现出来了,可以更好的处理更多的特定场景。
那该方法是什么时候释放资源的呢? 当 writeAndFlush()方法被调用时才被释放,我们点进去源码验证一下:
上面源码中,最后资源是通过ReferenceCountUtil来释放的。也就是说,当我们需要释放ByteBuf相关内存的时候,也可以使用ReferenceCountUtil.release()。ReferenceCountUtil底层实现是 ReferenceCounted,当新的对象初始化的时候计数为1,retain()方法被调用时引用计数加1,release()方法被调用时引用计数减1,当计数减少到0的时候会被显示清除,再次访问被清除的对象会出现访问冲突(这里想起了JVM判断对象是否存活的引用计数算法)。ReferenceCountUtil.release源码如下:
SimpleChannelInboundHandler 和 ChannelInboundHandlerAdapter区别:
在客户端的业务 Handler继承的是 SimpleChannelInboundHandler,而在服务器端继承的是 ChannelInboundHandlerAdapter。最主要的区别就是 SimpleChannelInboundHandler在接收到数据后会自动release掉数据占用的Bytebuffer资源(自动调用Bytebuffer.release())。而为何服务器端不能用呢,因为我们想让服务器把客户端请求的数据发送回去,而write() 操作是异步的,而服务器端有可能在channelRead方法返回后还没有写完数据,因此不能让它自动release。SimpleChannelInboundHandler是有泛型参数的。可以指定一个具体的类型参数,通过 decoder配合使用,非常方便。ChannelInboundHandlerAdapter则是直接操作byte数组的。
SimpleChannelInboundHandler的好处是可以处理不同的类型对象,并且可以做释放。ChannelInboundHandlerAdapter的好处则是更自由,在异步的场景下更适合。
ChannelInboundHandlerAdapter 注意事项 : 用户自定义了一个Handler类,代码如下:
行时出现了如下堆栈异常:
异常说是引用计数 refCnf = 0,也就是没有被引用,因此报错;常规的 channelRead中消息读取完毕是要立即释放当前消息的引用计数即(减一操作)如下代码操作。但是其他 `Handler`也去释放时,发现没有引用,就会报错。
分析: 通过调试代码发现根源就在super.channelRead(ctx, msg);这个函数。其实 ChannelInboundHandlerAdapter是提供了一种默认实现,子类如果要继承,需要覆盖父类中的方法,并且不需要调用 super.xxxxMethod()。源码如下:意思是此实现只是将操作转发到下一个 channelhandler。子类可以重写方法实现来改变这一点。
/** * <p> * This implementation just forward the operation to the next {@link ChannelHandler} in the * {@link ChannelPipeline}. Sub-classes may override a method implementation to change this. * </p> */ public class ChannelInboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelInboundHandler {