8.池化内存分配

  • netty内存管理思想
  • PooledByteBufAllocate
  • PoolChunk
    • Chunk初始化
    • PoolChunk分配内存

netty内存管理思想

java作为一门拥有GC机制的语言,长久以来它的使用者都不必手动管理内存,这比起c/c++是一个巨大的进步。但现在netty却反其道而行之,实现了一套不依赖GC而自行管理内存的机制。
那么netty为什么要这么做?众所周知netty是一个网络通信层框架,涉及到大量IO操作,对于此类操作,DirectByteBuffer无疑比起堆内存有更多性能上的优势。而与此同时,DirectByteBuffer也有自身的不足,那就是它的申请和释放成本更高。如果直接交给JVM管理,频繁的GC将使DirectByteBuffer的优势荡然无存。所以,对DirectByteBuffer进行池化管理,多次重用以减少申请、释放的想法就比较自然了。但是,不同于一般于我们常见的对象池、连接池等池化的案例,ByteBuffer有大小一说,且申请多大的内存进行管理也难以确定,如果大了会浪费,小了会导致频繁扩容和内存复制以及碎片化。因此netty需要在解决高效分配内存的同时又解决内存碎片化的问题,显然,它使用的算法就非常具有学习价值了。

由于内存管理有许多概念,有必要在前期做一些准备工作以帮助理解。

  1. 首先,考虑到netty是一个多线程环境,netty使用了ThreadLocal,在ThreadLocal中绑定一些ByteBuf。这样每个线程都可以独立的占有一段内存区间,在线程内操作内存,避免加锁的损耗。这个特殊的ThreadLocal称为PoolThreadLocalCache。
  2. 其次,即使使用了ThreadLocal,还是不可避免的有线程竞争,netty便将申请的大内存区域划分为多个小的内存区域,提高并发执行的效率。这些内存区域被成为Arena,它们组成了一个内存池,分配时每个线程按照轮训策略选择一个Arena进行内存分配。
  3. 最后,虽然将大内存区域划分成了Arena,它仍然还是有些大,如果直接在上面任意分配,很容易产生内存碎片,所以还需要再细分。netty将Arena切分成一小块一小块的Chunk,Chunk内部是一些Page的集合。Page也即内存页,是真正分配内存的地方。Page默认大小为8KB,但实际使用时很难一次用到这么大的内存,而是使用像16B,32B这种大小的内存。为了应对这种需求,netty进一步切分Page为更小的SubPage。
    以上3点从大方向上简单介绍了内存管理的几个概念,具体的实现形式和细节将一步步探讨。

PooledByteBufAllocate

ByteBufAllocator的默认实现类是PooledByteBufAllocator,而PooledByteBufAllocate及其实现类正是这套内存管理机制的承载者。
首先看一下它的构造函数,来了解哪些类参与了池化内存分配。

public PooledByteBufAllocator(boolean preferDirect, int nHeapArena, int nDirectArena, int pageSize, int maxOrder,
                                int tinyCacheSize, int smallCacheSize, int normalCacheSize,
                                boolean useCacheForAllThreads, int directMemoryCacheAlignment) {
    super(preferDirect);
    threadCache = new PoolThreadLocalCache(useCacheForAllThreads);
    this.tinyCacheSize = tinyCacheSize;
    this.smallCacheSize = smallCacheSize;
    this.normalCacheSize = normalCacheSize;
    chunkSize = validateAndCalculateChunkSize(pageSize, maxOrder);
    int pageShifts = validateAndCalculatePageShifts(pageSize);
    if (nHeapArena > 0) {
        heapArenas = newArenaArray(nHeapArena);
        for (int i = 0; i < heapArenas.length; i ++) {
            PoolArena.HeapArena arena = new PoolArena.HeapArena(this,
                    pageSize, maxOrder, pageShifts, chunkSize,
                    directMemoryCacheAlignment);
            heapArenas[i] = arena;
        }
    } else {
        heapArenas = null;
    }
    if (nDirectArena > 0) {
        directArenas = newArenaArray(nDirectArena);
        for (int i = 0; i < directArenas.length; i ++) {
            PoolArena.DirectArena arena = new PoolArena.DirectArena(
                    this, pageSize, maxOrder, pageShifts, chunkSize, directMemoryCacheAlignment);
            directArenas[i] = arena;
        }
    } else {
        directArenas = null;
    }
}

