NETTY4中的BYTEBUF 内存管理

转 http://iteches.com/archives/65193

Netty4带来一个与众不同的特点是其ByteBuf的重现实现,老实说,java.nio.ByteBuf是我用得很不爽的一个API,相比之下,通过维护两个独立的读写指针,io.netty.buffer.ByteBuf要简单不少,也会更高效一些。不过,Netty的ByteBuf带给我们的最大不同,就是他不再基于传统JVM的GC模式,相反,它采用了类似于C++中的malloc/free的机制,需要开发人员来手动管理回收与释放。从手动内存管理上升到GC,是一个历史的巨大进步,不过,在20年后,居然有曲线的回归到了手动内存管理模式,正印证了马克思哲学观:社会总是在螺旋式前进的,没有永远的最好。

的确,就内存管理而言,GC带给我们的价值是不言而喻的,不仅大大的降低了程序员的心智包袱,而且,也极大的减少了内存管理带来的Crash困扰,为函数式编程(大量的临时对象)、脚本语言编程带来了春天。而且,高效的GC算法,也让大部分情况下,程序可以有更高的执行效率。不过,也有很多的情况,可能是手工内存管理更为合适的。譬如:

1、大量的长期生存的对象。如Cache管理等。GC在这里只是步履艰难的做着低效的清理工作。在Java中,一直没有什么很成熟的Cache方案,就跟这个是有莫大关系的。

2、高吞吐量下的过于频繁的对象分配。在这种模式下,单个服务处理的时间片其实很短,但却产生了很大的对象分配。虽然这里的对象也是具有很短暂的生命周期,但过于频繁的分配导致GC也变得频繁。即使是一次Younger GC,其成本也远大于一次简单的服务处理。

所以,理论上,尴尬的GC实际上比较适合于处理介于这2者之间的情况:对象分配的频繁程度相比数据处理的时间要少得多的,但又是相对短暂的,典型的,对于OLTP型的服务,处理能力在1K QPS量级,每个请求的对象分配在10K-50K量级,能够在5-10s的时间内进行一次younger GC,每次GC的时间可以控制在10ms水平上,这类的应用,实在是太适合GC行的模式了:而且结合Java高效的分代GC,简直就是一个理想搭配。

但是,对于类似于业务逻辑相对简单,譬如网络路由转发型应用(很多erlang应用其实是这种类型),QPS非常高,比如1M级,在这种情况下,在每次处理中即便产生1K的垃圾,都会导致频繁的GC产生。在这种模式下,或者是erlang的按进程回收模式,或者是C/C++的手工回收机制,效率更高。

至于Cache型应用,由于对象的存在周期太长,GC基本上就变得没有价值。

Netty 4 引入了手工内存的模式,我觉得这是一大创新,这种模式甚至于会延展,应用到Cache应用中。实际上,结合JVM的诸多优秀特性,如果用Java来实现一个Redis型Cache、或者 In-memory SQL Engine,或者是一个Mongo DB,我觉得相比C/C++而言,都要更简单很多。实际上,JVM也已经提供了打通这种技术的机制,就是Direct Memory和Unsafe对象。基于这个基础,我们可以像C语言一样直接操作内存。实际上,Netty4的ByteBuf也是基于这个基础的。

本文简单的分析一下Netty 4中是如何管理内存的分配的。

Netty 4 introduces a high-performance buffer pool which is a variant of jemalloc that combines buddy allocation and slab allocation.

根据官网的介绍,我查看了buddy allocation及slab allocation的基本算法,再结合Netty的源代码,大致整理了数据结构如下

PoolChunk:一大块连续的内存,Netty中,这个值为16M,一次性通过 java.nio.ByteBuf 进行分配,这个内存是Direct Memory,不在JVM的GC范围之内。多个PoolChunk可以共同构成一个PoolArea。每个PoolChunk按照Buddy算法分为多个block,最小的block:order-0 block(称之为一个PoolSubPage)是8K,而后是order-1 block:16K, order-2 block:32 K,一直到 order-11 block: 16M

在PoolChunk中,使用一个int[4096] memoryMap来描述所有的block,这其实是一个二叉树来的:

                 0

        1                2

    3       4       5       6

07 08 09 10 11 12 13 14

这里, memoryMap[1] 表示的是order-11的Block,它实际上可以切分为两个order-10的block,他们由 memoryMap[2]和 memoryMap[3]来表示,每个值由3部分组成:

31     17 16         2 1 0

0-1位:标志位,00(未使用,没有这个Block)、01(Branch,当前Block以拆分,由2个低级别的block组成)、02(已分配)、03(已分配的SubPage,这个是最底层分配的Block了,可以是大于8K的Page。)

2-16位:当前块的大小,以Page为单位。

17 – 31位:当前块相对Chunk基地址的偏移量。(以Page为单元)

使用数组来替代二叉树的优势是极大的节约了内存,第N的节点的子节点是2N+1, 2N+2。在这里,16M的Chunk需要使用16K来描述,占比为0.1%

PoolSubPage对应于一个已分配的block,在Slab实现中,每个PoolSubPage都仅用于某个一定size的内存分配,例如,这个值从16、32到512(TinyPool)是按16递增,而后是1K、2K、4K、8K、16K、32K、64K…(smallPool)的递增顺序,每个SubPage都近用于分配固定大小的内存(称之为一个Slab),这样的优势是只需要使用一个bitmap就可以记录哪些内存是已分配的,以最小的16字节为单位,每个Page(8K)只需要64个字节的位图信息(占比为0.78%),而在其它块中,这个值就更小了。(目前Netty的实现在这里有一些不足,每个Page都是用了64个字节的位图,估计是便于简化SubPage自身的Pool)

每个Chunk按照Slab的大小,组织了多条队列,队列中的每个成员是SubPage,因此,当需要进行分配时,是可以最快速的完成的。

每个ByteBuf()维持了对应的Chunk,当需要释放时,可以根据当前的内存地址,迅速的定位到Chunk中对应的page,再在相应的SubPage中进行释放。整个过程只需要更新相应的bitmap即可。

忽略掉其它的内存开销,Slab+Buddy方式的内存管理成本不到1%,分配和释放速度都非常快。但是Slab的方式,整体内存的使用率方面可能会小一些,不同Slab之间的内存是不会共享的,相当于给大猫挖一个大的猫洞的情况下,也得给小猫挖一个小的猫洞。

=========================

总的来说,Netty的ByteBuf带给我们的启示就是,即便在JVM中,我们也不必拘泥于GC的内存管理方式,在更适合使用手工方式管理的情况下,我们也可以这样做,这也为Java管理海量的内存、Cache化的数据、以及高频繁分配的模式下,仍然可以借助于JVM的强大的能力,而又不是去直接管理内存的灵活性

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。