如何设计一个内存分配器

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何设计一个内存分配器相关的知识,希望对你有一定的参考价值。

参考技术A 通常工程里不推荐自己写内存分配器,因为你费力写一个出来99%可能性没有内置的好,且内存出bug难调试
不过看书之余,你也可以动手自己试试,当个玩具写写玩玩。

1. 实现教科书上的内存分配器:

做一个链表指向空闲内存,分配就是取出一块来,改写链表,返回,释放就是放回到链表里面,并做好归并。注意做好标记和保护,避免二次释放,还可以花点力气在如何查找最适合大小的内存快的搜索上,减少内存碎片,有空你了还可以把链表换成伙伴算法,写着玩嘛。

2. 实现固定内存分配器:

即实现一个 FreeList,每个 FreeList 用于分配固定大小的内存块,比如用于分配 32字节对象的固定内存分配器,之类的。每个固定内存分配器里面有两个链表,OpenList 用于存储未分配的空闲对象,CloseList用于存储已分配的内存对象,那么所谓的分配就是从 OpenList 中取出一个对象放到 CloseList 里并且返回给用户,释放又是从 CloseList 移回到 OpenList。分配时如果不够,那么就需要增长 OpenList:申请一个大一点的内存块,切割成比如 64 个相同大小的对象添加到 OpenList中。这个固定内存分配器回收的时候,统一把先前向系统申请的内存块全部还给系统。

3. 实现 FreeList 池:

在你实现了 FreeList的基础上,按照不同对象大小(8字节,16字节,32,64,128,256,512,1K。。。64K),构造十多个固定内存分配器,分配内存时根据内存大小查表,决定到底由哪个分配器负责,分配后要在头部的 header 处(ptr[-sizeof(char*)]处)写上 cookie,表示又哪个分配器分配的,这样释放时候你才能正确归还。如果大于64K,则直接用系统的 malloc作为分配,如此以浪费内存为代价你得到了一个分配时间近似O(1)的内存分配器,差不多实现了一个 memcached 的 slab 内存管理器了,但是先别得意。此 slab 非彼 slab(sunos/solaris/linux kernel 的 slab)。这说白了还是一个弱智的 freelist 无法归还内存给操作系统,某个 FreeList 如果高峰期占用了大量内存即使后面不用,也无法支援到其他内存不够的 FreeList,所以我们做的这个和 memcached 类似的分配器其实是比较残缺的,你还需要往下继续优化。

4. 实现正统的 slab (非memcached的伪 slab)代替 FreeList:

这时候你需要阅读一下 http://citeseer.ist.psu.edu/bonwick94slab.html 这篇论文了,现代内存分配技术的基础,如何管理 slab 上的对象,如何进行地址管理,如何管理不同 slab 的生命周期,如何将内存回收给系统。然后开始实现一个类似的东西,文章上传统的 slab 的各种基础概念虽然今天没有改变,但是所用到的数据结构和控制方法其实已经有很多更好的方法了,你可以边实现边思考下,实在不行还可以参考 kernel 源码嘛。但是有很多事情应用程序做不了,有很多实现你是不能照搬的,比如页面提供器,可以提供连续线性地址的页面,再比如说 kernel 本身记录着每个页面对应的 slab,你查找 slab 时,系统其实是根据线性地址移位得到页面编号,然后查表得到的,而你应用程序不可能这么干,你还得做一些额外的体系来解决这些问题,还需要写一些额外的 cookie 来做标记。做好内存收缩工作,内存不够时先收缩所有分配器的 slab,再尝试重新分配。再做好内存回收工作,多余的内存,一段时间不使用可以还给操作系统。

5. 实现混合分配策略:

