Version: 2018.4
言語: 日本語
イベント関数の実行順序
プラットフォーム依存コンパイル

自動メモリ管理

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

値型と参照型

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

パラメーターを渡すときに直接格納されてコピーされる型は値型と呼ばれます。これらには int、float、boolean、および Unity の struct 型 (例えば、ColorVector3) も含まれます。ヒープに割り当てられて、ポインターを通してアクセスされる型は参照型と呼ばれます。なぜなら、変数に格納されている値はあくまでも実際のデータを “参照” しているからです。参照型の例にはオブジェクト、文字列、配列が含まれます。

割り当てとガベージコレクション

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

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

最適化

ガベージコレクションは自動的であり、プログラマーにとって目に見えませんが、GC プロセスは実際には裏でかなりの 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;
    }
}

これは、単に既存の配列のコンテンツを新しい値で置き換えます。初期の配列の割り当てが呼び出す側のコードで行われますが (ややエレガントでありませんが)、関数が呼び出されるときに新しいガベージを生成することはなくなります。

ガベージコレクションを無効にする

Mono や IL2CPP スクリプティングバックエンドを使用している場合、ガベージコレクション中の CPU の急増は、実行時にガベージコレクションを無効にすることで回避できます。ガベージコレクションを無効にすると、ガベージコレクターは参照を持たないオブジェクトを収集しないため、メモリ使用量が減少することはありません。ガベージコレクションを無効にすると、実際にはメモリ使用量が増加するのみです。時間がたつにつれ増加するメモリ使用を避けるために、メモリを管理するときに注意してください。理想的には、ガベージコレクターを無効にする前にすべてのメモリを割り当て、無効になっている間に追加のメモリ割り当てを行わないようにします。

実行時にガベージコレクションを有効/無効にする方法の詳細については、GarbageCollector スクリプティング API を参照してください。

コレクションの要求

前述のとおり、できるかぎり割り当てを避けることがベストです。しかし、完全には排除できないことを考えると、ゲームへの影響を防ぐには主に 2 つの方法があります。

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

この方法がベストであるのは、長時間プレイし、スムーズなフレームレートを実現することが重要であるゲームの場合です。このようなゲームは一般的に小さなブロックを頻繁に割り当てしますが、これらのブロックは短期間しか使用されません。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 を参照してください。


  • 2018–09–19 限られた 編集レビュー で修正されたページ
  • Mono と IL2CPP スクリプティングバックエンドでガベージコレクションを無効にする機能は [2018.3](https://docs.unity3d.com/2018.3/Documentation/Manual/30_search.html?q = newin20183) で追加 NewIn20183
イベント関数の実行順序
プラットフォーム依存コンパイル