プログラミングにおけるメモリリークの管理

プログラミングにおけるメモリリーク: 原因、検出、予防を理解する

メモリリークは、ソフトウェアエンジニアリングにおいて最も重大な欠陥の1つです。クラッシュのように実行が即座に停止するのとは異なり、メモリリークは利用可能なメモリを徐々に消費し、応答速度が低下したり、サービスが意図せず再起動したり、アプリケーションがメモリ不足エラーで終了したりするまで、システムを徐々に劣化させます。メモリリークは、主要なプログラミング言語すべてで発生します。ヒープ管理が完全に手動で行われるCやC++だけでなく、ガベージコレクションがほとんどのクリーンアップを処理するものの、微妙な参照チェーンによってメモリの解放が妨げられるJava、Python、JavaScript、C#などでも発生します。Androidアクティビティのイベントリスナーのリーク、Javaサービスの無制限のキャッシュ、プールされたスレッドから削除されないスレッドローカル変数など、これらはすべてメモリリークであり、システムがそれを明らかにするまで静かに蓄積されます。

メモリリークを修正する必要がありますか?

SMART TS XL 数百万行のコード内のメモリリークを検出するための理想的なソリューションです

今すぐ探索する

メモリリークが特に厄介なのは、開発段階ではめったに表面化しない点です。30秒間のテスト実行では、何十万回ものメモリの割り当てと解放が行われても、リークが測定可能なレベルに達することはありません。しかし、同じコードが本番環境で12時間も実行されると、サーバーがダウンしてしまう可能性があります。リークが発生してから初めて発見されるまでの期間は数週間にも及ぶことが多く、その頃には原因となったコミットは既にマージされており、コードを書いた開発者は見落とした詳細を覚えていないかもしれません。メモリリークの発見、修正、防止には、構造に関する知識、適切なタイミングで適切な検出ツールを適用すること、そして安全なメモリ管理を後回しにするのではなく、最初から優先的に行う設計習慣を組み合わせることが不可欠です。

目次

メモリリークとは何ですか?

メモリリークとは、プログラムが実行中にメモリを割り当てた後、そのメモリが不要になった後もオペレーティングシステムやランタイムに解放されない場合に発生する現象です。割り当てられたメモリブロックは、たとえどのコードも積極的に使用していなくても、プログラムの他の部分や他のプロセスから利用できない状態で予約されたままになります。長時間実行されるアプリケーションでは、こうした解放されないメモリブロックが蓄積されていきます。利用可能なメモリは徐々に減少し、パフォーマンスが低下します。最終的に、放置しておくとシステムはメモリを使い果たし、プロセスを強制終了させて​​しまいます。

IBMのプログラミングドキュメントにおける正式な定義では、メモリリークとは、メモリを解放せずに継続的に割り当て、メモリ使用量が時間とともに際限なく増加するプログラムのことであるとされています。この定義は、真のリークに必要な2つの要件、すなわち、対応する解放を伴わない割り当てと、時間経過に伴うメモリ使用量の持続性を明確に示している点で重要です。最終的に解放される一時的な割り当て(たとえ遅延したとしても)はリークではありません。解放されることなく、コードパスの実行ごとに増加し続ける割り当ては、リークです。

CやC++のような手動メモリ管理言語では、次のような場合にメモリリークが発生します。 malloc, callocまたは new 対応するものなしで呼び出されます free or deleteJava、Python、JavaScript、C#などのガベージコレクション機能を持つ言語では、メモリリークは異なる形で発生します。ガベージコレクタは、意図せず保持された参照であっても、少なくとも1つの有効な参照が残っているメモリを解放できません。メモリは孤立しているのではなく、プログラムが解放し忘れた参照チェーンによって保持されているのです。

メモリリークが問題となる原因とは?

メモリリークの影響は、状況によって軽微なものから壊滅的なものまで様々です。短命なコマンドラインツールにおける小さなリークは、気づかれないままになることもあります。プロセスが終了し、オペレーティングシステムがすべてのメモリを解放するため、リークによる影響は目に見えません。一方、数週間連続して実行されるサーバープロセスにおける同じリークは、着実にメモリ使用量を増加させます。リークによって消費されるRAMが増えるにつれて、オペレーティングシステムはページングを開始し、応答時間が長くなり、最終的にはプロセスがクラッシュするか、メモリ不足キラーによって強制終了されます。ギガバイトではなくキロバイト単位のメモリしか持たない組み込みシステムでは、1時間に数バイトしか増加しないような小さなリークでも、数日でデバイスが故障する可能性があります。