你实现了上面很多常见的算法后,该具体阅读各种内存分配器的代码了,这些都是经过实践检验的,比如 libc 的内存分配器,或者参考有自带内存管理的各种开源项目,比如 python 源码,做点实验对比他们的优劣,然后根据分配对象的大小采用不同的分配策略,区别对待各种情况。试验的差不多了就得引入多线程支持了,将你的锁改小。注意很多系统层的线程安全策略你是没法弄的,比如操作系统可以关中断,短时间内禁止本cpu发生任务切换,这点应用程序就很麻烦了,还得用更小的锁来代替。当锁已经小到不能再小,也可以选择引入 STM 来代替各种链表的锁。

6. 实现 Per-CPU Cache:

现代内存分配器,在多核下的一个重要优化就是给多核增加 cache,为了进一步避免多线程锁竞争,需要引入 Per-CPU Cache 了。分配内存先找到对应线程所在的cpu,从该cpu上对应的 cache 里分配,cache 不够了就一次性从你底层的内存分配器里多分配几个对象进来填充 cache,释放时也是先放回 cache,cache里面如果对象太多,就做一次收缩,把内存换个底层分配器,让其他 cpu 的cache有机会利用。这样针对很多短生命周期的频繁的分配、释放,其实都是在 cache 里完成的,没有锁竞争,同时cache分配逻辑简单,速度更快。操作系统里面的代码经常是直接读取当前的cpu是哪个,而应用层实现你可以用 thread local storage 来代替,目前这些东西在 crt的 malloc 里还暂时支持不到位(不排除未来版本会增加),可以更多参考 tc/jemalloc。

7. 实现地址着色:

现代内存分配器必须多考虑总线压力,在很多机型上,如果内存访问集中在某条 cache line相同的偏移上,会给总线带来额外的负担和压力。比如你经常要分配一个 FILE 对象,而每个 FILE对象使用时会比较集中的访问 int FILE::flag; 这个成员变量,如果你的页面提供器提供的页面地址是按照 4K对齐的,那么很可能多个 FILE对象的 flag 成员所处的 cache line 偏移地址是相同的,大量访问这些相同的偏移地址会给总线带来很大负担,这时候你需要给每个对象额外增加一些偏移,让他们能够均匀的分布在线性地址对应的cache line 偏移上,消减总线冲突的开销。

8. 优化缓存竞争:

多核时代,很多单核时代的代码都需要针对性的优化改写,最基本的一条就是 cache 竞争,这是比前面锁竞争更恶劣的情况:如果两个cpu同时访问相同的 cache-line 或者物理页面,那么 cpu 之间为了保证内存一致性会做很多的通信工作,比如那个cpu0需要用到这段内存,发现cpu1也在用,那么需要通知cpu1,将cpu1 L1-L2缓存里面的数据写回该物理内存,并且释放控制权,这时cpu0取得了控制权才能继续操作,期间cpu0-cpu1之间的通信协议是比较复杂的,代价也是比较大的,cache竞争比锁竞争恶劣不少。为了避免 cache 竞争,需要比先前Per-CPU cache 更彻底的 Per-CPU Page 机制来解决,直接让不同的cpu使用不同的页面进行二次分配,彻底避免 cache 竞争。具体应用层的做法也是利用线性地址来判断所属页面(因为物理页面映射到进程地址也是4k对齐的),同时继续使用 thread local storage 或者用系统提供的 api 读取当前属于哪个 cpu 来实现。为了避免核太多每个核占据大量的页面带来的不必要的浪费,你可以参考下 Linux 最新的 slub 内存分配算法,但是 slub 也有未尽之处,好几个 linux 发行版在实践中发现 slub 还是存在一些问题的(非bug,而是机制),所以大部分发行版默认都是关闭 slub 的,虽然,你还是可以借鉴测试一下。

9. 调试和折腾:

