02 | 内存池:如何提升内存分配的效率?

你好,我是陶辉。

上一讲我们提到,高频地命中CPU缓存可以提升性能。这一讲我们把关注点从CPU转移到内存,看看如何提升内存分配的效率。

或许有同学会认为,我又不写底层框架,内存分配也依赖虚拟机,并不需要应用开发者了解。如果你也这么认为,我们不妨看看这个例子:在Linux系统中,用Xmx设置JVM的最大堆内存为8GB,但在近百个并发线程下,观察到Java进程占用了14GB的内存。为什么会这样呢?

这是因为,绝大部分高级语言都是用C语言编写的,包括Java,申请内存必须经过C库,而C库通过预分配更大的空间作为内存池,来加快后续申请内存的速度。这样,预分配的6GB的C库内存池就与JVM中预分配的8G内存池叠加在一起,造成了Java进程的内存占用超出了预期。

掌握内存池的特性,既可以避免写程序时内存占用过大,导致服务器性能下降或者进程OOM(Out Of Memory,内存溢出)被系统杀死,还可以加快内存分配的速度。在系统空闲时申请内存花费不了多少时间,但是对于分布式环境下繁忙的多线程服务,获取内存的时间会上升几十倍。

另一方面,内存池是非常底层的技术,当我们理解它后,可以更换适合应用场景的内存池。在多种编程语言共存的分布式系统中,内存池有很广泛的应用,优化内存池带来的任何微小的性能提升,都将被分布式集群巨大的主机规模放大,从而带来整体上非常可观的收益。

接下来,我们就通过对内存池的学习,看看如何提升内存分配的效率。

隐藏的内存池

实际上,在你的业务代码与系统内核间,往往有两层内存池容易被忽略,尤其是其中的C库内存池。

当代码申请内存时,首先会到达应用层内存池,如果应用层内存池有足够的可用内存,就会直接返回给业务代码,否则,它会向更底层的C库内存池申请内存。比如,如果你在Apache、Nginx等服务之上做模块开发,这些服务中就有独立的内存池。当然,Java中也有内存池,当通过启动参数Xmx指定JVM的堆内存为8GB时,就设定了JVM堆内存池的大小。

你可能听说过Google的TCMalloc和FaceBook的JEMalloc,它们也是C库内存池。当C库内存池无法满足内存申请时,才会向操作系统内核申请分配内存。如下图所示:

回到文章开头的问题,Java已经有了应用层内存池,为什么还会受到C库内存池的影响呢?这是因为,除了JVM负责管理的堆内存外,Java还拥有一些堆外内存,由于它不使用JVM的垃圾回收机制,所以更稳定、持久,处理IO的速度也更快。这些堆外内存就会由C库内存池负责分配,这是Java受到C库内存池影响的原因。

其实不只是Java,几乎所有程序都在使用C库内存池分配出的内存。C库内存池影响着系统下依赖它的所有进程。我们就以Linux系统的默认C库内存池Ptmalloc2来具体分析,看看它到底对性能发挥着怎样的作用。

C库内存池工作时,会预分配比你申请的字节数更大的空间作为内存池。比如说,当主进程下申请1字节的内存时,Ptmalloc2会预分配132K字节的内存(Ptmalloc2中叫Main Arena),应用代码再申请内存时,会从这已经申请到的132KB中继续分配。

如下所示(你可以在这里找到示例程序,注意地址的单位是16进制):

# cat /proc/2891/maps | grep heap
01643000-01664000 rw-p 00000000 00:00 0     [heap]

当我们释放这1字节时,Ptmalloc2也不会把内存归还给操作系统。Ptmalloc2认为,与其把这1字节释放给操作系统,不如先缓存着放进内存池里,仍然当作用户态内存留下来,进程再次申请1字节的内存时就可以直接复用,这样速度快了很多。

你可能会想,132KB不多呀?为什么这一讲开头提到的Java进程,会被分配了几个GB的内存池呢?这是因为多线程与单线程的预分配策略并不相同

每个子线程预分配的内存是64MB(Ptmalloc2中被称为Thread Arena,32位系统下为1MB,64位系统下为64MB)。如果有100个线程,就将有6GB的内存都会被内存池占用。当然,并不是设置了1000个线程,就会预分配60GB的内存,子线程内存池最多只能到8倍的CPU核数,比如在32核的服务器上,最多只会有256个子线程内存池,但这也非常夸张了,16GB(64MB * 256 = 16GB)的内存将一直被Ptmalloc2占用。

