【总结笔记】深度理解内存池技术
一、内存池的演变 —— 5 种内存分配方式
评价内存分配方式的好坏:1)分配的速度,在堆大容量内存的分配任务上,分配速度尤其
重要;2)内存的使用率,即产生的内存碎片率。内存碎片率无法避免,但可以减少。
1 空闲链表 (Free List)
【数据结构:链表 + bitmap】
分配一整块内存,将内存中所有的空闲块以链表的形式串起来,形成空闲链表(Free List),对已分配的内存块用 bitmap 进行标记。
当内存归还时,将临近的空闲内存块进行合并。
- 优点:分配实现简单
- 缺点:容易产生太多内存碎片。【使用伙伴算法,可以减少一定程度的内存碎片】
如下图所示,灰色代表已分配内存块,白色代表空闲内存块。
空闲内存块的分配方式包括:First Fit (最先适配)、Best Fit (最佳适配)、Worst Fit (最差适配)
- First Fit (最先适配):从 Free List 链头开始找,取下第一个满足要求的空闲块
- Best Fit (最佳适配):从 Free List 链头开始找,取下最接近满足要求的空闲块
- Worst Fit (最差适配):从 Free List 链头开始找,选取最大空闲块,划分部分满足要求,剩余部分归还 FreeList。
备注:伙伴算法
页块分配规则:
- 当向内存请求分配一定数目的页框时,在页块链表查找处于 空闲 状态且 最小能够满足页框大小 的页块。
- 当分配的页块有多余的页框时,伙伴算法将页块分裂成大小相同的两部分,直至所分配页块无多余页框。
- 释放内存块时,需将空闲的 2 个伙伴内存块进行合并。
2 定长空闲链表
定长空闲链表就是在空闲链表的基础上,限定每次分配的内存块是固定的。
- 优点:分配速度快,对特定场景非常高效;
- 缺点:功能单一,只能解决定长的内存需求。
3 哈希映射的空闲链表池
在定长分配器的基础上,按照不同对象大小(8,16,...,64K)构造十多个固定内存分配器。
分配内存策略:
- 将申请内存进行对齐;
- 根据对齐的内存进行查表,判断该内存请求由哪个分配器负责分配。
- 如果超过 64KB,则由系统负责分配
优点:内存分配速度快,时间复杂度 O(1) 分配内存
缺点:消耗大量内存。即使某个 FreeList 有大量空闲页块,也无法支援其他无空闲页块的 FreeList。
4 分配区池化技术
以 ptmalloc 为代表,详见下述 【62 池化内存管理 —— 浅析 ptmalloc 的底层实现原理 】
5 多级缓冲并发池化技术
以 tcmalloc 为代表,详见下述【6.3 池化内存管理 —— tcmalloc 内存分配原理 】
二、传统 malloc 与 2 种内存池 malloc
5 malloc 的底层实现 —— 基础知识
参考链接:http://www.javashuo.com/article/p-nhbbrswl-kg.html
参考链接: https://www.modb.pro/db/152449
5.1 Linux 进程内存布局
Linux 进程内存布局如下,栈自顶向下扩展、堆自顶向上扩展、mmap 自顶向下扩展。堆顶用 brk(break)来维护。
** Q:mmap 向上扩展与向下扩展的区别 **
1)mmap 向下扩展与堆向上扩展相向而对,导致堆的空间变小
5.2 brk(sbrk)和 mmap 函数
Linux系统下,用户可以使用 brk(sbrk)和 mmap 函数进行内存申请。
5.2.1 brk(sbrk)
#include<unistd.h>
int brk(const void *addr); // 参数为新的 brk 上界地址,成功返回1,失败返回0
void* sbrk(intptr_t incr); // 参数为申请内存的大小,返回堆的新上界 brk
brk 和 sbrk 的目的是扩展堆的上界 brk。
5.2.2 mmap
#include <sys/mman.h>
// mmap 的第 1)种用法是映射磁盘文件到进程内存中
// mmap 的第 2)种用法是不映射文件,而向映射区申请一块内存
void *mmap(void *addr, size_t length,
int prot, int flags, int fd, off_t offset);
// 释放内存
int munmap(void *addr, size_t length);
6 内存管理的通常方法
包括 1)传统 malloc() 方法;2)ptmalloc ;3)tcmalloc;4)引用计数;5)垃圾回收
6.1 基于传统 malloc() 的内存管理
- malloc:该函数分配给定的字节数,并返回一个指向它们的指针。若是没有足够的内存,则返回空指针;
- free:释放内存,归还操作系统。(实际上只是将内存归还给程序,而无法归还给操作系统)
基于 malloc() 的内存管理方式实际上是经过调用 brk() 或者 mmap() 向进程申请为的虚拟内存。
优点:动态分配内存,需要多少内存就申请多少内存
缺点:1)频繁调用 malloc() 和 free() 系统调用,开销大,降低系统性能;2)容易产生很多内存碎片。
6.2 池化内存管理 —— 浅析 ptmalloc 的底层实现原理 【使用了分配区】
内存池是一种半内存管理方法,它能帮助程序进行自动内存管理。
ptmalloc [glic库的实现] 就是采用内存池的管理方式。 ptmaloc 采用 边界标记法 ,对内存进行分块,从而便于对内存块进行分配与回收。为了提高内存分配函数 maclloc 的高效性,ptmalloc() 会 预先向操作系统申请一块内存 供用户使用,也能产生更少的内存碎片。用户释放掉的内存并非直接返回给操作系统,而是返回给分配器,由分配器统一管理空闲内存。
6.2.1 内存块组织结构 —— chunk
ptmalloc 使用特定的数据结构 chunk 来组织内存块,在给用户分配的 内存空间前后加上控制信息 ,便于完成分配和释放工作。
一个使用中的 chunk 的结构如下:
说明:
- p=0时,表示前一个 chunk 为空闲;【空闲标记位】
- p=1时,表示前一个 chunk 正在使用;
- M=1时,表示 mmap 映射区分配;
- M=0时,表示 堆区 分配;
- A=0时,表示主分配区分配;
- A=1时,表示非主分配区分配;
6.2.2 分配区 —— arena
ptmalloc 对进程内存是通过一个个 分配区(arena) 来进行管理的,分配区用数据结构 struct malloc_state 表示。
在 ptmalloc 中,分配区分为主分配区(arena)和 非主分配区(arena),二者的区别是主分配区可以用 sbrk 和 mmap 向 os 申请内存,而非主分配区只能通过 mmap 向 os 申请。
分配区申请内存的详细过程:当线程调用 malloc 申请内存时,该线程会在分配区环形链表找一个未加锁的分配区,并对该分配区进行加锁,然后才进行内存分配。如果所有分配区都已经加锁,那么 malloc 会开辟一个新的分配区加入环形链表并加锁,用它来分配内存。【由于加锁的操作,使得 pcmalloc [glic的实现方式] 在多线程编程下,性能低于 tcmalloc [google 的实现]】
【Q:为什么分配区要用环形链表存储?优点是什么?】
【A:分配区使用环形链表存储与缓冲区使用环形缓冲区是基于同样目的的。如果使用链表,需要频繁申请和释放,降低系统性能。使用环形结构,可以复用所申请的内存。环形链表有 2 个指针—— pstart 和 pend,若 (pend + 1 ) % len == pstart,则分配区都可用,若 pend == pstat,则分配区都不可用】
6.2.3 空闲链表 —— bins
ptmalloc 统一管理堆和 mmap 区的空闲块,它将相同大小的 chunk 用双向链表连接起来,这样的链表称为 bin ,ptmalloc 一共有 128 个 bin。基于 chunk 的大小,可分为
1)Fast bin;2)Unsorted bin;3)Small bin;4)Large bin
Fast Bins
通常情况下,程序常常需要申请和释放较小的内存,当分配器合并了几个相邻的小 chunk 之后,可能立刻会有另外一个小块内存的请求,这样分配器又得从大空闲块进行切分,严重降低内存分配效率 。引入 Fast Bins,当释放内存块不大于 max_fast==64B 时,不立刻改变其标志位 P,而是将其放置于 Fast Bins,这样就无法将其合并。
6.3 池化内存管理 —— tcmalloc 内存分配原理 [Thread Cache malloc]
tcmalloc 相比 ptmalloc 更加适用于多核,具有更好的并行性支持等特性。
它提供了很多优化,如:
- 用固定的 page(页)来执行内存获取、分配等操作;
- 用固定大小的对象,如 8KB, 16KB 等用于特定大小对象的内存分配,简化内存的获取和释放;
- 缓存常用对象,以提高内存的速度;
- 基于每个线程或每个 CPU 来设置缓存大小
- 基于每个线程独立设置缓存分配策略,减少多线程之间锁的竞争;
申请流程:
- tcmalloc 将整个内存空间划分为 n 个同等大小的 Page,将 n 个连续的 page 连接在一起组成一个 Span,一个 page 是 OS 的页的 k 倍;
- PageHeap 向 OS 申请内存,申请的 span 由 1 个或多个 page 组成。备注:PageHeap 的单位 page 是 Linux 一页的 2 倍;
- ThreadCache 内存不够向 CentralCache 申请,CentralCache 内存不够向 PageHeap 申请。PageHeap 不够向 OS 申请。
具体言之:
1)每个线程都拥有自己的 ThreadCache,获取 obj 对象时无需加锁,所以 tcmalloc 比 ptmalloc 速度快;【Thread Local Storage,TLS】
2)若 ThreadCache 的 free list 为空,则从 CentralCache 获取 若干个 obj 到 ThreadCache 对应的 free list,然后再取出一个 obj 返回,由于 CentralCache 是单例模式,这个过程需要加锁;
3)若 CentralCache 的 spanlist 为空,则从 PageHeap 申请 span,申请时从上到下(或从小 page 到 大 page)的顺序查找,并将申请的页面切割成若干个等大的 obj,再将 部分 转移到 ThreadCache ;
4)若 PageHeap 为空,则向 OS 申请。
5)若申请的内存超过 ThreadCache 分配的最大 obj 大小,则想 PageHeap 申请,若实在太大,直接想 OS 申请。
可以看出,tcmalloc 是多级缓存思想的应用。
其中
1)【小对象内存分配】ThreadCache 是每个线程各自独立拥有的 cache,一个 cache 有一个 bins,一个 bins 有多个 freelists,每个 freelist 都有特定大小的 obj。
2)由于当 ThreadCache 内存不足时,会向 CentralCache 申请,因此 CentralCache 的 bins 与 ThreadCache 的 bins 相同,只不过 CentralCache 的 bins 不是直接挂载 obj,而是大小不同的 spanlist ,每个 span list 挂载 obj,同一 bin 下的不同 spanlist 挂载的 obj 大小相同。
3)PageHeap 的 bins 是 spanbins,每个 spanbin 挂载的是特定大小的 span。
6.3.1 补充一:Thread Local Storage (TLS)
线程本地存储(TLS)是一种 特殊的全局变量存储方式 ,每一个线程在使用全局变量的时候都产生一个只属于本线程的副本,这个变量在它所在的线程内是全局可访问的,但不能被其它线程访问。相比普通全局变量,它无需通过锁来控制,减少了控制成本。
TLS 包括 静态 TLS 和 动态 TLS。静态 TLS通过关键字 _declspec(thread)
来实现,动态 TLS 通过 TLS系列系统 API 来实现。
6.3.2 补充二:单例模式
为了保证全局只有唯一的 CentralCache 和 PageHeap,将二者设计为单例模式,并且为了避免高并发下资源的竞争,采用饿汉模式。
【什么是单例模式?】
单例模式是保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
定义一个单例类:
1)私有化构造函数,防止外界创建单例类对象;
private:
Singleton() {};
Singleton(const Single&) {};
Singleton& operator=(Singleton&) {};
2)使用类的私有静态指针变量指向类的唯一实例;
private:
static Singleton* Instance;
3)使用公有静态方法获取该实例。
【单例模式的 2 种写法】
单例模式有 2 种写法:懒汉式、饿汉式。其中饿汉式天然是线程安全的,也最容易实现,懒汉式需要加锁及使用静态变量才能实现安全。
- 懒汉式
懒汉式,又叫 延迟初始化,单例指针初始化指向空,单例实例在第一次使用时才进行初始化,并设置单例指针指向它 =>if (单例被创建) 返回单例; else 创建并返回单例
。
为了保证线程安全, 即在并发情况下,防止多个线程检测到无实例而同时创建实例,需要进行 加锁操作 或 使用内部静态变量实现。
【优点】:支持延迟加载,不到迫不得已才加载;
【缺点】:由于使用了锁,影响了并发度;
【适用场景】:在访问量下的场景下,使用懒汉式更优,这是以时间换空间。
· 非线程安全的懒汉式写法如下:
class Singleton {
private:
static Singleton* instance; // 单例是静态变量
private:
Singleton() {};
~Singleton() {};
Singleton(const Singleton&);
Singleton& operator=(const Singleton&);
public:
Singleton& getInstance() {
if (instance == nullptr)
instance = new Singleton();
return instance;
}
};
// 初始化单例指针
Singleton* Singleton::Instance = nullptr;
· 线程安全 的懒汉式(双检测锁)代码如下:
Singel* Single::getInstance() {
// 为了防止每次检测都加锁,降低程序性能,在锁的外部也判断是否已经初始化
if (p == NULL) {
// 加互斥锁,防止多线程创建多个单例
pthread_mutex_lock(&lock);
// 如果是第一次初始化构造
if (p == NULL) {
p = new Single();
}
pthread_mutex_unlock(&lock);
}
}
· 线程安全 的懒汉式(使用内部静态变量)代码如下:
class Singleton {
private:
Singleton() { // 类初始化的时候初始化锁
pthread_mutex_init(mutex);
}
public:
static pthread_mutex_t mutex;
static Singleton* getInstance();
};
pthread_mutex_t Singleton::mutex; // 初始化类锁
Singleton* Singleton::getInstance() {
pthread_mutex_lock(&mutex);
static Singleton instance;
pthread_mutex_unlock(&mutex);
return &instance;
}
备注: 编译器能够保证线程安全,所以使用静态变量,可加锁,可不加锁。
- 饿汉式
饿汉式是在程序一运行时就初始化了单例,因此天然就是线程安全,不用担心多个线程同时初始化单例对象,导致存在多个实例。
【优点】:在单例访问量大的时候,能够实现更好的性能,这是以空间换时间;
【缺点】:程序一运行就加载单例,如果单例没用到,会浪费内存。
【使用场景】:在单例访问量大的时候,使用饿汉式单例模式
· 饿汉式写法如下:
class Singleton {
private:
Singleton() {
a = 10;
};
public:
int a;
static Singleton* instance;
static Singleton* getInstance() {
return instance;
}
};
Singleton* Singleton::instance = new Singleton();
Singleton* getInstance() {
return Singleton::instance;
}
· 补充: C++ 中 static 对象的初始化
-
non-local static 对象
non-local static 对象的初始化发生在 main 函数之前,因此肯定是线程安全的。但是不同 non-local static 对象 的执行顺序是随机的。 -
local static 对象
local static 对象初始化发生在控制流第一次执行到该对象的初始化语句。由于多个线程的控制流可能到达其初始化语句,因此本身是线程不安全的,因此需要加锁保证线程安全性,否则还会造成内存泄漏。
5.3.4 引用计数
在引用计数中,全部共享的数据结构都有一个域来包含当前活动 “引用” 结构的次数。当某程序指向某个数据结构指针时,该指针的引用计数加1;若该程序完成对该指针的使用后,该指针的引用计数减1。若引用计数为 0,则将会释放内存。
缺点: 引用计数无法解决循环引用,因此只有在早期的 JVM 才会用到,现在已不再使用
5.3.5 根搜索算法
从某一些特定的根对象出发,一步步遍历找到和这个根对象具有引用关系的对象,再继续往下寻找,形成一个个引用链。不在引用链的对象,便标记为“垃圾”,被 JVM 回收。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)