すべての開発者が知っておくべきメモリ管理についての知識
プログラミングにおいてメモリ管理は重要な要素の一つですが、その重要性を見過ごされがちなものです。メモリ管理の高レベルな抽象化について、「すべての開発者が知っておくべき要素」としてプログラマーのザカリー・リー氏が解説しています。
Memory Management Every Developer Should Know
https://webdeveloper.beehiiv.com/p/memory-management-every-programmer-know
メモリは「スタック」と「ヒープ」という2つの領域に分かれています。
・スタック
スタックは「先入れ後出し」という特徴を持つデータ構造で、プログラムの関数呼び出しを記録するのに非常に適しています。例えば下図のように「test()」と「main()」という2つの関数があり、main()からtest()を呼び出す場合を考えてみます。
コードが「entry frame」として実行され、関数が実行されるたびにフレームと呼ばれる部分がスタックに割り当てられます。フレームには当該関数の汎用(はんよう)レジスタとローカル変数のコンテキスト情報が格納されています。
main()がtest()を呼び出すと、CPUで実行中のプロセスが一時的に中断され、main()の汎用レジスタのコピーがスタックに保存されます。test()の実行が終了すると、何も起こらなかったかのようにレジスタは元通りになります。このように関数が呼び出されるごとにスタックが追加され、呼び出しが終了すると関数フレームによって占有されていたメモリが解放されるわけです。
スタックにおいて「どの程度のメモリを確保するのか」はコードをコンパイルする際にコンパイラーが決定しています。したがって、コンパイル時にサイズを決定できないデータやサイズを変更できるデータはスタック領域に配置することができません。
・ヒープ
可変長配列のようにプログラムが動的にメモリを割り当てる必要がある場合、データはヒープ領域に配置されます。どれくらいメモリを使用するのか不明なため、最初に一定量のスペースを確保しておき、実際の配列のデータが事前に確保していた領域を超えてしまう場合にはより大きなメモリブロックを割り当て直し、既存の要素をコピーして古いメモリを解放するという手順でメモリのサイズを動的に変更しています。
動的に配置されるデータのほか、スタック全体から参照する必要があるデータもヒープ領域に配置する必要があります。スタックのメモリは関数の実行終了時に解放されますが、ヒープのメモリは解放するタイミングが難しく、プログラミング言語によって扱いが異なります。
初期のC言語ではメモリの確保や解放をすべてプログラマーが手動で管理する必要がありました。手動で管理することで細かくメモリを制御できるという利点はあるものの、常に見落としが発生する可能性があり、プログラムの実行が遅くなったりクラッシュしてしまったりして危険でした。
Javaを始め、多くの主要なプログラミング言語ではTracingガベージコレクションが採用されており、参照されなくなったオブジェクトを定期的にマークしてクリーンアップすることで自動でメモリを解放しています。ただし、ガベージコレクションが動作する際にはプログラムが一時停止するSTW(Stop The World)が発生するためリアルタイム要件の高いシステムではガベージコレクションはあまり使用されません。
AppleのObjective-CとSwiftではARC(自動参照カウント)が使用されています。ARCではコンパイル時に関数ごとに保持・解放ステートメントを挿入し、ヒープ上のオブジェクトの参照カウントを自動的に維持しています。参照カウントが0になったオブジェクトは解放できるというわけです。欠点として、参照カウントの処理のために多くのコードが追加されるためガベージコレクションよりも効率やスループットは低下します。
Rustはヒープ上のデータのライフサイクルとスタックフレームのライフサイクルを結びつける所有権メカニズムを使用しています。スタックフレームが廃棄される際にヒープ上のデータもあわせて廃棄され、占有されていたメモリが解放されます。
まとめると、スタック領域に格納されるデータは静的で、サイズやライフサイクルが固定されており、スタック全体からデータを参照することはできません。一方ヒープ領域に格納されるデータは動的で、サイズやライフサイクルも自由に変更でき、さらにさまざまなスタックから参照することができます。