《2021最新Java面试题全集-2021年第二版》不断更新完善!

    

第二十一章 Netty

1:JavaIONIO模型有哪些?

1)阻塞 IO 模型

最传统的一种 IO 模型,即在读写数据过程中会发生阻塞现象。当用户线程发出 IO 请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出 CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除 block 状态。

典型的阻塞 IO 模型的例子为: data = socket.read();如果数据没有就绪,就会一直阻塞在 read 方法。

2)非阻塞 IO 模型

当用户线程发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。 如果结果是一个error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。

一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。

所以事实上,在非阻塞 IO 模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞 IO不会交出 CPU,而会一直占用 CPU

典型的非阻塞 IO 模型一般如下:但是对于非阻塞 IO 就有一个非常严重的问题, while 循环中需要不断地去询问内核数据是否就绪,这样会导致 CPU 占用率非常高,因此一般情况下很少使用 while 循环这种方式来读取数据。

3)多路复用 IO 模型

多路复用 IO 模型是目前使用得比较多的模型。 Java NIO 实际上就是多路复用 IO。在多路复用 IO模型中,会有一个线程不断去轮询多个 socket 的状态,只有当 socket 真正有读写事件时,才真正调用实际的 IO读写操作。

因为在多路复用 IO 模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket 读写事件进行时,才会使用 IO资源,所以它大大减少了资源占用。

Java NIO 中,是通过 selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。

多路复用 IO模式,通过一个线程就可以管理多个 socket,只有当socket 真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用 IO 比较适合连接数比较多的情况。

另外多路复用 IO 为何比非阻塞 IO 模型的效率高是因为在非阻塞 IO 中,不断地询问 socket 状态时通过用户线程去进行的,而在多路复用 IO 中,轮询每个 socket 状态是内核在进行的,这个效率要比用户线程要高的多。

不过要注意的是,多路复用 IO 模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用 IO 模型来说, 一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。

4)信号驱动 IO 模型

在信号驱动 IO 模型中,当用户线程发起一个 IO 请求操作,会给对应的 socket 注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用 IO 读写操作来进行实际的 IO 请求操作。

5)异步 IO 模型

异步 IO 模型才是最理想的 IO 模型,在异步 IO 模型中,当用户线程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个 asynchronous read 之后,它会立刻返回,说明 read 请求已经成功发起了,因此不会对用户线程产生任何block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。

也就说用户线程完全不需要实际的整个 IO 操作是如何进行的, 只需要先发起一个请求,当接收内核返回的成功信号时表示 IO 操作已经完成,可以直接去使用数据了。

在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。

这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用 IO 函数进行实际的读写操作;而在异步 IO 模型中,收到信号表示 IO 操作已经完成,不需要再在用户线程中调用 IO 函数进行实际的读写操作。

 

2:BIO NIO AIO 的区别?

1BIO

一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理。线程开销大。

伪异步 IO:将请求连接放入线程池,一对多,但线程还是很宝贵的资源。

2NIO

一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 I/O 请求时才启动一个线程进行处理。

3AIO

一个有效请求一个线程,客户端的 I/O 请求都是由 OS 先完成了再通知服务器应用去启动线程进行处理。

4BIO是面向流的,NIO 是面向缓冲区的;BIO 的各种流是阻塞的。而NIO是非阻塞的;BIO Stream 是单向的,而NIOchannel 是双向的。

5NIO 的特点:

   1)事件驱动模型

   2)单线程处理多任务

   3)非阻塞 I/O I/O 读写不再阻塞,而是返回 0,基于 block 的传输比基于流的传输更高效

   4)更高级的 I/O 函数 zero-copy

   5I/O 多路复用,大大提高了 Java 网络应用的可伸缩性和实用性。基于 Reactor 线程模型。

Reactor 模式中,事件分发器等待某个事件或者可应用或个操作的状态发生,事件分发器就把这个事件传给事先注册的事件处理函数或者回调函数,由后者来做实际的读写操作。

