深入浅出netty - ByteBuf 和 ByteBufPool

ByteBuf

首先 java 本身就有 ByteBuffer, java的 nio 中用的就是他, 然后他有2个实现, 分别是 HeapByteBufferDirectByteBuffer
前者是基于 java 的堆实现, 后者则使用了unsafe的API进行了堆外的实现。

而 ByteBuf 其实也有类似的2个实现, 至于为什么要额外设计一个 ByteBuf 则需要我们看一下他们在使用上区别。

ByteBuffer 内部使用4个变量表示读写缓冲区。

1
2
3
4
position 当前读取位置
mark 为某一读过的位置做标记,便于某些时候回退到该位置。
capacity 初始化时候的容量
limit 读写上限

ByteBuf 内部则使用更多的变量, 主要是将 position, mark 变成 reader, write index 和 mark

1
2
3
4
5
6
readerIndex
writerIndex
markedReaderIndex
markedWriterIndex
maxCapacity
...

这样的好处则如下图所示:

由于 ByteBuffer 只有一个 position 表示当前位置,所以在进行 切换的时候需要手工调用 flip

而 ByteBuf 由于有 read write 2个 index, 所以不需要, 写代码体验更好。

零拷贝

在 linux 中传统的 IO操作是 一种 缓冲IO 传输数据时需要在缓冲区中进行多次的拷贝操作。

数据在内核态(硬件,比如硬盘) 到用户态(内存) 各有自己的缓冲。

而零拷贝可以直接将数据从 内核态(硬盘) 发给 内核态(网卡) 中间不经过用户态(ps, 当然也不能够修改数据)

linux 提供2种 零拷贝的支持

  1. 硬盘到网卡 (LinuxAPI: sendfile) (static file server)
  2. 网卡到网卡 (LinuxAPI: splice) (proxy server)

不过 java jdk 目前只支持 1的官方支持, 2 不支持

而 netty 提供了一种 CompositeByteBuf 的 ByteBuf 实现, 官方把它称之为 netty 的零拷贝,
其实它不是零拷贝, 主要解决了如下的问题

比如 http 协议的 1.0 版本中并没有要求 response 需要带上 content-length

那么普通情况下 读取数据的话需要做很多次memory copy, 以便上层使用者可以使用合并后的结果

而 netty 则不省略allocate 的部分,而仅省了 recopy 的过程, 用 CompositeByteBuf 内部的指针合并多个 ByteBuffer

如图

同样对上层来说仅仅是一个 合并的 ByteBuf, 但是其实内部可以减少 reallocate 和 copy 的过程,减少系统调用。

ByteBufPool

ByteBufPool 是 netty4 引入的新 feature。 目的在于减少内存的分配次数 (heap/direct)

至于使用之后带来的性能上的提升可以看 twitter 做的评测, 这里不重复展开。

netty 的 ByteBufPool 是 jemelloc 的精简版,大体流程是类似的。
都有一个 threadlocal 的 cache, 以及公有的 Arena。

从cache 取内存无锁,从 Arena取内存有锁。所以一般建议将 Arena 的数量设置为 EventLoop 数量,以达到无竞争。

关系图如下:

关于 参数的设定参看 io.netty.buffer.PooledByteBufAllocator 这个类.

如果先不管 Arena 是什么的话,Cache 是如何缓存的?

  1. 给每个 申请的内存块的大小 都分配一个Queue (一样大小的都recycle 都放回一个 Queue)
  2. 如果给每个大小都定义个Queue 反而浪费内存了,所以 可申请的内存块大小是指定规格的。
  3. 对申请的内存根据预定义的规格向上取整
  4. 所有的规格都放在一个数组里面,寻找起来 O1 的速度。

根据默认参数的话规格如下:

1
2
3
tiny:   [16 32 48 64 80 ... 496]
small: [512, 1024, 2048, 4096]
normal: [8192x2^0, 8192x2^1, 8192x2^2 .. 8192x2^11]

举个例子: 当申请的内存为 10个byte 则向上取整 16 , 分配16byte, 取 tiny[0] 里取内存即可

Arena 为分配器,是预申请内存块的管理者。 jemelloc 的初衷为了减少内存碎片,那么如何减少?

当申请比如 1 byte 的话就会预申请一大块内存,然后分配其中一部分给 1byte。

为什么是分配 一部分? 不然 内存管理花费的内存开销将大于内存实际的开销。。

Arena 是一个分配器,内部管理的内存块 为 Chunk, 申请大于 Chunk 大小的内存将直接向系统申请。

Chunk 内部由 n 个 page 组成, page 是一个虚拟的概念,因为 page 还可以切分为 n 个 SubPage

他们的关系图如下:

ps: 左侧为默认参数

这里说一下Arena 需要做的事情

首先, 一个 Arena 用 ChunkList 管理着 n 个 Chunk, 一个Chunk 默认 16mb, 当一个 Chunk 分配不了时,新建一个新的 Chunk

Chunk & ChunkList

Arena 内部还对 Chunk 做了 使用量的 监测。

Arena 新建了 6个 ChunkList 分别为 qInit, q000, q025, q050, q075, q100

她们依次组成一个双向链表,分别又一个 最小使用量 和 最大使用量 的阀值

1
2
3
4
5
6
7
//          PoolChunkList<T> nextList, int minUsage, int maxUsage, int chunkSize
q100 = new PoolChunkList<T>(null, 100, Integer.MAX_VALUE, chunkSize);
q075 = new PoolChunkList<T>(q100, 75, 100, chunkSize);
q050 = new PoolChunkList<T>(q075, 50, 100, chunkSize);
q025 = new PoolChunkList<T>(q050, 25, 75, chunkSize);
q000 = new PoolChunkList<T>(q025, 1, 50, chunkSize);
qInit = new PoolChunkList<T>(q000, Integer.MIN_VALUE, 25, chunkSize);

