メモリ使用状況の解析
この節の要点
- プログラム内で使われているオブジェクトが消費しているメモリ量について、全体でどのようなクラスオブジェクトが消費しているのかあるいは特定のクラスオブジェクトがどのメソッドでよく利用されているか測定するためには、プルダウンメニュー
セッションの構成からオブジェクトを選択し、プロファイルボタンを選択する。- Javaでは、プログラム実行時にユーザの動作に応じて適宜作成した動的なデータ(参照型)が不要になった際にメモリ上から除去する操作をガーベジコレクションと呼ばれる言語機能が自動で行われる形で実現している。
- 特定のオブジェクトが消費されているメソッドを測定するためには、プログラム全体のクラスオブジェクトの消費量を測定した後、測定結果のクラスオブジェクト一覧から希望するクラスオブジェクトを選択して右クリックメニューの
プロファイル・クラスを選ぶことで実施できる。- 特定のオブジェクトが消費されているメソッドを測定した結果における世代数とは、そのオブジェクトがメモリ上に残っている間に行われたガーベジコレクションの回数を表す。
- メモリリークとは除去する必要があるデータが除去されずにメモリ上に残り続ける現象のことであり、世代数が大きければ大きいほどメモリリークしている可能性が高いといえる。
プログラムにおいて、なんらかの値を変数に割り当てたり、プログラムを実行するために必要な情報を保存したりする際に、メモリ上の空間を利用します。 このようにしてメモリ上に割り当てられた値は、基本的に、スコープ(変数の参照可能・有効範囲)から外れたときにメモリから取り除かれます。しかし、これは値型に限った話で、参照型はこの限りではありません。
C言語の場合を思い出してみましょう。
図22に示すプログラムは、引数で渡された2つの値を加算する関数sumの利用例です。関数sumは、加算した結果の値をメモリ上のアドレスで返却します。このプログラムの実行結果を図23に示します。図23を見て分かるようにこのプログラムは実行時にエラーを出力します。
#include<stdio.h>
int* sum (int a, int b) {
int c = a + b; // 変数cにaとbを加算したデータを割り当てる
printf("*関数sumの中にて\n")
printf("メモリアドレス: %p\n", &c);
printf("割り当てられたデータ: %d\n", c);
return &c; // 変数cに割り当てられたデータのメモリ上のアドレスを返却
}
int main (void) {
int* x = sum(1, 2);
printf("\n*関数sumの外にて\n")
printf("メモリアドレス: %p\n", x);
printf("割り当てられたデータ: %d\n", *x); // Segmentation faultエラー
}
*関数sumの中にて
メモリアドレス: 0x7fff2a6ecb6c
割り当てられたデータ: 3
*関数sumの外にて
メモリアドレス: (nil)
Segmentation fault (core dumped)
図の関数sumは、内部で定義された変数cに割り当てられた値のメモリ上のアドレスを返却します。変数cのスコープは定義した直後から、ブロック・あるいは関数から脱出するまでです。従って、関数sumを脱出した後、変数cに割り当てられたデータは取り除かれるため関数sumの外で呼び出すことはできず、図の実行結果の様に19行目でSegmentation faultエラー(データを取り出そうとしたアドレスが不正)となります。
ですが、次の図のプログラムを見てください。このプログラムは図22のプログラムと処理内容についてはほぼ同じものです。しかし、関数sumで変数cがデータそのものでなくint型のデータ領域のアドレスを保持している(ポインタである)点が異なります。図24のプログラムは、図25の実行結果を見れば分かるように、Segmentation Faultが起こることなく、プログラムが実行できています。
#include<stdio.h>
#include<stdlib.h>
int* sum (int a, int b) {
// メモリ上にint領域を確保しそのメモリアドレスを変数cに割り当てる
int* c = (int*)malloc(sizeof(int));
*c = a + b; // 変数cのアドレスが指し示す領域にa+bの結果を割り当て
printf("*関数sumの中にて\n")
printf("メモリアドレス: %p\n", c);
printf("割り当てられたデータ: %d\n", *c);
return c; // 変数cに割り当てられたデータのメモリ上のアドレスを返却
}
int main (void) {
int* x = sum(1, 2);
printf("\n*関数sumの外にて\n")
printf("メモリアドレス: %p\n", x);
printf("割り当てられたデータ: %d\n", *x);
}
*関数sumの中にて
メモリアドレス: 0x2324010
割り当てられたデータ: 3
*関数sumの外にて
メモリアドレス: 0x2324010
割り当てられたデータ: 3
図24のプログラムにおいて、関数sumにて関数mallocを使って変数cに割り当てられた領域は、プログラムが実行された時の初期化処理で最初に確保しておくメモリ領域ではなく、動的に確保される(プログラムが実行時に決定する)任意の大きさのメモリ領域になります。
この関数mallocを使って確保されたメモリ領域はプログラムが終了するか、プログラム中で意図されたメモリ領域を解放する命令を実行するまで、メモリから取り除かれることがありません。
従って、図のプログラムにおける関数sumは実行すればするほど、追加でメモリ領域を確保していくことになり、プログラムが実行中に利用しているメモリ領域はどんどん大きくなります。
メモリ領域は、OSが管理している共有リソース領域であり、OSが同じく動作管理をしている他の複数のプログラムも利用しています。よって、なるべく他のプログラムの実行の妨げにならないようにプログラムで利用するメモリ領域の利用は適宜抑えておく必要があります。
ですから、基本的に利用したメモリ領域のうち、その後利用することがないものについては、先に述べたメモリ領域を解放する命令を意図して実行しなければなりません。図26に利用したメモリ領域を解放する命令を追加した関数mainの例を示します。C言語においては、関数freeが、渡されたアドレスが指し示すメモリ領域を解放する命令になります。
int main (void) {
int* x = sum(1, 2);
printf("\n*関数sumの外にて\n")
printf("メモリアドレス: %p\n", x);
printf("割り当てられたデータ: %d\n", *x);
free(x); // xに関数mallocを使って割り当てられたメモリ領域を解放
}
図26に示すように、C言語では手動で動的に確保したメモリ領域は、適宜手動で解放するようプログラムを組む必要があるわけです。 単純なプログラムであれば、そこまで難しい話ではないですが、プログラムの規模が大きくなっていくと、こうしたしなければならない解放処理の記述が膨大な量になってしまい煩雑になってきます。 そうなってくると、メモリの解放し忘れ(メモリリーク)や、二重で解放処理を行ったり(メモリ領域に意図しない修正を加えてしまうバグにつながる[1])、といったミスが出ないとも限りません。
Javaは、このような解放処理をガーベジコレクション(Garbage Collection) と呼ばれる自動化された言語機能として持ちます。 ガーベジコレクションは自動的に不必要と判断したメモリ領域を開放するため、プログラマはメモリの解放処理を意識することなくプログラムを記述することができます。
Javaにおいては、new演算子がC言語のmallocの役割を含み、C言語のポインタ型に相当する型が参照型になります。すなわち、オブジェクトは動的に確保されたメモリ領域にあるデータであり、オブジェクトを作れば作るほどメモリ領域を消費していくことになるわけです。
これらオブジェクトを自動的に解放するために、ガーベジコレクションは、メモリが不足していたり、何も処理していなかったりする時に、実行されます。
具体的には、プログラムが実行された時、(内部的な)初期化処理で利用されるメモリ領域にあるオブジェクトを順に見ていきます。 ここで、オブジェクトは内部で別のオブジェクトを必ず参照しているはずです。また、その別のオブジェクトも更に別のオブジェクトを参照しています。プログラムが現時点で利用しているオブジェクトは、参照関係が木構造のように繋がっています。この木構造から漏れたオブジェクトを全てメモリから解放することで、不必要なメモリ消費をなくすというわけです。
Javaのプログラムを作る際には、ガーベジコレクションがあるため、先に述べた通り、メモリ領域を開放することをあまり意識せずに、オブジェクトを組み合わせて処理を構築できてしまいます。これは逆に言えば、本来、解放すべきメモリ領域を無駄に保持してしまう(メモリリークしてしまう)オブジェクトが作れてしまうことを意味します。メモリ領域を無駄に消費するプログラムが容易に作れてしまうのです。
プロファイラには、プログラムがどの程度メモリを消費しているか可視化することができる機能があります。この機能を使うことで、メモリリークがどこで発生するのかある程度判断することができ、メモリ消費量を抑えるプログラムを作ることに繋げることができます。
先に述べたプロファイリングの構成と開始タブのセッションの構成プルダウンメニューからオブジェクトを選択すると、プログラムが利用しているオブジェクト数・メモリ消費量などメモリ使用状況の解析ができるオブジェクトタブ(図27)を開くことができます。

