オブジェクト,文字列,配列が作成されるとき,それを格納するのに必要なメモリは ヒープ と呼ばれる中央集約的なプールから割り当てされます。そのアイテムがもはや使用されなくなると,占有されたメモリは何か別のもののために確保することが出来ます。過去は一般的にヒープメモリブロックを割り当てて解放することを,適切な関数コールを通じて明示的に行うことはプログラマ自身の責任でした。最近では Mono エンジンのような ランタイムシステムががメモリ管理を自動化しています。自動メモリ管理では,明示的に割り当て/リリースするよりコーディングの労力が大幅に削減され,かつメモリリーク(メモリが割り当てされたけど後続でリリースされない状況)の潜在的な可能性を著しく下げます。
関数がコールされると,引数の値はその特定のコールのために予約されたメモリエリアにコピーされます。数バイトを占有するデータ型は速やかに間単にコピーが出来ます。しかし,オブジェクト,文字列,配列がそれよりも大きいことは良くあることであり,もしこれらのデータ型が定期的にコピーされることは非常に非効率的です。幸いにこれは必要ありません。大きなアイテムのための実際のストレージのスペースはヒープから割り当てされ,小さな “ポインタ” 値がロケーションを覚えるために存在しています。その先は,ポインタのみがパラメータ渡しの際に必要となります。ランタイムシステムがポインタにより識別されるアイテムを見つけられるかぎり,データのひとつのコピーは何回でも使用することが出来ます。
パラメータ渡しのときに直接格納されてコピーされる型は値型と呼ばれます。これらは整数,浮動小数点数,論理型,およびUnity の構造体型(例えば,Color および Vector3)も含みます。ヒープに割り当てられて,ポインタを通してアクセスされる型は参照型と呼ばれ,変数に格納されている値はあくまでも実際のデータへの “参照” です。参照型の例にはオブジェクト,文字列,および配列が含まれます。
メモリマネージャがヒープの中で未使用であると認識している領域をトラッキングします。新しいメモリブロックが要求されるとき(例えばオブジェクトがインスタンス化された),マネージャはブロックを割り当てる未使用領域を選択し,未使用と認識している割り当てされたメモリを取り除きます。後続のリクエストは,要求されたブロックサイズを割り当てるために十分な空き領域がなくなるまで,同じ方法でハンドリングされます。この時点ではピープに割り当てられた全てのメモリが使用されていることは極めて稀です。ヒープ上の参照されるアイテムは,まだそれを参照できる参照変数が存在するかぎりでしか,アクセスできません。もしメモリを参照する全てのメモリブロックがなくなったとき(すなわち参照変数が再度割り当てされたか,スコープ外となったローカル変数である場合)は,占有していたメモリは安全に再割り当てできます。
どのヒープブロックがもはや使用されていないか判断するために,メモリマネージャは全ての現在アクティブな参照変数を検索して,ブロックを “活動中” としてマーキングします。検索の最後に活動中のブロックの間の空間はメモリマネージャにより空いていると判断され,後続の割り当てで使用することが出来ます。明らかなことですが,未使用のメモリの検索および解放はガーベージコレクション,略して GC と呼ばれます。
ガーベージ コレクションは自動的であり,プログラマにとって目に見えませんが,コレクションプロセスは実際には裏で顕著に CPU 時間 を必要とします。正しく使用されたとき,自動メモリ管理は一般的に手動割り当てと同等またはそれ以上の全体パフォーマンスが得られます。しかし,プログラマにとって重要なことととして,コレクターを必要以上にトリガーし,実行中にポーズするとうい誤りを避けることがあります。
いくつかの悪名高いアルゴリズムにより, 一見では問題ないようにみえてGC の悪夢となります。繰り返しの文字列連結は昔からある事例です:-
function ConcatExample(intArray: int[]) {
var line = intArray[0].ToString();
for (i = 1; i < intArray.Length; i++) {
line += ", " + intArray[i].ToString();
}
return line;
}
ここで鍵となるポイントは,新しい部分が文字列の正しいところにひとつひとつ追加されないということです。実際にループするたびに行われることは,line 変数の前回の中身が消滅します - もとの部分に新しい部分を加えたまったく新しい文字列が格納されます。文字列は 1 づつ値が増加されるため,消費されるヒープの量もまた増加し,この関数がコールされるたびに数百バイトの空きヒープ領域を容易に占有します。もし多くの文字列を連結するとき, Mono ライブラリの System.Text.StringBuilder クラスを使用したより良い例があります。
しかし,繰り返しの連結ですら,頻繁にコールされないかぎりそこまでの問題を引き起こしませんが,Unityでその頻繁となりうる要因は暗黙的にはフレーム更新です。次のような場合です:
var scoreBoard: GUIText;
var score: int;
function Update() {
var scoreText: String = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
…このコードで各々の Update がコールされるたびに新しい文字列が割り当てされ,新しいガーベージがつねに生成されます。そのほとんどはスコアが変更されたときのみテキストを更新することで節約できます:
var scoreBoard: GUIText;
var scoreText: String;
var score: int;
var oldScore: int;
function Update() {
if (score != oldScore) {
scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
oldScore = score;
}
}
もうひとつの潜在的な問題は関数が配列の値を戻すときに発生します:
function RandomList(numElements: int) {
var result = new float[numElements];
for (i = 0; i < numElements; i++) {
result[i] = Random.value;
}
return result;
}
この種の関数のは,値で埋められた新しい配列を作成するときに,非常にエレガントで便利です。しかし,繰り返しコールされると,真新しいメモリが毎回割り当てられます。配列は非常に大きくなりえるため,空きヒープ領域があっという間に使い切られることがあり,結果的に頻繁にガーベージコレクションが行われます。この問題を回避する方法は配列が参照型であることを活用することです。関数に引数として渡される配列はその関数の中で更新することが出来て,結果は関数が戻った後も残ります。前述のような関数は,多くの場合に次のような関数で置き換え出来ます:
function RandomList(arrayToFill: float[]) {
for (i = 0; i < arrayToFill.Length; i++) {
arrayToFill[i] = Random.value;
}
}
これにより単に既存の配列の中身を新しい値で置き換えます。これにより初期割り当てがコールする元のコードの中で行われることが必要となるにも関わらず(ややエレガントでなくみえますが),関数はコールされるときに新しいガーベージを生成することはなくなります。
前述のとおり,できるかぎり割り当てをさけることがベストです。しかし,完全には排除できないことを考えると,ゲームプレイへの進入を防ぐには主に二つのアプローチがあります。
このアプローチがベストであるのは,長期間のゲームプレイにおいて,スムーズなフレームレートを実現することが主な関心事であるゲームの場合などです。このようなゲームは一般的に小さなブロックを頻繁に割り当てしますが,これらのブロックは短期間しか使用されません。iOS でこのアプローチをとるときの典型的ななヒープサイズは 200KB ぐらいであり,iPhone 3G において 5ms ほどかかります。もしヒープが 1 MB に増加すると,コレクションは 7ms かかります。このため定期的なフレームのインターバルでガーベージコレクションを要求することにメリットがあります。厳密にはこれは一般的にコレクションを必要以上に発生させますが,素早く処理されゲームプレイへの最小の影響で済みます:
if (Time.frameCount % 30 == 0)
{
System.GC.Collect]();
}
しかし,このテクニックは注意して使用すべきで,プロファイラ統計をチェックして,本当に自身のゲームでコレクション時間を削減していることを確認すべきです。
このアプローチがベストであるのは,メモリ割り当て(ひいてはコレクション)が相対的に頻繁でなく,ゲームプレイのポーズの際にハンドリング出来ます。OS がシステムメモリ 不足のためににヒープが破棄されない範囲で,できるかぎり ヒープを大きくすることが役立ちます。しかし, mono ランタイムはヒープを自動的に拡大することを出来るかぎりにおいて回避します。ヒープを手動で拡大するには起動のどこかでプレースホルダーを事前割り当てします(すなわち, “無意味な” オブジェクトをインスタンス化して,メモリ管理の効果のために割り当てます):
function Start() {
var tmp = new System.Object[1024];
// make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
for (var i : int = 0; i < 1024; i++)
tmp[i] = new byte[1024];
// release reference
tmp = null;
}
十分に大きなヒープにより,コレクションを伴うようなポーズの間に,完全に埋まることはすべきでありません。もしポーズが生じる場合は,コレクションを明示的に要求します:
System.GC.Collect();
繰り返しとなりますが,このアプローチをとるときは注意すべきであり,プロファイラ統計をチェックして,思い込みでなく本当に効果が得られていることをきちんと確認すべきです。
作成および破棄されるオブジェクトの数を削減するのみで,ガーベージを回避できる場合が良くあります。ゲームの中で,発射物のような特定のオブジェクトのように,わずかな数しか一回に必要なにもかかわらず何度も繰り返し遭遇するようなケースがあります。こういったケースでは,古いものを破棄して新しいもので置き換えるのではなく,オブジェクトを再利用することが可能です。
メモリ管理は巧妙で複雑なトピックであり,知識を獲得する労力も大きいものです。もし詳細を学びたい場合は memorymanagement.org が素晴らしい情報を抱えていて,出版物やオンラン記事が一覧になっています。オブジェクトプーリングの詳細については Wikipedia ページ および Sourcemaking.com に含まれます。