如在 Reactor 中实现读:注册读就绪事件和相应的事件处理器、事件分发器等待事件、事件到来,激活分发器,分发器调用事件对应的处理器、事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。

 

3:NIO 的组成?

NIO 主要有三大核心部分: Channel(通道) Buffer(缓冲区), Selector。传统 IO 基于字节流和字符流进行操作, NIO 基于 Channel Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。 Selector(选择区)用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。NIO 和传统 IO 之间第一个最大的区别是, IO 是面向流的, NIO 是面向缓冲区的。

1Buffer

缓冲区,与 Channel 进行交互,实际上是一个容器,是一个连续数组。 Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer

客户端发送数据时,必须先将数据存入 Buffer 中,然后将 Buffer 中的内容写入通道。服务端这边接收数据必须通过 Channel 将数据读入到 Buffer 中,然后再从 Buffer 中取出数据来处理。

NIO 中, Buffer 是一个顶层父类,它是一个抽象类,常用的 Buffer 的子类有:ByteBuffer IntBuffer CharBuffer LongBuffer DoubleBuffer FloatBufferShortBuffer

Buffer 创建和销毁的成本更高,不可控,通常会用内存池来提高性能。直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区。如果数据量比较小的中小应用情况下,可以考虑使用 heapBuffer,由 JVM 进行管理。

2Channel

表示 I/O 源与目标打开的连接,是双向的,但不能直接访问数据,只能与 Buffer进行交互。

NIO 中的 Channel 的主要实现有:

·FileChannel

·DatagramChannel

·SocketChannel

·ServerSocketChannel

这里看名字就可以猜出个所以然来:分别可以对应文件 IO UDP TCPServer Client

3Selector

Selector 类是 NIO 的核心类, Selector 能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的响应处理。

这样一来,只是用一个单线程就可以管理多个通道,也就是管理多个连接。这样使得只有在连接真正有读写事件发生时,才会调用函数来进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程,并且避免了多线程之间的上下文切换导致的开销。

Linux 的实现类是 EPollSelectorImpl,委托给 EPollArrayWrapper 实现,其中三个native 方法是对 epoll 的封装。

 

4:Netty 的特点?

一个高性能、异步事件驱动的 NIO 框架,它提供了:

(1)  TCP UDP 和文件传输的支持

(2)  使用更高效的 socket 底层,对 epoll 空轮询引起的 cpu 占用飙升在内部进行了处理

(3)  避免了直接使用 NIO 的陷阱,简化了 NIO 的处理方式

(4)  采用多种 decoder/encoder 支持

(5)  TCP 粘包/分包进行自动化处理

(6)  可使用接受/处理线程池,提高连接效率

(7)  对重连、心跳检测的简单支持

(8)  可配置 I/O 线程数、 TCP 参数

(9)  TCP 接收和发送缓冲区使用直接内存代替堆内存,通过内存池的方式循环利用 ByteBuf通过引用计数器及时申请释放不再引用的对象,降低了 GC 频率使用单线程串行化的方式

(10)高效的 Reactor 线程模型大量使用了 volitale、使用了 CAS 和原子类、线程安全类的使用、读写锁的使用

 

5:Netty 的线程模型?

Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,boss 线程池和 work 线程池。

其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 work线程池。

其中 work 线程池负责请求的 read write 事件,由对应的 Handler 处理。

1)单线程模型:所有 I/O 操作都由一个线程完成,即多路复用、事件分发和处理都是在一个Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取请求或应答/响应消息。一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适。

2)多线程模型:有一个 NIO 线程( Acceptor 只负责监听服务端,接收客户端的 TCP 连接请求; NIO 线程池负责网络 IO 的操作,即消息的读取、解码、编码和发送; 1 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应 1 NIO 线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个 Acceptor 线程可能会存在性能不足问题。

3)主从多线程模型: Acceptor 线程用于绑定监听端口,接收客户端连接,将 SocketChannel从主线程池的 Reactor 线程的多路复用器上移除,重新注册到 Sub 线程池的线程上,用于处理 I/O 的读写等操作,从而保证 mainReactor 只负责接入认证、握手等操作;

 