ここから、CPUパフォーマンスの解析の節と同様に、第5週の課題5のプログラムを元にプロファイリングしてみたいと思います。第5週の図10のシミュレーションフローと、図44のシミュレーションフローのメモリ利用の差を見てみましょう。
解析を開始する前に、サンプリングするメソッドの範囲を指定したほうが結果が見やすくなります。右上端にある設定ボタンを選択すると、図27のようにプロファイルプルダウンリストが表示されます。ここからプロジェクトのクラスを選びます。

解析を開始するためには、メソッドタブのときと同様に、オブジェクトタブの左上にある緑三角のプロファイルボタンを選択します。プロファイリングが完了した際の実行結果例を図28に示します。

CPUパフォーマンスの解析の節と同様に差分を求めることができ、同様の手順で図30のような結果を求めることができます。

図29の結果と図30の誤差を見る限り、図30においては第5週の図44のシミュレーションフローの方が軽量である傾向が出ています。ですが、第5週のシミュレーションプログラムでは、乱数を使ったオブジェクトの生成方法を行っているため、メモリ使用率が一定ではありません。従って、厳密に比較することがが難しいですが、試行回数を増やして平均を取ることである程度見積もることができます。よって、メモリにおいては、シミュレーションフローの差があまり現れないと言えます。
メモリの使用状況をプロファイリングすることで、メモリリークを見つける(推定する)試みをここで行ってみましょう。
ポイントとして、ライブ・オブジェクト数の割合が大きいものに注目することが重要です。
ここで、ライブ・オブジェクトとは、プログラムによって必要とされているオブジェクトのことです。例えば、図29においては、Emptyオブジェクトのライブオブジェクト数が90%で過半数を超えているため、疑う余地があります。
Netbeanプロファイラでは、クラスを指定してライブオブジェクトを更に追跡することができます。図31のように、スナップショットで絞りたい項目を右クリックして、プロファイル・クラスを選択してください。ここではEmptyオブジェクトを選択した状態で右クリックメニューのプロファイル・クラスを選択したと仮定して話を進めます。

すると、プロファイルプルダウンリストにて、選択したクラスが選ばれた状態になり、図32のように表示されます。ここではライブオブジェクトのトラッキングのチェックボックスにチェックを入れてください。

図32の状態で、再度緑三角のプロファイルボタンを押して、プロファイリングをしてみてください。図33に示すEmptyオブジェクトの内部のスナップショットを得ることができます。

図33を見ると、新たに、割り当てオブジェクト数および世代数の項目が増えていることが解ると思います。ここで注目してほしいのが 世代数 です。この数値は、そのオブジェクトがメモリ上に割り当てられている間にガーベジコレクションが行われた回数を示します。従って、世代数はオブジェクトが長期に渡って生存しつづけることで上昇します。今回のEmptyオブジェクトについては、世代数が1であり、他のオブジェクトと比較しても飛び抜けた数的な違いがないため、リークしてない、と判断できます。
ですが、たとえば、他のオブジェクトの世代数が1なのにも関わらず、あるオブジェクトだけ10ある、といった世代数の差は、メモリリークの兆候になります。
[1]: CWE - CWE-415: Double Free (4.2) (20/08/24 閲覧)
[2]: @IT:Java開発の問題解決を助ける(2) (20/08/24 閲覧)