1. サイトトップ
  2. ブログ
  3. Unity
  4. 【Unity】Unityでメモリリーク発生?原因と調査方法について

【Unity】Unityでメモリリーク発生?原因と調査方法について

あいさつ

こんにちは、 情熱開発部プログラム2課の樋宮です。
最近暑い日々が続いています、塩分・水分補給など気を付けて体調を崩さないようにしたいですね。

さて、今回はUnityでのメモリリークについてとそれの原因・調査方法についてです。
UnityってC#を使っているのにリークが発生するの?という方もいるかと思います。
自分も最初の内はそうでしたが実は違うようです。
メモリリークとは一体何か?Unityで発生する仕組みとは?について説明していきたいと思います。

メモリリークとは?

今回のテーマはUnityにおけるメモリリークについてですが、先にそもそもメモリリークとは何かを見ていこうと思います。

一言で説明すると「使えるメモリの場所が減っていってしまうこと」です。
もう少し詳しく説明します。
プログラム上でテクスチャなどを使用する際にはメモリを確保する必要があります。しかし、そうやって確保ばかりして解放を行っていないと段々と使用できるメモリが減っていてしまいます。
この状況をメモリリークといいます。

ではUnityではどうでしょうか?
Unityではスクリプト用の言語として、C#がサポートされています。
C#にはガベージコレクションといって、動的に確保したメモリの内の不要になったメモリ領域を自動で解放してくれる機能が備わっています。
一見これだけ聞くと「じゃあUnityではメモリリークは発生しないのでは?」と思うかもしれません。

しかし、実際にUnityでもメモリリークは発生しています。
ではなぜそんな事態になってしまうのか見ていきましょう。

今回使用した環境

OS:Windows10
Unity:2022.3.0.f1
Memory Profiler:1.1.0-exp.1

Unityでメモリリークが起きる仕組み

Unityでメモリリークが起きる仕組みを説明していきます。

皆さんが実際にスクリプトを記入する際には先ほども書いた通り、C#を使用しているかと思います。ですが、UnityエンジンはC#だけで動いているわけではありません
Unityエンジンを動かしている大部分のコードはC++で記入されています。

つまり、皆さんの書いたC#でのスクリプトとエンジン側のC++のコードが相互に協力して動いているわけですね。
この2つが不整合を起こしてしまうと不要なメモリが確保され続けてしまうというわけです。

一例を見てみましょう

Texture2D tex;

void Start()
{
  tex = new Texture2D(256, 256);
  Destroy(tex);
}

これはTexture2Dオブジェクトを生成してその後にDestroyをするという簡単なC#での処理です。

これだけ見てみるとTexture2Dのオブジェクトだけが存在しているように見えます。
しかし、実際にはC++で書かれたエンジンコード側にもTexture2Dオブジェクトが存在しています。

これからこの2つを区別するために、
C#側のオブジェクトをManaged Shell
C++側のオブジェクトをNative Objectと記載します。

実際の処理はC++側のNative Objectが行っています。
C#側でManaged Shellが作られるとNative Objectも作成されます。

ではここでDestroy()を呼ぶとどうなるでしょうか。

Destroy()を呼ぶとNative Objectは破棄されます。
ですが、Managed Shellはどうでしょうか?
コード上では一見片づけたように見えますが実際にはまだ生きている状態です。
これをLeaked Managed Shellと呼びます。

こうして一見片づけたようでも実際にはメモリを使用したまま残ってしまうことがあります。

Texture2D tex;

void Start()
{
  tex = new Texture2D(256, 256);
  Destroy(tex);
  tex = null; // nullを代入する
}

Managed Shellを破棄するためにはちゃんとnullを代入してガベージコレクションを待つ必要があります。

特別なnullの扱いについて

nullを代入する前にUpdate()内でDebug.Log()を使用してTexture2Dであるtexを出力してみます。

Texture2D tex;

void Start()
{
  tex = new Texture2D(256, 256);
  Destroy(tex);
}

void Update()
{
  Debug.Log(tex);
}

こうしてログを確認してみてもちゃんとnullと表示されます。
例えば、nullと比較してみてもtrueが返ってきます。

こうしてみるとnullを代入する必要はなさそうに見えますね。

tex = null; // ???

実はこれは見かけ上でnullを返しているだけで実際にはUnityObjectインスタンスが残っています。
nullを返してはいるけれど実際にはnullではないというわけですね。
ですから、改めてnullを代入する意味があるわけです。

実はこの仕様は修正が検討されたこともあったみたいですが、Unityの根本的な設計に関わってくるようで修正が難しいようです。
当分の間はこの仕様と付き合っていく必要がありそうです。

MemoryProfilerの導入

ではUnityでメモリリークが発生しているかを確認する方法から見ていきましょう。

MemoryProfilerの新機能であるLeaked Managed Shellを使用すれば確認することができます。
ですが、この機能があるのはMemoryProfiler1.1.x以降になります。
まだ Experimentalパッケージ なので通常とは導入方法が変わります。
<プロジェクトのパス>/Packages/manifest.json に以下を追記する必要があります。
"com.unity.memoryprofiler": "1.1.0-exp.1",
すると Experimental ラベル付きのMemoryProfilerが導入されます。

以下に通常のMemoryProfilerの導入方法を記載していますので普段使用する際はこちらからインストールしましょう。
こちらではLeaked Managed Shellの機能を使えませんので注意してください。
こちらにアクセスして以下の画像の部分をコピーします