继续参考各种现代内存分配器,取长补短,然后给你的分配器添加一些便于调试的机制,方便诊断各种问题。在你借鉴了很多开源项目,自己也做了一些所谓的优化,折腾了那么久以后,你或许以为你的分配器可以同各种开源分配器一战了,测试效果好像也挺好的,先别急,继续观察内存利用率,向操作系统申请/归还内存的频率等一系列容易被人忽视的指标是否相同。同时更换你的测试用例,看看更多的情况下,是否结果还和先前一样?这些都差不多的时候,你发现没有个一两年的大规模持续使用,你很难发现一些潜在的隐患和bug,可能你觉得没问题的代码,跑了两年后都会继续报bug,这很正常,多点耐心,兴许第三年以后就比较稳定了呢?

有卯用呢?

十多年前 libc 还不成熟的情况下,为了程序长时间运行的稳定性,大部分程序员都必须针对自己的应用来实现针对特定情况的内存分配器。当年如果不自己管理内存,很多客户端,如果计算密集频繁分配,才开始可能没什么区别,但跑个几个小时性能立马就下降下来了;服务器进程持续运行个10多天不重启,速度也会越来越慢,碎片多了嘛。如今 libc 的 malloc 也进步了很多,这样的情况比较少了,那你再做一个内存池的意义何在呢?

在你的玩具比较稳定的情况下,终于可以产生一些价值了,因为一些性能指标你无法兼得,标准的分配器往往提供了一个类似保守和中庸的做法,来针对大部分的情况,你可以做的第一步,就是打破这样的平衡,让你的分配器倾向于某些情况比如:

1. 现代计算机内存都很大,你是不是可以牺牲内存利用率为代价换取更高的内存归还/重用的效率?同时换取更快的分配速度?或许你会发现,你可以比 libc 的 malloc 平均浪费 30%内存的代价换来两倍以上的性能提升,在一些内存分配成为瓶颈的应用中起到积极的作用。

2. 比如你可以调整大小内存的比值,libc如果认为 8K以下是小内存,那么你可以不那么认为。

3. 比如如果你的系统就是一个单线程的东西,那么你是否能提供开关,完全以单线程的模式进行运作,完全绕过各种锁和针对多核进行的各种冗余操作呢?

4. 比如你的机器内存有限,你应用需要耗费大量的内存,那么你可以引入其他机制,以牺牲少量性能为代价,换取更好的内存回收效果和内存利用率。

5. 最近分配的内存在线性地址上能否尽量连续?能否在地址上尽量规避缺页中断?

6. 比如你程序里面某些对象需要被跟踪,你能否直接在分配器上实现对象跟踪机制,跟踪各种泄漏,越界问题?

7. 每个内存分配都在寻求最佳的公平,你在乎的公平是什么?