从构造函数中可以看到,上述管理思想中的ThreadLocal、PoolArena、PoolChunk、Page、PoolSubpage都一一出现。
而且构造函数虽然长,但大致可以分为4个步骤:

  1. 调用父类构造器,设置是否优先考虑分配堆外内存;
  2. 设置PoolThreadLocalCache相关的参数,包括threadCache,tinyCacheSize,smallCacheSize,normalCacheSize;
  3. 设置Chuck大小,并根据Chunck大小计算Page数量;
  4. 设置heapArena和directArena的数量。
    这个构造函数虽然只是简单的设置了一些数值,但这些数值对于后续如何分配内存却有着至关重要的影响。
    我们需要先分析一下上述各个来,再进入PoolByteBufAllocate中了解默认的内存分配是怎样起到适合各种情况的。

PoolChunk

PoolChunk在内存分配中处于一个承上启下的位置,所以我们首先来了解一下它。

Chunk初始化

在PooledByteBufAllocate构造函数值,初始化了chunkSize和pageShifts这2个变量,且用到了pageSize和maxOrder,它们分别由"io.netty.allocator.pageSize"和"io.netty.allocator.maxOrder"设置,pageSize不得小于2^12(4096)且必须为2的幂次方,默认为2^13(8192);maxOrder不得大于14,默认为11。validateAndCalculateChunkSize/PageShifts这2个方法用于检测用户设定值的正确性并计算chunkSize和pageShifts。其中chunkSize是将pageSize左移maxOrder位,且不得大于2^30,默认会是2^24。pageShifts是pageSize相对于‘1‘左移的位数,由于pageSize为2^13,这里pageShifts为13。
如果不了解Chunk与Page的关系,在这里会困惑很久。前文说到Chunk是Page的集合,那么Chunk是如何组织Page的呢?我们进入PoolChunk的构造函数一窥全貌。

PoolChunk(PoolArena<T> arena, T memory, int pageSize, int maxOrder, int pageShifts, int chunkSize, int offset) {
    unpooled = false;
    this.arena = arena;
    this.memory = memory;
    this.pageSize = pageSize;
    this.pageShifts = pageShifts;
    this.maxOrder = maxOrder;
    this.chunkSize = chunkSize;
    this.offset = offset;
    unusable = (byte) (maxOrder + 1);
    log2ChunkSize = log2(chunkSize);
    subpageOverflowMask = ~(pageSize - 1);
    freeBytes = chunkSize;
    maxSubpageAllocs = 1 << maxOrder;
    memoryMap = new byte[maxSubpageAllocs << 1];
    depthMap = new byte[memoryMap.length];
    int memoryMapIndex = 1;
    for (int d = 0; d <= maxOrder; ++ d) {
        int depth = 1 << d;
        for (int p = 0; p < depth; ++ p) {
            memoryMap[memoryMapIndex] = (byte) d;
            depthMap[memoryMapIndex] = (byte) d;
            memoryMapIndex ++;
        }
    }
    subpages = newSubpageArray(maxSubpageAllocs);
    cachedNioBuffers = new ArrayDeque<ByteBuffer>(8);
}

这也是一个相当长的构造函数,其中unpooled、arena、memory、subpages、cachedNioBuffers这几个成员变量与这一节关系不大,先忽略。pageSize、maxOrder、pageShifts、chunkSize已在上文阐述。
重点放在memoryMap和depthMap这2个名为map实为数组的成员变量里。它们的长度为maxSubpageAllocs <<1,为2^12,4096。之后,通过一个循环,将memoryMap和depthMap各自初始化。2个map在初始化后,都变成了一个从下标1开始,每个元素的下标index和值val满足 2^(val) <= index < 2^(val + 1)的数组。这类型的数组是标准的满二叉树数据结构。
由于从下标1开始,这两颗满二叉树一共4095个节点,其中有2048个叶子节点。如果将视线回到ChunkSize的计算,会发现ChunkSize也是通过将pageSize*2048得到,这说明满二叉树的每一个叶子结点都代表了一个Page,且父节点可分配的容量是2个子节点的容量和。

PoolChunk分配内存

在初始化完成后,depthMap就不再变化,而memoryMap会随着内存分配而变化。我们来看一下PoolChunk如何分配内存:

boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
    final long handle;
    if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
        handle =  allocateRun(normCapacity);
    } else {
        handle = allocateSubpage(normCapacity);
    }
    if (handle < 0) {
        return false;
    }
    ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null;
    initBuf(buf, nioBuffer, handle, reqCapacity);
    return true;
}

