メモリアロケータ入門 - malloc/free はヒープに何をしているのか

メモリアロケータ入門 - malloc/free はヒープに何をしているのか

作成日:
読了:43
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

malloc(24) を呼ぶと何バイト確保されるでしょうか。24バイトでしょうか。手元のMacで測ると32バイトでした。では free() を呼ぶと、そのメモリはOSに返るのでしょうか。20万個の1KBブロックを確保してすべて解放してもRSSは199MBのまま動きませんでした。

mallocfree は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 PAGESIZE16384 を返しました)。取得手段は主に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のソースから引用)。

glibc 2.44 malloc/malloc.c より(使用中のチャンク)
    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ビットが空きます。そこにフラグが詰め込まれています。

ビット名前意味
0x1PREV_INUSE (P)直前の隣接チャンクが使用中かどうか。クリアなら手前のワードにその大きさが入る
0x2IS_MMAPPED (M)このチャンクが mmap で個別に確保されたもの
0x4NON_MAIN_ARENA (A)メインアリーナ以外のアリーナに属する

サイズと定数の関係を64ビット環境で具体化すると次のようになります。

  • SIZE_SZ は8、ヘッダは CHUNK_HDR_SZ = 2 * SIZE_SZ で16バイト
  • MALLOC_ALIGNMENT2 * SIZE_SZ__alignof__ (long double) の大きい方なので16
  • 最小チャンク MINSIZEoffsetof(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 がやっていること

このチャンク構造を知っていると、callocrealloc の挙動にも説明がつきます。

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 bin1解放直後のチャンクと分割の余り未分類のキュー。mallocが1度だけ再利用のチャンスを与える
small bin2〜63チャンクサイズ32〜1008バイト各binが同一サイズのみを持つ。FIFO
large bin64〜1261024バイト以上対数的に幅が広がる。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のソースでの定義は次の通りです。

glibc 2.44 malloc/malloc.c(抜粋)
# 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_max64ビットで1032バイト、32ビットで516バイトtcacheで処理する要求サイズの上限
glibc.malloc.tcache_count16各サイズごとにキャッシュする最大チャンク数。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_csize2tidxstdc_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_MAX4 * 1024 * 1024 * sizeof(long)(=32MB)なので、64MBになります。アドレス空間を64MB単位で予約しているだけなので、実際に触ったページしか物理メモリを消費しません。

アリーナの数の上限は、実はglibc 2.44で変わりました。

バージョン上限のロジック64ビットでの結果
2.43までNARENAS_FROM_NCORES(n) すなわちコア数かける88コアなら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() で実際のブロックサイズを見てみます。

sizes.c
#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と合わせて見てみます。

frag.c(抜粋)
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 mallocGNUtcache + 複数アリーナどこにでもある。汎用でバランス型
jemalloc元Facebook/Metaarena + tcache断片化回避と可観測性。decayでOSに返す
tcmallocGoogleper-CPUキャッシュ(rseq)huge page対応。スレッド数に依存しない
mimallocMicrosoftfree 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_structinode のような固定サイズの構造体を、ページ単位のバディアロケータから毎回切り出すのは非効率です。そこで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回だけ行います。

arena.c(抜粋)
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-uniti(実行命令数)、--stacksno--pages-as-heapno--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でストリームに書き出します。

全アリーナの状態をXMLでダンプする(glibc)
#include <malloc.h>
#include <stdio.h>
 
void dump_heap_state(void) {
    malloc_info(0, stderr);   /* 第1引数は将来の拡張用で現状は0 */
}

mallinfo2() は glibc 2.33 で追加されたので、それ以前では intmallinfo() しかなく、4GBを超えると値がオーバーフローします。

macOSでの計測

macOSには MallocStackLogging があります。man malloc の記述によれば、値によって挙動が変わります。

挙動
lite(または1)現在生きている割り当てのスタックのみをメモリ内に記録。leaks(1)malloc_history(1) が使う
full割り当てと解放の履歴をディスク上のログに記録
vmmmap などの仮想メモリ領域の割り当てのみ記録
mallocmalloc 系の割り当てのみ記録し、VM領域は含めない
# 生存中の割り当てのスタックを記録して leaks で調べる
MallocStackLogging=lite ./myapp &
leaks myapp
 
# 割り当て直後のメモリを0xaa、解放後を0x55で埋めてバグを顕在化させる
MallocScribble=1 ./myapp
 
# 出力先ディレクトリを変える(既定は /tmp)
MallocStackLogging=full MallocStackLoggingDirectory=/tmp/logs ./myapp

Xcodeを使う場合は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が下がらないのはリークなのか断片化なのか、スレッドを増やしたらメモリが増えたのはなぜか。こうした問いに一次情報で答えられるようになると、無駄な調査に費やす時間が確実に減ります。

参考リンク

CPUキャッシュとメモリ階層 入門 - キャッシュラインと局所性から実測まで

CPUキャッシュとメモリ階層 入門 - キャッシュラインと局所性から実測まで

30

CPUキャッシュとメモリ階層の仕組みを、キャッシュライン・参照の局所性・マッピング方式・書き込みポリシーから、配列の走査順やAoS/SoA、false sharing、perfやInstrumentsでの計測まで、一次情報と実測値をもとに解説します。

ハッシュテーブルの仕組み - 衝突解決・負荷率・各言語の実装から理解する

ハッシュテーブルの仕組み - 衝突解決・負荷率・各言語の実装から理解する

14

ハッシュテーブル(連想配列・ハッシュマップ)が平均O(1)で読み書きできる仕組みを、ハッシュ関数とバケット、衝突解決(チェイン法・オープンアドレス法)、負荷率とリハッシュの順で整理します。Python・Java・Go・C++・Rustの実装の違いや、ハッシュ衝突を悪用したDoSとSipHashによる緩和まで、公式ドキュメントを一次ソースにまとめます。