Netty4|Netty-内存管理

通过NIO传输数据时需要一个内存地址,并且在数据传输过程中这个地址不可发生变化。但是,GC为了减少内存碎片会压缩内存,也就是说对象的实际内存地址会发生变化,所以Java就引入了不受GC控制的堆外内存来进行IO操作。那么数据传输就变成了这样
Netty4|Netty-内存管理
文章图片

但是内存拷贝对性能有可能影响比较大,所以Java中可以绕开堆内存直接操作堆外内存,问题是创建堆外内存的速度比堆内存慢了10到20倍,为了解决这个问题Netty就做了内存池。
内存池是一套比较成熟的技术了,Netty的内存池方案借鉴了jemalloc。了解一下其背后的实现原理对阅读Netty内存池的源代码还是很有帮助的

  1. jemalloc原理
  2. glibc内存管理ptmalloc源代码分析
总体结构 Netty各版本的内存管理差异比较大,这里以4.1版本为例。为了不陷入无尽的细节泥沼之中,应该先了解下jemalloc的原理,然后就可以构想出内存池大概思路:
  1. 首先应该会向系统申请一大块内存,然后通过某种算法管理这块内存并提供接口让上层申请空闲内存
  2. 申请到的内存地址应该透出到应用层,但是对开发人员来说应该是透明的,所以要有一个对象包装这个地址,并且这个对象应该也是池化的,也就是说不仅要有内存池,还要有一个对象池
所以,自然可以带着以下问题去看源码:
  1. 内存池管理算法是怎么做到申请效率,怎么减少内存碎片
  2. 高负载下内存池不断扩展,如何做到内存回收
  3. 对象池是如何实现的,这个不是关键路径,可以当成黑盒处理
  4. 内存池跟对象池作为全局数据,在多线程环境下如何减少锁竞争
  5. 池化后内存的申请跟释放必然是成对出现的,那么如何做内存泄漏检测,特别是跨线程之间的申请跟释放是如何处理的。
PoolChunk Netty一次向系统申请16M的连续内存空间,这块内存通过PoolChunk对象包装,为了更细粒度的管理它,进一步的把这16M内存分成了2048个页(pageSize=8k)。页作为Netty内存管理的最基本的单位 ,所有的内存分配首先必须申请一块空闲页。Ps: 这里可能有一个疑问,如果申请1Byte的空间就分配一个页是不是太浪费空间,在Netty中Page还会被细化用于专门处理小于4096Byte的空间申请 那么这些Page需要通过某种数据结构跟算法管理起来。最简单的是采用数组或位图管理
Netty4|Netty-内存管理
文章图片

如上图1表示已申请,0表示空闲。这样申请一个Page的复杂度为O(n),但是申请k个连续Page,就立马退化为O(kn)。
Netty采用完全二叉树进行管理,树中每个叶子节点表示一个Page,即树高为12,中间节点表示页节点的持有者。
Netty4|Netty-内存管理
文章图片

这样的一个完全二叉树可以用大小为4096的数组表示,数组元素的值含义为:

private final byte[] memoryMap; //表示完全二叉树,共有4096个 private final byte[] depthMap; //表示节点的层高,共有4096个

  1. memoryMap[i] = depthMap[i]:表示该节点下面的所有叶子节点都可用,这是初始状态
  2. memoryMap[i] = depthMap[i] + 1:表示该节点下面有一部分叶子节点被使用,但还有一部分叶子节点可用
  3. memoryMap[i] = maxOrder + 1 = 12:表示该节点下面的所有叶子节点不可用
