typora-copy-images-to: imgs
Netty 是什么呢?Netty 用于做什么呢?
答:
Netty 是一个 NIO 客户服务端框架,可以快速开发网络应用程序
,如协议服务端和客户端,极大简化了网络编程,如 TCP 和 UDP 套接字服务(来自官网)
热门开源项目如 Dubbo、RocketMQ 底层都是用了 Netty
Netty怎么实现高性能设计?
答:
Netty 高性能的三个方面:
- 传输:用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,IO 模型在很大程度上决定了框架的性能。
IO模型的选择
- 协议:采用什么样的通信协议,HTTP 或者内部私有协议。协议的选择不同,性能模型也不同。相比于公有协议,内部私有协议的性能通常可以被设计的更优。
协议的选择
- 线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,Reactor 线程模型的不同,对性能的影响也非常大。
线程模型的选择
介绍一下 AIO、BIO 和 NIO?
答:
- AIO:
从 Java.1.7 开始,Java 提供了 AIO(异步IO),Java 的 AIO 也被称为 “NIO.2”
Java AIO 采用订阅-通知
模式,应用程序向操作系统注册 IO 监听,之后继续做自己的事情,当操作系统发生 IO 事件并且已经准备好数据时,主动通知应用程序,应用程序再进行相关处理
(Linux 平台没有这种异步 IO 技术,而是使用 epoll 对异步 IO 进行模拟)
- BIO:
BIO 即同步阻塞 IO,服务端实现模式为一个连接对应一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理
BIO简单工作流程:
- 服务器端启动一个 ServerSocket
- 客户端启动 Socket 对服务器进行通信,默认情况下服务器端需要对每个客户端建立一个线程与之通讯
- 客户端发出请求后, 先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
- 如果有响应,客户端线程会等待请求结束后,再继续执行
BIO存在问题:
- 当并发量较大时,需要创建大量线程来处理连接,比较占用系统资源
- 连接建立之后,如果当前线程暂时没有数据可读,则线程会阻塞在 Read 操作上,造成线程资源浪费
- NIO:
从 Java1.4 开始,Java 提供了 NIO,NIO 即 “Non-blocking IO”(同步非阻塞IO)
NIO 的几个核心概念:
-
Channel、Buffer:BIO是基于字节流或者字符流的进行操作,而NIO 是基于
缓冲区
和通道
进行操作的,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中 -
Selector:选择器用于监听多个通道的事件(如,连接打开,数据到达),因此,单个线程可以监听多个数据通道,极大提升了单机的并发能力
当 Channel 上的 IO 事件未到达时,线程会在 select 方法被挂起,让出 CPU 资源,直到监听到 Channel 有 IO 事件发生,才会进行相应的处理
- NIO和BIO有什么区别?
- NIO是以
块
的方式处理数据,BIO是以字节流或者字符流
的形式去处理数据。 - NIO是通过
缓存区和通道
的方式处理数据,BIO是通过InputStream和OutputStream流
的方式处理数据。 - NIO的通道是双向的,BIO流的方向只能是单向的。
- NIO采用的多路复用的同步非阻塞IO模型,BIO采用的是普通的同步阻塞IO模型。
- NIO的效率比BIO要高,NIO适用于网络IO,BIO适用于文件IO。
NIO如何实现了同步非阻塞?
通过 Selector 和 Channel 来进行实现,一个线程使用一个 Selector 监听多个 Channel 上的 IO 事件,通过配置监听的通道Channel为非阻塞,那么当Channel上的IO事件还未到达时,线程会在select方法被挂起,让出CPU资源。直到监听到Channel有IO事件发生时,才会进行相应的响应和处理。
介绍一下 Netty 使用的线程模型?
答:
Netty 主要基于主从 Reactor 多线程模型,其中主从 Reactor 多线程模型将 Reactor 分为两部分:
- mainReactor:监听 Server Socket,用来处理网络 IO 连接建立操作,将建立的 SocketChannel 指定注册给 subReactor
- subReactor:和建立起来的 socket 做数据交互和业务处理操作
因为客户端的连接数量相对来说比较少,而数据的读和写会比较多一点,使用 mainReactor 只接受客户端连接,由其他线程 subReactor 负责读和写,将业务处理剥离出,让线程池来处理,降低了 Reactor 的性能开销
扩展:单 Reactor 单线程模型、单 Reactor 多线程模型
-
单 Reactor 单线程模型
通过 1 个线程负责客户端连接、网络数据的读写、业务处理
缓存 Redis 就是单 Reactor 单线程模型
-
单 Reactor 多线程模型
通过 1 个线程负责客户端的连接、网络数据的读写,将业务处理剥离出去,通过线程池来进行处理
三种 Reactor 模型的优缺点:
- 单 Reactor 单线程模型是单线程进行业务处理,当负载过重时,处理速度将会变慢,影响系统性能,因此引出单 Reactor 多线程模型
- 单 Reactor 多线程模型时多个线程处理业务,业务处理速度上来了,但是单 Reactor 承担了所有时间的监听和响应,可能存在性能问题。当有数百万客户端进行连接或者服务端需要对客户端握手进行安全认证,认证本身非常消耗性能,因此出现了主从 Reactor 多线程模型
- 主从 Reactor 多线程模型中 1 个主 Reactor 只用来处理网络 IO 的连接建立操作,而对于接入认证、IP 黑白名单过滤、握手等操作由从 Reactor 进行处理,这样进一步提升性能,在主从 Reactor 多线程模型中,从 Reactor 有多个,可以与 CPU 个数相同
TCP 粘包、拆包是什么?如何解决?
答:
TCP本身的机制决定了一定会有粘包、拆包,因为 TCP 传输协议时基于数据流传输的,而流化的数据没有界限,因此 TCP 作为传输层协议并不了解上层业务数据的具体含义,会根据 TCP 缓冲区的实际情况进行数据包的划分,所以业务上认为的一个完整的包,可能被 TCP 拆成多个包或者把多个小的包封装成一个大的包进行发送。
产生原因:
- 粘包:客户端发送的包的大小比socket的缓存小或者接收方读取socket缓存不及时,因此多个包一起发送了
- 拆包:客户端发送的包的大小比socket的缓存大或者发送的数据大于协议的MTU(最大传输单元)必须拆包,那么这个包就被拆分成了多个包进行发送
解决方法:
有三种方式:
- 通过指定分隔符来进行分割
- 通过指定固定长度来进行分割
- 上边两种方式灵活性不好,因此常用的是通过指定接收数据的长度来解决,也就是
LengthFieldBasedFrameDecoder()
这个类
Netty 中常用组件?
答:
Channel
:Netty 网络操作抽象类,包括了基本的 IO 操作,如 bind、connect、read、write 等等EventLoop
:主要是配合 Channel 处理 IO 操作,用来处理连接的生命周期中所发生的事件ChannelFuture
:Netty 中的所有 IO 操作都是异步的,我们通过 ChannelFuture 的 addListener() 注册一个 ChannelFutureListener 监听事件,当操作执行完毕后,监听就会返回结果ChannelHandler
:作为处理器,用于处理入站和出战的数据ChannelHandlerContext
:用于包裹 ChannelHandler,维护了 pipeline 这个双向链表中的 pre 和 next 指针,这样可以方便的找到与其相邻的 ChannelHandler,并且过滤出一些符合执行条件的 ChannelHandler,Netty 的异步事件在 pipeline 中传播就是依靠 ChannelHandlerContextChannelPipeline
:每一个 Channel 都会分配一个 ChannelPipeline,pipeline 是一个双向链表的结构,Netty 中产生的 IO 异步事件都会在这个 pipeline 中传播
Netty 如何发送消息?
答:
有两种发送消息的方式:
- 直接写入 Channel,消息从 ChannelPipeline 的尾节点开始向前传播至头节点,代码
channelHandlerContext.write()
- 使 write 事件从当前 ChannelHandler 开始沿着 pipeline 向前传播,代码
channelHandlerContext.channel().write()
这里解释一下,上边发送消息为什么是向前传播:
在 Netty 中,IO 异步事件基本上分为两类:inbound(入站) 事件、outbound(出站) 事件,那么入站事件是沿着 pipeline 的头结点一直向后传播,因此出站事件就是沿着 pipeline 的尾结点一直向前传播,而上边发送消息也就是出站事件,因此是沿着 pipeline 从后向前
进行传播
直接内存比堆内存快在了哪里?
答:
首先直接内存不是 Java 虚拟机中的内存,是直接向系统内存申请的空间,来源于 NIO,通过 Java 堆中的 DirectByteBuffer 来进行操作。
直接内存相比于堆内存,避免了数据的二次拷贝。
-
我们先来分析
不使用直接内存
的情况,我们发送数据需要将数据先写入 Socket 的缓冲区内,那么如果数据存储在 JVM 的堆内存中的话,会先将堆内存中的数据复制一份到直接内存中,再将直接内存中的数据写入到 Socket 缓冲区中,之后进行数据的发送-
为什么不能直接将 JVM 堆内存中的数据写入 Socket 缓冲区中呢?
在 JVM 堆内存中有 GC 机制,GC 后可能会导致堆内存中数据位置发生变化,那么如果直接将 JVM 堆内存中的数据写入 Socket 缓冲区中,如果写入过程中发生 GC,导致我们需要写入的数据位置发生变化,就会将错误的数据写入 Socket 缓冲区
-
-
那么如果使用直接内存的时候,我们将
数据直接存放在直接内存中
,在堆内存中只存放了对直接内存中数据的引用,这样在发送数据时,直接将数据从直接内存取出,放入 Socket 缓冲区中即可,减少了一次堆内存到直接内存的拷贝
什么是 Netty 的零拷贝?什么是 TCP 缓冲区?
答:
Netty 的零拷贝主要包含三个方面:
-
Netty 的接收和发送使用堆外(直接内存)进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。
-
Netty 提供 CompositeByteBuf 组合缓冲区类,可以将多个 ByteBuf合并为一个逻辑上的ByteBufer,避免了各个ByteBufer之间的拷贝,将几个小buffer合并成一个大buffer的繁琐操作。
-
Netty 的文件传输使用了
FileChannel
的transferTo
方法,该方法底层使用了sendfile
函数实现了 cpu 零拷贝。sendfile
函数通过网络发送数据的流程为:磁盘
----DMA拷贝---->
文件读取缓冲区----CPU拷贝---->
套接字发送缓冲区(SO_SNDBUF)----DMA拷贝---->
网络设备缓冲区(网卡)图片流程如下:(3次拷贝、2次上下文切换,这里上下文切换是在用户空间发起write操作,此时用户态切换为内核态,write调用完毕后又会从内核态切换回用户态)
什么是TCP 缓冲区?
每个 TCP 的 Socket 的内核中都有一个发送缓冲区(SO_SNDBUF)和一个接收缓冲区(SO_RECVBUF)(在通过 TCP 需要进行网络数据传输时,数据都是会写入 Socket 的缓冲区中的,之后再通过网络协议发送出去)
了解 Netty 中的 ByteBuf 类吗?
答:
在 Java NIO 编程中,Java 提供了 ByteBuffer 作为字节缓冲区类型(缓冲区可以理解为一段内存区域),来表示一个连续的字节序列。
Netty 中并没有使用 Java 的 ByteBuffer,而是使用了新的缓冲类型 ByteBuf,特性如下:
-
允许自定义缓冲类型
-
复合缓冲类型中内置的透明的零拷贝实现
-
开箱即用的动态缓冲类型,具有像 StringBuffer 一样的动态缓冲能力
-
不再需要调用 flip() 方法
Java 的 ByteBuffer 类中,需要使用 flip() 来进行读写两种模式的切换
-
正常情况下具有比 ByteBuffer 更快的响应速度
Java 中的 ByteBuffer:
主要需要注意有 3 个属性:position、limit、capacity
- capacity:当前数组的容量大小
- position:写入模式的可写入数据的下标,读取模式的可读取数据下标
- limit:写入模式的可写入数组大小,读取模式的最多可以读取数据的下标
假如说数组容量是 10,那么三个值初始值为:
position = 0
limit = 10
capacity = 10
假如写入 4 个字节的数据,此时三个值如下:
position = 4
limit = 10
capacity = 10
如果切换到读取数据模式(使用 flip()
),会改变上边的三个值,会从 position 的位置开始读取数据到 limit 的位置
position = 0
limit = 4
capacity = 10
Netty 中的 ByteBuf:
ByteBuf 主要使用两个指针来完成缓冲区的读写操作,分别是: readIndex
和 writeIndex
- 当写入数据时,writeIndex 会增加
- 当读取数据时,readIndex 会增加,但不会超过 writeIndex
ByteBuf 的使用:
public static void main(String[] args) {
ByteBuf buffer = Unpooled.buffer(10);
System.out.println("----------初始化ByteBuf----------");
printByteBuffer(buffer);
System.out.println("----------ByteBuf写入数据----------");
String str = "hello world!";
buffer.writeBytes(str.getBytes());
printByteBuffer(buffer);
System.out.println("----------ByteBuf读取数据----------");
while (buffer.isReadable()) {
System.out.print((char)buffer.readByte());
}
System.out.println();
printByteBuffer(buffer);
System.out.println("----------ByteBuf释放无用空间----------");
buffer.discardReadBytes();
printByteBuffer(buffer);
System.out.println("----------ByteBuf清空----------");
buffer.clear();
printByteBuffer(buffer);
}
private static void printByteBuffer(ByteBuf buffer) {
System.out.println("readerIndex:" + buffer.readerIndex());
System.out.println("writerIndex:" + buffer.writerIndex());
System.out.println("capacity:" + buffer.capacity());
}
/**输出**/
----------初始化ByteBuf----------
readerIndex:0
writerIndex:0
capacity:10
----------ByteBuf写入数据----------
readerIndex:0
writerIndex:12
capacity:64
----------ByteBuf读取数据----------
hello world!
readerIndex:12
writerIndex:12
capacity:64
----------ByteBuf释放无用空间----------
readerIndex:0
writerIndex:0
capacity:64
----------ByteBuf清空----------
readerIndex:0
writerIndex:0
capacity:64
ByteBuf 的 3 种使用模式:
ByteBuf 共有 3 种使用模式:
-
堆缓冲区模式(Heap Buffer)
堆缓冲区模式又称为 “支撑数据”,其数据存放在 JVM 的
堆空间
优点:
- 数据在 JVM 堆中存储,可以快速创建和释放,并且提供了数组直接快速访问的方法
缺点:
- 每次数据与 IO 进行传输时,都需要将数据复制到直接缓冲区(这里为什么要将数据复制到直接缓冲区的原因在上边的
直接内存比堆内存快在了哪里?
问题中已经讲过)
创建代码:
ByteBuf buffer = Unpooled.buffer(10);
-
直接缓冲区模式(Direct Buffer)
直接缓冲区模式属于堆外分配的直接内存,不占用堆的容量
优点:
- 使用 socket 传输数据时性能很好,避免了数据从 JVM 堆内存复制到直接缓冲区
缺点:
- 相比于堆缓冲区,直接缓冲区分配内存空间和释放更为昂贵
创建代码:
ByteBuf buffer = Unpooled.directBuffer(10);
-
复合缓冲区模式(Composite Buffer)
本质上类似于提供一个或多个 ByteBuf 的组合视图
优点:
- 提供一种方式让使用者自由组合多个 ByteBuf,避免了复制和分配新的缓冲区
缺点:
- 不支持访问其支撑数据,如果要访问,需要先将内容复制到堆内存,再进行访问
创建代码:
public static void main(String[] args) { // AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Test.class); // 创建一个堆缓冲区 ByteBuf heapBuf = Unpooled.buffer(2); String str1 = "hi"; heapBuf.writeBytes(str1.getBytes()); // 创建一个直接缓冲区 ByteBuf directBuf = Unpooled.directBuffer(5); String str2 = "nihao"; directBuf.writeBytes(str2.getBytes()); // 创建一个复合缓冲区 CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer(10); compositeByteBuf.addComponents(heapBuf, directBuf); // 检查是否支持支撑数组,发现并不支持 if (!compositeByteBuf.hasArray()) { for (ByteBuf buf : compositeByteBuf) { // 第一个字节偏移量 int offset = buf.readerIndex(); // 总共数据长度 int length = buf.readableBytes(); byte[] bytes = new byte[length]; // 不支持访问支撑数组,需要将内容复制到堆内存中,即 bytes 数组中,才可以进行访问 buf.getBytes(offset, bytes); printByteBuffer(bytes, offset, length); } } } private static void printByteBuffer(byte[] array, int offset, int length) { System.out.println("array:" + array); System.out.println("array->String:" + new String(array)); System.out.println("offset:" + offset); System.out.println("len:" + length); } /**输出**/ array:[B@4f8e5cde array->String:hi offset:0 len:2 array:[B@504bae78 array->String:nihao offset:0 len:5
Netty 中 ByteBuf 如何分配?有池化的操作吗?
答:
ByteBuf 的分配接口定义在了 ByteBufAllocator
中,他的直接抽象类是 AbstracByteBufAllocator
,而 AbstractByteBufAllocator
有两种实现:PooledByteBufAllocator
和 UnpooledByteBufAllocator
- PooledByteBufAllocator 提供了池化的操作,将 ByteBuf 实例放入池中,提升了性能,将内存碎片化减到了最小UnpooledByteBufAllocator。(这个实现采用了一种内存分配的高效策略,成为 jemalloc,已经被好几种现代操作系统所采用)
- UnpooledByteBufAllocator 在每次创建缓冲区时,都会返回一个新的 ByteBuf 实例,这些实例由 JVM 负责 gc 回收
NioEventLoopGroup 默认启动了多少线程?
答:
NioEventLoopGroup 是一个多线程的事件循环器,默认启动了电脑可用线程数的两倍,在调用 NioEventLoopGroup 的构造方法之后,如果不传入线程数,最后启动的默认线程数的计算公式为:
Netty 如何解决 Selector 空轮询 Bug 的策略
答:
空轮询 Bug 也就是网络上发生了唤醒 Selector 的事件,但是 Selector 去取事件取不到,就一直去取,发生了空轮询,导致 CPU 使用率达到 100%
Netty解决机制:
判断如果发生了 N(默认是512次) 次空轮询,就新建一个Selector,把原来Selector事件都迁移过来
为什么没有使用 Netty 5?
答:
Netty版本分别是netty3.x、netty4.x、netty5.x
Netty5出现重大bug,已经被官网废弃,目前推荐使用Netty4.x稳定版本