こんにちは。田原です。
前回までに説明したプログラム用メモリ、静的変数用メモリ、スタック用メモリは、基本的にコンパイル時にサイズが決まります。もし、これらの領域しか使わない場合、巨大なメモリを搭載しているコンピュータでプログラムを走らせても、その巨大なメモリを有効活用できません。それは悲しいですよね。
その巨大なメモリをうまく使うための仕組みが「ヒープ用メモリ」です。今回は、このヒープとその使い方について解説します。
第4回目 コンピュータの仕組みについてで、メモリとそのアドレス、そしてポインタについて少し説明しました。これらはたいへん重要ですので、軽く復習します。
int a; int* b=&a;
上記のようにint型の変数aがあったとします。その変数aはどこかのメモリ上に割り当てられますので、アドレスが割り振られています。その先頭アドレスを取り出す演算子が & でした。つまり、 &a にてaに割り当てられているメモリの先頭アドレスが取り出されます。そして、そのメモリ・アドレスを記録する変数bがポインタです。
Visual C++で32ビット・ビルドした場合の例
(アドレスが32ビット長なので、ポインタも32ビット=4バイトあります。)
gccで64ビット・ビルドした場合の例
(アドレスが64ビット長なので、ポインタも64ビット=8バイトあります。)
メモリを獲得するために、new演算子を使います。new演算子には「型」を指定することでその型を記録できるメモリを割り当て、その先頭アドレスを返します。
int* b=new int;
1.ポインタの復習で出てきたポインタbには変数aのアドレスを設定していました。
しかし、今回は変数を定義せず、new演算子でint型変数を記録できるメモリを確保している点が最大の相違であり、決定的な相違です。
さて、newするということは、なんとなく無から変数を生成しているように見えますね。
しかし、ご存知のようにメモリはハードウェアです。プログラムで無から生成することはできません。当たり前ですね。
ということは、newは何か作っているわけではありません。メモリを借りて来て変数を割り当て、その先頭アドレスを渡してくれます。
そして、コンピュータに搭載されているメモリは限り有る資源ですから、どんどん借り続けるといつか枯渇してしまいます。ですので、当たり前ですが使い終わったらちゃんと返すことが大事です。その時は受け取っていた先頭アドレスを渡して返却します。
メモリを「獲得する」とか「取ってくる」とよく表現するのですが「借りてくる」の方が分かりやすいかもしれませんね。
そして、メモリを返す時はdeleteを使います。 delete ポインタ;
と書きます。この場合のポインタは右辺値です。中身のアドレス値が使われます。
int* b=new int; // ここでbを使った処理 delete b;
newはメモリを借りてきますし、deleteはメモリを返却します。ちょっと解りにくいですね。しかし、C++のnewはメモリを借りてくるだけでなく、そのメモリの上に「オフジェクト」と呼ばれるデータ構造の構築処理を行うことができます。deleteはメモリを返す前にその「オブジェクト」の解体処理を行うことができます。なので、このようなネーミングになっているのだと思います。構築処理(コンストラクタ)や解体処理(デストラクタ)については後日クラスにて解説します。
なお、当講座では、newを獲得や生成、deleteを解放と表現します。
メモリ・リーク
実際にnewとdeleteを、何の工夫もなく使うと解放し損なうことが多々あります。例えば上記の「ここでbを使った処理」の途中で関数を抜けたような場合、delete b;が実行されません。また、もしも、ここで再度b=new int;してしまうと以前のbに入っていたアドレスが上書きされて失われるため、解放しようとしても解放できません。
このようにしてメモリを解放し損なうことをメモリ・リークやリソース・リークと言います。水筒に穴が開いていて水が漏れているような感じです。
メモリ・リークをやらかしてしまうと自分自身を含めて多くの人に多大な迷惑をかけるかも知れませんので、注意が必要です。メモリ・リーク不具合は顕在化するまでに時間がかかることがありますし、デバッグも難しいからです。このようなメモリ・リークをスマートに防げるスマート・ポインタや可変長配列、線形リスト、ハッシュ・リスト等のコンテナなど、便利な仕組みがC++では提供されています。これらを積極的に使うことでメモリ・リークのリスクを大きく軽減できます。(これらはC言語に対するC++の大きな優位性の1つと思います。)
そして、第6回目で説明した「左辺値」や「右辺値」を理解していると、これらを使ってより効率のよいプログラムを書けるようになります。
当講座ではステップを踏んで徐々に解説を進めています。
さて、先述したようにnewでメモリを獲得してくることができますが、「どこから」獲得してくるのでしょう?
貸し出すメモリをストックし、解放された時そのストックへ戻して次の貸し出しに備えるための場所があれば良いですね。
それが「ヒープ」です。第5回目冒頭の図では「ヒープ用メモリ」と記載しています。
ヒープ(heap)と言う単語はスタック(stack)と同様に山積みの意味ですが、スタックに較べて「大量」で「乱雑」なニュアンスを含んでいます。
スタックは下から順に獲得し上から順に解放するため、原則として隙間なく使われます。しかし、ヒープは解放される領域がヒープの最下位部分とは限らないため、ヒープ領域には隙間ができます。なので「乱雑」なニュアンスを含む用語を使ったのではないかと思います。
昔、PCで同時に実行できるプロセスが1つしかなかった時代があります。(*1)
その頃は、下図のようなメモリの使い方をしていました。
プロセスが1つしかないということは、コンピュータに搭載されているメモリを専有できました。そして、スレッドが1つしかないということは、スタックも1つしかありません。そのため、このように単純にメモリを配分して使うことができたのです。
現在は、マルチ・プロセスですからメモリを専有できず他のプロセスと共有します(*2)。また、マルチ・スレッドですので、スタックは複数あります。それに対応するため、メモリの使い方は拡張されています。しかし、基本的な考え方は同じですし、イメージしやすいと思いますので参考にして下さい。
(*1)PCで同時に実行できるプロセスが1つしかなかった時代
聞いたことがある人もいると思いますが、昔MS-DOSと言う簡易的なOSがPCの主流だったころまでの話です。
初期にメジャーだった簡易OSはデジタル・リサーチ社のCP/Mです。汎用大型コンピュータで最大手のIBMがバーソナル・コンピュータを発売する際にCP/Mを搭載しようとしたけど商談が成立せず、マイクロソフト社のMS-DOSを採用したのが、現在のPC(IBM PC/AT互換機)のルーツです。
(*2)他のプロセスとメモリを共有する
OSがメモリを管理し、各プロセスのヒープを管理するメモリ・マネージャ(標準ランタイム・ライブラリに含まれています)が、OSから大きな単位でメモリを借りたり返したりし、大きな単位(例えば4KBytes単位)で借りてきたメモリを、ユーザ・プログラムに小分けして貸し出します。
C++で大量のメモリを獲得するのは一般に配列です。例えばchar型で要素数が1,024+1,024個の配列は1MBytesのメモリを使います。
配列の詳しい話については後日解説しますが、配列をnewする手順は簡単です。
ポインタ変数 = new 型名[要素数];
です。
char* p0 = new char[1024*1024];
下記のように書いてもほぼ同じです。(微細な差はありますが、ここでは重要ではないので割愛します。)
char* p0; p0 = new char[1024*1024];
そして、new 型名[N];
で獲得していたメモリを返す方法は、delete[] ポインタの値;
です。このポインタも右辺値です。
delete[] p0;
C++では、ポインタは配列を指すこともできますし、1つの変数領域だけを指すこともできます。そのどちらを指しているか、文法的には判断できません。(ここがポインタの理解し辛い部分の1つと思います。)
そのため、new 型名;
で1つだけ獲得したものを解放するのか、もしくは、new 型名[N];
で配列として獲得したものを解放するのか、指定する必要があります。
つまり、下記のように対応付けるて解放することを忘れないようにして下さい。
- new 型名;で獲得した時は、delete ポインタ;で解放する。
- new 型名[N];で獲得した時は、delete[] ポインタ;で解放する。
また、もう一点注意事項があります。
- new 型名[N];で獲得したものは小分けして解放できない。
つまり、delete[0] &(p0[10]);
のように獲得した配列の途中や一部を解放することはできません。獲得したものを獲得した時と同じ単位でまとめて解放するようにして下さい。
std::vector
を使おう
このように配列を獲得する場合、約束事が多く使い勝手がたいへん悪いです。
std::vectorには、配列を獲得したり、解放したりする手続きを持ち、更に獲得している配列の要素数も管理してくれます。プロセス実行中に要素数が決まる/変わる配列を使う場合は、std::vector をお勧めします。
newでメモリを獲得してきますが、メモリは限り有る資源です。足りなくなったらどうなるでしょう?
この状況は実は2つあります。
- 物理的な空きメモリがない時
- アドレス空間に空きがない時
前者は、例えば1GBytesメモリが搭載されているコンピュータで1GBytesのメモリが既に割り当てられてしまい、新たに割り当てることができない状態です。
最近のPCで採用されているOS(WindowsやLinux)は仮想記憶システムを搭載しています。これは物理メモリが不足したら、あまり使っていない物理メモリをハードディスクへ退避し、物理メモリの空きを作って割り当てます。
その退避領域はページ・ファイルやスワップ・ファイルと呼ばれていますが、その上限まで使い切って初めて物理メモリの空きが無くなります。
後者は、32ビット環境なら比較的簡単に発生します。プロセスにメモリが割り当てられますが、32ビット・ビルドされたプロセスはポインタが32ビットしかありません。つまり、アクセス可能なメモリは32ビットでアドレスを割り振れることができるメモリに限定されます。
32ビットでアドレス可能なメモリは1バイト単位でアドレスする場合、4GBytesです。
このアドレス空間を全て使い切ってしまうと、物理メモリに空きがあっても、それ以上メモリをプロセスに割り当てることができません。
そして、これは64ビット環境でも同じですが、4ギガ・バイトの4ギガ倍と言う途方もないメモリを搭載したPCは今は存在しませんので、実際に発生することはありません。ムーアの法則が今後も成り立ったとしても今後の数十年間では64ビット環境でメモリ空間が不足することはないでしょう。
C言語を知っている方ならnewした時メモリ不足ならNULLが返るのではないかと思うかも知れません。特殊なことをすればNULL(C++11以降はnullptr)を返すことも可能ですが、通常はstd::bad_allocという「例外」が発生します。
「例外」についてはまた後日解説しますが、ここではNULLが返るわけではないことを把握しておいて下さい。
実験してみましょう。
上記の2.のアドレス空間に空きが無い時は簡単に実験できます。
単純にアドレス可能なメモリの最大サイズをnewすれば確実にメモリ不足が起きます。
アドレス可能なメモリの最大サイズの指定方法ですが、2つの機能を使うことでできます。
- uintptr_t
これはその処理系で使われるポインタの値を記録できる符号なし整数型をtypedefしたものです。
32ビットでビルドされた場合は、32ビット(以上)の符号なし整数型です。
64ビットでビルドされた場合は、64ビット(以上)の符号なし整数型です。 -
std::numeric_limits<T>::max()
型Tが表現できる最大値を返します。
従って、std::numeric_limits<uintptr_t>::max()
は、その処理系のポインタが表現できるアドレスの最大値以上の値になりますので、これだけのメモリを要求すれば必ずメモリ不足になります。
「例外」は、try-catch構文で受け取ることができます。catchでは受け取りたい例外を指定します。(指定していない例外が発生した場合はプログラムが異常停止します。)
以下、サンプルです。実行結果まで一気に示します。
#include <iostream> #include <limits> #include <exception> int main() { std::cout << std::hex << std::numeric_limits<uintptr_t>::max() << std::endl; try { char* temp=new char[std::numeric_limits<uintptr_t>::max()]; std::cout << temp << "\n"; } catch (std::bad_alloc& e) { std::cout << e.what() << "\n"; } return 0; }
project(new-exception) if(MSVC) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc") else() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++11") endif() add_executable(new-exception new-exception.cpp)
ffffffff bad array new length
ffffffffffffffff std::bad_alloc
スタックもヒープも先述したように、プログラム実行中に領域サイズが変わります。このようなことを「動的に領域サイズが変わる」と表現することが多いです。逆にプログラム用メモリや静的変数用メモリ(static領域)はコンパイル時に領域サイズが決まり以後変化しません。このようなことを「静的に領域サイズが決まる」と表現します。
実行中に変化するものを「動的」、コンパイル時に決まってしまうものを「静的」と表現します。
さて、スタックとヒープはどちらも動的に領域サイズが変わりますが、領域の使い方が全く異なります。
スタックは下(アドレスが高い方)から順に使っていき、解放する時は上からと決まっています。ですので、スタックは原則としてギチギチに詰めて隙間なく使われます。(コンピュータのハードウェアの都合上少しの空き領域が入ることはありますが、その領域は原則として再割り当てされません。)
ヒープはプログラムから要求される度にメモリを使用中としてマークし、それを貸し出します。
そして、解放される度に使用中マークを外し、貸し出し可能領域として記録します。
更に解放順序はプログラムの自由です。複数の領域A, B, CをA→B→Cの順序で獲得したとき、B→C→A等の全く無関係な順序で解放しても問題ありません。
どちらも注意深く使う必要がありますが、特にヒープは注意が必要です。
- スタック
関数から戻る時や定義されたブロックを抜ける時に解放されます。使っていようがいまいが、それはもう強制的に解放されます。つまり、解放後も使い続けようとすると不具合になります。
その時、処理系がエラーを出してくれればよいのですか、残念ながらエラーさえでないことがあります。
経験的にはテストをキチンと行っていれば比較的検出されやすい不具合です。しかし、この不具合が見つかった時、大きな設計変更に至る可能性があるので、ポインタや参照(*3)がスタック領域(ローカル変数や実引数)を指す時は、ローカル変数や実引数の解放後にアクセスしないよう注意深く設計して下さい。 -
ヒープ
ヒープは強制的に解放されると言うことはありません。プログラマが解放するまで解放されませんので解放タイミングを自由にコントロールできます。
しかし、獲得した領域へのポインタに記録されていたアドレスを失ってしまうと解放できません。もう使ってないのだから勝手に回収してくれれば良いのにと思っても残念ながら解放してくれません。ほっておくとメモリ・リークになります。
メモリ・リークは潜伏期間のある不具合です。通常の機能テストを行っただけでは検出できないため、発生しないよう非常に注意深く設計し、できれば更にツールを使ってテストすることが望まれます。
先に述べたスマート・ポインタやコンテナ等を使ったり、RAIIパターンと呼ばれる確実にリソースを返却できる仕組みを使ったりする等の対策で、メモリ・リークのリスクを大幅に削減できます。
(*3)参照
一種のポインタですがポインタに較べて機能が大幅に制限されているため、細かい振る舞いがかなり異なります。
機能が制限されている分より安全に使えますが、ポインタより複雑なので少しずつ解説していきたいと思います。
今回は、主にヒープに焦点を当てて解説しました。これでC++が取り扱う主要なメモリ(プログラム用、静的変数用、スタック用、ヒープ用)については一通り解説したことになります。(他にも様々なバリエーションが有りますが基本的な振る舞いは以上の4つの応用です。)
メモリの種別 | 特徴 | 静動 |
---|---|---|
プログラム用メモリ | プログラムの機械語コードや定数を記録します。 ハードウェア的に可能な時は書き込みできないようになっています。 |
静的 |
静的変数用メモリ(static領域) | プログラムの開始から終了まで有効な変数領域です。 | 静的 |
スタック用メモリ | 関数の戻り先と実引数、ローカル変数を記録する変数領域です。 | 動的 |
ヒープ用メモリ | new/delete, new ~[N]/deleteで獲得するメモリ領域です。 | 動的 |
C++がC#やJavaと大きく異なる点は、このヒープ領域の使い方です。
C#やJavaはヒープから借りてきたメモリは、誰も使わなくなってからガベージ・コレクション処理中に解放されます。つまり使っている限り解放されません。
C++は逆です。明示的に解放しない限り解放されませんが、解放したら誰かが使っていても解放されます。ちょっと危険な感じがします。誰も使っていないことをプログラマが保証しないといけないのですが、保証しそこなうと頭の痛いバグになるからですね。
でも、その分、省資源で高速なプログラムを書くことができますし、各種スマート・ポインタやコンテナ等を使えばリークのリスクを最小限にすることが可能です。これらを安易に使うとコピーが頻発するので遅くなりますが、これら4種類のメモリと左辺値や右辺値について適切に理解し、使いこなすことで高速性を維持できます。今後、徐々に解説していきます。
さて、次回は1週お休みして、過去の投稿について少し整理したいと思います。
その次からの2~3回で、組み合わせ型(ユーザ定義型)について解説します。enum型、構造体、配列、ポインタ、参照です。構造体はC言語レベルの構造体の解説を予定しています。クラスについてはもう少し先で解説します。
それでは次回をお楽しみに。
追記
実はここまでで解説した4大メモリ以外にもう一つ領域があります。スレッド毎に存在するstaticな領域であるthread_local領域です。C++11にて新規に定義されました。マルチ・スレッド・プログラミングを行う場合に有用な領域です。ただ入門講座でマルチ・スレッドまで解説するのはちょっと厳しいので入門では扱いません。