6:TCP 粘包/拆包的原因及解决方法?

TCP 是以流的方式来处理数据,一个完整的包可能会被 TCP 拆分成多个包进行发送,也可能把小的封装成一个大的数据包发送。

TCP 粘包/分包的原因: 应用程序写入的字节大小大于套接字发送缓冲区的大小,会发生拆包现象,而应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包现象。

进行 MSS 大小的 TCP 分段,当 TCP 报文长度-TCP 头部长度>MSS 的时候将发生拆包以太网帧的 payload(净荷)大于 MTU 1500 字节)进行 ip 分片。

解决方法 消息定长: FixedLengthFrameDecoder 类包尾增加特殊字符分割:行分隔符类: LineBasedFrameDecoder 或自定义分隔符类 DelimiterBasedFrameDecoder将消息分为消息头和消息体: LengthFieldBasedFrameDecoder 类。分为有头部的拆包与粘包、长度字段在前且有头部的拆包与粘包、多扩展头部的拆包与粘包。

7:了解哪几种序列化协议?

序列化(编码)是将对象序列化为二进制形式(字节数组),主要用于网络传输、数据持久化等;而反序列化(解码)则是将从网络、磁盘等读取的字节数组还原成原始对象,主要用于网络传输对象的解码,以便完成远程调用。

影响序列化性能的关键因素:序列化后的码流大小(网络带宽的占用)、序列化的性能( CPU 资源占用);是否支持跨语言(异构系统的对接和开发语言切换)。

1Java 默认提供的序列化:无法跨语言、序列化后的码流太大、序列化的性能差

2XML,优点:人机可读性好,可指定元素或特性的名称。 缺点:序列化数据只包含数据本身以及类的结构,不包括类型标识和程序集信息;只能序列化公共属性和字段;不能序列化方法;文件庞大,文件格式复杂,传输占带宽。 适用场景:当做配置文件存储数据,实时数据转换。

3JSON,是一种轻量级的数据交换格式 优点:兼容性高、数据格式比较简单,易于读写、序列化后数据较小,可扩展性好,兼容性好、与 XML 相比,其协议比较简单,解析速度比较快。 缺点:数据的描述性比 XML 差、不适合性能要求为 ms 级别的情况、额外空间开销比较大。 适用场景(可替代XML):跨防火墙访问、可调式性要求高、基于 Webbrowser Ajax 请求、传输数据量相对小,实时性要求相对低(例如秒级别)的服务。

4Fastjson,采用一种假定有序快速匹配的算法。 优点:接口简单易用、目前 java 语言中最快的 json 库。 缺点:过于注重快,而偏离了标准及功能性、代码质量不高,文档不全。 适用场景:协议交互、 Web 输出、 Android 客户端

5Thrift,不仅是序列化协议,还是一个 RPC 框架。 优点:序列化后的体积小, 速度快、支持多种语言和丰富的数据类型、对于数据字段的增删具有较强的兼容性、支持二进制压缩编码。 缺点:使用者较少、跨防火墙访问时,不安全、不具有可读性,调试代码时相对困难、不能与其他传输层协议共同使用(例如 HTTP)、无法支持向持久层直接读写数据,即不适合做数据持久化序列化协议。 适用场景:分布式系统的 RPC 解决方案

6Avro Hadoop 的一个子项目,解决了 JSON 的冗长和没有 IDL 的问题。 优点:支持丰富的数据类型、简单的动态语言结合功能、具有自我描述属性、提高了数据解析速度、快速可压缩的二进制数据形式、可以实现远程过程调用 RPC、支持跨编程语言实现。 缺点:对于习惯于静态类型语言的用户不直观。 适用场景:在 Hadoop 中做 Hive Pig MapReduce的持久化数据格式。

