把 bins 数组想象成一个拥有 254 个格子的巨型收纳柜。每个格子(或每两个格子)就是一个“抽屉”(bin),用来挂一个空闲内存块链表。关键是,这些格子不是随意使用的,它们被严格分成了几个功能区。
数组布局:一个 254 格的收纳柜
bins 数组的大小是 254(即 128 * 2 – 2)。为什么是 254?因为每个双向链表需要两个指针(fd和 bk)作为“抽屉把手”(哨兵),所以 127 个链表就需要 254 个格子。
它的布局如下(索引从 0 开始):
| 0 和 1 | Unsorted Bin | 1 号临时中转台(唯一一个不分类的抽屉) | 1 个 |
| 2 ~ 63 | Small Bins | 固定大小货架(每个格子对应一个精确尺寸) | 62 个 |
| 64 ~ 126 | Large Bins | 大件分类货架(每个格子覆盖一个尺寸范围) | 63 个 |
| 127 | (保留) | 未使用,用于对齐 | – |
具体对应关系:如何计算索引?
当程序请求分配一个大小为 sz 的内存块时,ptmalloc2 会通过以下规则,瞬间算出应该去数组的哪个位置:
1. Unsorted Bin:永远在 bins[0] 和 bins[1]
-
特殊位置:它就是数组的“第一个抽屉”。
-
规律:无论什么大小的内存块,如果被丢进 Unsorted Bin,都会挂在这个固定的位置上。
2. Small Bin 的精确映射(sz <= 1024 字节)
-
规则:每个索引对应一个精确大小。公式是 逻辑索引 = (sz / 16) – 1。
-
示例:
-
请求 32 字节:逻辑索引 = (32/16) – 1 = 1。对应的数组位置是 bins[2*1] 和 bins[2*1+1],即 bins[2] 和 bins[3]。这个抽屉专门挂 32 字节的空闲块。
-
请求 80 字节:逻辑索引 = (80/16) – 1 = 4。对应的数组位置是 bins[8] 和 bins[9]。这个抽屉专门挂 80 字节的空闲块。
-
-
规律:Small Bin 的逻辑索引 n 对应数组的 2n 和 2n+1 位置,n 从 1 到 62。
3. Large Bin 的区间映射(sz > 1024 字节)
-
规则:每个索引覆盖一个大小范围,利用位运算快速定位。
-
示例:请求 2048 字节。ptmalloc2 不会去比较大小,而是直接用位运算(如 __builtin_clz 计算前导零)判断它属于哪个幂次区间,然后映射到对应的 Large Bin 索引。比如它可能被映射到逻辑索引 64,对应数组位置 bins[128] 和 bins[129]。这个抽屉里挂着的所有空闲块,大小都在 1024~2048 字节之间,并且按大小排好序。
为什么是这个公式?—— 哨兵节点机制
你可能会好奇,为什么 Small Bin 的逻辑索引 1 对应的是数组 bins[2] 而不是 bins[1]?
这是因为数组的每个“抽屉”需要两个格子来作为链表的头尾哨兵。这两个格子本身不存数据,只存指针(指向链表的第一个和最后一个空闲块)。
-
bins[2*n]:存放指向链表头部的指针(fd,前向指针)。
-
bins[2*n+1]:存放指向链表尾部的指针(bk,后向指针)。
当链表为空时,bins[2*n] 和 bins[2*n+1] 都指向 bins[2*n] 自身(即指向哨兵本身)。这种设计使得链表操作非常统一和高效。
一张图看懂全局对应关系
bins 数组 (254 个 mchunkptr 元素)
│
├── 索引 0, 1 ──► Unsorted Bin (1 个抽屉,挂载任意大小)
│
├── 索引 2, 3 ──► Small Bin [1]:专门挂 32 字节的块
├── 索引 4, 5 ──► Small Bin [2]:专门挂 48 字节的块
├── 索引 6, 7 ──► Small Bin [3]:专门挂 64 字节的块
│ …
├── 索引 62, 63 ──► Small Bin [31]:专门挂 512 字节的块
│
├── 索引 64, 65 ──► Large Bin [64]:管理 1024~X 字节的块 (范围1)
│ …
├── 索引 124, 125 ──► Large Bin [126]:管理超大的块 (范围63)
│
└── 索引 127 ──► (未使用)
终极一句总结
bins 数组的对应关系是:
-
Unsorted Bin 固定占用数组的 前 2 个位置。
-
Small Bin 的索引 n 对应数组的 2n 和 2n+1 位置,n 从 1 开始。
-
Large Bin 的索引 m 对应数组的 2m 和 2m+1 位置,m 从 64 开始。
这种设计让 malloc 能在 O(1) 时间内,根据请求大小直接计算出目标“抽屉”在数组中的精确位置,从而快速决定去哪个链表查找空闲块。
共254 个,只用了126个吗?
是的,从逻辑上说,bins 数组的 254 个位置,真正被用作独立“抽屉”(链表哨兵)的只有 127 个,也就是 NBINS (128) 减去 1 个未使用的。
但是,这并不意味着剩下的 127 个位置就浪费了。这背后是一个精妙的“空间换时间”的设计。
为什么是 254 = 2 * 127?
这个公式直接说明了原因:每个“抽屉”(bin)需要2 个连续的数组元素来充当双向链表的头尾哨兵。
-
127 个“抽屉” × 每个抽屉 2 个哨兵 = 254 个数组元素。
所以,254 个位置对应的是 127 个抽屉。
那 127 个抽屉是怎么分配的?
这 127 个抽屉的分配如下:
| Unsorted Bin | 1 个 | 无 | bins[0] 和 bins[1] |
| Small Bins | 62 个 | 1 到 62 | bins[2] 到 bins[125] |
| Large Bins | 63 个 | 64 到 126 | bins[126] 到 bins[251] |
| 未使用 | 1 个 | 127 | bins[252] 和 bins[253] |
| 总计 | 127 个 | – | 254 个元素 |
为什么保留 bins[127] 不用?
Glibc 源码中定义 NBINS = 128,但实际使用的 bin 数量是 NBINS – 1 = 127。这看起来确实有点浪费,主要有两个原因:
历史与兼容性:dlmalloc 和早期的 ptmalloc2 设计就是如此。bins[127] 被保留下来,可能是为了避免未来修改 NBINS 定义时带来的复杂影响。
简化索引计算:保留一个未使用的 bin,可以让 Small/Large Bin 的索引计算逻辑保持简洁和一致性。例如,bin_at(m, i) 宏可以安全地处理 i 从 0 到 NBINS-1 的所有值,而无需额外检查边界。
总结:并非浪费,而是索引的对齐需要
-
254 个位置是 127 个抽屉的物理实现,每个抽屉需要一对哨兵。
-
逻辑上,只有 127 个抽屉被使用,bins[127] 是保留的“空位”。
-
这个空位不是浪费,它允许代码将所有的“抽屉”从 bins[0] 到 bins[127] 统一索引,简化了数组边界检查的代码。
所以,理解“只用了 126 个”,只不过实际上是有 1 个 Unsorted Bin + 62 个 Small Bins + 63 个 Large Bins = 126 个逻辑上独立的“功能分区”,但它们在数组里的“抽屉”(哨兵对)数量是 127 个,因为还有一个未使用的保留抽屉。



