Skip to content

LuckyZhanJava/jemalloc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 

Repository files navigation

这是一个jemalloc的实现,和netty的ByteBuf的部分实现几乎一致,原理则出自:《A Scalable Concurrent malloc(3) Implementation for FreeBSD》

简单来说,这是一个针对多核心优化后的malloc实现,更好的支持了逐渐普及的大内存和多核心。在此之前FreeBSD的malloc实现是phkmalloc。

内存部分:过去十年RAM变得越来越便宜和普及,当时phkmalloc使用专门的优化以最大限度地减少用到的内存页数量,相较于phkmalloc,jemalloc更加关注缓存的位置,如果可以连续分配,那么可以提高内存局部性。jemalloc首先尝试最大限度地减少内存占用,在这基础之上才尝试连续分配。

内存分配的过程中会产生两类碎片:外部内存碎片和内部内存碎片。外部碎片通常是由于进程内存释放产生的间隙空间不够其他进程使用而产生,内部碎片则由于单次分配的内存不能够被进程充分使用产生。分配器需要通过权衡,尽量减少两种碎片的发生量。

多核心部分:现代多核心系统在缓存行上提供了内存的一致性视图。如果多个线程在多个cpu上同时操作同一缓存行上的不同数据,将会发生错误的缓存行共享,这会导致严重的性能下降。

考虑这样一种情况,多个线程分布在多个核心上,这些线程同时去申请某块内存,为了保证线程安全,这些线程需要同时访问或者修改某个标记锁状态的值,错误的缓存行共享就发生了。

jemalloc通过多个allocation arenas来解决这个问题。简单来说就是预先分配多个arena,线程在需要申请内存时,需要先获取当前线程绑定的arena,在此基础上再进行内存申请,这样可以减少缓存行错误共享和线程的锁争用(并行的线程增多了,进入上下文切换的线程数变少了)。

假设有4个核心,16个线程,对应有4个arena,这些线程分散绑定在不同的arena上,其中4个线程对应一个arena a,这4个线程又恰好被分配在同一个核心cpu0上,那么对于aa指向的其他内存的访问将只发生在cpu0的缓存行上,也就不存在错误共享的问题了。又假设这4个线程分散在不同的核心上,但线程对arena的内存只有读取操作,没有写入操作,写入操作发生在arena引用的其他对象,这些对象又分散在物理内存的各处,相应的对象可能分散在不同的缓存行或是不会同时存在多个cpu的缓存之中,也减少了错误共享发生的概率。

线程绑定arena使用ThreadLocal来实现,相比于hash绑定,jemalloc使用round-robin的方式来实现。优点是线程和arena的分配要比hash均匀。

为了提高效率,减少频繁的系统调用,单次通过系统调用申请一个大的chunk,所有的内存分配都是在chunk的基础上进行。

也就是说线程t需要先获取绑定的arena,然后arena申请chunk再进行后续分配

jemalloc把内存分配划分为三个分类:

  1. Huge。Huge申请的内存大小大于chunk的一半,这类申请的数量很少,通常用单个红黑树来管理,不存在可伸缩性问题。
  2. Large。对于Large申请,则采用buddy算法,把单个chunk多次对半切分形成runs,分配的结果对应多个连续的runs
  3. Small。对于Small申请又分为三个部分:TinyQuantum-spacedSub-page这三类。

对于Quantum-spaced来说,因为一些指令集架构要求指令操作的数据必须是16bytes对齐的,所以Quantum-spaced对应一组16*nbytes大小的分配。比如:16,32,48,64 bytes....

对于Tiny来说,这个分类的最大值要小于Quantum-spaced最小值的一半,仍然使用2^n大小来确定分配值。比如:2,4,8 bytes

对于Sub-page来说,它的最小值要大于Quantum-spaced上限的大小,同时小于runs大小的一半,也使用2^n来确定分配值。比如:1024,2048 bytes

