netty-ByteBuf

送分小仙女□ 提交于 2020-01-28 17:12:23

一,ByteBuf简介

        在原生Java NIO中,应用程序和channel输入输出数据都是通过ByteBuffer进行的。但是原生Java  NIO中ByteBuffer的使用非常不方便,首先在对它进行写入的时候不能动态地进行扩容,需要应用者自己显示扩容。其次它没有读写索引的概念,从写模式切换到读模式需要显示调用flip()方法,对使用者的要求比较高,同时它也没有内存池的概念,内存无法复用。

       基于此,netty对ByteBuffer做了优化,提出了自己的ByteBuf,它具有如下几个优点:

       1.1,引入读写指针

       1.2,写入可以动态扩容,使用者无需关心

       1.3,引入内存池提高内存利用率;提高内存分配释放速度, 减少GC发生频率

       下面先从整体上讲解一下ByteBuf的设计:

标题

 

       如上图所示,ByteBuf中有四个核心的索引点,分别解释如下:

ByteBuf核心索引介绍
索引英文名称 索引中文名称 索引解释
readerIndex 读索引 可以读取的第一个字节索引
writerIndex 写索引 可以写入的第一个字节索引
capacity 缓冲区当前容量 缓冲区的当前容量大小
maxCapacity 缓冲区最大容量 缓冲区最大容量,写入的时候不能超过该大小

       [0 , writerIndex)之间的字节空间为已经被写入的字节空间

       [0 , readerIndex)之间的字节空间为已经被读取过的字节空间

       [readerIndex , writerIndex)之间的字节空间为还可以继续读取的字节空间

       [writerIndex , capacity)之间的字节空间为还可以继续写入的字节空间

       [capacity , maxCapacity)之间的字节空间为可以动态扩容的字节空间,当写入的字节索引大于capacity的时候,就会动态申请扩容。但是最大申请字节空间不能超过maxCapacity。在默认实现下,maxCapacity=Integer.MAX_VALUE,一般也不会超过这么大。

       [0 , readerIndex)之间的字节空间是可以被抛弃的字节空间,因为它已经被读取过了。如果被抛弃的话,[readerIndex , writerIndex)之间的字节空间会整体向左边移动readerIndex个字节,同时readerIndex和writerIndex索引也会同时向左减小readerIndex。这样就可以尽量复用内存空间。

 

       下面我们用一个示例来展示字节缓存如何使用,假定我们刚刚申请一个大小为128的字节缓冲区,代码如下:

       ByteBuf byteBuf = new UnpooledByteBufAllocator(false).buffer(128);

       此时对于该缓冲区来说:

              readerIndex = 0 , writerIndex = 0 , capacity = 128 , maxCapacity = Integer.MAX_VALUE = 0x7fffffff;

 

       byteBuf.write("hello world".getBytes(Charset.forName("UTF-8")));

       此时对于该缓冲区来说:

             readerIndex = 0 , writerIndex = 11 , capacity = 128 , maxCapacity = 0x7fffffff;

       也即对于写入来说,会使writerIndex一直增大,增大到capacity的时候如果还需要写入,就扩容;扩容时需要重新申请一块更大的内存空间的,然后把原内存空间的数据复制过去。

 

       byte[] array = new byte[5];

       byteBuf.read(array);

       此时对于该缓冲区来说:

             readerIndex = 5 , writerIndex = 11 , capacity = 128 , maxCapacity = 0x7fffffff;

       对于读取来说,会增大readerIndex , 将[0,readerIdex)之间的数据复制到array中

 

二,ByteBuf内部实现

       从内存所位于的区域来说,字节缓冲区分为堆内存和直接内存。堆内存是直接在Java堆空间中开辟空间,对于ByteBuf来说就是直接new byte[capacity]; 直接内存是直接在本地内存中开辟空间,在Java堆空间中仅仅持有一个本地内存的引用,用的是Java NIO原生的ByteBuffer。 

       对于堆内存来说,本质上就是字节数组(new byte[capacity]),readerIndex , writerIndex , capacity , maxCapacity;读写索引的移动都好理解,扩容复制利用的就是最原生的System.arrayCopy(),实现上也比较简单,这里不再赘述。

       对于直接内存来说,是依靠JVM在本地内存中开辟的空间,对于我们用户来说是不可见的,我们可以理解为它也是一块字节数组。

      堆内存和直接内存各有自己的优缺点,各有自己的适用场景。堆内存的分配和释放速度非常快,但是如果要用它来读写套接字,要涉及到内核空间和用户空间之间来回复制,复制开销比较大。直接内存的分配和释放要依靠JVM,速度比较慢,但是用它来读写套接字速度比较快,因为它不需要内核空间和用户空间的复制,这方面的开销比较小。所以一般在业务Handler的处理中用堆内存比较好,在IO线程读写套接字的时候用直接内存比较好。     

 

