Version: 5.5
イベント関数の実行順
プラットフォーム依存コンパイル

自動メモリ管理を理解する

オブジェクト、文字列、配列が作成されるとき、それを格納するのに必要なメモリは ヒープ と呼ばれる中央集約的なプールから割り当てされます。そのアイテムがもはや使用されなくなると、占有されたメモリは何か別のもののために確保することができます。以前は一般的にヒープメモリブロックを割り当てて解放することを、適切な関数コールを通じて明示的に行うことはプログラマ自身の責任でした。最近では Mono エンジンのようなランタイムシステムがメモリ管理を自動化しています。自動メモリ管理では、明示的に割り当て/リリースするよりコーディングの労力が大幅に削減され、かつメモリリーク(メモリが割り当てされたけど後続でリリースされない状況)の潜在的な可能性を著しく下げます。

値型と参照型

関数がコールされると、引数の値はその特定のコールのために予約されたメモリエリアにコピーされます。数バイトを占有するデータ型は速やかに簡単にコピーができます。しかし、オブジェクト、文字列、配列がそれよりも大きいことは良くあることであり、もしこれらのデータ型が定期的にコピーされることは非常に非効率的です。幸いにこれは必要ありません。大きなアイテムのための実際のストレージのスペースはヒープから割り当てされ、小さな “ポインタ” 値がロケーションを覚えるために存在しています。その先は、ポインタのみがパラメーター渡しの際に必要となります。ランタイムシステムがポインタにより識別されるアイテムを見つけられるかぎり、データのひとつのコピーは何回でも使用することができます。

パラメーター渡しのときに直接格納されてコピーされる型は値型と呼ばれます。これらは整数、浮動小数点数、論理型、および Unity の構造体型(例えば、 Color および __Vector3__)も含みます。ヒープに割り当てられて、ポインタを通してアクセスされる型は参照型と呼ばれ、変数に格納されている値はあくまでも実際のデータへの “参照” です。参照型の例にはオブジェクト、文字列、および配列が含まれます。

メモリ割り当ておよびガベージコレクション

メモリマネージャがヒープの中で未使用であると認識している領域をトラッキングします。新しいメモリブロックが要求されるとき(例えばオブジェクトがインスタンス化された)、マネージャはブロックを割り当てる未使用領域を選択し、未使用と認識している割り当てされたメモリを取り除きます。後続のリクエストは、要求されたブロックサイズを割り当てるために十分な空き領域がなくなるまで、同じ方法でハンドリングされます。この時点ではピープに割り当てられたすべてのメモリが使用されていることはきわめて稀です。ヒープ上の参照されるアイテムは、まだそれを参照できる参照変数が存在するかぎりでしか、アクセスできません。もしメモリを参照するすべてのメモリブロックがなくなったとき(すなわち参照変数が再度割り当てされたか、スコープ外となったローカル変数である場合)は、占有していたメモリは安全に再割り当てできます。

どのヒープブロックがもはや使用されていないか判断するために、メモリマネージャはすべての現在アクティブな参照変数を検索して、ブロックを “活動中” としてマーキングします。検索の最後に活動中のブロックの間の空間はメモリマネージャにより空いていると判断され、後続の割り当てで使用することができます。明らかなことですが、未使用のメモリの検索や解放はガベージコレクション、略して GC と呼ばれます。

Optimization

ガベージコレクションは自動的であり、プログラマにとって目に見えませんが、コレクションプロセスは実際には裏で顕著に CPU 時間を必要とします。正しく使用されたとき、自動メモリ管理は一般的に手動割り当てと同等かそれ以上の全体パフォーマンスが得られます。しかし、プログラマにとって重要なこととして、コレクターを必要以上にトリガーし、実行中にポーズするという誤りを避けることがあります。

いくつかの悪名高いアルゴリズムにより、一見では問題ないようにみえて GC の悪夢となります。繰り返しの文字列連結は昔からある事例です。

//C# script サンプル
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();
        
        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }
        
        return line;
    }
}


//JS script サンプル
function ConcatExample(intArray: int[]) {
    var line = intArray[0].ToString();
    
    for (i = 1; i < intArray.Length; i++) {
        line += ", " + intArray[i].ToString();
    }
    
    return line;
}

ここで鍵となるポイントは、新しい部分が文字列の所定の位置に 1 つずつ追加されるわけではないということです。実際に行われることは、ループするたびに line 変数の前回の中身が削除されます。新しい文字列全体は、もとの部分の後ろに新しい部分を加えたコンテンツに置き換えられます。文字列は i の増加分だけ長くなるため、消費されるヒープの量もまた増加し、この関数が呼び出されるたびに数百バイトの空きヒープ領域を容易に使用します。もし多くの文字列を連結する場合は、Mono ライブラリの System.Text.StringBuilder クラスを使用したほうがずっと効率的と言えます。

ただし、繰り返しの連結ですら、頻繁に呼び出されない限りそこまでの問題を引き起こしません。Unity でその頻繁となりうる要因は暗黙的にはフレーム更新です。次のような場合です。

//C# サンプル
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}


//JS サンプル
var scoreBoard: GUIText;
var score: int;