这段逻辑可以分为2段

  1. 判断normCapacity的大小,以决定分配Page还是分配SubPage。
  2. 尝试从缓存的NioBuffers中分配。
  3. 初始化ByteBuf。
    第一个步骤的分支点在于normCapacity与subpageOverflowMask的结果,normCapacity是一个规整化后的需要分配的大小。所谓规整化就是向上调整为2的幂次方。而subpageOverflowMask由Mask不难猜出它的作用相当于一个掩码,在构造函数中,它通过~(pageSize - 1)得到,拿出计算器后可知当pageSize为8192时它等于0xFFFFFFFFFFFFE000,确实符合掩码的格式。当normCapacity & subpageOverflowMask != 0时,normCapacity大于等于8192,需要分配Page级别的内存。
    跟进allocateRun(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;
}

由于预先对normCapacity进行了规整化,log2(normCapacity)可以得到的对数是一个2的幂次,在这里也相当于二叉树的深度。计算d是为了知晓在二叉树哪一层的节点进行分配内存。为了帮助理解,不妨假设当前需要分配一个16KB大小的内存。则d = 11 - (14 - 13) = 10,为第10层节点。以这个例子再进入allocateNode(d)中。

private int allocateNode(int d) {
    int id = 1;
    int initial = - (1 << d);
    byte val = value(id);
    if (val > d) {
        return -1;
    }
    while (val < d || (id & initial) == 0) {
        id <<= 1;
        val = value(id);
        if (val > d) {
            id ^= 1;
            val = value(id);
        }
    }
    setValue(id, unusable);
    updateParentsAlloc(id);
    return id;
}

当d=10时,initial不难算出为-1024。val = value(id)=memoryMap[id],此时memory由于还未分配,map里的值还是对应的深度。到了while循环,2个条件里val < d还比较好理解,它表示当前节点的深度不超过d,也就意味着当前节点有足够的容量分配内存;(id & initial)倒是有点晦涩难懂。继续以例子做参照,由于initial=-1024,将它转化为2进制,也可以当做一个掩码,而id从1开始左移,若id左移过多,id & initial就不再为0了,而id左移的意味着深度增加,所以这个条件表达式也是为了确保当前节点的深度不超过d。乍一看这2个条件是重复的,的确,初次分配时,第二个条件没有发挥作用,它得等到第二次分配才体现出效果。
当d等于10,val=10时,会跳出循环,然后将id下标对应的元素值设置为unusable,unusable在PoolChunk的构造函数中定义,为maxOrder+1,意味着不可达的深度,这里为12。

除了更新分配好的节点外,还需要更新其父节点,具体逻辑在updateParentAlloc(id),通过不断将id右移,同时更新父节点为2个子节点中较小的子节点的值,直到根节点。这里value(id ^ 1)就是求得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;
    }
}

回到allocateNode方法,之前说过(id & initial)需要在第二次分配才体现出效果。这是因为在第一次分配后,从根节点到分配节点的父节点深度都减少了1,当第二次分配16KB内存时,在512节点处时,val就已经等于10了,
id & initial = 512 & -1024 = 0,从而进入循环。在循环后,id继续左移,找到1024节点,此时由于1024节点已被分配,memory[1024] = 12 > 10 = d,进入if条件内,通过异或1,找到1024节点的兄弟节点1025进行分配。
allocateNode调用完毕后,回到allocateRun方法,在可用ChunkSize里减去该次分配的内存大小。

由于第二三点涉及到其他一些知识,留在以后分析。

原文地址:https://www.cnblogs.com/spiritsx/p/12246378.html

时间: 2024-10-15 09:43:46

8.池化内存分配的相关文章

Java内存分配之堆、栈和常量池(转)

摘录自http://www.cnblogs.com/SaraMoring/p/5687466.html Java内存分配主要包括以下几个区域: 1. 寄存器:我们在程序中无法控制 2. 栈:存放基本类型的数据和对象的引用,但对象本身不存放在栈中,而是存放在堆中 3. 堆:存放用new产生的数据 4. 静态域:存放在对象中用static定义的静态成员 5. 常量池:存放常量 6. 非RAM(随机存取存储器)存储:硬盘等永久存储空间 *********************************

C#中字符串的内存分配与驻留池

刚开始学习C#的时候,就听说CLR对于String类有一种特别的内存管理机制:有时候,明明声明了两个String类的对象,但是他们偏偏却指向同一个实例.如下: String s1 = "Hello";String s2 = "Hello"; //s2和s1的实际值都是"Hello"bool same = (object) s1 == (object) s2; //这里比较s1.s2是否引用了同一个对象实例 //所以不能写作bool same =

