深入剖析Netty的核心组件
前言
从上一个章节的学习,对使用Netty进行网络应用开发已经有了一个初步的感性的印象。但是仅仅依靠这种简单的介绍就上手Netty还是过于勉强了。所以本文会剖析Netty中的重点组件。通过对这些组件的理解和掌握,从底层熟悉和理解Netty。
核心组件分析
ByteBuf
在JDK的NIO中,我们学习到了其原生的数据承载组件ByteBuffer。ByteBuffer的体验着实不太好,读写状态的区别,还有flip这种乍看下不直观的操作。
Netty设计了自己的数据存储组件ByteBuf。和ByteBuffer一样,ByteBuf也是代表了一段连续的二进制数据空间。同样的,ByteBuf也按照数据存储的位置区分为:数据存储在堆上的HeapByteBuf和数据存储在直接内存的DirectByteBuf。
ByteBuf的设计目标是简化用户的使用,所以不像ByteBuffer那样还有读写状态的区分,ByteBuf当中只有2个指针:读指针和写指针。读指针指向的位置,意味着可以从这个位置开始读取;写指针指向的位置,意味着可以将数据写入指向位置。
用图表来表达更为形象,刚申请一个ByteBuf的时候其状态如下

此时读写指针均指向位置0。此时没有数据可以读取,但是可以写入。
写入4个字节后,状态如下

写指针随着数据的写入增加。此时读写之间存在着一片区域,这片区域就可以读取的内容区域。
接着读取2个字节后,状态如下

读取数据时,读指针对应的增加读取的字节数。在读指针之前的数据则属于“不可读“的范畴。加了引号是因为读写指针可以在外部被修改,这也是将数据重复读取的原理。
从上面的三个示例来看,ByteBuf的使用十分简单,没有什么读写状态更加不需要翻转。ByteBuf带来的便利还不止如此,ByteBuf还具备自动扩容的能力。在Netty中申请一个ByteBuf都会指定一个初始容量,但是在写入的时候,如果剩余容量不足,则会自动扩容。扩容规则为:
- 当写入后新的容量小于512,则选择一个小于512但是大于容量且为16的倍数的值作为新容量。
- 当写入后新的容量大于512,则选择一个大于容量且为2的次方幂的值作为新容量。
这个扩容规则可以不用关心,其容量确定本质上是因为其采用的内存管理办法。我们只需要知道其可以自动扩容满足我们的写入要求即可。同时还有一点需要明确,扩容并不是我们想的直观的在原有的后续区域上继续增加新的写入区域,而是重新申请了一个满足写入大小要求的区域,将原先的数据复制到了新的区域。只不过在外部的用户无法感知到罢了。因此,为了减少因为扩容带来的数据复制引起的性能损耗,建议在初始化的时候选择一个相对合适的大小。
但是ByteBuf比上面说的要复杂的许多,我们来看看其相关的部分类图