ゲームにおけるメモリリークは、ガベージコレクタが増大するヒープ負荷を管理しようとして負荷が増大するため、フレームレートの低下やカクつきを引き起こし、最終的にはプレイヤーがクラッシュとして報告する「メモリ不足」エラーが発生します。Androidアプリケーションにおけるメモリリークはバッテリーを消費し、システムがリソースを解放するためにバックグラウンドアプリを終了させる原因となります。ブラウザにおけるメモリリークはタブの動作速度低下を引き起こし、長時間の使用においてユーザーがページの応答性の低下として体感します。

メモリリークの原因とは

メモリリークの原因は言語や実行環境によって大きく異なるが、それらすべてに共通するいくつかの根本的なパターンが存在する。

C言語およびC++言語における手動メモリ管理エラー

C および C++ では、すべての動的割り当てには明示的な解放が必要です。 free or delete 数百万回実行されるコードパスでは、重大なメモリリークが発生します。最も一般的な原因は次のとおりです。

  • エラーパスでメモリ解放が不足しています。 メモリを早期に割り当ててから一連の操作を実行する関数は、エラーが発生した場合、割り当てたメモリを解放せずに早期に終了する可能性があります。エラーが発生するケースがまれな場合、テストでメモリリークが顕在化しない可能性があります。
  • ポインターが失われました。 割り当てられたメモリへのポインタが、元のメモリが解放される前に新しい値で上書きされると、元の割り当て領域にアクセスできなくなります。
  • 元のリソースを解放せずに再割り当てを行う。 呼び出し realloc 誤って元のポインタを破棄する場合 realloc nullを返すと、元の割り当て領域に到達できなくなります。

c

// Bug: early return on error loses the allocation
char *process_data(int size) {
    char *buf = malloc(size);
    if (!buf) return NULL;

    if (validate(buf) < 0) {
        return NULL;   // BUG: buf is never freed
    }
    return buf;
}

// Fix: free before returning on every error path
char *process_data_fixed(int size) {
    char *buf = malloc(size);
    if (!buf) return NULL;

    if (validate(buf) < 0) {
        free(buf);     // release before returning
        return NULL;
    }
    return buf;
}

ガベージコレクション言語における循環参照

最新のガベージコレクタは、参照カウントではなく到達可能性に基づいて収集対象を決定します。オブジェクトは、どの実行中のコードパスからも到達できない場合に収集対象となります。しかし、互いに参照し合っているものの、どのルート参照からも到達できないオブジェクトのグループは、参照サイクルを形成します。単純なマークアンドスイープ方式のコレクタはサイクルを正しく処理しますが、古い方式や単純な方式のコレクタ、および参照カウントのみに基づくシステムはサイクルを収集できません。

「循環参照はガベージコレクション言語でメモリリークを引き起こすのか」という質問は、この分野で最も検索されている質問の1つであり、明確な答えが必要です。CPythonでは、循環参照は、関係するオブジェクトが __del__ 方法。CPython の循環ガベージコレクタはほとんどの循環を処理しますが、ファイナライザを持つオブジェクトが関わる循環は、従来回収できませんでした。Java と最新の .NET では、ガベージコレクタが循環を正しく処理します。JavaScript では、古いバージョンの Internet Explorer の DOM における循環参照がメモリリークを引き起こしていました。これは、JS エンジンの DOM ノードの参照カウントが循環を処理しなかったためです。

パイソン

# Python circular reference example
class Node:
    def __init__(self, value):
        self.value = value
        self.parent = None
        self.child = None

a = Node(1)
b = Node(2)
a.child = b    # a references b
b.parent = a   # b references a -- cycle formed

del a          # neither a nor b collected immediately
del b          # Python's cyclic GC will eventually collect them
               # but __del__ on either object would block collection
               # in older Python versions

閉じられていないリソース:ファイルハンドル、データベース接続、ソケット

ファイルディスクリプタ、データベース接続、ネットワークソケット、GUIハンドルなどのオペレーティングシステムリソースは、ガベージコレクタによって管理されません。これらは明示的に閉じる必要があります。閉じないと、リソースリークが発生し、ファイルディスクリプタの枯渇(Linuxでは「開いているファイルが多すぎます」)、接続プールの枯渇、高スループットサーバーにおけるソケットの枯渇といった形で現れます。

パイソン

# Bug: file handle leaked if exception occurs between open and close
def read_config(path):
    f = open(path)
    data = f.read()
    # if processing raises an exception, f is never closed
    process(data)
    f.close()

# Fix: context manager guarantees closure regardless of exceptions
def read_config_fixed(path):
    with open(path) as f:
        data = f.read()
    process(data)

ジャワ