有了上面的数据结构,那么页的申请跟释放就非常简单了,只需要从根节点一路遍历找到可用的节点即可,复杂度为O(lgn)。代码为:
#PoolChunk //根据申请空间大小,选择申请方法 long allocate(int normCapacity) { if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize return allocateRun(normCapacity); //大于1页 } else { return allocateSubpage(normCapacity); } } //按页申请 private long allocateRun(int normCapacity) { //计算需要在哪一层开始 int d = maxOrder - (log2(normCapacity) - pageShifts); int id = allocateNode(d); if (id < 0) { return id; } freeBytes -= runLength(id); return id; } / /申请空间,即节点编号 private int allocateNode(int d) { int id = 1; //从根节点开始 int initial = - (1 << d); // has last d bits = 0 and rest all = 1 byte val = value(id); if (val > d) { // unusable return -1; } while (val < d || (id & initial) == 0) { // id & initial == 1 << d for all ids at depth d, for < d it is 0 id <<= 1; //左节点 val = value(id); if (val > d) { id ^= 1; //右节点 val = value(id); } } byte value = https://www.it610.com/article/value(id); assert value == d && (id & initial) == 1 << d : String.format("val = %d, id & initial = %d, d = %d", value, id & initial, d); //更新当前申请到的节点的状态信息 setValue(id, unusable); // mark as unusable //级联更新父节点的状态信息 updateParentsAlloc(id); return id; } //级联更新父节点的状态信息 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; } }

PoolSubpage 对于小内存(小于4096)的分配还会将Page细化成更小的单位Subpage。Subpage按大小分有两大类,36种情况:
  1. Tiny:小于512的情况,最小空间为16,对齐大小为16,区间为[16,512),所以共有32种情况。


  2. Small:大于等于512的情况,总共有四种,512,1024,2048,4096。 Netty4|Netty-内存管理
    文章图片

    PoolSubpage中直接采用位图管理空闲空间(因为不存在申请k个连续的空间),所以申请释放非常简单。
    代码:
#PoolSubpage(数据结构) final PoolChunk chunk; //对应的chunk private final int memoryMapIdx; //chunk中那一页,肯定大于等于2048 private final int pageSize; //页大小 private final long[] bitmap; //位图 int elemSize; //单位大小 private int maxNumElems; //总共有多少个单位 private int bitmapLength; //位图大小,maxNumElems >>> 6,一个long有64bit private int nextAvail; //下一个可用的单位 private int numAvail; //还有多少个可用单位;

这里bitmap是个位图,0表示可用,1表示不可用. nextAvail表示下一个可用单位的位图索引,初始状态为0,申请之后设置为-1. 只有在free后再次设置为可用的单元索引。在PoolSubpage整个空间申请的逻辑就是在找这个单元索引,只要理解了bitmap数组是个位图,每个数组元素表示64个单元代码的逻辑就比较清晰了
#PoolSubpage long allocate() { if (elemSize == 0) { return toHandle(0); }if (numAvail == 0 || !doNotDestroy) { return -1; }final int bitmapIdx = getNextAvail(); //查找下一个单元索引 int q = bitmapIdx >>> 6; //转为位图数组索引 int r = bitmapIdx & 63; //保留最低的8位 assert (bitmap[q] >>> r & 1) == 0; bitmap[q] |= 1L << r; //设置为1if (-- numAvail == 0) { removeFromPool(); }return toHandle(bitmapIdx); //对索引进行特化处理,防止与页索引冲突 }private int getNextAvail() { int nextAvail = this.nextAvail; if (nextAvail >= 0) { //大于等于0直接可用 this.nextAvail = -1; return nextAvail; } return findNextAvail(); //通常走一步逻辑,只有第一次跟free后nextAvail才可用 } //找到位图数组可用单元,是一个long类型,有[1,64]单元可用 private int findNextAvail() { final long[] bitmap = this.bitmap; final int bitmapLength = this.bitmapLength; for (int i = 0; i < bitmapLength; i ++) { long bits = bitmap[i]; if (~bits != 0) { return findNextAvail0(i, bits); } } return -1; } //在64的bit中找到一个可用的 private int findNextAvail0(int i, long bits) { final int maxNumElems = this.maxNumElems; final int baseVal = i << 6; for (int j = 0; j < 64; j ++) { if ((bits & 1) == 0) { int val = baseVal | j; if (val < maxNumElems) { return val; } else { break; } } bits >>>= 1; } return -1; }