回到本文开头的问题,Linux下的JVM编译时默认使用了Ptmalloc2内存池,因此每个线程都预分配了64MB的内存,这造成含有上百个Java线程的JVM多使用了6GB的内存。在多数情况下,这些预分配出来的内存池,可以提升后续内存分配的性能。

然而,Java中的JVM内存池已经管理了绝大部分内存,确实不能接受莫名多出来6GB的内存,那该怎么办呢?既然我们知道了Ptmalloc2内存池的存在,就有两种解决办法。

首先可以调整Ptmalloc2的工作方式。通过设置MALLOC_ARENA_MAX环境变量,可以限制线程内存池的最大数量,当然,线程内存池的数量减少后,会影响Ptmalloc2分配内存的速度。不过由于Java主要使用JVM内存池来管理对象,这点影响并不重要。

其次可以更换掉Ptmalloc2内存池,选择一个预分配内存更少的内存池,比如Google的TCMalloc。

这并不是说Google出品的TCMalloc性能更好,而是在特定的场景中的选择不同。而且,盲目地选择TCMalloc很可能会降低性能,否则Linux系统早把默认的内存池改为TCMalloc了。

TCMalloc和Ptmalloc2是目前最主流的两个内存池,接下来我带你通过对比TCMalloc与Ptmalloc2内存池,看看到底该如何选择内存池。

选择Ptmalloc2还是TCMalloc?

先来看TCMalloc适用的场景,它对多线程下小内存的分配特别友好。

比如,在2GHz的CPU上分配、释放256K字节的内存,Ptmalloc2耗时32纳秒,而TCMalloc仅耗时10纳秒(测试代码参见这里)。差距超过了3倍,为什么呢?这是因为,Ptmalloc2假定,如果线程A申请并释放了的内存,线程B可能也会申请类似的内存,所以它允许内存池在线程间复用以提升性能。

因此,每次分配内存,Ptmalloc2一定要加锁,才能解决共享资源的互斥问题。然而,加锁的消耗并不小。如果你监控分配速度的话,会发现单线程服务调整为100个线程,Ptmalloc2申请内存的速度会变慢10倍。TCMalloc针对小内存做了很多优化,每个线程独立分配内存,无须加锁,所以速度更快!

而且,线程数越多,Ptmalloc2出现锁竞争的概率就越高。比如我们用40个线程做同样的测试,TCMalloc只是从10纳秒上升到25纳秒,只增长了1.5倍,而Ptmalloc2则从32纳秒上升到137纳秒,增长了3倍以上。

下图是TCMalloc作者给出的性能测试数据,可以看到线程数越多,二者的速度差距越大。所以,当应用场景涉及大量的并发线程时,换成TCMalloc库也更有优势!

那么,为什么GlibC不把默认的Ptmalloc2内存池换成TCMalloc呢?因为Ptmalloc2更擅长大内存的分配。

比如,单线程下分配257K字节的内存,Ptmalloc2的耗时不变仍然是32纳秒,但TCMalloc就由10纳秒上升到64纳秒,增长了5倍以上!现在TCMalloc反过来比Ptmalloc2慢了1倍!这是因为TCMalloc特意针对小内存做了优化。

多少字节叫小内存呢?TCMalloc把内存分为3个档次,小于等于256KB的称为小内存,从256KB到1M称为中等内存,大于1MB的叫做大内存。TCMalloc对中等内存、大内存的分配速度很慢,比如我们用单线程分配2M的内存,Ptmalloc2耗时仍然稳定在32纳秒,但TCMalloc已经上升到86纳秒,增长了7倍以上。

所以,如果主要分配256KB以下的内存,特别是在多线程环境下,应当选择TCMalloc;否则应使用Ptmalloc2,它的通用性更好。

从堆还是栈上分配内存?

不知道你发现没有,刚刚讨论的内存池中分配出的都是堆内存,如果你把在堆中分配的对象改为在栈上分配,速度还会再快上1倍(具体测试代码可以在这里找到)!为什么?

可能有同学还不清楚堆和栈内存是如何分配的,我先简单介绍一下。

如果你使用的是静态类型语言,那么,不使用new关键字分配的对象大都是在栈中的。比如:

C/C++/Java语言:int a = 10;

否则,通过new或者malloc关键字分配的对象则是在堆中的:

C语言:int * a = (int*) malloc(sizeof(int));
C++语言:int * a = new int;
Java语言:int a = new Integer(10);

另外,对于动态类型语言,无论是否使用new关键字,内存都是从堆中分配的。

了解了这一点之后,我们再来看看,为什么从栈中分配内存会更快。

这是因为,由于每个线程都有独立的栈,所以分配内存时不需要加锁保护,而且栈上对象的尺寸在编译阶段就已经写入可执行文件了,执行效率更高!性能至上的Golang语言就是按照这个逻辑设计的,即使你用new关键字分配了堆内存,但编译器如果认为在栈中分配不影响功能语义时,会自动改为在栈中分配。

当然,在栈中分配内存也有缺点,它有功能上的限制。一是, 栈内存生命周期有限,它会随着函数调用结束后自动释放,在堆中分配的内存,并不随着分配时所在函数调用的结束而释放,它的生命周期足够使用。二是,栈的容量有限,如CentOS 7中是8MB字节,如果你申请的内存超过限制会造成栈溢出错误(比如,递归函数调用很容易造成这种问题),而堆则没有容量限制。

所以,当我们分配内存时,如果在满足功能的情况下,可以在栈中分配的话,就选择栈。

小结

最后我们对这一讲做个小结。

进程申请内存的速度,以及总内存空间都受到内存池的影响。知道这些隐藏内存池的存在,是提升分配内存效率的前提。

隐藏着的C库内存池,对进程的内存开销有很大的影响。当进程的占用空间超出预期时,你需要清楚你正在使用的是什么内存池,它对每个线程预分配了多大的空间。

不同的C库内存池,都有它们最适合的应用场景,例如TCMalloc对多线程下的小内存分配特别友好,而Ptmalloc2则对各类尺寸的内存申请都有稳定的表现,更加通用。

内存池管理着堆内存,它的分配速度比不上在栈中分配内存。只是栈中分配的内存受到生命周期和容量大小的限制,应用场景更为有限。然而,如果有可能的话,尽量在栈中分配内存,它比内存池中的堆内存分配速度快很多!

OK,今天我们从内存分配的角度聊了分布式系统性能提升的内容,希望学习过今天的内容后,你知道如何最快速地申请到内存,了解你正在使用的内存池,并清楚它对进程最终内存大小的影响。即使对第三方组件,我们也可以通过LD_PRELOAD环境变量,在程序启动时更换最适合的C库内存池(Linux中通过LD_PRELOAD修改动态库来更换内存池,参见示例代码)。

内存分配时间虽然不起眼,但时刻用最快的方法申请内存,正是高手与初学者的区别,相似算法的性能差距就体现在这些编码细节上,希望你能够重视它。

思考题

最后,留给你一个思考题。分配对象时,除了分配内存,还需要初始化对象的数据结构。内存池对于初始化对象有什么帮助吗?欢迎你在留言区与大家一起探讨。

感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