// Java: try-with-resources guarantees closure
try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement(sql)) {
    ResultSet rs = stmt.executeQuery();
    while (rs.next()) {
        // process results
    }
}  // conn and stmt closed automatically, even on exception

無制限または増加するコレクション

際限なく拡大し続けるコレクション、つまりエントリが追加されるだけで削除されないコレクションは、どの言語においてもメモリリークである。一般的な例としては、以下のようなものがある。

  • 削除ポリシーなしで結果を無期限に保存するキャッシュ
  • 古いエントリを削除せずにすべてのメッセージを追加するイベントログリスト
  • 新しい接続を追加するが、閉じられた接続は決して削除しない接続レジストリ。

ジャワ

// Bug: cache grows indefinitely -- classic Java memory leak pattern
private static final Map<String, Object> cache = new HashMap<>();

public void process(String key) {
    cache.put(key, expensiveOperation(key));
    // key is never removed from cache
}

// Fix: use a cache with eviction policy
private static final Map<String, Object> cache =
    Collections.synchronizedMap(
        new LinkedHashMap<String, Object>(1000, 0.75f, true) {
            protected boolean removeEldestEntry(Map.Entry e) {
                return size() > 1000;  // LRU eviction at 1000 entries
            }
        }
    );

イベントリスナーとコールバックのリーク

リスナーまたはコールバックがイベントソースに登録された後、登録解除されない場合、イベントソースはリスナーへの参照を保持します。この参照が存在するため、他のすべての参照が解放されたとしても、リスナーはガベージコレクションの対象になりません。これは、JavaScript、Android、およびJava Swingアプリケーションにおけるメモリリークの最も一般的な原因です。

ジャバスクリプト

// JavaScript: event listener leak
function setup() {
    const handler = () => doWork();
    document.addEventListener('click', handler);
    // handler is never removed -- listener holds a reference forever
}

// Fix: remove listener when no longer needed
function setup() {
    const handler = () => doWork();
    document.addEventListener('click', handler);
    return () => document.removeEventListener('click', handler);  // cleanup function
}

ジャワ

// Android: Activity leaked via static listener
class MainActivity extends Activity {
    private static OnDataListener listener;  // static holds Activity reference

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        listener = data -> updateUI(data);   // BUG: Activity can't be GC'd
        dataService.register(listener);
    }

    @Override
    protected void onDestroy() {
        dataService.unregister(listener);    // Fix: deregister on destroy
        listener = null;
    }
}

スレッドローカルストレージリーク

Javaでは、 ThreadLocal 変数は値をスレッドにバインドします。スレッドプールを備えたアプリケーションサーバーでは、スレッドはリクエスト間で再利用されます。 ThreadLocal 値は各リクエスト後に削除されず、スレッドに紐づけられたままになり、リクエスト間で蓄積されます。

ジャワ

// Bug: ThreadLocal not cleared -- leaks across pooled threads
private static final ThreadLocal<UserContext> context = new ThreadLocal<>();

public void handleRequest(Request req) {
    context.set(new UserContext(req.getUser()));
    processRequest();
    // BUG: context.remove() never called
    // Next request on this thread inherits previous request's context
}

// Fix: always remove in a finally block
public void handleRequest(Request req) {
    try {
        context.set(new UserContext(req.getUser()));
        processRequest();
    } finally {
        context.remove();  // guarantees cleanup even on exception
    }
}

C++ スマートポインタの誤用

std::shared_ptr 参照カウントを使用します。2 つのオブジェクトが保持している場合 shared_ptr 互いの参照カウントは決してゼロにならず、どちらも破壊されない。

cpp

#include <memory>

struct Node {
    std::shared_ptr<Node> next;  // strong reference
};

// Cycle: neither node destroyed
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;  // cycle -- both a and b leaked

// Fix: use weak_ptr to break the cycle
struct Node {
    std::weak_ptr<Node> next;   // weak reference does not affect refcount
};

静的およびグローバル変数の累積

静的変数とグローバル変数は、プロセスの実行期間中ずっと存在します。それらに格納されているオブジェクト、またはそれらからアクセスできるオブジェクトは、ガベージコレクションの対象になりません。レジストリとして使用される静的マップ、フラッシュせずにメッセージをバッファリングするグローバルロガー、状態を蓄積するシングルトンはすべて、ガベージコレクタからは見えない潜在的なメモリ増加を表しています。

言語別のメモリリーク

C言語におけるメモリリーク