PoolSubpage池 第一次申请小内存空间的时候,需要先申请一个空闲页,然后将该页转成PoolSubpage,再将该页设为已被占用,最后再把这个PoolSubpage存到PoolSubpage池中。这样下次就不需要再去申请空闲页了,直接去池中找就好了。Netty中有36种PoolSubpage,所以用36个PoolSubpage链表表示PoolSubpage池。
#PoolArena private final PoolSubpage[] tinySubpagePools; private final PoolSubpage[] smallSubpagePools; #PoolSubpage PoolSubpage prev; PoolSubpage next;

Netty4|Netty-内存管理
文章图片

#PoolArena allocate(...reqCapacity...){ final int normCapacity = normalizeCapacity(reqCapacity); //找到池的类型跟下标 boolean tiny = isTiny(normCapacity); if (tiny) { // < 512 tableIdx = tinyIdx(normCapacity); table = tinySubpagePools; } else { tableIdx = smallIdx(normCapacity); table = smallSubpagePools; } final PoolSubpage head = table[tableIdx]; synchronized (head) { final PoolSubpage s = head.next; if (s != head) { //通过PoolSubpage申请 long handle = s.allocate(); ... } } }

PoolChunkList 上面讨论了PoolChunk的内存分配算法,但是PoolChunk只有16M,这远远不够用,所以会很很多很多PoolChunk,这些PoolChunk组成一个链表,然后用PoolChunkList持有这个链表
#PoolChunkList private PoolChunk head; #PoolChunk PoolChunk prev; PoolChunk next;

Netty4|Netty-内存管理
文章图片

这里还没这么简单,它有6个PoolChunkList,所以将PoolChunk按内存使用率分类组成6个PoolChunkList,同时每个PoolChunkList还把各自串起来,形成一个PoolChunkList链表。
#PoolChunkList private final int minUsage; //最小使用率 private final int maxUsage; //最大使用率 private final int maxCapacity; private PoolChunkList prevList; private final PoolChunkList nextList; #PoolArena //[100,) 每个PoolChunk使用率100% q100 = new PoolChunkList(this, null, 100, Integer.MAX_VALUE, chunkSize); //[75,100) 每个PoolChunk使用率75-100% q075 = new PoolChunkList(this, q100, 75, 100, chunkSize); //[50,100) q050 = new PoolChunkList(this, q075, 50, 100, chunkSize); //[25,75) q025 = new PoolChunkList(this, q050, 25, 75, chunkSize); //[1,50) q000 = new PoolChunkList(this, q025, 1, 50, chunkSize); qInit = new PoolChunkList(this, q000, Integer.MIN_VALUE, 25, chunkSize);

Netty4|Netty-内存管理
文章图片

既然按使用率分配,那么PoolChunk在使用过程中是会动态变化的,所以PoolChunk会在不同PoolChunkList中变化。同时申请空间,使用哪一个PoolChunkList也是有先后顺序的
#PoolChunkList boolean allocate(PooledByteBuf buf, int reqCapacity, int normCapacity) { if (head == null || normCapacity > maxCapacity) { return false; } for (PoolChunk cur = head; ; ) { long handle = cur.allocate(normCapacity); if (handle < 0) { cur = cur.next; if (cur == null) { return false; } } else { cur.initBuf(buf, handle, reqCapacity); if (cur.usage() >= maxUsage) { remove(cur); nextList.add(cur); //移到下一个PoolChunkList中 } return true; } } }#PoolArena allocateNormal(...){ if (q050.allocate(...) || q025.allocate(...) || q000.allocate(...) || qInit.allocate(...) || q075.allocate(...)) { return; } PoolChunk c = newChunk(pageSize, maxOrder, pageShifts, chunkSize); ... qInit.add(c); }

