几个月前翻译了Malloc 会加倍 Ruby 多线程应用的内存消耗,从这篇文章中得知了 jemalloc,以及它在减少内存碎片方面的作用。直到最近在生产环境中看到了它真实的表现,更加惊叹其神奇。它是如何做到让 Ruby 进程减少了数倍的内存消耗?对于这个点,得花点精力搞明白才行。
Ruby 进程特别是在多线程环境下,其内存消耗令人震惊,生产中一个 SIdekiq 进程轻轻松松可以消耗 3 个 G 以上。Ruby 垃圾回收这些年已经做了很多优化,变得相对成熟,但是其内存消耗为何如此高?
原因是有内存碎片。而且碎片同时存在于两个隔离的层级:
ObjectSpace 中以页为单位管理堆,每页中有固定数量的插槽,每个槽中可以放一个 ruby 对象 (RVALUE)。随着应用程序的运行,GC 会清除垃圾对象,同时也会申请新的页,慢慢地页中的槽就会有大量空闲,也就造成了内存碎片。
在 Ruby 中,ObjectSpace 中放不下的数据 (使 RVALUE 整体大于 40bytes) 都会通过动态内存分配器单独申请内存存放,只在 RVALUE 中保留对象的指针。而在多线程环境下,由于常用的 Malloc 实现上的缺陷,导致其在 Arena 中有严重的内存碎片,具体的可见上面的文章。
使用 jemalloc,其实就是让 jemalloc 来扮演动态内存分配器的角色,替换 malloc 的工作。由于 jemalloc 的优异表现,多线程 Ruby 应用的内存消耗骤降数倍,这说明了内存消耗上的锅,glibc malloc 占大头,ObjectSpace 中的内存碎片造成的消耗要远小于 malloc 所产生的。
那 jemalloc 为何如此优秀?,终于来到本文的主题,简单了解一下 jemalloc 的设计概要。
jemalloc 将对象等级按大小分为三大级别,每个级别中有数十个小等级:
小于 8 字节的,分配 8 字节;大于 4KB 小于 8KB 的,分配 8KB···
像这样根据对象的大小进行严格分级,并将同一级别的对象存放在一起,不同级别的对象分开存放。通过紧凑的组织进行分级存放。
上图是 jemalloc 的核心结构图。可以看到有以下几个部分:
通过将内存分成多个档次,且大小以整数倍增长,各自分别管理。同时优先复用低地址的可用区域,使得内存内存块被高效利用,不会被遗忘,同时避免了某些内存区尾部不够大 (要申请 32 字节,却只剩 8 字节了),而不能被使用的情况,这在很大程度上减少了碎片。(glibc malloc 就是将对象混在一起存放的)
为了提高并发能力,jemalloc 实现了多级缓冲机制,为每个线程实现了独立的tcache
(图右上)。线程在申请内存时,先到自己的 tcache 缓冲中去取,当缓冲为空或者满了,才去 arena 进货或将内存刷回 (此时会有锁,粒度为每个小档)。这种设计可以极大地避开并发中的竞争,减少锁的产生。同时,可以让同一线程中的数据尽量在同一块相邻的内存中,这对于 cpu 高效的利用 cache line 有极大帮助。
同时,jemalloc 用自己特地改进过的左倾红黑树 (红节点在左边),作为元数据来追踪可用的内存区,提高搜索速度。
jemalloc 会限制被使用页 (dirty page) 的数量,当数量超过一定比例,就会启动 GC 对数据页进行清洗 (purge),并尝试进行合并,挤出未被使用的空间,将其释放回操作系统或返回可用区域,进一步减少了碎片。
以上。
本文内容参考了以下资料,想要准确详细的细节,请进一步查阅: