You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

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

你好,我是陶辉。

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

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

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

掌握内存池的特性既可以避免写程序时内存占用过大导致服务器性能下降或者进程OOMOut 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的内存池呢这是因为多线程与单线程的预分配策略并不相同

每个子线程预分配的内存是64MBPtmalloc2中被称为Thread Arena32位系统下为1MB64位系统下为64MB。如果有100个线程就将有6GB的内存都会被内存池占用。当然并不是设置了1000个线程就会预分配60GB的内存子线程内存池最多只能到8倍的CPU核数比如在32核的服务器上最多只会有256个子线程内存池但这也非常夸张了16GB64MB * 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修改动态库来更换内存池参见示例代码)。

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

思考题

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

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