这样设计的目的是考虑到随着内存的申请与释放,PoolChunk的内存碎片也会相应的升高,使用率越高的PoolChunk其申请一块连续空间的失败的概率也会大大的提高。同时,注意观察代码跟上图可以发现q000没有前驱节点,所以一旦PoolChunk使用率为0,就从PoolChunkList中移除,释放掉这部分空间,避免在高峰的时候申请过内存一直缓存到池中。同时,各个PoolChunkList的区间是交叉的,这是故意的,因为如果介于一个临界值的话,PoolChunk会在前后PoolChunkList不停的来回移动。
PoolArena PoolArena是上述功能的门面,通过PoolArena提供接口供上层使用,屏蔽底层实现细节。为了减少线程成间的竞争,很自然会提供多个PoolArena。Netty默认会生成2×CPU个PoolArena跟IO线程数一致。然后第一次使用的时候会找一个使用线程最少的PoolArena
private PoolArena leastUsedArena(PoolArena[] arenas) { if (arenas == null || arenas.length == 0) { return null; }PoolArena minArena = arenas[0]; for (int i = 1; i < arenas.length; i++) { PoolArena arena = arenas[i]; if (arena.numThreadCaches.get() < minArena.numThreadCaches.get()) { minArena = arena; } }return minArena; }

本地线程存储 虽然提供了多个PoolArena减少线程间的竞争,但是难免还是会存在锁竞争,所以需要利用ThreaLocal进一步优化,把已申请的内存放入到ThreaLocal自然就没有竞争了。大体思路是在ThreadLocal里面放一个PoolThreadCache对象,然后释放的内存都放入到PoolThreadCache里面,下次申请先从PoolThreadCache获取。
但是,如果thread1申请了一块内存,然后传到thread2在线程释放,这个Netty在内存holder对象里面会引用PoolThreadCache,所以还是会释放到thread1里
对象池 从上面的分析知道,只要知道一个PoolChunk,一个PoolChunk里面的页索引,申请到的空间容量,如果是小内存还需要知道页内索引就可以定位到这块内存了。这些信息保存到PooledByteBuf对象
protected PoolChunk chunk; protected long handle; //索引 protected T memory; //chunk.memory protected int offset; //偏移量 protected int length; int maxLength; PoolThreadCache cache;

所以再申请完内存后,还需要创建这么个对象来保存这些信息。在Netty中会有一个轻量级的对象池(Recycler)来保存PooledByteBuf对象。这里用了ThreadLocal来减少锁的争用,所以同样的会出现不通线程之间申请与释放的问题。在这个地方Netty的处理方式为:它为每个线程维持一个对象队列,同时又有一个全局的队列来保存这种情况释放的对象。当线程从自身的队列拿不到对象时,会从全局队列中转移一部分对象到自身队列中去。
内存泄露检测 这种手动的申请与释放难免会出现遗漏,Netty总结了一套ByteBuf的释放准则跟内存泄露检测方法ByteBuf使用准则
内存泄露检测的原理是利用虚引用,当一个对象只被虚引用指向时,在GC的时候会被自动放到了一个ReferenceQueue里面,每次去申请内存的时候最后都会根据检测策略去ReferenceQueue里面判断释放有泄露的对象。
#AbstractByteBufAllocator protected static ByteBuf toLeakAwareBuffer(ByteBuf buf) { ResourceLeakTracker leak; switch (ResourceLeakDetector.getLevel()) { case SIMPLE: leak = AbstractByteBuf.leakDetector.track(buf); if (leak != null) { buf = new SimpleLeakAwareByteBuf(buf, leak); } break; case ADVANCED: case PARANOID: leak = AbstractByteBuf.leakDetector.track(buf); if (leak != null) { buf = new AdvancedLeakAwareByteBuf(buf, leak); } break; default: break; } return buf; }

性能测试 可以写两个简单的测试用例,感受一下Netty内存池带来的效果。
  1. 申请10000000个HeapBuffer,DirectBuffer,池化的DirectBuffer花的时间, 可以看出池化效果非常明显,而且十分平和
capacity HeapBuffer DirectBuffer 池化的DirectBuffer
64Byte 465 13211 2059
256Byte 946 15074 2309
512Byte 2528 19516 2188
1024Byte 4393 21928 2044
  1. 启一个DiscardServer,然后发送80G的数据,看下GC次数,效果感人
非池化 池化 池化+COMPOSITE_CUMULATOR
208 27 0
dd if=/dev/zero bs=8092 count=10240000|nc 127.0.0.1 8000

【Netty4|Netty-内存管理】
小礼物走一走,来简书关注我


作者:冰冻爱心小烧烤
链接:https://www.jianshu.com/p/7882689e7fe5
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

    推荐阅读