内存分配及变量存储位置(堆、栈、方法区常量池、方法区静态区)

转载来源:http://www.cnblogs.com/protected/p/6419217.html 侵删! 程序运行时,有六个地方都可以保存数据: 1. 寄存器:这是最快的保存区域,因为它位于和其他所有保存方式不同的地方:处理器内部.然而,寄存器的数量十分有限,所以寄存器是根据需要由编译器分配.我们对此没有直接的控制权,也不可能在自己的程序里找到寄存器存在的任何踪迹. 2. 堆栈:存放基本类型的数据和对象的引用,但对象本身不存放在栈中,而是存放在堆中(new 出来的对象).驻留于常规RAM

java内存分配(堆、栈、常量池)

Java内存分配: ◆寄存器:我们在程序中无法控制 ◆栈:存放基本类型的数据和对象的引用,以及成员方法中的局部变量 ◆堆:存放对象本身(成员变量+成员方法的引用) ◆静态域:存放在对象中用static定义的静态成员 ◆常量池:存放常量 ◆非RAM存储:硬盘等永久存储空间 栈的存取速度比堆快,仅次于寄存器. 常量池:专门用于存储.管理在编译时就可以确定的保存在.class文件中的一些数据.包括代码中所定义的各种基本类型(如int.long等等)和对象型(如String及数组)的常量值(final)

运行时数据区域(堆 栈 方法区 常量池)和内存分配策略

内存管理 内存分配和内存释放 内存分配由程序完成,内存释放由GC完成 运行时数据区域 (1)程序计数器(program counter register) 一块较小的内存空间 当前线程所执行的字节码的行号指示器,字节码解释器在工作的时候就是通过改变程序计数器的值来选取下一跳要执行的指令 多线程环境下,线程轮流切换执行,程序计数器保证线程切换之后能恢复到正确的位置 每个线程都有一个独立的程序计数器 线程私有 没有任何异常 java方法,程序计数器的值为当前正在执行的虚拟机字节码指令的地址 nati

jvm 内存分配 (转)

深入理解JVM-JVM内存模型 http://www.cnblogs.com/dingyingsi/p/3760447.html 我们知道,计算机CPU和内存的交互是最频繁的,内存是我们的高速缓存区,用户磁盘和CPU的交互,而CPU运转速度越来越快,磁盘远远跟不上CPU的读写速度,才设计了内存,用户缓冲用户IO等待导致CPU的等待成本,但是随着CPU的发展,内存的读写速度也远远跟不上CPU的读写速度,因此,为了解决这一纠纷,CPU厂商在每颗CPU上加入了高速缓存,用来缓解这种症状,因此,现在CP

SQLite剖析之动态内存分配

SQLite通过动态内存分配来获取各种对象(例如数据库连接和SQL预处理语句)所需内存.建立数据库文件的内存Cache.以及保存查询结果.我们做了很多努力来让SQLite的动态内存分配子系统可靠.可预测.健壮并且高效.本文概述SQLite的动态内存分配,软件开发人员在使用SQLite时可以据此获得最佳性能. 1.特性    SQLite内核和它的内存分配子系统提供以下特性:    (1)对内存分配失败的健壮处理.如果一个内存分配请求失败(即malloc()或realloc()返回NULL),SQ

JAVA基础-栈与堆,static、final修饰符、内部类和Java内存分配

Java栈与堆 堆:顺序随意 栈:后进先出(Last-in/First-Out). Java的堆是一个运行时数据区,类的对象从中分配空间.这些对象通过new.newarray.anewarray和multianewarray等指令建立,它们不需要程序代码来显式的释放.堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据.但缺点是,由于要在运行时动态分配内存,存取速度较慢. 栈的优势是

Java内存分配、管理小结

 想写这篇总结酝酿了有个来月了,却始终感觉还差点什么东西,一直未敢动笔. 最近两天连夜奋战,重新整理下前面查阅的资料.笔记,还是决定将它写出来. 现在提出几个问题,如果都能熟练回答的大虾,请您飘过.如以往一样,我是小菜,本文自然也是针对小菜阶层的总结. 首先是概念层面的几个问题: Java中运行时内存结构有哪几种? Java中为什么要设计堆栈分离? Java多线程中是如何实现数据共享的? Java反射的基础是什么? 然后是运用层面: 引用类型变量和对象的区别? 什么情况下用局部变量,什么情况下用