コピーしたらUnity側でPackageManagerを開き、左上の「+」マークから Add package by name を選びペーストします。
その後「Add」を押せばインポートができると思います。
もしくは単純にPackageManager上で検索しても問題ありません。

実際にメモリリークを起こしてみる

では実際にメモリリークを起こしてみてMemoryProfilerで確認してみます。

using UnityEngine;

public class MyComponent : MonoBehaviour
{
    Texture2D _texture2D;
    private void Start()
    {
        _texture2D = new Texture2D(512, 512, TextureFormat.RGBA32, false);

        GetComponent<Renderer>().material.mainTexture = _texture2D;
    }
}

まずこちらのスクリプトがアタッチされたPrefabを作成します。
このスクリプトではTexture2Dを動的に生成しています。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MemoryTest : MonoBehaviour
{
    [SerializeField] GameObject _myComponentPrefab = null;

    List<GameObject> _prefabList = new List<GameObject>();

    private void Start()
    {
        // 大量にInstantiate
        for (var i = 0; i < 256; i++)
        {
            var obj = Instantiate(_myComponentPrefab, new Vector3(i * 1.0f, 0.0f, 0.0f), Quaternion.identity);
            _prefabList.Add(obj);
        }

        StartCoroutine(DelayCoroutine());
    }

    private IEnumerator DelayCoroutine()
    {
        // 数秒待つ
        yield return new WaitForSeconds(3.0f);

        // 全てDestroy
        foreach (var obj in _prefabList)
        {
            Destroy(obj.gameObject);
        }
    }
}

このスクリプトはシーン上の適当なオブジェクトにアタッチして先ほどのprefabを生成するように設定します。

この状態でMemory Profilerを使って確認してみます。
オブジェクトが消えたのを確認してシーンを一時停止します。
Window→Analysis→MemoryProfiler
開いたら画面中央のボタンをクリックしてスナップショットを撮ります。

画面左からスナップショットを選択して上部のAll of Memoryタブに移動します。

検索欄に「Leaked Managed Shell」と入力すると発生しているメモリリークを確認することができます。

このようにメモリリークを発見できました。

メモリリークの修正

では先ほどのスクリプトを修正してメモリリークが発生しないようにしてみましょう。

using UnityEngine;

public class MyComponent : MonoBehaviour
{
    Texture2D _texture2D;
    private void Start()
    {
        _texture2D = new Texture2D(512, 512, TextureFormat.RGBA32, false);

        GetComponent<Renderer>().material.mainTexture = _texture2D;
    }
  
  // 明示的に破棄する
    private void OnDestroy()
    {
        Destroy(_texture2D);
        _texture2D = null;
    }
}

OnDestroy()を用意してその中できちんと破棄するよう変更します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MemoryTest : MonoBehaviour
{
    [SerializeField] GameObject _myComponentPrefab = null;

    List<GameObject> _prefabList = new List<GameObject>();

    private void Start()
    {
        // 大量にInstantiate
        for (var i = 0; i < 256; i++)
        {
            var obj = Instantiate(_myComponentPrefab, new Vector3(i * 1.0f, 0.0f, 0.0f), Quaternion.identity);
            _prefabList.Add(obj);
        }

        StartCoroutine(DelayCoroutine());
    }

    private IEnumerator DelayCoroutine()
    {
        // 数秒待つ
        yield return new WaitForSeconds(3.0f);

        // 全てDestroy
        foreach (var obj in _prefabList)
        {
            Destroy(obj.gameObject);
        }
     
     _prefabList.Clear(); // ちゃんとリストの中身を破棄する
    }
}

こちらも使用した後のList<>の中身の破棄を忘れず行うようにします。

修正してから改めて確認してみると以下の通りです。

いくつかのメモリリークは確認できますが、これは何も生成していなくても発生しているものです。
先程と比較してGameObjectによるリークがかなり減っていてこちら側で生成したものに関してはリークが発生していないことがわかるかと思います。

補足

・Resources.UnloadUnusedAssetsを呼んだ場合はどうなるか ?
こちらですが結果から言いますと正しく破棄されません
Resources.UnloadUnusedAssets()の詳しい実装を見ることができないので推測になってしまいますが、この記事でも書いた特殊なnullの扱いが関係していそうです。
nullと表示はされますが実際にはnullではないので参照が残ってしまっているのかなと思います。

・SceneをUnloadした場合は?
LoadSceneMode.Additiveなどのオプションでシーンを保持したまま遷移した後にUnloadScene()などを呼ぶとUnloadされたSceneのメモリは正しく解放されるようです。

まとめ

今回のような小さなメモリリークは普段はあまり表立って問題になることは少ないかもしれません。
ですが、DontDestroyOnLoad()などで登録された生存期間の長いオブジェクトがメモリリークを起こしてしまうと長期にわたってメモリ不足を起こしてしまいます。

また、いくら小さいリークといっても数が多くなれば問題になりますし修正も大変になります。普段から丁寧に管理していくことが大事ですね。
困った際には今回のようにMemoryProfilerなどの機能を思い出していただければと思います。

今回紹介したLeakedManagedShellはいまだ試験段階の新機能なので早く正式にリリースされてほしいですね。
皆さんの開発のお役に立てれば幸いです。

参考・引用

メモリリークとは
MemoryProfiler 1.1.0リファレンス
UnityJapan – Unity でメモリリーク? Memory Profiler で Leaked Managed Shell をチェックしてみよう!
Unityにおけるnullチェック(4) – メモリリークを防ぐ


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。