function Update() {
    var scoreText: String = "Score: " + score.ToString();
    scoreBoard.text = scoreText;
}

このコードで各 Update が呼び出されるたびに新しい文字列が割り当てされ、新しいガベージが常に生成されます。そのほとんどはスコアが変更されたときのみテキストを更新することで節約できます。

//C# サンプル
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}


//JS サンプル
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;
    }
}

もうひとつの潜在的な問題は関数が配列の値を返すときに発生します。

//C# サンプル
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}


//JS サンプル
function RandomList(numElements: int) {
    var result = new float[numElements];
    
    for (i = 0; i < numElements; i++) {
        result[i] = Random.value;
    }
    
    return result;
}

この種の関数は、値で埋められた新しい配列を作成するときに、非常にエレガントで便利です。しかし、繰り返し呼び出されると、真新しいメモリが毎回割り当てられます。配列は非常に大きくなりえるため、空きヒープ領域があっという間に使い切られることがあり、結果的に頻繁にガベージコレクションが行われます。この問題を回避する方法は配列が参照型であることを活用することです。関数にパラメーターとして渡される配列はその関数の中で更新することができて、結果は関数が返した後も残ります。前述のような関数は、多くの場合に次のような関数で置き換えできます。

//C# サンプル
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}


//JS サンプル
function RandomList(arrayToFill: float[]) {
    for (i = 0; i < arrayToFill.Length; i++) {
        arrayToFill[i] = Random.value;
    }
}

これにより単に既存の配列の中身を新しい値で置き換えます。これにより初期割り当てがコールする元のコードの中で行われることが必要となるにも関わらず(ややエレガントでなくみえますが)、関数はコールされるときに新しいガベージを生成することはなくなります。

ガベージコレクション要求

前述のとおり、できるかぎり割り当てをさけることがベストです。しかし、完全には排除できないことを考えると、ゲームプレイへの進入を防ぐには主に二つのアプローチがあります。

小さなヒープで速く頻繁なガベージコレクション

この方法がベストであるのは、長時間プレイし、スムーズなフレームレートを実現することが重要であるゲームの場合です。このようなゲームは一般的に小さなブロックを頻繁に割り当てしますが、これらのブロックは短期間しか使用されません。iOS でこのアプローチをとるときの典型的なヒープサイズは 200KB ぐらいであり、ガベージコレクションは iPhone 3G で 5ms ほどかかります。もしヒープが 1MB に増加すると、コレクションは 7ms かかります。このため定期的なフレームのインターバルでガベージコレクションを要求することにメリットがあります。厳密にはこれは一般的にコレクションを必要以上に発生させますが、素早く処理されゲームへの影響は最小で済みます。

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

しかし、このテクニックは注意して使用すべきで、プロファイラー統計をチェックして、ゲームで本当にコレクション時間が削減されていることを確認してください。

大きなヒープで遅いが頻繁でないガベージコレクション

このアプローチがベストであるのは、メモリ割り当て (ひいてはコレクション) が相対的に頻繁でなく、ゲームの一時停止の際に処理できるようなゲームです。システムメモリ不足のために、OS がアプリケーションを強制終了しない範囲で、ヒープをできる限り大きくするのに役立ちます。ただし、Mono ランタイムはヒープを自動的に拡大することをできるかぎり回避します。ヒープを手動で拡大するには、起動中、プレースホルダーによってスペースを事前に割り当てします (すなわち、“無意味な” オブジェクトをインスタンス化して、メモリ管理の効果のために割り当てます)。

//C# サンプル
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // 大きなブロックのためにデザインされた特殊な方法で処理されるのを避けるために、小さなブロックに割り当てします
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // 参照を解放
        tmp = null;
    }
}


//JS 例
function Start() {
    var tmp = new System.Object[1024];

    // 大きなブロックのためにデザインされた特殊な方法で処理されるのを避けるために、小さなブロックに割り当てします
        for (var i : int = 0; i < 1024; i++)
        tmp[i] = new byte[1024];

    // 参照を解放
        tmp = null;
}

十分に大きなヒープは、コレクションを伴うゲームの一時停止の合間に、完全に使い切ることはありません。もしそのような一時停止が生じる場合は、コレクションを明示的に要求します。

System.GC.Collect();

繰り返しとなりますが、このアプローチをとるときは注意すべきであり、プロファイラー統計をチェックして、思い込みでなく本当に効果が得られていることをきちんと確認すべきです。

再利用可能なオブジェクトプール

作成および破棄されるオブジェクトの数を削減するのみで、ガベージを回避できる場合が良くあります。ゲームの中で、発射物のような特定のオブジェクトのように、わずかな数しか一回に必要なにもかかわらず何度も繰り返し遭遇するようなケースがあります。こういったケースでは、古いものを破棄して新しいもので置き換えるのではなく、オブジェクトを再利用することが可能です。

詳細情報

メモリ管理は巧妙で複雑なトピックであり、知識を獲得する労力も大きいものです。もし詳細を学びたい場合は memorymanagement.org が素晴らしい情報を抱えていて、出版物やオンラン記事が一覧になっています。オブジェクトプーリングの詳細については Wikipedia ページ および Sourcemaking.com に含まれます。

イベント関数の実行順
プラットフォーム依存コンパイル