JVM-内存分配与回收策略

    JAVA技术体系中的自动内存管理实际上就是自动化的解决了给对象分配内存以及回收给对象分配的内存这两个问题。回收部分通过之前的《GC设计思路分析》《垃圾收集器》这两篇博文进行了总结,那么接下来主要就是谈谈自己对JVM是如何给对象分配内存这一部分的理解。JVM的内存空间是有限的,并且堆内存是共享的,那么不同线程共用堆内存如何保证线程安全都是需要考虑的问题。
    通过之前对JVM中的内存模型的分析以及GC的学习,我们知道JAVA内存分配往大了说就是在JAVA堆上分配内存,对象主要分配在新生代的Eden区; Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间 TLAB (Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁(顺带说一下为什么new一个对象消耗较大就是在堆内存中创建对象需要加锁),因此如果启动了TLAB,JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C语言基本是一样高效的;但如果对象过大的话则仍然是直接使用堆空间分配。对于少数的大对象会被直接分配到老年代中。JVM中的内存分配规则并不是百分百固定的,细节取决于当前使用的垃圾收集器组合(我认为其实就是考虑组合在不同年代使用的是哪种收集算法,不同的收集算法导致了分配细节不一样),还有虚拟机中与内存相关的参数的设置。

     对于Client模式下的JVM来说(只有32位的JDK安装才有Client模式的JVM,64位的JDK只有Server模式的JVM),默认的新生代和老年代的垃圾收集器是单线程的Serial(复制算法,并且存在担保机制,默认Eden区和Survivor是8:1)和Serial Old(标记-整理算法)。下面将研究,在这种组合的情况下,JVM的内存分配和回收策略(ParNew和Serial的组合也差不多)。
  1. 对象优先分配到新生代的Eden区
  2. 大对象直接进入老年代:所谓的大对象就是指需要大量连续内存空间的JAVA对象,最典型的大对象就是那种很长的字符串和数组。经常产生大对象容易导致额外的GC操作,JVM中提供了一个-XX:PretenureSizeThreshold参数(这个参数只对Serial和ParNew这两个新生代垃圾收集器有效),令大于这个参数的对象直接在老年代中分配,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝。为什么这样,就在于Serial使用的是复制算法,如果不了解可以参看之前的介绍JVM垃圾收集机制的博文。
  3. 长期存活的对象将进入老年代
    • 不知道大家在学习GC机制的时候,有没有疑问:对象什么时候才被放到老年代去,具体是怎么做的?
    • 我们知道,JVM产生一个对象的时候,首先将其放在新生代的Eden区中,并且随着young gc的产生,大部分的对象都被回收了,那么“熬过”这次GC的对象呢?JVM给了每个对象一个“年龄计数器”,所谓的年龄计数器就是指,这个对象熬过第一次GC,并且进入了Survivor区中,那么就将这个对象的年龄设为1,之后,每熬过一次GC,年龄+1,当这个值到达一个阀值(默认15,可通过-XX:MaxTenuringThreshold来设置)时,这个对象就会被移到老年代中。
  4. 动态对象年龄判断
    • 为了更好的适应不同程序的内存状况,JVM也不是要去一个对象必须达到MaxTenuringThreshold设置的年龄阀值才能进入老年代。如果Survivor中的对象满足同年龄(比如N)对象所占空间达到了Survivor总空间的一半的时候,那么年龄大于或者等于N的对象都可以进入老年代,无需等待阀值。
  5. 空间分配担保
    • 在学习JVM垃圾收集机制的时候,我们就知道了新生代采用复制算法,但是会造成空间的浪费,故而提出了一种“空间担保机制”来提高复制算法的空间利用率,使复制算法的浪费从50%降到了10%。而老年代的内存就充当了这个担保者,并且由于没有其他内存来担保老年代,所以老年代如果不想产生空间内存碎片那么只能使用“标记-整理”算法了。看到这,我们其实心里肯定有疑问——如何保证老年代有足够的空间来执行空间担保机制呢?Full GC,是否触发根据经验值判断,即使不允许担保失败,也有可能发生担保失败。
    • 当发生YGC的时候,JVM都会检测之前每次晋升到老年代的对象的平均大小是否大于老年代的剩余内存空间,如果大于,则触发Full GC;如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,则不会触发Full GC,反之,触发Full GC,保证老年代有足够的空间支持空间分配担保成功。
    • 其实在每次GC发生的时候,我们也不知道到底会有多少对象被回收,又有多少对象能存活。故而只好取之前每次回收晋升到老年代的对象的平均值作为经验值来判断,但是如果某次GC后存活对象激增,任然会导致担保失败,那么只能重新进行Full GC了,虽然这样会绕个圈子,但是大部分情况下还是会将HandlePromotionFailure的值设为true,从而 避免Full GC过于频繁。换句话说,就是大部分情况,允许担保失败

以上是关于如何设计一个内存分配器的主要内容,如果未能解决你的问题,请参考以下文章

设计和编码一个非碎片化的静态内存池

C语言中内存分配 (转)

JVM-内存分配与回收策略

设计模式之争:新分配内存还是内存池?(含评测)

[C++常见面试笔试题汇总] 程序设计基础 - 内存分配sizeof指针篇

JVM-内存分配与回收策略