7Protobuf,将数据结构以.proto 文件进行描述,通过代码生成工具可以生成对应数据结构的POJO 对象和 Protobuf 相关的方法和属性。 优点:序列化后码流小,性能高、结构化数据存储格式( XML JSON 等)、通过标识字段的顺序,可以实现协议的前向兼容、结构化的文档更容易管理和维护。 缺点:需要依赖于工具生成代码、支持的语言相对较少,官方只支持Java C++ python 适用场景:对性能要求高的 RPC 调用、具有良好的跨防火墙的访问属性、适合应用层对象的持久化

8Hessian 采用二进制协议的轻量级 remoting onhttp 工具 kryo 基于 protobuf 协议,只支持 java 语言,需要注册( Registration),然后序列化( Output),反序列化( Input

 

8:如何选择序列化协议?

对于公司间的系统调用,如果性能要求在 100ms 以上的服务,基于 XML SOAP 协议是一个值得考虑的方案。

基于 Web browser Ajax,以及 Mobile app 与服务端之间的通讯, JSON 协议是首选。

对于性能要求不太高,或者以动态类型语言为主,或者传输数据载荷很小的的运用场景, JSON也是非常不错的选择。 对于调试环境比较恶劣的场景,采用 JSON XML 能够极大的提高调试效率,降低系统开发成本。

当对性能和简洁性有极高要求的场景, Protobuf Thrift Avro 之间具有一定的竞争关系。 对于 T 级别的数据的持久化应用场景, Protobuf Avro 是首要选择。

如果持久化后的数据存储在 hadoop 子项目里, Avro 会是更好的选择。 对于持久层非 Hadoop 项目,以静态类型语言为主的应用场景, Protobuf 会更符合静态类型语言工程师的开发习惯。 由于 Avro 的设计理念偏向于动态类型语言,对于动态语言为主的应用场景, Avro 是更好的选择。

如果需要提供一个完整的 RPC 解决方案, Thrift 是一个好的选择。 如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,Protobuf 可以优先考虑。

 

9:Netty 的零拷贝实现?

Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。堆内存多了一次内存拷贝, JVM 会将堆内存Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。

ByteBuffer ChannelConfig 分配,而 ChannelConfig 创建 ByteBufAllocator 默认使用 Direct BufferCompositeByteBuf 类可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer

addComponents 方法将 header body 合并为一个逻辑上的 ByteBuf, 这两个 ByteBuf CompositeByteBuf 内部都是单独存在的, CompositeByteBuf 只是逻辑上是一个整体通过 FileRegion 包装的。

FileChannel.tranferTo 方法 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。

通过 wrap 方法, 我们可以将 byte[] 数组、 ByteBuf ByteBuffer 等包装成一个 NettyByteBuf 对象, 进而避免了拷贝操作。

Selector BUG:若 Selector 的轮询结果为空,也没有 wakeup 或新消息处理,则发生空轮询, CPU 使用率 100% Netty 的解决办法:对 Selector select 操作周期进行统计,每完成一次空的 select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则触发了 epoll 死循环 bug

重建Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭。

 

10:Netty 的高性能表现在哪些方面?

1)心跳:

对服务端:会定时清除闲置会话,对客户端:用来检测会话是否断开,是否重来,检测网络延迟,其中 idleStateHandler 用来检测会话状态。

2)串行无锁化设计:

即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。

表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。

3)可靠性,链路有效性检测:

链路空闲检测机制,读/写空闲超时机制

4)内存保护机制:

通过内存池重用 ByteBuf;ByteBuf 的解码保护

5)优雅停机:

不再接收新消息、退出前的预处理操作、资源的释放操作。

6Netty 安全性:

支持的安全协议: SSL V2 V3 TLS SSL 单向认证、双向认证和第三方 CA认证。

7)高效并发编程的体现:

volatile 的大量、正确使用

CAS 和原子类的广泛使用;

线程安全容器的使用;

通过读写锁提升并发性能。

8I/O 通信性能三原则:

传输( AIO)、协议( Http)、线程(主从多线程) 流量整型的作用(变压器):防止由于上下游网元性能不均衡导致下游网元被压垮,业务流中断;防止由于通信模块接受消息过快,后端业务线程处理不及时导致撑死问题。