这里面只是一部分,ByteBuf是一个很庞大的继承体系。初略分的话大致是两个维度:
- 数据存储在堆和数据存储在直接内存的区别
ByteBuf持有的内存区域是一次性的依靠JVM进行GC,还是池化的内存依靠Netty自行管理的区别
在Netty3的年代,由于池化内存只是刚刚开发的功能,处于观望状态,所以官方的建议是推荐使用非池化的ByteBuf。不过到了Netty4,经过了验证和内存泄漏追踪功能的加入,池化内存也就成为了首选,也是官方所推荐的。使用池化内存可以有效的降低JVM的GC压力,平稳系统的GC毛刺。在高并发的场景下,性能表现更加稳定。而不是像Nettty3那样因为频繁的申请内存和GC回收造成GC的CPU占用成折线式的不停抖动。
考虑到堆外内存在进行socket读取和写入的时候可以减少一次内核态和用户态之间的数据拷贝,一般而言都是推荐使用堆外内存。
两点相结合,可以总结出在实践中我们推荐使用的ByteBuf的具体类型,也就是PoolDirectByteBuf。不过在编写代码的时候我们并不会直接实例化这个类。而是通过io.netty.buffer.ByteBufAllocator这个接口的buffer方法来获得具体的实例。可以通过io.netty.buffer.ByteBufAllocator#DEFAULT属性来获得系统默认的分配器。初始化的Netty会进行判断,如果当前是Android,则使用非池化的分配器;其余情况使用池化的分配器。对于服务器应用而言,池化分配器肯定是不二选择。
CompositeByteBuf
Netty的官网介绍自己的ByteBuf是一个具备零拷贝能力的富ByteBuffer实现。ByteBuf也的确提供了ByteBuffer更多的功能,但是零拷贝本身而言,对于DirectByteBuf和DirectByteBuffer而言底层都是相同的,都是使用了堆外内存本身的零拷贝的特性。不过Netty还有额外提供了自己实现的零拷贝特性的ByteBuf,它是一个虚拟组合式的ByteBuf视图,也就是这个章节的主角:CompositeByteBuf。
在应用程序的编写过程中,部分场景下存在着需要将多个ByteBuf合并的需求。此时简单的使用io.netty.buffer.ByteBuf#writeBytes(io.netty.buffer.ByteBuf)接口可以完成需求,不过就是需要额外的内存拷贝。
针对这种需要聚合多个ByteBuf的使用场景,Netty设计了CompositeByteBuf类。这个类代表着一个虚拟的ByteBuf,其内部是由多个ByteBuf实例组成的数组。每一个ByteBuf都代表着虚拟Buffer中的某一段数据。
举个例子,如下便是由三个ByteBuf实例组成的CompositeByteBuf。

可以看到,三个不同的ByteBuf实例分别映射了虚拟Buffer不同区域的部分。CompositeByteBuf通过聚合的方式,对外提供了一个整体的Buffer的效果。
这种虚拟视图在某种情况下特别的好用。比如说Http协议的实现上都是按照协议头和内容体进行区分,而协议头和内容体往往会采用不同的ByteBuf进行存放,因此其解析方式不同的原因。而当我们需要组装一个完整的Http报文的时候,如果将代表协议头和报文体的ByteBuf实例一起写到一个新的ByteBuf自然是可以满足需求,不过也带来了数据拷贝的消耗。此时使用CompositeByteBuf作为一个虚拟视图聚合2个ByteBuf,既能避免内存拷贝,又可以在对用户表现上呈现出一个完整单一的ByteBuf的效果。提升了开发效率和性能。
不过需要注意,CompositeByteBuf聚合了多个ByteBuf,其在数据的读写实现上都较单一的ByteBuf要复杂。特别是如果数据读写跨越了多个ByteBuf实际的承载时,由于多次的操作,性能会有一些影响。因此一般而言,累积动作很多时候仍然使用ByteBuf直接写入另外一个ByteBuf。比如Netty编解码的默认父类等。
Channel
Netty也实现了自己对于通道的抽象,以便在接口的层面上添加更多能力,同时也与NIO的通道区分开。对于Channel而言,我们通常不会接触到其接口,而是一般接触到它实际使用的实现类,比较常用的实现类主要有:
- io.netty.channel.socket.nio.NioSocketChannel:这个实现类一般是在网络编程中,引导程序帮助我们实例化的,而且实例化的时候传递给我们也是接口
io.netty.channel.socket.SocketChannel,并不会让我们感知到这个具体的实现。这个类代表着一个具体的TCP通道。 - io.netty.channel.socket.nio.NioServerSocketChannel:这个实现类提供的是TCP协议下服务端监听Socket通道的能力。这个实现类一般直接将类对象传递给引导程序用于启动一个基于TCP协议的服务端。
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
<p> 通过本专刊的学习,对网络开发所需掌握的基础理论知识会更加牢固,对网络应用涉及的线程模型,设计模式,高性能架构等更加明确。通过对Netty的源码深入讲解,使得读者对Netty达到“知其然更之所以然”的程度。在遇到一些线上的问题时,具备了扎实理论功底的情况,可以有的放矢而不会显得盲目。 本专刊购买后即可解锁所有章节,故不可以退换哦~ </p> <p> <br /> </p>
查看15道真题和解析