この記事の対象読者
「スタック」「ヒープ」という言葉は聞いたことがあるけれど、説明しろと言われると詰まる人に向けた入門記事です。
-
Maximum call stack size exceededを見たことがあるが、なぜ出たのか分からない - 「メモリリーク」と言われても、何が漏れているのかピンとこない
- 「スタックは速い」と聞くが、じゃあ全部スタックでいいのでは?と思っている
プログラミングの経験は少しあれば十分です。関数を書いたことがあって、変数にはスコープ(有効範囲)があると知っていれば読めます。
サンプルコードはC言語ですが、Cを書けなくても大丈夫です。Cを使うのは、普段は隠れているメモリの様子を数字で見られる言語だからで、それ以外の理由はありません。
実行結果は Apple clang / macOS で確認したものです。アドレスなどの具体的な数値は環境によって変わります。
まずはイメージから
メモリにはスタックとヒープという2つの置き場所があります。
スタック = 積み上げた本の山
机の上に本を積んでいくイメージです。
- 新しい本は一番上に置く
- 取るときも一番上から
- 途中の本だけ抜き取ることはできない
この「後に置いたものから先に取る」ルールを LIFO(Last In First Out、後入れ先出し)と呼びます。
ヒープ = 広い倉庫
こちらは倉庫です。
- 空いているところに好きな大きさで置ける
- 取り出す順番は自由
- ただし「どこに何を置いたか」を自分で管理しないといけない
- 不要になったら片付けないと、倉庫が埋まっていく
この2つがどう使い分けられるか
ざっくり言うと、こうなります。
| スタック | ヒープ | |
|---|---|---|
| たとえ | 積み上げた本 | 倉庫 |
| 置ける期間 | 関数が動いている間だけ | 好きなだけ |
| 片付け | 自動 | 自分でやる(またはGC任せ) |
| 速さ | 速い | スタックほどではない |
| 大きさ | 小さめ・上限あり | 大きい |
なぜ2つに分かれているのか
「スタックが速いなら全部スタックでいいのでは?」と思うかもしれません。
でもスタックには置けないものがある。ここが2つに分かれている理由です。
スタックは積み上げた本でした。関数が呼ばれると本を1冊積み、関数が終わるとその本を降ろします。
つまり、関数が終わった時点で消えてしまうということです。
関数A が呼ばれる → 本を1冊積む
関数A が終わる → その本を降ろす(中身は消える)
ほとんどの場合はこれで困りません。関数の中で使った作業用の変数は、関数が終われば用済みだからです。
逆にいうと、関数が終わった後も残しておきたいデータがある場合は困ります。
- 関数の中で作ったデータを、呼び出し元に返したい
- あとで別の場所から使いたい
こういうデータは、本を降ろされたら困ります。そのために倉庫(ヒープ)に置くという解決策があります。
つまり「速いからスタックを使う」のではなく、**「関数と一緒に消えていいものだけがスタックに置ける」**というのが先です。スタックが速いのは、置き方が単純だから自然とそうなった結果です。
スタックのしくみ
関数1回分 = スタックフレーム
関数が呼ばれるたびに積まれる「本1冊」のことを、スタックフレームと呼びます。
この1冊には何が入っているのでしょうか。
- その関数のローカル変数
- 関数が終わったらどこに戻るかという情報
- そのほか、処理に必要な細かい情報
「戻り先の情報」が入っているのがポイントです。関数はいろんな場所から呼ばれるので、終わったときにどこへ帰ればいいかを覚えておく必要があります。
1冊の厚さ(フレームのサイズ)は関数によって違います。 ローカル変数が多い関数ほど厚くなります。実際に測ってみると、こうなりました。
void small(void) { int a; /* ... */ } // 変数1つだけ
void big(void) { int buf[256]; /* ... */ } // 1024バイトの配列
small のフレーム: 48 バイト
big のフレーム: 1088 バイト
配列を持っているぶん、big の方が約1000バイト厚くなっています。この厚さが後で効いてきます。
降ろした場所は使い回される
「関数が終わると本を降ろす」と書きましたが、実際には中身を消しているわけではありません。「ここから上は空き」という目印を戻しているだけです。
だから、次に別の関数が呼ばれると同じ場所が使い回されます。
void first(void) { int x = 1; /* x のアドレスを表示 */ }
void second(void) { int y = 2; /* y のアドレスを表示 */ }
first();
second();
first() の変数 x : 0x16b56e7ac
second() の変数 y : 0x16b56e7ac ← 同じ場所!
まったく別の関数の、まったく別の変数が、同じ場所に置かれました。
これがスタックが速い理由です。片付けといっても目印を戻すだけなので、ほとんど時間がかかりません。
そしてこの性質が、後で出てくるバグの原因にもなります。
スタックオーバーフロー
Maximum call stack size exceeded というエラーを見たことはないでしょうか?これがスタックオーバーフローです。
原因はシンプルで、本を積みすぎて天井にぶつかったような状態です。
スタックには上限があります。そのため、関数が関数を呼び、その関数がまた関数を呼び……と積み重なっていくと、いつか上限に達します。
一番よくあるのが無限再帰です。
void recurse(void) {
recurse(); // 自分を呼び続ける = 本を積み続ける
}
「深さ」だけでは決まらない
ここが意外と知られていないポイントです。
同じ回数の再帰でも、落ちる場合と落ちない場合があります。 1冊の厚さが違うからです。
先ほどの small(48バイト)と big(1088バイト)で、同じ上限のまま何回積めるか試してみました。
| 1冊の厚さ | 積めた回数 |
|---|---|
| 薄い(48バイト) | 32,500回 |
| 厚い(1088バイト) | 950回 |
30倍以上の差です。
つまりスタックオーバーフローは「再帰が深いから」起きるのではなく、1冊の厚さ × 積んだ冊数が上限を超えたから起きます。
だから対処法も2通りあります。
- 再帰の回数を減らす(そもそも再帰をやめる、ループにする)
- 1冊を薄くする(関数の中の大きな変数を減らす)
再帰しなくても起きる
更にもう1つ。再帰しなくてもスタックオーバーフローは起きます。
int main(void) {
int a[10000000]; // 約40MBの配列をスタックに置こうとする
}
これは関数を1回呼んだだけで落ちます。1冊が分厚すぎて、そもそも1冊も積めない状態です。
大きなデータをローカル変数で持つと危ないのは、こういったことが発生する可能性が高いからです。
ヒープのしくみ
ここからは倉庫(ヒープ)の話です。
なぜ必要か、実例で
「関数が終わっても残したいデータ」の例を見てみます。
int *make_on_stack(void) {
int value = 42;
return &value; // 変数の置き場所を返す(← 危ない)
}
&value は「value が置かれている場所」という意味です。この関数は、その場所を呼び出し元に教えています。
ところが関数が終わった時点で、その本は降ろされています。しかも先ほど見たように、降ろした場所は次の関数に使い回されます。
実際に動かすと、こうなりました。
スタックに置いたデータ -> 1 ← 壊れている(42のはずが違う値)
ヒープに置いたデータ -> 42 ← ちゃんと残っている
コンパイラも警告してくれます。
warning: address of stack memory associated with local variable 'value' returned
「ローカル変数の場所を返しているぞ」という警告です。
壊れた側に出た 1 という数字に意味はありません。タイミングによって何が出るか分からないのがこの手のバグの怖いところで、運が悪いと 42 のまま見えてしまうこともあります。だから気づきにくいのです。
これを正しく書くと、倉庫(ヒープ)を使います。
int *make_on_heap(void) {
int *p = malloc(sizeof(int)); // 倉庫に場所を借りる
*p = 42;
return p; // 関数が終わっても残る
}
malloc が「倉庫に場所を借りる」命令です。ここに置いたデータは、自分で片付けるまで残り続けます。
メモリリーク
倉庫の代償がメモリリークです。
malloc で借りた場所は、free で返さないといけません。
int *p = malloc(...); // 借りる
/* ... 使う ... */
free(p); // 返す
この free を忘れると、使わなくなった荷物が倉庫に置きっぱなしになります。
実際に試してみました。50MBを20回借りて、返す場合と返さない場合を比べます。
| 使ったメモリ | |
|---|---|
free しない |
約1GB |
free する |
50MBだけ |
free しないと20個ぶんが全部残って約1GBまで膨らみました。一方 free すれば、返した場所が次に使い回されるので、常に1個ぶんの50MBで済みます。
これが積み重なると、動かし続けているうちにメモリを食い潰すわけです。サーバーアプリが数日で落ちる、といった不具合の典型的な原因になります。
GCがあってもリークする
「JavaScript や Java には GC(ガベージコレクション)があるから関係ない」と思うかもしれません。
そんなことはありません。
GC は「もう誰からも使われていない荷物」を自動で片付けてくれる仕組みです。逆に言えば、誰かが使っている限り片付けません。
だから、こういうときにリークします。
- 使い終わったのに、変数がデータを掴んだままになっている
- イベントリスナーを登録したまま外し忘れている
- キャッシュに追加し続けて、消していない
「free を忘れた」か「参照が残っている」かの違いだけで、起きていることは同じです。倉庫に荷物が残り続けています。
おまけ: 本当に別の場所にあるのか
最後に、スタックとヒープが本当に別々の場所なのかを見てみます。
いろいろな変数の「置かれている場所」を表示してみました。
コード(プログラム本体): 0x100d74554
グローバル変数 : 0x100d7c000
ヒープ(malloc) : 0x1013b9e10
スタック(ローカル変数): 0x16f08a778
数字の桁を見てください。ヒープとスタックだけ、大きく離れています。 その差は約1.7GBありました。
さらに、伸びていく方向も逆です。
- スタックは使うほどアドレスが小さくなる(下へ伸びる)
- ヒープは使うほどアドレスが大きくなる(上へ伸びる)
お互いの領域を侵さないように、両端から向かい合って伸びる作りになっています。
高級言語ではどうなっているのか
普段 JavaScript や Java を書いている人にとって、malloc も free も出てきません。では関係ない話かというと、そうではありません。
スタックとヒープという仕組みはどの言語にもあります。ただ、隠されているだけです。
違うのは「どちらに置くかを誰が決めるか」です。
| 言語 | 決めるのは |
|---|---|
| C / C++ | プログラマ(自分で malloc する) |
| Java | JVM |
| JavaScript | エンジン(V8 など) |
| Go | コンパイラが自動で判断する |
自分で書かないだけで、裏では同じことが起きています。だから、
-
Maximum call stack size exceededは、この記事のスタックオーバーフローそのものです - メモリリークも、片付け方が違うだけで同じ現象です
- クロージャが変数を保持し続けられるのは、その変数がヒープに置かれているからです
エラーメッセージを見たときに「ああ、本を積みすぎたんだな」と思えるだけでも、原因の見当がつけやすくなります。
まとめ
- スタックは積み上げた本。関数が呼ばれたら積み、終わったら降ろす。速いが上限がある
- ヒープは倉庫。好きなだけ置けるが、自分で片付ける必要がある
- 2つに分かれているのは速度のためではなく、関数と一緒に消えていいかどうかが違うから
- スタックフレームは関数1回分の「本1冊」。ローカル変数と戻り先の情報が入っている
- スタックオーバーフローは本の積みすぎ。深さだけでなく1冊の厚さも効く
- メモリリークは倉庫の片付け忘れ。GCがあっても、参照が残っていれば片付かない
- 高級言語でも仕組みは同じ。誰が決めるかが違うだけ
普段は意識しない部分ですが、一度イメージを持っておくと、エラーメッセージの読み方が変わります。