C にはガベージ コレクタがなく、割り当てを追跡する標準的なメカニズムもありません。 malloc, callocまたは realloc 呼び出しとペアにする必要があります free主要な検出ツールはValgrindです(valgrind --leak-check=full ./program)実行時にメモリ操作を計測し、解放されなかったすべての割り当てを報告します。 AddressSanitizer(-fsanitize=addressコンパイル時に最小限のオーバーヘッドでメモリリークを検出し、継続的インテグレーションパイプラインに適しています。

C言語における最も効果的な予防策は、所有権を明確にすることです。すべてのメモリ割り当てには、それを解放する責任を負う所有者が必ず1人存在し、その所有権はコメントや関数シグネチャに明記されるべきです。

C++におけるメモリリーク

C++ は、C の割り当てモデルにコンストラクタ、デストラクタ、スマートポインタを追加します。リソースはコンストラクタで取得され、デストラクタで解放されるという RAII (Resource Acquisition Is Initialization) 原則が、主な防止メカニズムです。 std::unique_ptr (NAIST) と std::shared_ptr 生ポインタの代わりにこれを使用することで、手動によるメモリ解放のほとんどの必要性がなくなります。検出ツールとしては、Valgrind、AddressSanitizer、およびWindows上のVisual StudioのCRTデバッグライブラリなどがあります。

C++ でよくある原因: 基底クラスでデストラクタを仮想として宣言し忘れること (派生クラスのデストラクタは基底ポインタを介して呼び出されない)、生ポインタとスマートポインタを混在させること、そして shared_ptr 上記で説明した円形参照パターン。

Javaにおけるメモリリーク

Javaのガベージコレクタはヒープオブジェクトを管理しますが、OSリソースは管理しません。Javaでよく見られるメモリリークのパターンは以下のとおりです。

  • オブジェクト参照を保持する静的フィールド
  • 無制限のキャッシュとコレクション
  • 閉じられていないストリーム、接続、およびリーダー
  • finallyブロック内でThreadLocal変数がクリアされない
  • リスナー登録は削除されません

検出ツール:VisualVM(無料、JDKに付属)、ヒープダンプ分析用のEclipse Memory Analyzer(MAT)、YourKit、JProfiler、およびJVMフラグ -XX:+HeapDumpOnOutOfMemoryError メモリ不足が発生した際に、ヒープダンプを自動的に取得する。

Pythonにおけるメモリリーク

Pythonは、循環参照検出に循環ガベージコレクタを用いた参照カウント方式を採用しています。Pythonにおけるメモリリークは、以下の原因で発生します。

  • 際限なく増大する長期保存型のキャッシュまたはレジストリ
  • オブジェクトを含む循環参照 __del__ 古いバージョンのPythonのメソッド
  • モジュールレベルのグローバル変数に格納される大きなオブジェクト
  • 参照カウントを適切に管理しないC言語拡張機能

検出ツール: tracemalloc (Python 3.4以降に組み込まれています) objgraph オブジェクト参照グラフを視覚化するために、 memory_profiler 行ごとのメモリ測定用。

パイソン

import tracemalloc

tracemalloc.start()

# ... run the code under test ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:10]:
    print(stat)

JavaScriptにおけるメモリリーク

JavaScriptのガベージコレクタは到達可能性を利用します。意図しない参照によってガベージコレクションが妨げられると、メモリリークが発生します。

  • DOMノードはドキュメントから削除されたが、JavaScriptクロージャからは依然として参照されている。
  • 時間の経過とともにデータが蓄積されるグローバル変数
  • タイマーは以下で作成されました setInterval 決してクリアされないもの
  • イベントリスナーは長寿命オブジェクトから削除されません

検出方法:Chrome DevToolsのメモリタブ(ヒープスナップショット、割り当てタイムライン)、Firefoxのメモリプロファイラ。

ジャバスクリプト

// Bug: interval holds reference to elements indefinitely
const elements = [];
const interval = setInterval(() => {
    elements.push(document.createElement('div'));  // grows forever
}, 100);

// Fix: clear interval when done
clearInterval(interval);
elements.length = 0;  // release array contents

C#におけるメモリリーク

C#と.NETは世代別ガベージコレクタを使用します。メモリリークは次のような場合に発生します。

  • 長期間存続するオブジェクトに登録されたイベントハンドラは、登録解除されていません。
  • 際限なく成長する静的コレクション
  • 管理されていないリソースは、 IDisposable
  • 頻繁な大規模割り当てによるラージオブジェクトヒープ(LOH)の断片化

検出方法:詳細なGC分析には、dotMemory、Visual Studio診断ツール、PerfViewを使用します。

Cシャープ

// Fix: implement IDisposable for explicit resource cleanup
public class DatabaseConnection : IDisposable {
    private SqlConnection _connection;
    private bool _disposed = false;

    public DatabaseConnection(string connectionString) {
        _connection = new SqlConnection(connectionString);
    }

    public void Dispose() {
        if (!_disposed) {
            _connection?.Dispose();
            _disposed = true;
        }
    }
}

// Use with 'using' to guarantee Dispose is called
using (var conn = new DatabaseConnection(connectionString)) {
    // use connection
}  // Dispose called here automatically

メモリリーク検出:ツールとテクニック

言語別検出ツール

言語ツール検出するもの
C / C ++ヴァルグリンド(Memcheck)ヒープリーク、無効な読み書き、解放後使用
C / C ++AddressSanitizerメモリリーク、バッファオーバーフロー、解放後使用、高速ランタイム
C + +メモリー博士Windows/Linuxのヒープおよびハンドルリーク
JavaEclipse MATヒープダンプ解析、ドミネーターツリー、リーク容疑者
JavaVisualVMリアルタイムヒープ監視、GC動作、スレッド分析
JavaJProfiler / YourKit詳細な割り当て追跡機能を備えた商用プロファイラー
Python トレースマロックPython 3.4以降、組み込みのメモリ割り当てトレース機能が搭載されています。
Python オブジェクトグラフオブジェクト参照グラフの可視化
Python メモリプロファイラー行ごとのメモリ測定
JavaScriptをChrome DevToolsヒープスナップショット、割り当てタイムライン、保持サイズ
C# / .NETドットメモリオブジェクト保持、ガベージコレクション分析
C# / .NETパフォーマンスビューGCイベント、割り当てスタック、メモリ負荷
すべて / 制作ニューレリック、データドッグ、ダイナトレース継続的なメモリ監視、異常検知

メモリリークを見つける方法:段階的なアプローチ

ステップ1:漏洩箇所を確認する。 アプリケーションを通常の負荷で実行し、システムツールを使用して時間の経過に伴うメモリ使用量を監視します (top, htop(タスクマネージャーや監視ダッシュボードなど)メモリ使用量が安定せずに継続的に増加する場合は、メモリリークが発生している可能性が高いです。

ステップ2:メモリリークが発生しているコードパスを特定します。メモリ増加と相関する操作を特定します。特定のワークフロー(ログイン、ファイルアップロード、検索クエリなど)を繰り返し実行し、各反復処理でメモリが増加するかどうかを観察することで、そのワークフローを特定できます。

ステップ3:実行前後のヒープスナップショットを取得します。プロファイラを使用して、疑わしいワークフローを複数回繰り返す前後にスナップショットを取得します。スナップショットを比較して、どのオブジェクトが蓄積されているかを特定します。

ステップ4:参照チェーンをトレースします。ほとんどのプロファイリングツールは、保持ツリーを表示します。これは、オブジェクトがメモリに残っている理由と、どのルート参照がオブジェクトを保持しているかを示します。このチェーンをたどって、保持参照を作成したコードを見つけます。

ステップ5:修正と検証。疑わしい原因を修正した後、スナップショットの比較を再度実行します。ワークフローの各反復後にオブジェクト数が増加しないことを確認します。

メモリを経時的に監視する:緩やかなメモリリークを検出する

1時間に数キロバイトしか漏洩しないような低速なメモリリークは、短時間のテスト実行では検出されません。そのため、長時間の監視が必要です。メモリ使用量を定期的に追跡し、使用量が基準値を超えた場合や定義されたレートを超えて増加した場合にアラートを発するように、監視システムを設定してください。本番環境では、Datadog、New Relic、DynatraceなどのAPMツールが、アラート機能と履歴比較機能を備えた継続的なメモリ監視を提供します。

メモリリークを防ぐ方法

構造化されたリソース管理を使用する

どのプログラミング言語にも、リソースを確実にクリーンアップする仕組みが備わっています。それを常に活用しましょう。

  • C ++: RAII、コンストラクタで取得し、デストラクタで解放します。 std::unique_ptr (NAIST) と std::shared_ptr ヒープメモリ用、およびファイルハンドルとソケット用のカスタムRAIIラッパー。
  • Java: try-with-resources の AutoCloseable リソース。
  • Python: with ファイル、ロック、およびデータベース接続のためのステートメント(コンテキストマネージャ)。
  • C#: using に対する声明 IDisposable オブジェクト。
  • JavaScript: 明示的なクリーンアップ関数、 WeakRef (NAIST) と FinalizationRegistry キャッシュ用。

リスナーとコールバックの登録解除

すべての登録に対して、登録解除を対応させてください。コンポーネントベースのフレームワーク(React、Android、Angular、Qt)では、コンポーネントのティアダウンライフサイクルメソッド内で登録解除を実行してください。 useEffect React のクリーンアップ、 onDestroy Androidでは、 ngOnDestroy Angular では、デストラクタまたは disconnectedCallback ウェブコンポーネントにおいて。

循環参照を解除する

2つのオブジェクトが互いを参照する必要がある場合は、一方向の弱い参照を使用します。ほとんどのプログラミング言語では、この機能が提供されています。

  • Python: weakref.ref() or weakref.WeakValueDictionary
  • C ++: std::weak_ptr
  • Java: java.lang.ref.WeakReference
  • C#: WeakReference<T>
  • JavaScript: WeakMap, WeakSet, WeakRef

キャッシュに追放ポリシーを実装する

最大サイズのないキャッシュはメモリリークの可能性があります。制限を強制するデータ構造を使用します: Java の LRU キャッシュ (LinkedHashMap   removeEldestEntry), functools.lru_cache Pythonでは、 WeakHashMap 追跡したいオブジェクトのライフサイクルによってキー付けされたキャッシュ、または Caffeine (Java)、cachetools (Python) などの専用キャッシュライブラリ、 node-lru-cache (JavaScript)

CI/CDにメモリテストを組み込む

メモリリーク検出は、コードが変更されるたびに自動的に実行されるべきです。

  • C/C++のビルドおよびテストパイプラインにValgrindまたはAddressSanitizerを追加する
  • ヒープスナップショットの比較で予期しないオブジェクトの増加が示された場合は、ビルドを失敗させる。
  •   pytest-memray or pytest-leaks Pythonテストスイート用
  • メモリ監視を有効にした状態でステージング環境で負荷テストを実行し、しきい値違反が発生した場合はテストを失敗させる。

影響分析や静的コード分析の文脈で検討されているように、特定のリソースのライフサイクルに変更を加える前に、そのリソースを管理するコードの全範囲を把握することは、メモリ管理におけるリグレッションを防ぐために不可欠です。依存関係グラフ分析で説明されているように、どのコンポーネントが共有リソースに依存しているかを理解することは、割り当てと解放のパターンを安全に変更するための前提条件となります。

特定の状況におけるメモリリーク

ゲームにおけるメモリリーク

ゲームは、敵の出現と死亡、レベルの読み込みとアンロード、毎秒数千ものオブジェクトを生成するパーティクルエフェクトなど、オブジェクトの生成と破壊が継続的に行われるため、メモリリークが発生しやすい。ゲームにおけるメモリリークは、徐々にパフォーマンスが低下し、フレームレートが上昇し、最終的にはメモリ不足によるクラッシュを引き起こす。

一般的なゲームのメモリリークパターン:

  • 視覚的には破壊されるが、内部レジストリやイベントシステムからは削除されないゲームオブジェクト
  • シーン遷移後にテクスチャやメッシュがアンロードされないようにするアセット参照
  • 物理エンジンオブジェクトは、エンティティが破棄されたときに明示的に解放されない。
  • グラフィックスAPI呼び出し時にシェーダーまたはGPUリソ​​ースハンドルがリークする

ゲームにおける検出には、エンジン固有のツール(Unity Profiler、Unreal Insightsなど)と標準的なヒーププロファイラの両方が使用されます。シーンのロード間のスナップショット比較は特に効果的です。レベルのロードとアンロード後のヒープサイズは、ロード前とほぼ同じサイズに戻るはずです。

組み込みC言語およびネットワークプログラミングにおけるメモリリーク

組み込みシステムは、メモリ容量が固定されているか、あるいは非常に制限されています。マイクロコントローラのRAM容量は2KBから256KB程度です。デスクトップシステムでは1回の操作で10バイト増加するようなメモリリークでも、組み込みハードウェアでは致命的な問題となります。そのため、組み込み環境では、検出よりも予防​​が重要です。なぜなら、メモリリークが検出可能になった時点で、システムが既に故障している可能性があるからです。

組み込みC言語におけるメモリリークの防止:

  • 可能な限り、動的割り当ては完全に避けるようにしてください。 固定サイズの静的またはスタック割り当てバッファを使用します。動的割り当ては malloc 組み込みシステムにおいては、それは危険であり、多くの場合不必要である。
  • 動的なメモリ割り当てが必要な場合は、固定サイズのメモリプールを使用してください。 起動時にメモリブロックを割り当て、システムの汎用アロケータを呼び出さないプールアロケータで管理する malloc.
  • すべての割り当てには、文書化された所有者とリリース経路があります。 対応する計画なしに一時的な割り当てを行うべきではない free 同じコードパス内、または文書化されたクリーンアップ関数内。

ネットワークプログラミングにおけるリソースリーク防止には、ソケットハンドル、ファイルディスクリプタ、バッファ割り当てに対しても同様の規律を適用する必要があります。開いたソケットはすべて閉じなければならず、ネットワークI/O用に割り当てられたバッファはすべて解放しなければならず、ネットワークデータの読み取り用に取得したファイルディスクリプタはすべて解放しなければなりません。 SO_REUSEADDR (NAIST) と SO_REUSEPORT 適切なソケット閉鎖の代わりにはなりません。

メモリリーク、ダングリングポインタ、バッファオーバーフロー

これら3つは、いずれもメモリ管理の誤りに関わるため混同されがちですが、それぞれ異なる問題です。

問題 結果
メモリーリーク割り当てられたメモリは決して解放されないメモリ枯渇が徐々に進行し、OOMクラッシュが発生する。
ぶら下がりポインタポインタが既に解放されたメモリを参照している未定義の動作、クラッシュ、セキュリティ脆弱性
バッファオーバーフロー割り当てられたバッファの範囲を超えて書き込む隣接メモリの破損、セキュリティ脆弱性

メモリリークは、プログラムが時間とともに過剰なメモリを消費する原因となります。ダングリングポインタは、プログラムが既に所有していないメモリ領域にアクセスしてしまう原因となり、その領域には別のメモリ割り当てによって書き込まれた任意のデータが含まれている可能性があります。バッファオーバーフローは、隣接するメモリ領域を破損させ、予期せぬ動作を引き起こしたり、攻撃者が制御データを上書きしたりすることを可能にします。

これら3つはすべて、メモリ操作を計測し、実行時に違反を報告するC/C++のAddressSanitizerで検出可能です。

メモリリークのコード例

C:漏水箇所を完全に塞ぐ

c

#include <stdlib.h>
#include <string.h>

// Bug: user->name is never freed before user itself
typedef struct {
    char *name;
    int age;
} User;

User *create_user_buggy(const char *name, int age) {
    User *user = malloc(sizeof(User));
    user->name = strdup(name);  // allocates a copy of name
    user->age = age;
    return user;
}

void free_user_buggy(User *user) {
    free(user);           // BUG: user->name leaked
}

// Fix: free nested allocations before the container
void free_user_fixed(User *user) {
    if (user) {
        free(user->name); // free nested allocation first
        free(user);       // then free the container
    }
}

Java: リスナーのリークと修正

ジャワ

import java.util.ArrayList;
import java.util.List;

// Bug: listeners registered but never removed
public class EventBus {
    private static final List<Runnable> listeners = new ArrayList<>();

    public static void register(Runnable listener) {
        listeners.add(listener);
    }

    // Fix: provide a deregistration method
    public static void unregister(Runnable listener) {
        listeners.remove(listener);
    }
}

// Usage -- always pair register with unregister
public class MyComponent {
    private final Runnable listener = this::onEvent;

    public void attach() {
        EventBus.register(listener);
    }

    public void detach() {
        EventBus.unregister(listener);  // ensures no retained reference
    }

    private void onEvent() {
        // handle event
    }
}

C++: RAIIリソースマネージャ

cpp

#include <cstdio>
#include <stdexcept>

// RAII wrapper: file is closed when FileHandle goes out of scope
class FileHandle {
    FILE *file_;
public:
    explicit FileHandle(const char *path, const char *mode)
        : file_(std::fopen(path, mode)) {
        if (!file_) throw std::runtime_error("Cannot open file");
    }
    ~FileHandle() { std::fclose(file_); }  // destructor guarantees close

    // Disable copy to prevent double-close
    FileHandle(const FileHandle&) = delete;
    FileHandle &operator=(const FileHandle&) = delete;

    FILE *get() const { return file_; }
};

void process_file(const char *path) {
    FileHandle fh(path, "r");  // opened here
    // use fh.get() ...
}   // ~FileHandle() called here automatically -- file closed even on exception

Python: tracemalloc リーク検出

パイソン

import tracemalloc

def leaking_function():
    data = []
    for _ in range(10000):
        data.append("x" * 1000)  # 10MB allocated, never freed
    return None  # data goes out of scope here but items may be cached

tracemalloc.start()
leaking_function()
snapshot = tracemalloc.take_snapshot()

top_stats = snapshot.statistics("lineno")
print("Top memory consumers:")
for stat in top_stats[:5]:
    print(stat)

認定条件 SMART TS XL 大規模なメモリリークを検出

手動によるコードレビューとランタイムプロファイリングはどちらもコードの実行を必要とし、レビュー担当者やツールが1回のセッションで確認できる範囲に限定されます。静的解析は、実行前のコード構造をコードベース全体にわたって同時に検査し、メモリリークが実際に実行時に発生する必要なく、メモリリークの原因となることが知られているパターンを特定します。

SMART TS XL 環境内のあらゆる言語のソースコードを取り込み、コードベース全体にわたる割り当てと解放の関係を表す統一された相互参照モデルを構築します。以下を識別します。

  • 割り当てサイト(呼び出し先) malloc, new, open, connect(および各言語におけるそれらに相当するもの)到達可能なすべてのコードパスに対応する解放がない
  • リソースが前に割り当てられる例外処理パス throw しかし、捕獲時にリリースされず、最終的に
  • 時間の経過とともに蓄積されるオブジェクトへの参照を保持する静的フィールドおよびグローバルフィールド
  • コンポーネントのライフサイクルにおいて対応する登録解除がないリスナー登録呼び出し
  • ThreadLocal.set 対応する呼び出しがない remove finallyブロック内で

このプラットフォームの静的コード分析機能は、開発者が数百行を手動で検査するのにかかる時間で、数百万行のコード全体にこれらの検出結果を均一に適用します。パターンが特定されると、分析結果は、割り当てが解放されない理由を示すコードパスとともに、特定のファイル、行、割り当て箇所を返します。これにより、開発者は単なるフラグのリストではなく、問題を修正するために必要なコンテキストを得ることができます。

COBOL、JCL、および最新のアプリケーションコードがすべて相互作用するレガシーシステムの場合、 SMART TS XLさん レガシーの近代化 分析はこれを言語間のリソースフローにまで拡張します。つまり、メインフレームプログラムで取得されたリソースが、リリースパスが保証されていないJavaサービスで消費される場所、またはCOBOLプログラムで開かれたデータベース接続がJCLジョブストリームが終了する前に閉じられない場所を特定します。

ほとんどの記憶漏れを防ぐたった一つの習慣

どの言語、どのフレームワーク、どのランタイムにも独自のメモリ管理メカニズムがありますが、それらすべてに共通する最も効果的な習慣はただ一つ、リソースを作成した瞬間にその所有者を決定し、コード内でその所有権を明示的にすることです。所有権は責任を意味します。ヒープ割り当ての所有者はそれを解放し、データベース接続の所有者はそれを閉じ、イベントリスナーの所有者はそれを削除します。所有権が明確であれば、クリーンアップは容易です。所有権が曖昧であれば、クリーンアップは後回しにされ、後回しにされたクリーンアップこそがメモリリークの原因となります。

メモリリークを防ぐコードパターンは、この原則から直接導き出される。C++のRAII(Return-Assessed Interpretation)では、所有権をスタックオブジェクトに移転し、そのデストラクタが自動的にクリーンアップ処理を行う。 try-with-resources Javaと with Pythonのステートメントは、リソースの所有権の範囲を構文的に可視化します。C++のスマートポインタは、所有権を譲渡および共有可能にし、最後の所有者が終了したときに確実にクリーンアップされるようにします。ティアダウンメソッドでの登録解除は、リスナーと発行者の関係のライフサイクルを明確かつ限定的なものにします。これらのパターンはすべて、本質的に所有権を可視化し、強制を自動化する方法です。

所有権を明確にするのと対をなすのが、明確なテストです。メモリリークは、戻り値のみをチェックする機能テストでは検出できません。接続が閉じられたこと、リスナーが削除されたこと、スレッドローカルがクリアされたこと、バッファが解放されたことなど、リソースの状態をチェックするテストが必要です。これらのアサーションをテストスイートに追加し、CIの一部としてメモリプロファイラを実行し、ステージング環境で継続的に増加するヒープを既知の問題ではなくビルドの失敗として扱うことは、メモリリークが蓄積して本番環境でのインシデントにつながるのを防ぐための運用習慣です。

メモリは有限です。割り当てられて解放されないバイトはすべて、システムの他の部分では使用できないバイトです。何百万ものリクエストを処理するサーバー、何時間も実行されるゲーム、再起動メカニズムのない組み込みデバイスでは、この制約は理論上のものではありません。メモリの所有権を、正確性とセキュリティに適用されるのと同じ規律で扱うことが、システムを最初の展開後、そして最初の割り当てを書いた開発者が他の作業に移った後も、長期間安定させる秘訣です。何百万ものリクエストを処理するサーバー、何時間も実行されるゲーム、再起動メカニズムのない組み込みデバイスでは、この制約は理論上のものではありません。メモリの所有権を、正確性とセキュリティに適用されるのと同じ規律で扱うことが、システムを最初の展開後、そして最初の割り当てを書いた開発者が他の作業に移った後も、長期間安定させる秘訣です。