然后在每次 allocate 和 deallocate 时判断 Chunk 是否触及 阀值,然后分别 上移/下移 到合适的 ChunkList

为了减小内存碎片, 分配的优先级如下:

q050 > q025 > q000 > qInit > q075

代码如下

1
2
3
4
5
6
7
8
private synchronized void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
if (q050.allocate(buf, reqCapacity, normCapacity) || q025.allocate(buf, reqCapacity, normCapacity) ||
q000.allocate(buf, reqCapacity, normCapacity) || qInit.allocate(buf, reqCapacity, normCapacity) ||
q075.allocate(buf, reqCapacity, normCapacity)) {
++allocationsNormal;
return;
}
// ...

为什么这么弄?

首先, 可以分配的内存时这样的, 回到之前的规格。 对于 8192x2^11 byte 相当于 100% 的 chunk 了。

而 比他小的分别为 50%, 25%, 12.5%, 6.25%, 3.125% …

所以如果将优先级 的第一第二换个位置则可能造成 大量的 75% 。。。

Page & SubPage

Chunk 由 2048 个 Page 组成。

一个 Page 默认 8k。

Chunk 里面将这 2048 个 page 用其 2 倍的空间 byte[4096] memoryMap 以完全二叉树管理。

深度为 11 的共 2048 个元素表示该 page 有没有被分配

深度 1-10 的节点都是索引

每个节点的 value 默认为该节点在树的深度。

当 page 被分配掉时, 则将自己的 value + 1 也就是变成12

每次被分配后都要同时更新自己的 parent 的 value, parent 的 value 取 2 个 child 中小的一个。

1
2
3
4
5
6
7
8
9
10
private void updateParentsAlloc(int id) {
while (id > 1) {
int parentId = id >>> 1;
byte val1 = value(id);
byte val2 = value(id ^ 1);
byte val = val1 < val2 ? val1 : val2;
setValue(parentId, val);
id = parentId;
}
}

比如当要分配 8k 的大小时直接找 第 11 层找 第一个 val 为 11 的(unused), 找到后更新自己以及parent节点。

这样当比如需要分配 8MB 内存是,立即找 depth 为 1 的2个节点, 只要她的 value 不等于 depth 就知道该节点的 child 被分配过了。

然后 Subpage 的作用是用来标示分配小于一个 page 的内存块。

但是 Subpage 不再是用二叉树管理了。而是用更方便的方式。

一个 Subpage 只能分配一种 size 大小, 然后用 3个long 共 8x8x8 个bit 标示是否被使用。

比如 如果 page 为 8k , 由于共 512 个标示可用,所以最小分配单元 为 16 byte.

但是 如果 最小分配单元为 32 byte, 则 共只需要 256 个标示即可。(3个 long 不需要全部用完)

Subpage 也是一个链表, 相同规格的subpage 链在一起。

然后 PoolArena 初始化了 n 个size 的 Subpage 的链表头, 并用来维护。

1
2
private final PoolSubpage<T>[] tinySubpagePools;
private final PoolSubpage<T>[] smallSubpagePools;

每次新创建 Subpage 会吧 subpage 链到 相应的 idx 的 PoolSubpage 内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private long allocateSubpage(int normCapacity) {
// Obtain the head of the PoolSubPage pool that is owned by the PoolArena and synchronize on it.
// This is need as we may add it back and so alter the linked-list structure.
PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
synchronized (head) {
int d = maxOrder; // subpages are only be allocated from pages i.e., leaves
int id = allocateNode(d);
if (id < 0) {
return id;
}

final PoolSubpage<T>[] subpages = this.subpages;
final int pageSize = this.pageSize;

freeBytes -= pageSize;

int subpageIdx = subpageIdx(id);
PoolSubpage<T> subpage = subpages[subpageIdx];
if (subpage == null) {
subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
subpages[subpageIdx] = subpage;
} else {
subpage.init(head, normCapacity);
}
return subpage.allocate();
}
}

图如下

内存开销

最后算一下开销。

如果 一个 netty server 配置了 1 个 acceptor Thread,32个 IO Thread, 我们这里仅仅考虑默认配置。

为了让 arena 不被锁影响,则需要33 个 arena, 那么就会需要 33个 chunk, 共计 33 * 16mb 共计 528MB (默认配置)

由于 ByteBufPool 默认是使用堆外内存的, 而 jdk 默认堆外内存 64 mb, 所以需要手动修改。

不然 netty 会使用 堆内存。 不经计算,贸然使用可能会造成full gc。

内存分配需要根据实际情况自己计算参数:

举一个例子

算一台服务 2k qps的服务,每个包 2k 的话 预计需要

1
2
In [13]: 2000 * 2000 / 1024/1024.0
Out[13]: 3.814453125

ps 这是没有算上 请求包的,就算 * 2 8mb 因该够了。

可以将 pageSize 调为一半,节省内存 io.netty.allocator.pageSize = 4096

于是一个 chunk 8mb 可以少分配一半的内存。

最佳实践

建议不使用 heap 的bytebuf pool,减小堆内gc 压力

堆外的size 量力而为,有时候 并不一定需要分配时并行,毕竟只有第一次分配时需要锁。

但要注意, 如果当 iosize 远大于 arena size的话, 当大流量进来的时候, 锁的影响还是很大的.

关于锁的影响, 完全可以通过预申请 buffer 解决,

reference

https://my.oschina.net/flashsword/blog/159613

http://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy1/

http://www.jianshu.com/p/c4bd37a3555b

http://www.jianshu.com/p/d91060311437

http://www.jianshu.com/p/a1debfe4ff02

http://www.jianshu.com/p/4856bd30dd56

avatar

lelouchcr's blog