总的来说:chunk要适当大,这样chunk释放后可以供其他进程申请,减少外部碎片,同时不会产生过多浪费。tiny要适当小,对于小内存申请有合适大小的分配,可以减少内部碎片。

这里我们假设chunk的大小为:2MB,单个run的大小:4kb

对于Huge分配,也即大于1MB大小的内存申请,申请的大小可能为1MB+1byte,或者为3MB,在申请内存之前,会先对申请的数字进行对齐,对齐到对应分类的可选大小,因为单个chunk的大小为2MB,分配以chunk为单位进行,所以对齐的大小可能是:2*n倍,也即:2,4,6,8...

对于Large分配,chunk被多次二等分成512个4k大小的run。这里简单描述一下buddy算法的实现,可以想象一颗完整的二叉树,树的最底层有512的节点,这相当于512个连续的runs分配在chunk上,512个节点的上一层是256个节点,256个节点向上是128个节点,每个结点都有一个索引i,这是一个完整的二叉树,可以用数组来表示,对于一个节点i,以及树高等信息,我们可以计算出以它为根节点的子树在最后一层叶子节点上对应的偏移量和长度。

在进行分配时,分配发生在二叉树的某个节点之上,而这些节点对应的容量大小为:runSize*2^n,所以,这里的请求内存对齐要在这个基础上进行。首先,通过请求内存大小对齐,然后我们可以确定当前请求的大小对应的节点应该处于二叉树的哪个高度,然后通过判断根节点的容量大小是否足够,向下移动选择一个容量足够的子节点,以这个子节点为父节点继续向下移动,最后直到选择到对应层数的对应节点。对于高度为3的二叉树,我们假设:两个子节点的容量为2^2,那么他们的父节点容量就为2^3,子节点的下一级子节点的容量为2^1,假设其中一个2^1被分配,那么倒数第二级节点的容量变为:2^1和2^2,最高一层根节点的最大容量为2^2。分配在某个节点上发生时,它们的父节点,祖父节点等等也要依次做出调整。当一个节点被分配时,它的容量变为0,同时父节点的容量会变为它的兄弟节点的容量,父节点的父节点也要依次做出调整。

对于Small分配,因为这些分配的大小都小于run的大小,所以这些分配通常是在run上进行的,因为这个分类包含不同的分配值,在同一个run上很难管理,所以,单个run只会被用于一个分配大小,比如一个run第一次分配的大小为Tiny分类的2bytes,那么后续的2byte分配都会在这个run上进行,直至这个run被分配完全。记录某段内存是否被分配则采用的bitmap的方式来管理。

chunk的分类。分配发生在chunk上,chunk的占用空间在不断变化,如果一个chunkc的占用到了75%以上,那么大概率下次分配会失败,只能申请新的chunk,但是当c的占用被释放之后,c的又能够被重新使用,为了追求内存的局部性,分配最好连续进行,同时又要尽量保证分配的效率,尽可能一次分配就成功。arena把chunk分成6个分类:QINIT,Q0,Q25,Q50,Q75,Q100。代表他们的占用程度,为了避免chunk在这些分类上频繁移动,或者chunk被频繁申请和释放(比如说:第一个alloc会申请一个chunk,并被移入到QINIT分类,紧接着释放可能会导致这个chunk被回收),处于QINIT类别的chunk只有在占用大于25%时才能进入到Q0分配,只有在Q0分类的chunk的内存占用降为0之后才能回收。处于Q0分类的chunk占用大于50%才能移动到Q25,处于Q25类别的chunk占用降低到25%一下才能移动了Q0。

在arena上发生分配时,分配的顺序依次为:Q50,Q25,Q0,QINIT,Q75。Q100代表占用已经到100%无法进行分配,Q75占用率已经大于75%分配失败的概率较高,放置在最后一级。

释放时则采用和分配相反的方式进行。

About

jemalloc implemention

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages