
メモリアロケータ入門 - malloc/free はヒープに何をしているのか
低レイヤの探検に必携のハック集。
実験しながらOSの挙動を確かめる本。
slab割り当てなど内部構造の定番書。
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
malloc(24) を呼ぶと何バイト確保されるでしょうか。24バイトでしょうか。手元のMacで測ると32バイトでした。では free() を呼ぶと、そのメモリはOSに返るのでしょうか。20万個の1KBブロックを確保してすべて解放してもRSSは199MBのまま動きませんでした。
malloc と free は2つの関数でしかありませんが、その裏には「OSから見たページ」と「プログラムから見たバイト」の巨大なギャップを埋めるための、かなり込み入った仕組みがあります。この記事では、glibc malloc(ptmalloc2)のデータ構造を出発点に、tcacheやアリーナ、断片化、そしてjemalloc / tcmalloc / mimallocといった代替実装の設計思想までを整理します。
数値は原則としてglibcのソースと公式マニュアルから引き、手元で測ったものは「手元の環境での目安」と明記します。実測環境はApple M3 Max / macOS 26.5 / Apple clang 21.0.0 (arm64) です。
アロケータが解いている問題
OSがプロセスに渡せるメモリの単位はページです。x86-64のLinuxでは通常4KB、Apple Siliconでは16KBです(手元では getconf PAGESIZE が 16384 を返しました)。取得手段は主に2つで、データセグメントの末尾を伸縮させる brk/sbrk と、匿名領域を丸ごとマップする mmap です。
プログラム アロケータ カーネル
malloc(24) ------> free listから切り出す
malloc(40) ------> free listから切り出す (システムコールなし)
malloc(1MB) ------> mmap(...) ------> 新しい匿名マッピング
free(p) ------> free listに戻すだけ (システムコールなし)もし malloc が毎回システムコールを呼んでいたら、1回あたり数百ナノ秒〜マイクロ秒のコストが乗ります。しかも最小単位がページなので、24バイトの要求に16KBを渡すことになります。そこでlibcは、OSから大口でページを仕入れ、プログラムには小口で切り売りする小売業者として振る舞います。これがユーザ空間のメモリアロケータです。
アロケータが同時に満たしたい要件は互いに衝突します。
| 要件 | 内容 | 衝突する相手 |
|---|---|---|
| 速度 | 1回の割り当てを数十ナノ秒で終える | メタデータを増やせば速くなるが空間を食う |
| 空間効率 | 断片化を抑え、無駄を減らす | 隙間を探すほど時間がかかる |
| スケーラビリティ | 多スレッドでロック競合を起こさない | スレッドごとにキャッシュを持つとメモリが増える |
| 局所性 | 近い時期の割り当てを近いアドレスに置く | 空間効率のためのbest fitと相性が悪い |
以降で見るアロケータの違いは、ほぼすべてこの4つのトレードオフのどこに重心を置いたか、という話に還元できます。ページやアドレス変換そのものについては仮想メモリとページングの仕組みで扱っているので、そちらも合わせて読むと立体的になります。
チャンク - glibc mallocのデータ構造
glibcのアロケータは、Doug Leaのdlmallocを起源とし、スレッド対応を加えたptmalloc2系です。管理単位はチャンク(chunk)で、malloc/malloc.c のコメントに図が載っています(以下はglibc 2.44のソースから引用)。
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
. |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+malloc が返すポインタは chunk ではなく mem です。その16バイト手前(64ビット環境の場合)にヘッダがあり、free(p) は p から16バイト引いてチャンクの先頭を求めます。これが境界タグ(boundary tag)法で、コメントにも "Chunks of memory are maintained using a `boundary tag' method as described in e.g., Knuth or Standish." とあります。
サイズは常に16の倍数に丸められるため、下位3ビットが空きます。そこにフラグが詰め込まれています。
| ビット | 名前 | 意味 |
|---|---|---|
| 0x1 | PREV_INUSE (P) | 直前の隣接チャンクが使用中かどうか。クリアなら手前のワードにその大きさが入る |
| 0x2 | IS_MMAPPED (M) | このチャンクが mmap で個別に確保されたもの |
| 0x4 | NON_MAIN_ARENA (A) | メインアリーナ以外のアリーナに属する |
サイズと定数の関係を64ビット環境で具体化すると次のようになります。
SIZE_SZは8、ヘッダはCHUNK_HDR_SZ = 2 * SIZE_SZで16バイトMALLOC_ALIGNMENTは2 * SIZE_SZと__alignof__ (long double)の大きい方なので16- 最小チャンク
MINSIZEはoffsetof(struct malloc_chunk, fd_nextsize)を16に切り上げた32バイト - 要求サイズの丸めは
(req + SIZE_SZ + 15) & ~15で、下限がMINSIZE
+ SIZE_SZ であって + 16 でないのがポイントです。使用中のチャンクでは、次のチャンクの prev_size フィールドはユーザデータとして流用されます(図で (size of chunk, but used for application data) と書かれている部分です)。つまりヘッダの実質コストは1チャンクあたり8バイトです。
この計算に従うと、64ビット環境では malloc(1) から malloc(24) までがすべて32バイトのチャンクになり、malloc(25) から48バイトになります。glibcの malloc_usable_size(3) や、macOSの malloc_size() で確認できます。
calloc と realloc がやっていること
このチャンク構造を知っていると、calloc と realloc の挙動にも説明がつきます。
calloc は「ゼロ埋めされた領域を返す」関数ですが、常に memset しているわけではありません。glibc 2.44の __libc_calloc2 は、チャンクが mmap で取られたもの(chunk_is_mmapped)ならゼロ埋めを丸ごと省きます。カーネルが返す匿名ページは必ずゼロクリアされているからです。さらに、sbrk で新しく伸ばしたばかりのtop chunk領域についても、"clear only the bytes from non-freshly-sbrked memory" というコメント通り、既存部分だけをクリアします。大きな配列をゼロ初期化したいなら、malloc してから memset するより calloc のほうが速いことがあるのはこのためです。
realloc は、まず伸長がその場でできないかを試みます。次の隣接チャンクが空きか、あるいはtop chunkであれば、それを取り込んで拡張できます。無理ならば新しく確保して内容をコピーし、古い領域を解放します。mmap で確保されたチャンクの場合は mremap_chunk が呼ばれ、Linuxの mremap によってコピー無しでマッピングを伸ばせます。だからこそ、realloc のコストは「呼ぶたびに全コピー」とは限らず、ヒープの状況次第で大きく変わります。動的配列で容量を1.5倍や2倍ずつ増やす戦略が有効なのは、この不確実性を償却するためです。
bin - 空きチャンクの置き場所
解放されたチャンクは、サイズに応じたbinという双方向リンクリストに繋がれます。NBINS は128で、コメントには "There are a lot of these bins (128). This may look excessive, but works very well in practice." とあります。
| bin | インデックス | 対象 | 性質 |
|---|---|---|---|
| unsorted bin | 1 | 解放直後のチャンクと分割の余り | 未分類のキュー。mallocが1度だけ再利用のチャンスを与える |
| small bin | 2〜63 | チャンクサイズ32〜1008バイト | 各binが同一サイズのみを持つ。FIFO |
| large bin | 64〜126 | 1024バイト以上 | 対数的に幅が広がる。bin内はサイズ降順でbest fit |
| top chunk | (binではない) | ヒープ末尾の未使用領域 | 他に候補が無いときだけ切り出す |
in_smallbin_range の閾値 MIN_LARGE_SIZE は (NSMALLBINS - SMALLBIN_CORRECTION) * SMALLBIN_WIDTH で、64ビットでは64かける16の1024バイトです。large binの幅はソースのコメント通り「64本が幅8、32本が幅64、16本が幅512、8本が幅4096、4本が幅32768、2本が幅262144、残り1本」という対数スパンになっています。
unsorted binの役割が独特です。コメントは "All remainders from chunk splits, as well as all returned chunks, are first placed in the 'unsorted' bin. They are then placed in regular bins after malloc gives them ONE chance to be used before binning." と説明しています。解放のたびに正しいbinへソートすると重いので、いったん未分類の箱に放り込み、次の malloc が走査するついでに分類する、という遅延処理です。
top chunkはヒープの末端にある特別なチャンクで、どのbinにも属しません。"It is never included in any bin, is used only if no other chunk is available, and is released back to the system if it is very large" とあります。ヒープを縮められるかどうかは、このtop chunkが十分大きいかにかかっています。
[ used ][ free ][ used ][ free ][ ...... top chunk ...... ] <- ヒープ末端(brk)
^
ここが大きければ malloc_trim でOSに返せるなお128本のbinは1本のロック(アリーナのmutex)で守られます。だから小さな割り当てを高速化するには、ロックを取らない別の経路が要ります。それがtcacheです。
tcache - ロックを取らない高速路
tcache(thread-local cache)はglibc 2.26(2017年)で導入された、スレッドローカルの単方向リストキャッシュです。malloc はまずtcacheを見に行き、当たればロックを一切取らずにポインタを1つ外して返します。
glibc 2.44のソースでの定義は次の通りです。
# define TCACHE_SMALL_BINS 64
# define TCACHE_LARGE_BINS 12 /* Up to 4M chunks */
# define TCACHE_MAX_BINS (TCACHE_SMALL_BINS + TCACHE_LARGE_BINS)
# define tidx2csize(idx) (((size_t) idx) * MALLOC_ALIGNMENT + MINSIZE)
# define TCACHE_FILL_COUNT 16チューニング可能なパラメータはtunablesとして公開されています(glibc 2.44のマニュアルより)。
| tunable | 既定値 | 意味 |
|---|---|---|
| glibc.malloc.tcache_max | 64ビットで1032バイト、32ビットで516バイト | tcacheで処理する要求サイズの上限 |
| glibc.malloc.tcache_count | 16 | 各サイズごとにキャッシュする最大チャンク数。0で無効化 |
NOTE
tcache_count の既定値は長らく7でしたが、glibc 2.44のマニュアルでは16になっています。実際にglibc 2.42のソースでは TCACHE_FILL_COUNT 7、2.43以降は16です。「tcacheは7個まで」という説明はglibc 2.42までの話だと覚えておくと安全です。
もう1つ大きな変化があります。fastbinが廃止されました。fastbinは16〜160バイト程度の小さなチャンクを、隣接チャンクとの結合(consolidate)を省いて単方向リストに積む仕組みでしたが、tcacheと役割が完全に重複していました。glibc 2.42の malloc/malloc.c には fastbin の語が106箇所ありますが、2.43と2.44では0箇所です。2.44のマニュアルも glibc.malloc.mxfast について "This tunable has no effect since the 'fastbins' have been removed." と明記しています。
したがって、現代のglibcでの malloc の探索順は次のようになります。
malloc(n)
1. tcache に当たりがあるか -> あれば即返す(ロック無し)
2. アリーナのロックを取得
3. small bin / unsorted bin を走査 -> 見つかれば切り出す(ついでにtcacheへ補充)
4. large bin を best fit で探索
5. top chunk を切り出す
6. 足りなければ brk / mmap で OS から追加取得tcacheには軽量なセキュリティ機構も入っています。エントリに key フィールドを置いて二重解放を検出し、単方向リストの next ポインタはASLR由来の値でマスクされます(Safe-Linking)。ソースのコメントに "Use randomness from ASLR (mmap_base) to protect single-linked lists of TCache." とあります。
さらにglibc 2.42では、tcacheが大きいブロックにも対応しました。NEWSには "The thread-local cache in malloc (tcache) now supports caching of large blocks. This feature can be enabled by setting the tunable glibc.malloc.tcache_max to a larger value (max 4194304)." とあります。大きいサイズのbinは2の冪で区切られ(large_csize2tidx が stdc_bit_width を使います)、12本用意されています。ただし既定では有効になっていないので、使うにはtunableを明示的に上げる必要があります。
アリーナ - マルチスレッドとロック競合
tcacheを外れたサイズや、tcacheが空のときはアリーナのロックを取ります。ここで全スレッドが1つのロックを奪い合うと詰まるため、glibcは複数のアリーナ(arena)を持ちます。
- メインアリーナ: 最初のスレッドが使う。
brkでデータセグメントを伸ばして拡張する - 非メインアリーナ: 後から作られる。
mmapでheapを確保し、チャンクのAビットが立つ
非メインアリーナのheapは HEAP_MAX_SIZE 単位でアラインされた領域として確保されます。malloc/arena.c の定義は HEAP_MAX_SIZE (2 * DEFAULT_MMAP_THRESHOLD_MAX) で、64ビットの DEFAULT_MMAP_THRESHOLD_MAX が 4 * 1024 * 1024 * sizeof(long)(=32MB)なので、64MBになります。アドレス空間を64MB単位で予約しているだけなので、実際に触ったページしか物理メモリを消費しません。
アリーナの数の上限は、実はglibc 2.44で変わりました。
| バージョン | 上限のロジック | 64ビットでの結果 |
|---|---|---|
| 2.43まで | NARENAS_FROM_NCORES(n) すなわちコア数かける8 | 8コアなら64 |
| 2.44以降 | __get_nprocs()(ただし arena_test 未満にはしない) | 8コアなら8 |
glibc.malloc.arena_test(既定値は64ビットで8)本まではテスト無しで作られ、それを超えると上限判定が入ります。上限に達したあとは、既存のアリーナがスレッド間で再利用されます。
環境変数 MALLOC_ARENA_MAX(tunableでは glibc.malloc.arena_max)で明示的に固定できます。
# アリーナを2本に固定してRSSを抑える(Linux, glibc)
MALLOC_ARENA_MAX=2 ./myserver
# tunable経由でも同じ
GLIBC_TUNABLES=glibc.malloc.arena_max=2 ./myserver多スレッドのサーバでRSSが想定より大きい場合、アリーナの数が効いていることがよくあります。アリーナごとに未使用のfree chunkを抱えるため、コア数が多いマシンほど「使っていないのに返らないメモリ」が増えます。スレッドとプロセスの使い分けは並行と並列 プロセスとスレッドでも触れています。
mmap閾値 - 大きい割り当ての扱い
一定サイズ以上の要求は、binを使わず mmap で個別に取ります。mallopt(3) によれば M_MMAP_THRESHOLD の既定値は 128*1024(128KB)です。この方式には利点と欠点があります。
- 利点:
freeするとmunmapで即座にOSへ返る。断片化がヒープに残らない - 欠点: 確保も解放もシステムコールで、ページフォルトも都度発生する
やっかいなのは、この閾値が動的に上がることです。man pageの記述はこうです。"The initial value of the threshold is 128*1024, but when blocks larger than the current threshold and less than or equal to DEFAULT_MMAP_THRESHOLD_MAX are freed, the threshold is adjusted upward to the size of the freed block."
つまり、200KBのブロックを確保して解放すると、以後の閾値は200KB相当まで上がり、同じサイズの割り当てはヒープから供給されるようになります。上限は32ビットで512KB、64ビットで 4*1024*1024*sizeof(long)(=32MB)です。「同じサイズの確保と解放を繰り返すワークロードでは、2回目以降が速くなる」という設計ですが、ベンチマークの再現性を下げる原因にもなります。固定したい場合は mallopt(M_MMAP_THRESHOLD, ...) を呼ぶか MALLOC_MMAP_THRESHOLD_ を設定します(明示的に設定すると動的調整は止まります)。
関連して M_TRIM_THRESHOLD は既定値128KBで、top chunkがこれを超えたらヒープを縮めます。手動で縮めるには malloc_trim(3) を呼びます。
断片化 - 内部と外部
断片化は2種類あります。
- 内部断片化: 要求より大きなブロックを渡したときの、ブロック内部の無駄。サイズクラスへの丸めとヘッダが原因
- 外部断片化: 空き領域の合計は足りているのに、連続した領域が取れない状態
内部断片化は簡単に測れます。macOSの malloc_size() で実際のブロックサイズを見てみます。
#include <stdio.h>
#include <stdlib.h>
#include <malloc/malloc.h> /* macOS: malloc_size() */
int main(void) {
size_t reqs[] = {1, 8, 16, 17, 24, 25, 32, 48, 100, 128, 200, 512, 1000,
4096, 16384, 100000, 1000000};
printf("%10s %12s %10s %8s\n", "request", "malloc_size", "overhead", "ratio");
for (size_t i = 0; i < sizeof(reqs)/sizeof(reqs[0]); i++) {
void *p = malloc(reqs[i]);
size_t got = malloc_size(p);
printf("%10zu %12zu %10zu %7.1f%%\n", reqs[i], got, got - reqs[i],
100.0 * (double)(got - reqs[i]) / (double)reqs[i]);
free(p);
}
return 0;
}$ cc -O2 -o sizes sizes.c && ./sizes
request malloc_size overhead ratio
1 16 15 1500.0%
8 16 8 100.0%
16 16 0 0.0%
17 32 15 88.2%
24 32 8 33.3%
32 32 0 0.0%
48 48 0 0.0%
100 112 12 12.0%
128 128 0 0.0%
200 224 24 12.0%
512 512 0 0.0%
1000 1024 24 2.4%
16384 16384 0 0.0%
100000 114688 14688 14.7%
1000000 1015808 15808 1.6%手元の環境での目安です。16バイト刻みのnano zone、その先は224や112といったサイズクラスが見えます。1バイトの要求に16バイトが割り当てられるので、小さな構造体を大量に持つプログラムでは丸めだけで数十パーセント損をすることがあります。glibcの場合はさらに8バイトのヘッダが乗ります。
WARNING
malloc_size() はmacOS(<malloc/malloc.h>)の関数、glibcでは malloc_usable_size()(<malloc.h>)です。どちらも「実際に使えるバイト数」を返しますが、要求サイズを超えた領域に書き込んでよいと考えるのは危険です。glibcのman pageもこの用途を推奨していません。
外部断片化のほうは、もう少し理解しにくい形で現れます。典型は「合計10MB空いているのに1MBが取れない」という状況です。
空き合計 10MB、しかし連続領域は最大 48KB
[使][空48KB][使][空32KB][使][空64KB][使][空16KB]... top chunk
^ ^
1個でも生きているチャンクがあると ここまで縮められない
その手前の空き領域は結合できないglibcは隣接する空きチャンクを結合(consolidate)しますが、間に生きているチャンクが1つでも挟まると結合できません。しかもヒープの縮小はtop chunk側からしかできないので、ヒープ末尾近くに長寿命のオブジェクトが1つ残っているだけで、その手前の全領域がOSに返らなくなります。長期稼働のサーバでメモリが右肩上がりに見えるとき、リークではなくこのパターンであることは珍しくありません。
これを実測してみます。1KBのブロックを20万個確保し、1つおきに解放してから全部解放する様子をRSSと合わせて見てみます。
static void report(const char *label) {
struct mstats m = mstats();
printf("%s RSS %6.1f MB | malloc used %6.1f MB / total %6.1f MB\n",
label, rss_bytes()/1048576.0,
m.bytes_used/1048576.0, m.bytes_total/1048576.0);
}
#define N 200000
int main(void) {
static void *p[N];
report("起動直後 ");
for (int i = 0; i < N; i++) p[i] = malloc(1024);
report("1KB x 20万 確保後 ");
for (int i = 0; i < N; i += 2) { free(p[i]); p[i] = NULL; }
report("1つおきに解放後 ");
for (int i = 1; i < N; i += 2) { free(p[i]); p[i] = NULL; }
report("全解放後 ");
return 0;
}$ cc -O2 -o frag frag.c && ./frag
起動直後 RSS 1.4 MB | malloc used 0.0 MB / total 8.0 MB
1KB x 20万 確保後 RSS 199.0 MB | malloc used 195.3 MB / total 204.0 MB
1つおきに解放後 RSS 199.0 MB | malloc used 97.7 MB / total 204.0 MB
全解放後 RSS 199.1 MB | malloc used 0.0 MB / total 204.0 MBアロケータから見た使用量(malloc used)は0MBに戻っているのに、RSSは199MBのまま動きません。手元の環境での目安ですが、この挙動自体はglibcでも同様に起きます。アロケータはメモリを自分のfree listに戻しただけで、OSには返していないからです。
「メモリリークではないのにRSSが下がらない」という相談の大半はこれです。返すには次のような手段があります。
- glibc:
malloc_trim(0)を呼ぶ。ただしtop chunkが連続していないと効果が薄い - 大きい割り当てをmmap経路に寄せる(
M_MMAP_THRESHOLDを下げる) - jemallocやtcmallocのように、バックグラウンドで
madvise(MADV_DONTNEED)を出す実装を使う - そもそも長寿命のオブジェクトと短命のオブジェクトを別の領域に分ける
スワップやページ回収と絡む話でもあるので、zramによる圧縮スワップも合わせて見ておくと、RSSという数字の読み方が変わります。
代替アロケータの設計思想
glibc mallocは汎用性重視の実装で、特定のワークロードでは他の実装のほうが速かったり、メモリを使わなかったりします。主要な3つを一次情報から整理します。
| 実装 | 主開発元 | スレッド戦略 | 特徴 |
|---|---|---|---|
| glibc malloc | GNU | tcache + 複数アリーナ | どこにでもある。汎用でバランス型 |
| jemalloc | 元Facebook/Meta | arena + tcache | 断片化回避と可観測性。decayでOSに返す |
| tcmalloc | per-CPUキャッシュ(rseq) | huge page対応。スレッド数に依存しない | |
| mimalloc | Microsoft | free list sharding | ページ単位の分割リスト。メタデータが小さい |
jemalloc
jemallocは "emphasizes fragmentation avoidance and scalable concurrency support" を掲げる実装で、2005年にFreeBSDのlibcアロケータとして使われ始めました。jemalloc(3) の記述から主要な既定値を拾うと次の通りです。
- arena数: CPU数の4倍(CPUが1つなら1)
- tcache: 既定で有効。キャッシュする最大サイズクラスは
opt.tcache_maxの既定で32KiB - small/large: ページサイズの4倍未満がsmall、そこから
PTRDIFF_MAXを超えない最大のサイズクラスまでがlarge - dirty_decay_ms: 10秒。使われなくなったページを段階的にOSへ返す
- muzzy_decay_ms: 0(既定で無効)
このdecayという考え方がjemallocの肝です。解放されたページを即座に返すとページフォルトのコストが跳ね返り、返さないとRSSが膨らみます。そこでdirty(内容が残っている)、muzzy(MADV_FREE 済みで回収されうる)、clean(返却済み)という状態を設け、時間経過に応じて段階的に返します。
開発体制については注意が必要です。作者のJason Evansは2025年6月12日に "jemalloc Postmortem" を公開し、GitHubリポジトリは同年6月2日にアーカイブされました。ただし2026年9月現在、リポジトリのアーカイブは解除されており、2026年4月13日に5.3.1がリリースされています。ChangeLogには "over 390 commits" と "The release has gone through large-scale production testing at Meta." と記載されています。「開発終了」で情報が止まっている記事も多いので、採用を検討するなら現在のリポジトリ状態を自分で確認してください。
tcmalloc
Googleのtcmallocは、フロントエンド / ミドルエンド / バックエンドの3層構造です。設計文書 docs/design.md の記述に沿うと次のようになります。
[アプリ] -> フロントエンド(per-CPU または per-thread キャッシュ)
| ミス時
v
ミドルエンド(transfer cache / central free list)
| ミス時
v
バックエンド(pageheap / hugepage-aware allocator)
|
v
カーネル特徴的なのはper-CPUモードです。"In this mode each logical CPU in the system has its own cache from which to allocate memory." とあり、Linuxのrestartable sequences(rseq)を使って、ロックもアトミック命令も使わずにCPUローカルの配列を操作します。rseqは「シーケンス中にプリエンプトされたら最初からやり直す」という仕組みで、成功パスが極めて短くなります。
per-threadキャッシュとの違いは文書に明快に書かれています。per-threadだとキャッシュの総量がスレッド数に比例するため、"Modern applications can have large thread counts, which result in either large amounts of aggregate per-thread memory, or many threads having minuscule per-thread caches." という問題が出ます。CPU数はスレッド数より安定しているので、per-CPUのほうが読みやすい、という発想です。docs/tuning.md には "The default is for TCMalloc to run in per-cpu mode as this is faster" とあり、per-CPUが既定です。論理ページサイズの既定は8KiBで、32KiBや256KiBも選べます。小さいオブジェクトは60〜80のサイズクラスに割り当てられます。
mimalloc
Microsoftのmimallocはfree list shardingという考え方が中心です。READMEの表現では、サイズクラスごとに1本の大きなfree listを持つのではなく "many smaller lists per 'mimalloc page'" を持ちます。mimallocの「ページ」は通常64KiBで、その中は単一のサイズクラスで統一されます。
さらに1つのページに対して複数のfree listを持ちます。"one list for thread-local free operations, and another one for concurrent free operations" とあり、自スレッドからの解放と他スレッドからの解放を分けることで、アトミック操作をローカルパスから追い出しています。メタデータのオーバーヘッドは約0.2パーセントと主張されています。2026年9月1日にv3.5.1がリリースされており、ライセンスはMITです。
複数実装を試すときは、Linuxなら LD_PRELOAD で差し替えるのが手軽です。
# 再ビルドせずにアロケータを差し替えて比較する(Linux)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./myapp
LD_PRELOAD=/usr/lib/libtcmalloc.so ./myapp
LD_PRELOAD=/usr/local/lib/libmimalloc.so ./myappカーネル側のアロケータ - slab/SLUB
ここまではユーザ空間の話でしたが、カーネル自身も同じ問題を抱えています。task_struct や inode のような固定サイズの構造体を、ページ単位のバディアロケータから毎回切り出すのは非効率です。そこでslabアロケータが使われます。
考え方はシンプルで、「同じ型のオブジェクト専用のキャッシュを作り、そこから配る」ものです。オブジェクトのサイズが揃っているので、free listの管理は「使うか使わないか」だけになり、初期化済みの状態を再利用できるという副次的な利点もあります。ユーザ空間の側で言えば、後述するプールアロケータと同じ発想です。
Linuxでの現在の実装はSLUBです。カーネル文書 Documentation/mm/slub.rst の冒頭は "The basic philosophy of SLUB is very different from SLAB. SLAB requires rebuilding the kernel to activate debug options for all slab caches. SLUB always includes full debugging but it is off by default." と説明しています。デバッグはカーネルコマンドラインの slab_debug で有効化でき、Z(red zoning)、P(poisoning)、U(user tracking)といったオプションを個別のslabキャッシュに対して指定できます。
# 稼働中のslabキャッシュを見る(Linux)
sudo slabtop -o | head -20
cat /proc/slabinfo | head -5カーネルのメモリ管理はコンテナのリソース制御とも直結します。cgroupによるメモリ制限についてはLinuxコンテナの仕組みで扱っています。
汎用アロケータを使わないという選択
一番速い malloc は、呼ばれない malloc です。ライフタイムが揃っているオブジェクト群なら、汎用アロケータを迂回してアリーナ(リージョン、バンプ)アロケータを使うほうが圧倒的に速くなります。
実装は数十行です。大きな領域を1回だけ確保し、ポインタを進めるだけで割り当て、解放は領域丸ごと1回だけ行います。
typedef struct { char *base; size_t cap, used; } Arena;
static void arena_init(Arena *a, size_t cap) {
a->base = malloc(cap); a->cap = cap; a->used = 0;
}
static void *arena_alloc(Arena *a, size_t n) {
n = (n + 15) & ~(size_t)15; /* 16バイト境界に切り上げ */
if (a->used + n > a->cap) return NULL;
void *p = a->base + a->used;
a->used += n;
return p;
}
static void arena_reset(Arena *a) { a->used = 0; }
static void arena_free(Arena *a) { free(a->base); a->base = NULL; }200万個の16バイトノードで連結リストを作り、走査して、破棄するまでを比較しました。
$ cc -O2 -o arena arena.c && ./arena
malloc/free : alloc 59.3 ms traverse 5.6 ms free 61.3 ms total 126.2 ms
arena : alloc 8.0 ms traverse 8.0 ms free 0.0 ms total 16.1 ms手元の環境での目安(Apple M3 Max / macOS 26.5 / clang 21)ですが、合計で約8倍の差が出ました。内訳を見ると、割り当てで7倍、解放にいたっては61msが実質0msになっています。個別の free はチャンクの結合やbinへの再挿入を伴うので、200万回積み上がると効いてきます。
アリーナ方式が向くのは次のようなケースです。
- コンパイラのASTのように、1回のパスで作って最後にまとめて捨てるデータ
- HTTPリクエスト1件のライフタイムに閉じたオブジェクト群
- ゲームの1フレーム内でしか生きない一時オブジェクト
逆に、個別に解放したい、寿命がばらばら、という状況では使えません。その中間として、同一サイズのブロックだけを扱うプールアロケータ(スラブの発想)もよく使われます。空きブロックのfree listを自分で持つだけなので、割り当ても解放もポインタ操作数回で済みます。
NOTE
自前アロケータはASan(AddressSanitizer)やValgrindの検知範囲から外れます。バグを見つけにくくなるので、導入するなら計測で効果を確認してからにしてください。GCを持つ言語における世代別のバンプ割り当ても同じ発想です(ガベージコレクションの仕組み)。
計測する
推測ではなく計測です。用途別に道具を挙げます。
プロセス全体のヒープ推移
Linuxならvalgrindのmassifが定番です。実行するとスナップショットのファイルが出力され、ms_print で時系列グラフと割り当て元のツリーが読めます。
valgrind --tool=massif ./myapp
ms_print massif.out.<pid> | less
# ページ単位(mmap/brk)で見る。コードやBSSも含めた全体を捉えられる
valgrind --tool=massif --pages-as-heap=yes ./myapp主なオプションの既定値はマニュアルに記載があります。--time-unit は i(実行命令数)、--stacks は no、--pages-as-heap は no、--detailed-freq は10、--max-snapshots は100です。
より軽量に、割り当て元のスタックトレースまで含めて追いたいならheaptrackです。"traces all memory allocations and annotates these events with stack traces" と説明されており、リークだけでなく「一時的な割り当て(確保直後に解放されるもの)」や「割り当て回数のホットスポット」も見つけられます。
heaptrack ./myapp
heaptrack --pid $(pidof myapp)
heaptrack_gui heaptrack.myapp.12345.zstアロケータ自身の統計
glibcには自己申告の統計APIがあります。mallinfo2() は size_t のフィールドで統計を返しますが、man pageに "Information is returned for only the main memory allocation area. Allocations in other arenas are excluded." とある通り、メインアリーナ分しか見えません。マルチスレッドのプログラムでは malloc_info(3) のほうが有用で、全アリーナの状態をXMLでストリームに書き出します。
#include <malloc.h>
#include <stdio.h>
void dump_heap_state(void) {
malloc_info(0, stderr); /* 第1引数は将来の拡張用で現状は0 */
}mallinfo2() は glibc 2.33 で追加されたので、それ以前では int の mallinfo() しかなく、4GBを超えると値がオーバーフローします。
macOSでの計測
macOSには MallocStackLogging があります。man malloc の記述によれば、値によって挙動が変わります。
| 値 | 挙動 |
|---|---|
| lite(または1) | 現在生きている割り当てのスタックのみをメモリ内に記録。leaks(1) や malloc_history(1) が使う |
| full | 割り当てと解放の履歴をディスク上のログに記録 |
| vm | mmap などの仮想メモリ領域の割り当てのみ記録 |
| malloc | malloc 系の割り当てのみ記録し、VM領域は含めない |
# 生存中の割り当てのスタックを記録して leaks で調べる
MallocStackLogging=lite ./myapp &
leaks myapp
# 割り当て直後のメモリを0xaa、解放後を0x55で埋めてバグを顕在化させる
MallocScribble=1 ./myapp
# 出力先ディレクトリを変える(既定は /tmp)
MallocStackLogging=full MallocStackLoggingDirectory=/tmp/logs ./myappXcodeを使う場合はSchemeのDiagnosticsタブから同等の設定ができます。なお MallocZeroOnFree はmacOS 13以降で既定になっており、free した領域が即座にゼロ埋めされるため、use-after-freeが以前と違う形で現れる点に注意してください。
まとめ
mallocはOSからページを大口で仕入れ、プログラムに小口で切り売りする小売業者。速度・空間効率・スケーラビリティ・局所性のトレードオフを解いている- glibcの管理単位はチャンク。64ビットではヘッダの実質コストが8バイト、最小チャンクが32バイト、アラインメントが16バイト。サイズの下位3ビットにPREV_INUSE / IS_MMAPPED / NON_MAIN_ARENAのフラグが入る
- 空きチャンクはunsorted / small / largeのbinとtop chunkで管理される。unsorted binは分類を遅延させるための箱
- tcacheはglibc 2.26導入のスレッドローカルキャッシュ。既定でサイズ1032バイトまで、glibc 2.43以降は各16個(2.42までは7個)。同2.43でfastbinは廃止された
- アリーナ数の既定上限はglibc 2.43まではコア数かける8、2.44からはコア数。
MALLOC_ARENA_MAXで抑えられる - mmap閾値の既定は128KBだが動的に最大32MBまで上がる。ベンチマークの再現性が気になるなら明示的に固定する
freeしてもRSSは下がらないのが普通。手元の実測でも20万個の1KBブロックを全解放してRSSは199MBのままだった- ライフタイムが揃っているならアリーナアロケータが強い。手元の実測で合計約8倍
- 数値は必ず自分の環境で測る。glibcのバージョンによって既定値そのものが変わっている
アロケータの中身を知る一番の効用は、性能を追うことよりも「この現象は異常なのか正常なのか」を切り分けられるようになることだと思います。RSSが下がらないのはリークなのか断片化なのか、スレッドを増やしたらメモリが増えたのはなぜか。こうした問いに一次情報で答えられるようになると、無駄な調査に費やす時間が確実に減ります。
参考リンク
- The GNU C Library Manual 2.44 - Memory Allocation Tunables: https://sourceware.org/glibc/manual/2.44/html_node/Memory-Allocation-Tunables.html
- glibc ソース配布(malloc/malloc.c, malloc/arena.c, NEWS を確認): https://ftp.gnu.org/gnu/glibc/
- glibc Wiki - MallocInternals: https://sourceware.org/glibc/wiki/MallocInternals
- mallopt(3) - Linux manual page: https://man7.org/linux/man-pages/man3/mallopt.3.html
- malloc(3) - Linux manual page: https://man7.org/linux/man-pages/man3/malloc.3.html
- mallinfo(3) / mallinfo2(3) - Linux manual page: https://man7.org/linux/man-pages/man3/mallinfo.3.html
- malloc_info(3) - Linux manual page: https://man7.org/linux/man-pages/man3/malloc_info.3.html
- malloc_usable_size(3) - Linux manual page: https://man7.org/linux/man-pages/man3/malloc_usable_size.3.html
- malloc_trim(3) - Linux manual page: https://man7.org/linux/man-pages/man3/malloc_trim.3.html
- jemalloc(3) man page(公式): https://jemalloc.net/jemalloc.3.html
- jemalloc GitHubリポジトリ(README / ChangeLog): https://github.com/jemalloc/jemalloc
- Jason Evans, "jemalloc Postmortem"(2025年6月12日): https://jasone.github.io/2025/06/12/jemalloc-postmortem/
- TCMalloc Design(google/tcmalloc docs/design.md): https://github.com/google/tcmalloc/blob/master/docs/design.md
- TCMalloc Tuning(google/tcmalloc docs/tuning.md): https://github.com/google/tcmalloc/blob/master/docs/tuning.md
- mimalloc(microsoft/mimalloc README): https://github.com/microsoft/mimalloc
- Leijen, Zorn, de Moura, "Mimalloc: Free List Sharding in Action"(Microsoft Research): https://www.microsoft.com/en-us/research/publication/mimalloc-free-list-sharding-in-action/
- Linux kernel Documentation/mm/slub.rst: https://www.kernel.org/doc/Documentation/mm/slub.rst
- Valgrind Massif マニュアル: https://valgrind.org/docs/manual/ms-manual.html
- heaptrack(KDE/heaptrack): https://github.com/KDE/heaptrack
- Apple - Enabling the Malloc Debugging Features: https://developer.apple.com/library/archive/documentation/Performance/Conceptual/ManagingMemory/Articles/MallocDebug.html
- Apple - Malloc Debug Environment Variables Release Notes: https://developer.apple.com/library/archive/releasenotes/DeveloperTools/RN-MallocOptions/


