目录
Netty内存池
创建堆外内存的速度比堆内存慢了10到20倍,为了解决这个问题Netty就做了内存池,Netty的内存池是不依赖于JVM本身的GC的。
可以带着以下问题去看源码:
- 内存池管理算法是怎么做到申请效率,怎么减少内存碎片
- 高负载下内存池不断扩展,如何做到内存回收
- 对象池是如何实现的,这个不是关键路径,可以当成黑盒处理
- 内存池跟对象池作为全局数据,在多线程环境下如何减少锁竞争
- 池化后内存的申请跟释放必然是成对出现的,那么如何做内存泄漏检测,特别是跨线程之间的申请跟释放是如何处理的。
设计思路
Netty采用了jemalloc的思想,这是FreeBSD实现的一种并发malloc的算法。jemalloc依赖多个Arena来分配内存,运行中的应用都有固定数量的多个Arena,默认的数量与处理器的个数有关。系统中有多个Arena的原因是由于各个线程进行内存分配时竞争不可避免,这可能会极大的影响内存分配的效率,为了缓解高并发时的线程竞争,Netty允许使用者创建多个分配器(Arena)来分离锁,提高内存分配效率。
线程首次分配/回收内存时,首先会为其分配一个固定的Arena。线程选择Arena时使用round-robin的方式,也就是顺序轮流选取。
每个线程各种保存Arena和缓存池信息,这样可以减少竞争并提高访问效率。Arena将内存分为很多Chunk进行管理,Chunk内部保存Page,以页为单位申请。
内存池结构
Netty中将内存池分为五种不同的形态从大到小依次是:PoolArena,PoolChunkList,PoolChunk,PoolPage,PoolSubPage.
PoolArena
是由多个PoolChunkList和两个SubPagePools(一个是tinySubPagePool,一个是smallSubPagePool)组成的。
PoolArena是功能的门面,通过PoolArena提供接口供上层使用,屏蔽底层实现细节。为了减少线程成间的竞争,很自然会提供多个PoolArena。Netty默认会生成2×CPU个PoolArena跟IO线程数一致。然后第一次使用的时候会找一个使用线程最少的PoolArena
private <T> PoolArena<T> leastUsedArena(PoolArena<T>[] arenas) {
if (arenas == null || arenas.length == 0) {
return null;
}
PoolArena<T> minArena = arenas[0];
for (int i = 1; i < arenas.length; i++) {
PoolArena<T> arena = arenas[i];
if (arena.numThreadCaches.get() < minArena.numThreadCaches.get()) {
minArena = arena;
}
}
return minArena;
}
PoolChunk
Netty一次向系统申请16M的连续内存空间,这块内存通过PoolChunk对象包装,为了更细粒度的管理它,进一步的把这16M内存分成了2048个页(pageSize=8k)。页作为Netty内存管理的最基本的单位 ,所有的内存分配首先必须申请一块空闲页。可能有一个疑问,如果申请1Byte的空间就分配一个页是不是太浪费空间,在Netty中Page还会被细化用于专门处理小于4096Byte的空间申请
那么这些Page需要通过某种数据结构跟算法管理起来。最简单的是采用数组或位图管理
如上图1表示已申请,0表示空闲。这样申请一个Page的复杂度为O(n)。
Netty采用完全二叉树进行管理,树中每个叶子节点表示一个Page,即树高为12,中间节点表示页节点的持有者。
这样的一个完全二叉树可以用大小为4096的数组表示,数组元素的值含义为:
private final byte[] memoryMap; //表示完全二叉树,共有4096个
private final byte[] depthMap; //表示节点的层高,共有4096个
- memoryMap[i] = depthMap[i]:表示该节点下面的所有叶子节点都可用,这是初始状态
- memoryMap[i] = depthMap[i] + 1:表示该节点下面有一部分叶子节点被使用,但还有一部分叶子节点可用
- 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 = 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;
}
}
PoolChunkList
上面讨论了PoolChunk的内存分配算法,但是PoolChunk只有16M,这远远不够用,所以会很很多很多PoolChunk,这些PoolChunk组成一个链表,然后用PoolChunkList持有这个链表.
#PoolChunkList
private PoolChunk<T> head;
#PoolChunk
PoolChunk<T> prev;
PoolChunk<T> next;
它有6个PoolChunkList,所以将PoolChunk按内存使用率分类组成6个PoolChunkList,同时每个PoolChunkList还把各自串起来,形成一个PoolChunkList链表。
#PoolChunkList
private final int minUsage; //最小使用率
private final int maxUsage; //最大使用率
private final int maxCapacity;
private PoolChunkList<T> prevList;
private final PoolChunkList<T> nextList;
#PoolArena
//[100,) 每个PoolChunk使用率100%
q100 = new PoolChunkList<T>(this, null, 100, Integer.MAX_VALUE, chunkSize);
//[75,100) 每个PoolChunk使用率75-100%
q075 = new PoolChunkList<T>(this, q100, 75, 100, chunkSize);
//[50,100)
q050 = new PoolChunkList<T>(this, q075, 50, 100, chunkSize);
//[25,75)
q025 = new PoolChunkList<T>(this, q050, 25, 75, chunkSize);
//[1,50)
q000 = new PoolChunkList<T>(this, q025, 1, 50, chunkSize);
qInit = new PoolChunkList<T>(this, q000, Integer.MIN_VALUE, 25, chunkSize);
既然按使用率分配,那么PoolChunk在使用过程中是会动态变化的,所以PoolChunk会在不同PoolChunkList中变化。同时申请空间,使用哪一个PoolChunkList也是有先后顺序的
#PoolChunkList
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
if (head == null || normCapacity > maxCapacity) {
return false;
}
for (PoolChunk<T> 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<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
...
qInit.add(c);
}
这样设计的目的是考虑到随着内存的申请与释放,PoolChunk的内存碎片也会相应的升高,使用率越高的PoolChunk其申请一块连续空间的失败的概率也会大大的提高。同时,注意观察代码跟上图可以发现q000没有前驱节点,所以一旦PoolChunk使用率为0,就从PoolChunkList中移除,释放掉这部分空间,避免在高峰的时候申请过内存一直缓存到池中。同时,各个PoolChunkList的区间是交叉的,这是故意的,因为如果介于一个临界值的话,PoolChunk会在前后PoolChunkList不停的来回移动。
PoolPage
Page的大小默认是8192字节,也可以设置系统变量io.netty.allocator.pageSize来改变页的大小。自定义页大小有如下限制:
- 1.必须大于4096字节;
- 2.必须是2的整次数幂。
通常一个页(page)的大小就达到了10^13(8192字节),通常一次申请分配内存没有这么大,可能很小。于是Netty将页(page)划分成更小的片段——SubPage
PoolSubpage
对于小内存(小于4096)的分配还会将Page细化成更小的单位Subpage。Subpage按大小分有两大类,36种情况:
- Tiny:小于512的情况,最小空间为16,对齐大小为16,区间为[16,512),所以共有32种情况。
-
Small:大于等于512的情况,总共有四种,512,1024,2048,4096。
PoolSubpage中直接采用位图管理空闲空间(因为不存在申请k个连续的空间),所以申请释放非常简单。
代码:
#PoolSubpage(数据结构)
final PoolChunk<T> 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个单元代码的逻辑就比较清晰了
本地线程存储
虽然提供了多个PoolArena减少线程间的竞争,但是难免还是会存在锁竞争,所以需要利用ThreaLocal进一步优化,把已申请的内存放入到ThreaLocal自然就没有竞争了。大体思路是在ThreadLocal里面放一个PoolThreadCache对象,然后释放的内存都放入到PoolThreadCache里面,下次申请先从PoolThreadCache获取。
但是,如果thread1申请了一块内存,然后传到thread2在线程释放,这个Netty在内存holder对象里面会引用PoolThreadCache,所以还是会释放到thread1里。
对象池
只要知道一个PoolChunk,一个PoolChunk里面的页索引,申请到的空间容量,如果是小内存还需要知道页内索引就可以定位到这块内存了。这些信息保存到PooledByteBuf对象
protected PoolChunk<T> 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实现了ReferenceCounted接口,当实例化ByteBuf对象时,引用计数加1。
当应用代码保持一个对象引用时,会调用retain方法将计数增加1,对象使用完毕进行释放,调用release将计数器减1.
当引用计数变为0时,对象将释放所有的资源,返回内存池。
内存泄露检测
内存泄露检测的原理是利用虚引用,当一个对象只被虚引用指向时,在GC的时候会被自动放到了一个ReferenceQueue里面,每次去申请内存的时候最后都会根据检测策略去ReferenceQueue里面判断释放有泄露的对象。
#AbstractByteBufAllocator
protected static ByteBuf toLeakAwareBuffer(ByteBuf buf) {
ResourceLeakTracker<ByteBuf> 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支持下面四种级别,使用-Dio.netty.leakDetectionLevel=advanced
可以调节等级。
- 禁用(DISABLED) - 完全禁止泄露检测,省点消耗。
- 简单(SIMPLE) - 默认等级,告诉我们取样的1%的ByteBuf是否发生了泄露,但总共一次只打印一次,看不到就没有了。
- 高级(ADVANCED) - 告诉我们取样的1%的ByteBuf发生泄露的地方。每种类型的泄漏(创建的地方与访问路径一致)只打印一次。对性能有影响。
- 偏执(PARANOID) - 跟高级选项类似,但此选项检测所有ByteBuf,而不仅仅是取样的那1%。对性能有绝大的影响。
Handler中的内存处理机制
Netty中有handler链,消息有本Handler传到下一个Handler。所以Netty引入了一个规则,谁是最后使用者,谁负责释放。
根据谁最后使用谁负责释放的原则,每个Handler对消息可能有三种处理方式
对原消息不做处理,调用 ctx.fireChannelRead(msg)把原消息往下传,那不用做什么释放。
将原消息转化为新的消息并调用 ctx.fireChannelRead(newMsg)往下传,那必须把原消息release掉。
如果已经不再调用ctx.fireChannelRead(msg)传递任何消息,那更要把原消息release掉。
假设每一个Handler都把消息往下传,Handler并也不知道谁是启动Netty时所设定的Handler链的最后一员,所以Netty在Handler链的最末补了一个TailHandler,如果此时消息仍然是ReferenceCounted类型就会被release掉。