三,ByteBuf内存池

      我们重点讲一下内存池。内存池的概念和线程池一样,都是为了复用,提高内存利用率,提高内存申请和释放的速度,降低GC发生的概率。从直观概念上讲,内存池肯定是预先申请一大块内存,利用一定的数据结构将该大块内存分片,需要用的时候从内存片中申请,不需要用的时候将该内存片重新归还给大块内存用来满足其他内存申请请求。我们先从宏观上看下netty内存池的内部实现:

标题

 

          上图是netty内存池中很核心的一个数据结构PoolChunk(池块)。PoolChunk是一块连续的内存空间,在堆内存分配中,可以认为PoolChunk就是一个字节数组(memory = new byte[16M])。

          下面我们看看PoolChunk类具有的一些核心属性:

PoolChunk核心字段
字段英文名 字段类型 字段中文名 字段解释
memory byte[] 实际内存空间 =new byte[(2^maxOrder) * pageSize]
pageSize long 单个页的字节大小

一个chunk可以被分成2^maxOrder个页,pageSize代表每个页的大小;

pageSize = 8*1024

chunkSize long 整个内存块的字节大小 chunkSize = (2^maxOrder) * pageSize
maxOrder long memory被转换为完全平衡二叉树后的深度

maxOrder = 11

一个chunk有11层

memoryMap byte[] 完全平衡二叉树每个节点状态

memoryMap.length = (2^11)*2

每个数组元素的含义见下文分析

depthMap byte[] 完全平衡二叉树每个节点深度

depthMap.length = (2^11)*2

每个数组元素的含义见下文分析

subpages PoolSubpage<byte[]>[] 所有叶子节点组成的数组 真实的内存字节空间
freeBytes long 该chunk剩余可用字节空间大小  

       

         一个chunk(连续内存块)由2^maxOrder个页组成,一个页的大小为pageSize = 8*1024 = 8K。在我们netty的默认配置中,maxOrder=11 , pageSize = 8K,所以一个chunk=2^11 * 8K = 2^14 * 1K = 2^4 *1M = 16M。所以一个chunk直接是一个16M的字节数组。我们所有的内存申请都基于该16M的连续字节数组内存空间进行。这16M的连续字节数组内存空间又按照pageSize = 8K分成2^11 = 2048个页。netty的内存分配采取类似Linux内核bubby的方式,每次分配内存都按照2的整数次幂的方式来分配内存。

        根据上图所示,如果我们要申请capacity = pageSize = 8K的连续内存空间,netty会从叶子结点从左到右选择一个可用的节点对应的连续内存空间分配出去。对于刚刚说的情形,分配出去的就是chunk字节数组中[0 , 8K)部分的连续内存空间。如果我们再次申请capacity = pageSize = 8K的连续内存空间,netty会继续从左到后选择一个可用的节点对应的连续内存空间分配出去,此时分配出去的就是chunk字节数组中[8K , 16K)部分的连续内存空间。

        如果我们要再申请capacity = 2*pageSize = 16K的连续内存空间,netty会从叶子结点的父节点那一层从左到右选择一个可用的节点对应的连续内存空间分配出去,因为叶子结点那一层能够分配的最大内存空间为8K,所以要在它的父节点那一层级进行分配。在父节点层级中,第一个节点肯定不能被分配了,因为它对应的连续内存空间[0,16k)已经被分配出去了。所以只能从这一层的第二个节点开始分配,对应分配出去的连续内存空间是[16K , 32K)。

        如果我们要再申请capacity = 4*pageSize = 32K的连续内存空间, netty会从倒数第三层从左到右选择一个可用的节点对应的连续内存空间分配出去,同理,这次选择到该层的第二个节点,分配出去的连续内存空间为[32K , 64K)。

        netty就是按照这种方式来分配内存的。上面所举的例子都是在假定用户指定申请2的整数次幂的基础上分配的,如果用户申请的内存数不是2的整数次幂,netty会将其对齐到比它大的最小的2的整数次幂上进行内存分配。

        当释放内存的时候,会在二叉树中标记该节点以及所有子节点为可用状态。如果该节点可以和兄弟节点合并,就会再更新父节点为可用状态。当扩容的时候,会重新计算需要在树的哪个层级开始找,找到了一个可用节点就把数据复制过去,同时释放原来的节点对应的内存空间。

         整个chunk的内存管理就是按照这种思路来的。上面的memoryMap就是用来标记二叉树各个节点的占用状态的。

       

               

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!