在Linux平台上,一些GC实现(如JVM)中使用mmap函数首先申请一大块内存,然后自己管理对象的分配;一些GC实现使用glibc库函数直接调用malloc函数满足对象的分配;还有一些GC实现使用第三方库函数(如TCMalloc)管理对象的分配。不同的选择其考量是什么?
要理解GC设计的策略,需要理解malloc/free的实现。先来看一段C程序员使用malloc/free管理内存代码片段:
int* pInt = (int*) malloc(10 * sizeof(int)); //使用pInt,直到free分配的内存才释放 free(pInt);
一个问题是free是如何知道释放10个int大小的内存空间?在函数原型中free只是接收1个参数:待释放的指针,所以这个指针指向的地址一定经过特殊的处理,让free在执行时不需要内存的长度空间。
典型的实现是在使用malloc时对分配的内存做额外的变化,多申请一块空间用于存储内存的实际长度,这样使用free的时候按照同样的约定就可以找到内存的实际长度。下面给出malloc和free的功能描述:
函数malloc(size)实际完成的功能可以分解为:
1)实际向OS分配的内存长度为size+4,其中4字节用于存储内存的长度;假设OS返回的内存地址为pStart。
2)将长度写入地址开始的位置,即*((int*)pStart)=size。
3)返回真实可用的内存空间给应用,即(void*)((char*)pStart + 4)。
函数free(pPointer)实际完成的功能可以分解为:
1)获得指针指向的内存真实起始地址,即char* pRealStart = (char*)pPointer-4。
2)获得应用实际使用的内存长度,即int size = *((int*)pRealStart )。
3)通过OS的API真正释放内存起始位置为pRealStart,长度为size+4的内存空间。
当然类库在malloc中还可以额外分配更多的内存用于其他功能,例如校验。这样的设计就会导致真实分配的内存超过用户请求的内存,意味着在使用库函数的分配/释放函数时有额外的内存消耗。
另外一种管理内存的方案是直接向OS请求一大块内存空间,即使用类似mmap(Linux系统的API)的方式,由VM提供内存分配和回收的功能,VM通常不需要记录内存使用的长度(在JVM中内存的长度信息通过类的元数据提供),这样就可以避免这种内存消耗。在一些基准测试中,发现直接使用库函数的分配/释放与VM直接管理内存的方式相比会有额外的5%~15%的内存消耗。
由于glibc使用弱符号引用的方式允许用户提供运行时的malloc/free,这样就可以使用一些成熟的类库(如TCMalloc)来提供高效的malloc/free。TCMalloc有一个非常大的优点——高效,基于线程/CPU的缓存分配方式,能极大地提高应用运行的效率。当然TCMalloc也有不足之处,可能存在一定的内存浪费。除此之外,虽然TCMalloc是基于线程/CPU的缓存分配方式,避免了多线程分配的锁竞争问题,但是效率与后文介绍的TLAB的效率还是略有差异。关于TCMalloc的更多内容可以参考官方文档 。
最后做一个简单的总结,直接使用库函数malloc甚至TCMalloc可能存在的问题如下:
1)回收效率不够高,内存使用free释放后,不一定会被立即重复使用。
2)内存使用效率不够高,在malloc、new库函数中除了分配真正的对象空间外,还会附加一些额外占用内存的信息,比如分配的长度、越界信息。
3)分配效率不够高,通常在malloc中需要对堆进行加锁,用于保证多个进程同时竞争堆空间的分配。即便TCMalloc中优化了基于线程的分配,也无法达到Mutator中TLAB的分配效率。