文章目录
推荐阅读:
【01】Netty从0到1系列之I/O模型
【02】Netty从0到1系列之NIO
【03】Netty从0到1系列之Selector
【04】Netty从0到1系列之Channel
【05】Netty从0到1系列之Buffer(上)
【06】Netty从0到1系列之Buffer(下)
【07】Netty从0到1系列之零拷贝技术
【08】Netty从0到1系列之整体架构、入门程序
【09】Netty从0到1系列之EventLoop
【10】Netty从0到1系列之EventLoopGroup
【11】Netty从0到1系列之Future
【12】Netty从0到1系列之Promise
【13】Netty从0到1系列之Netty Channel
【14】Netty从0到1系列之ChannelFuture
【15】Netty从0到1系列之CloseFuture
【16】Netty从0到1系列之Netty Handler
【17】Netty从0到1系列之Netty Pipeline【上】
【18】Netty从0到1系列之Netty Pipeline【下】
【19】Netty从0到1系列之Netty ByteBuf【上】
【20】Netty从0到1系列之Netty ByteBuf【中】
【21】Netty从0到1系列之Netty ByteBuf【下】
【22】Netty从0到1系列之Netty 逻辑架构【上】
【23】Netty从0到1系列之Netty 逻辑架构【下】
【24】Netty从0到1系列之Netty 启动细节分析
【25】Netty从0到1系列之Netty 线程模型【上】
【26】Netty从0到1系列之Netty 线程模型【下】
【27】Netty从0到1系列之Netty ChannelPipeline
【28】Netty从0到1系列之Netty ChannelHandler
【29】Netty从0到1系列之Netty拆包、粘包【1】
【30】Netty从0到1系列之Netty拆包、粘包【2】
【31】Netty从0到1系列之Netty拆包、粘包【3】
【32】Netty从0到1系列之Netty拆包、粘包【4】
【33】Netty从0到1系列之Netty拆包、粘包【5】
高性能内存分配器Jemalloc
ByteBuf 在 Netty 中随处可见,那么这些 ByteBuf 在 Netty 中是如何被分配和管理的呢?
一、概述
1.1 Jemalloc介绍
- 官方参考文档:
https://www.bsdcan.org/2006/papers/jemalloc.pdf
https://www.bsdcan.org/2006/papers/jemalloc.pdf
jemalloc 是由 Jason Evans 在 FreeBSD 项目中引入的新一代内存分配器。它是一个通用的 malloc 实现,侧重于减少内存碎片和提升高并发场景下内存的分配效率,其目标是能够替代 malloc
。
其设计哲学可以概括为:
- 减少锁竞争:通过线程本地缓存(Thread-Caching) 和多分配区(Arenas),使得大部分内存分配操作无需加锁。
- 减少碎片:通过大小分级(Size Classes) 和智能分配策略,将相似大小的对象存储在一起,最大化内存的连续使用。
- 平滑性能:通过提前分配(Pre-allocation) 和缓存(Caching) 来将内存分配的系统调用和计算开销均摊到程序生命周期中,避免突发分配带来的延迟峰值。
jemalloc 应用十分广泛,在 Firefox、Redis、Rust、Netty 等出名的产品或者编程语言中都有大量使用.
除了 jemalloc
之外,业界还有一些著名的内存分配器实现,例如 ptmalloc
和 tcmalloc
。我们对这三种内存分配器做一个简单的对比:
1.1.1 ptmalloc
ptmalloc
是基于 glibc 实现的内存分配器,它是一个标准实现,所以兼容性较好。
- pt 表示 per thread 的意思。
- ptmalloc 确实在多线程的性能优化上下了很多功夫。
- 由于过于考虑性能问题,多线程之间内存无法实现共享,只能每个线程都独立使用各自的内存,所以在内存开销上是有很大浪费的。
1.1.2 tcmalloc
tcmalloc 出身于 Google,全称是 thread-caching malloc
,所以 tcmalloc 最大的特点是带有线程缓存,tcmalloc 非常出名,目前在 Chrome、Safari 等知名产品中都有所应有。
- tcmalloc 为
每个线程分配了一个局部缓存
,对于小对象的分配,可以直接由线程局部缓存来完成 - 大对象的分配场景,tcmalloc 尝试采用自旋锁来减少多线程的锁竞争问题。
jemalloc
借鉴了 tcmalloc 优秀的设计思路,所以在架构设计方面两者有很多相似之处,同样都包含 thread cache
的特性。但是 jemalloc 在设计上比 ptmalloc 和 tcmalloc 都要复杂,jemalloc 将内存分配粒度划分为 Small、Large、Huge
三个分类,并记录了很多 meta 数据
,所以在空间占用上要略多于 tcmalloc,不过在大内存分配的场景,jemalloc 的内存碎片要少于 tcmalloc。tcmalloc 内部采用红黑树管理内存块和分页,Huge 对象通过红黑树查找索引数据可以控制在指数级时间.一种通用的 malloc (3) 实现,强调避免内存碎片和可扩展的并发支持.
1.2 内存碎片
什么是内存碎片呢?
Linux 中物理内存会被划分成若干个 4K 大小的内存页 Page
[!note]
- 物理内存的分配和回收都是基于 Page 完成的.
- Page 内产生的内存碎片称为
内部碎片
- Page 之间产生的内存碎片称为
外部碎片
1.2.1 内部碎片
因为内存是按 Page 进行分配的,即便我们只需要很小的内存,操作系统至少也会分配 4K 大小的 Page
,单个 Page 内只有一部分字节都被使用,剩余的字节形成了内部碎片
.
1.2.2 外部碎片
外部碎片与内部碎片相反,是在分配较大内存块时产生的。当需要分配大内存块的时候,操作系统只能通过分配连续的 Page 才能满足要求,在程序不断运行的过程中,这些 Page 被频繁的回收并重新分配
,Page 之间就会出现小的空闲内存块,这样就形成了外部碎片.
第2、3个Page就成为了内存碎片了
,不能存储大对象了.超过了2个Page的就不能放下了.
1.3 传统内存分配的缺点
- 内存碎片(Fragmentation):
- 内部碎片:分配器分配的内存块大于请求的大小,多余的空间被浪费。
- 外部碎片:频繁地分配和释放不同大小的内存块,导致空闲内存被割裂成许多小块。这些小块总量很大,但无法分配给一个大请求,从而造成浪费。
- 锁竞争(Lock Contention):多线程环境下,所有线程向一个全局的堆(Heap)申请内存,需要一个全局锁来保护。这成为了巨大的性能瓶颈。
- 性能(Performance):频繁的系统调用(brk/mmap)和复杂的查找算法(如寻找合适大小的空闲块)会导致分配和释放速度变慢。
jemalloc
通过 线程本地缓存 + 多层缓存架构 + 延迟合并 + 惰性释放 解决了上述问题。
1.4 常见内存分配算法
jemalloc 的实现原理之前,我们先了解下最常用的内存分配器算法:
[!tip]
- 动态内存分配
- 伙伴算法
- Slab 算法
二、动态从内存分配
2.1 什么是动态内存分配?
动态内存分配(Dynamic Memory Allocation) 是指程序在运行时(而非编译时)向操作系统或内存管理器请求内存空间,并在使用完毕后归还内存的过程。
✅ 核心特点:
- 运行时决定内存大小
- 手动或自动管理生命周期
- 位于堆(Heap)区域
- 需要显式或隐式释放
2.2 核心概念: 动态内存 vs 静态内存
程序运行时,其数据所占用的内存主要来自两个区域:栈(Stack) 和堆(Heap)。
特性 | 静态分配(栈内存) | 动态分配(堆内存) |
---|---|---|
管理方式 | 由编译器自动管理 | 由程序员手动管理(C/C++) 或运行时(RT)自动管理(Java, Go, Python等) |
分配时机 | 编译期确定,函数调用时创建 | 运行时(Runtime) 按需分配 |
生命周期 | 与函数/代码块绑定,结束后自动释放 | 与代码块无关,生命周期由程序员或垃圾回收器(GC)决定 |
大小 | 必须是编译期已知的常量 | 可以是运行时才能确定的变量 |
效率 | 极快(移动栈指针即可) | 相对较慢(需在堆中查找和分配) |
灵活性 | 低 | 高 |
动态内存分配就是指在程序运行时(Run-time) 从堆(Heap)中申请所需大小的内存空间的行为。
一个简单的比喻:
- 静态分配(栈) 就像在一场预定座位的会议上就坐。你的座位(内存)是固定的,会议结束(函数返回)你就必须离场(内存释放)。
- 动态分配(堆) 就像在一个开放式公园里野餐。你可以在任何时间、占用任意大小的一块空地(内存),并且你想待多久就待多久(生命周期不确定),但离开时必须自己收拾干净(手动释放),否则就会留下垃圾(内存泄漏)。
2.3 为什么需要动态内存分配
动态内存分配解决了静态分配无法应对的场景:
- 未知的数据大小:程序需要处理的数据量在编写代码时无法确定。
- 示例:读取一个用户输入的文件、从网络接收一个数据包、存储一个用户动态添加的购物车商品列表。这些数据的大小只有在程序运行时才知道。
- 可变生命周期:需要创建一个对象,其生命周期要独立于创建它的函数。
- 示例:一个函数需要创建一个数据结构并返回给调用者。如果它在栈上分配,函数返回后内存即失效。必须在堆上分配,并将指针返回。
- 大型对象:栈空间通常很小(默认几MB),分配大型对象(如大数组、大图像)可能导致栈溢出(Stack Overflow)。堆空间则大得多(通常与虚拟内存相关)。
动态内存的核心价值:
适配可变需求:支持程序处理不确定大小的数据(如用户上传的文件、数据库查询结果),避免静态分配导致的内存浪费或不足;
提升内存利用率:通过内存复用(释放后重新分配),减少进程整体内存占用,尤其适合长期运行的服务(如 Web 服务器、数据库);
支撑复杂数据结构:动态分配是链表、树、图等动态数据结构的实现基础,这些结构的节点数量和大小需在运行时动态调整。
2.4 动态内存分配底层原理
2.4.1 虚拟内存与堆管理
操作系统为每个进程提供虚拟地址空间,其中“堆”区域由动态分配器管理:
2.4.2 堆的结构
堆不是连续的物理内存,而是由多个“内存块(Chunk)”组成:
graph LR
H[Heap Start] --> C1[Chunk 1: Allocated]
C1 --> C2[Chunk 2: Free]
C2 --> C3[Chunk 3: Allocated]
C3 --> C4[Chunk 4: Free]
C4 --> H_end[Heap End]
每个 Chunk 包含:
- 元数据(Metadata):大小、是否空闲、前后块指针等
- 用户数据区(Payload):实际存储用户数据
2.4.3 动态内存分配接口
这里以c语言为例
✅ 1. malloc - 分配内存
#include <stdlib.h>
void *malloc(size_t size);
void free(void *ptr);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
The malloc() function allocates size bytes and returns a pointer to the allocated memory.
The memory is not initialized. If size is 0, then malloc() returns either NULL, or a unique
pointer value that can later be successfully passed to free().
- 功能:向操作系统申请一块连续可用的、大小为
size
字节的内存。 - 返回值:成功则返回指向这块内存起始地址的指针(类型为
void*
,可强制转换为任何类型);失败则返回NULL
。 - 注意:
malloc
分配的内存中的内容是未初始化的(是“垃圾值”)。
示例:
// 测试动态内存分配
void test08() {
int n;
printf("Enter the number of elements: ");
scanf("%d", &n);
// 动态分配 n 个 int 大小的内存块
int* dynamic_array = (int*)malloc(n * sizeof(int));
if (dynamic_array == NULL) {
printf("Memory allocation failed!\n");
exit(1); // 分配失败,退出程序
}
// 使用分配的内存
for (int i = 0; i < n; i++) {
dynamic_array[i] = i * 10;
}
for (int i = 0; i < n; i++) {
printf("数组元素是: %d \t", dynamic_array[i]);
}
// ... 使用完毕后,必须释放!
free(dynamic_array); // 释放内存
}
✅ 2. calloc - 分配并初始化内存
void* calloc(size_t num, size_t size);
- 功能:为
num
个元素分配连续的内存空间,每个元素的大小为size
字节。 - 与
malloc
的区别:calloc
会将分配到的内存初始化为全零。 - 等效于:
malloc(num * size)
+memset(ptr, 0, num * size)
示例:
// 分配一个包含 10 个 int 的数组,并全部初始化为 0
int* zeroed_array = (int*)calloc(10, sizeof(int));
✅ 3. realloc - 调整已分配内存的大小
void* realloc(void* ptr, size_t new_size);
- 功能:调整之前通过
malloc
、calloc
或realloc
分配的内存块的大小为new_size
。 - 行为:
new_size > 原大小
:尝试扩大内存块。如果原内存块后方有足够空间,则直接扩展;否则,会重新分配一块新的足够大的内存,将旧数据拷贝过去,并自动释放旧内存块。new_size < 原大小
:缩小内存块,通常直接截断,后半部分被释放。ptr 为 NULL
:则等价于malloc(new_size)
。new_size 为 0
:则等价于free(ptr)
,但行为取决于实现,不推荐。
- 返回值:指向调整后内存块的新指针。这个指针很可能和原来的
ptr
不同!
伪代码示例:
int* arr = (int*)malloc(5 * sizeof(int));
// ... 使用数组后,发现需要更多空间 ...
int* new_arr = (int*)realloc(arr, 10 * sizeof(int));
if (new_arr == NULL) {
// 处理错误,注意原 arr 指针依然有效,需要释放
free(arr);
exit(1);
} else {
arr = new_arr; // 让 arr 指向新的内存块
// 现在 arr 的大小是 10 个 int
}
// ... 最终仍需 free(arr) ...
✅ 4. free - 释放内存
void free(void* ptr);
- 功能:释放
ptr
所指向的内存块,将其归还给堆分配器,以便后续重用。 - 至关重要:必须且只能释放之前由
malloc
、calloc
或realloc
返回的指针。释放已释放的指针(双重释放,Double-Free)或非堆上的指针会导致未定义行为(通常是程序崩溃)。 - 常见错误:内存泄漏(Memory Leak)。即分配了内存但忘记释放。对于长期运行的程序(如服务器、操作系统),内存泄漏是致命的。