精选留言

  • 忆水寒

    2020-04-27 19:35:40

    1、思考题:内存池中可以利用享元模式将常用的对象一直保留着,减少重复申请导致的性能的顺耗。
    2、最后一段话“内存分配时间虽然不起眼,但时刻用最快的方法申请内存,正是高手与初学者的区别。”说的很好,是的,真正的高手应该能够从算法到底层都能优化。
    作者回复

    忆水寒同学说得对,享元模式这个词用得也很好!享元模式有广泛的应用,不只在应用层,在内核中也被广泛使用。

    2020-04-27 22:02:26

  • 吃苹果的考拉

    2020-04-27 18:19:48

    解决了我很多疑惑。比如mysql很多人建议把内存分配换成tcmalloc,就是因为mysql要支持大量并发,适合tcmalloc的应用场景。没有对比就没有发现,两库一比,知识点就出来了
    作者回复

    很高兴能帮到你,我跟编辑一直担心这个会不会讲得太深了呢,其实很多性能优先的组件都可以用得上

    2020-04-27 22:08:47

  • aoe

    2020-04-28 01:19:02

    1. 原来Java堆的内存空间是通过C库内存池申请!
    2. 第一次知道内存分配器的存在:Ptmalloc2、TCMalloc
    3. 在栈中申请内存比堆中快是因为不需要加锁。
    收获惊呆了!
    作者回复

    又添aoe大招了^_^

    2020-04-28 10:14:17

  • 子不语

    2020-04-30 14:11:12

    老师好,一直在学习老师的课程,发现出了性能的,赶紧来学习。学习这篇遇到几个问题,麻烦老师能解惑。
    第一个问题:
    文中提到: 预分配的 6GB 的 C 库内存池就与 JVM 中预分配的 8G 内存池叠加在一起,造成了 Java 进程的内存占用超出了预期。

    这里预分配的6g内存池是虚拟地址空间吗?

    第二个问题,如果我的虚拟机总共10g,jvm预分配了6g堆内存,那这6g内存是不是不能用作其他的地方了?
  • 探索无止境

    2020-06-10 17:12:05

    老师你好,我有一个疑问
    TCMalloc既然可以针对小内存做优化,为何不对中等内存和大内存一起做优化?是技术上实现有困难吗?能否从技术实现的角度来聊聊,为何它只优化了小内存的场景
    作者回复

    对小内存做优化,其实是会造成内存利用率下降的,特别是每个线程独立维护内存池,就拒绝线程之间共享内存池了。由于小内存的使用最为频繁,所以利用率的下降表现不明显。但对大内存做上述优化,就会造成利用率下降过多,性价比不划算。

    2020-06-11 09:01:19

  • 万历十五年

    2020-08-27 07:17:42

    解决ptmalloc2内存过大的三种方案(转自http://fengfu.io):

    第一种:控制分配区的总数上限。默认64位系统分配区数为:cpu核数*8,如当前环境16核系统分配区数为128个,每个64M上限的话最多可达8G,限制上限后,后续不够的申请会直接走mmap分配和munmap回收,不会进入ptmalloc2的buffer池。
    所以第一种方案调整一下分配池上限个数到4:
    export MALLOC_ARENA_MAX=4

    第二种:之前讲到ptmalloc2默认会动态调整mmap分配阈值,因此对于较大的内存请求也会进入ptmalloc2的内存buffer池里,这里可以去掉ptmalloc的动态调整功能。可以设置 M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个。这里可以固定分配阈值为128K,这样超过128K的内存分配请求都不会进入ptmalloc的buffer池而是直接走mmap分配和munmap回收(性能上会有损耗,当前环境大概10%)。:
    export MALLOC_MMAP_THRESHOLD_=131072
    export MALLOC_TRIM_THRESHOLD_=131072
    export MALLOC_TOP_PAD_=131072
    export MALLOC_MMAP_MAX_=65536

    第三种:使用tcmalloc来替代默认的ptmalloc2。google的tcmalloc提供更优的内存分配效率,性能更好,ThreadCache会阶段性的回收内存到CentralCache里。 解决了ptmalloc2中arena之间不能迁移导致内存浪费的问题。
    作者回复

    谢谢万历十五年同学的分享!

    2020-09-01 14:54:38

  • 万历十五年

    2020-08-27 07:39:00

    计算机领域解决运算速度的两大法宝:1.加一层 2.化整为零。无论是cpu 寄存器, L1/2/3 cache,以及本节讲的glibc 内存池,都是通过“加一层”的方法,预先取得可能用到的资源,通过空间的代价来换得时间的效益。
    作者回复

    对的,“加一层”缓存:-)

    2020-09-01 14:49:37

  • 唐朝首都

    2020-05-09 08:04:42

    今天课程学习到了很多,感觉一整篇 的知识盲点,学完很受启发。另外有个问题:文中提到“预分配的 6GB 的 C 库内存池就与 JVM 中预分配的 8G 内存池叠加在一起,造成了 Java 进程的内存占用超出了预期。”中“预分配的6GB的C库内存池”为堆外内存么?如果代码中不适用堆外内存是不是就不会预分配6GB的C库内存池,还是说有那么多的线程就一定会使用到6GB的C库内存池??
    作者回复

    1、是的。
    2、不使用堆外内存即可。

    2020-05-09 15:15:47

  • eason2017

    2020-05-06 18:36:27

    碰巧今天看一个CMS GC的问题,就点击到了一个作者的网站,其中一篇也是分析堆外内存的文章,更具体的解释了如何替换pt到tc ,地址如下:
    http://fengfu.io/2019/01/22/%E8%BD%AC-Java%E5%A0%86%E5%A4%96%E5%86%85%E5%AD%98%E5%A2%9E%E9%95%BF%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5Case/
    只是分享哈,和作者无任何关系~~
  • 奥特曼

    2020-04-28 16:46:14

    老师好,我有3个问题:
    ------------------问题1--------------------
    讲义里面提到64位的环境下,一个子线程创建会有64M的内存申请,我最开始理解的这64M是这个子线程独有的。
    在后面又提到“Ptmalloc2 假定,如果线程 A 申请并释放了的内存,线程 B 可能也会申请类似的内存,所以它允许内存池在线程间复用以提升性能。”,那是不是理解成每个子线程在创建的时候会有对应64M的内存申请给它,但是这64M的内存是所有子线程之间共享使用的?

    ------------------问题2--------------------
    关于堆外内存:老师这里说到的堆外内存不单单是相对JVM来说的吧?这里指的堆外内存再具体一点,是指使用了Ptmalloc2,在创建进程/线程时默认的分享的内存吗?

    ------------------问题3--------------------
    讲义里面提到堆外内存“更稳定、持久,处理 IO 的速度也更快”。能理解更稳定、持久,是因为堆外内存是由Ptmalloc2去维护,基本和进程的生命周期一样。但是没理解为什么堆外内存在处理IO上的速度会更快?这里面的IO是指具体的磁盘IO?还是网络IO?

    ------------------问题4--------------------
    老师提到过,进程里面的线程数要和CPU的核数对应上,不知道老师这里说的CPU核数是物理核,还是逻辑核?


    分享一下学习到的内容:
    1、之前只是知道创建/销毁线程,会造成资源的浪费。而具体造成了那些资源的浪费其实是没有深究的。其实中很大一部分资源,应该就是老师提到的每个线程默认的C库内存池。
    2、堆内存比栈内存更快,其实是快在管理,而不是快在使用。malloc/new时会有共享的C库内存池内加锁申请,而释放的时候,也会加锁释放,并伴随着内存碎片的整理
  • 独孤魂

    2020-05-02 17:38:26

    1、“每个子线程预分配的内存是 64MB” 这个让我有点崩溃啊,
    2、也有些文章也说“节约线程,因为启动一个消耗2MB内存“。
    3、但是亲测启动200个线程,实际内存RES没有多大变化,VIRT 也只增长了1.4G,并没有12G啊? 求解
  • 码农桃花源

    2020-04-28 08:03:53

    TCMalloc 对中等内存、大内存的分配速度很慢,比如我们用单线程分配 2M 的内存,Ptmalloc2 耗时仍然稳定在 32 纳秒,但 TCMalloc 已经上升到 86 纳秒,增长了 7 倍以上。

    老师你好,这块能展开说一下吗?对中等内存、大内存,为什么 TCMalloc 慢,而 Ptmalloc2 快呢?
  • Ken

    2020-05-02 22:27:55

    内存池可以使用享元模式加速对象的初始化速度
  • Run

    2020-07-22 16:25:47

    不愧是有多门课的人
  • Geek3340

    2020-06-23 09:46:37

    有Java实例吗?Java在哪些场景下,会触发ptmalloc进行分配,然后不释放呢?
  • 李志华

    2020-05-30 10:06:48

    关于"每个子线程预分配的内存是 64MB(Ptmalloc2 中被称为 Thread Arena,32 位系统下为 1MB,64 位系统下为 64MB)",
    假设语言是java,
    这里预分配的内存是java堆内存, java栈内存(这可以通过-xss限制的), 还是C库内存, 操作系统内存?
  • Wienbc

    2020-05-21 17:38:43

    老师好,认真看完又涨姿势了,有个问题不太明白求解:为什么不受JVM管理的堆外内存的IO速度更快呢?
    作者回复

    你好Wienbl,两个原因,1、本质上就是路程越短,速度越快。比如A->B->C,肯定没有A->C块。而B就是JVM的内存,使用它处理IO会多出一次拷贝。2、JVM内存要GC,管理成本要比堆外内存高。

    2020-05-22 07:23:19

  • 中年男子

    2020-05-19 18:16:15

    用对象池 来节省 频繁创建、初始化对象造成的时间开销,
    忆水寒提到的享元模式思想是对细粒度对象的共享和复用,
    对象池是对享元模式的升级, 维护装载对象的池子, 提供获取、释放资源的方法。
    感觉思考题的场景用对象池更为合适。
    作者回复

    是的

    2020-09-11 09:05:46

  • kofssl

    2020-04-27 23:24:54

    确实写的很清晰,前面处理三方组件内存问题时,开始以为内存泄漏了,占用内存一点一点上去,就不释放,后来查代码也都没有明显的错误,最后是通过同事提到的内存碎片解决的,就用到了jemalloc的替换方式,同事是高手,你也是,哈哈
    作者回复

    呵呵,谢谢kofssl的夸奖。在你构建出分布式系统的完整执行路径后,相信面临其他疑难杂症时都会有明确的方向,可以google上自行找答案!

    2020-04-28 09:24:09

  • 云海

    2022-03-29 13:02:40

    可以给飞行中的飞机换发动机吗? 生产环境 不停机更换 内存分配方式。