17 人が閲覧(直近 30 日) プログラミング言語

メモリリークとは?原因の特定方法と直し方を4言語の再現コードで解説

メモリリークとは?原因の特定方法と直し方を4言語の再現コードで解説

メモリリークとは、プログラムが使い終わったメモリを解放しないまま保持し続け、メモリ使用量が時間とともに増えていく不具合です。短時間で終わるプログラムでは、漏れる量が大きくない限り表に出ないこともありますが、サーバーや常駐アプリ、開きっぱなしのブラウザタブのように長く動くものでは、動作が重くなった末に強制終了やクラッシュに至ります。

この記事では、原因、容量不足との見分け方、原因箇所の特定手順を説明したうえで、Node.js・Java・Python・Cでリークを再現し、検出ツールの実際の出力を示します。ChromeやPCのメモリを今すぐ解放したい利用者向けの手順も最後にまとめています。

まとめ:メモリリークの要点

  • メモリリークの正体は、多くの場合「もう使わないのに、どこかから参照が残っているオブジェクト」です。ガベージコレクション(GC)のあるJava・JavaScript・Pythonでも起こります。
  • GCのある言語での代表的な原因は、外し忘れたイベントリスナーやタイマー、上限のないキャッシュやstaticなコレクション、大きな変数を抱えたクロージャです。C/C++では確保したメモリの解放漏れがそのまま原因になります。
  • 判定は使用量の形で行います。同じ負荷でGC直後のヒープ使用量を比べ、戻る水準が上がり続けるならリークを疑います。水準が一定でも、ヒープ外のメモリが増えていればそちらを調べます。
  • 原因の特定は、同じ操作の前後でヒープの中身を2回記録し、増えたオブジェクトの「型」と「保持元」を見るのが基本です。本記事の再現では、Node.jsの差分に外し忘れた関数名がそのまま現れました。
  • GCのある言語では不要な参照を切るか保持量に上限を設け、C/C++では確保に対応する解放をエラー時も含めて確実に行います。setMaxListeners(0)で警告を消したり、-Xmxを上げたりするのは発症を遅らせるだけです。

メモリリークの仕組みと回収されない理由

プログラムは動きながらメモリを確保し、不要になったら返します。C/C++のような手動管理の言語では返すのはプログラマーの仕事で、freeやdeleteを書き忘れればそれがそのままリークになります。Java・JavaScript・Pythonなど自動管理の言語では、GCが実行中のスレッドやグローバル変数などのGCルートから参照をたどり、到達できなくなったオブジェクトを回収します。

GCが調べるのは参照の種類と到達可能性で、「プログラマーがまだ使うつもりか」は判断しません。グローバル変数・staticフィールド・長く生きるオブジェクトから通常の参照(強参照)が1本でも残っていれば、その先のオブジェクトはまとめて生存扱いになります。GCのある言語で起きるリークのほとんどは、この「意図せず残った参照」です。GCが到達可能性をどう判定するかはガベージコレクション(garbage collection)とは?仕組み・アルゴリズム・言語別の違いとGCログの見方で詳しく扱っています。

メモリリーク・リソースリーク・ハンドルリーク・メモリ不足の違い

似た言葉が混同されやすいので整理します。リソースリークは、ファイル・ソケット・データベース接続などメモリ以外の資源も含めた解放漏れの総称で、メモリリークはその一部です。ハンドルリークは、WindowsなどOSが発行するハンドルを閉じ忘れる現象を指します。メモリ不足(out of memory)は原因ではなく結果の状態で、原因がリークのこともあれば、単に必要量が上限を超えただけのこともあります。

用語 対象 性質
メモリリーク ヒープなどのメモリ領域 解放漏れ(原因)
リソースリーク ファイル・接続など資源全般 解放漏れ(上位概念)
ハンドルリーク OSのハンドル 解放漏れ(原因)
メモリ不足 プロセスやシステム全体 枯渇した状態(結果)

メモリリークが起こる主な原因

GCのある言語では、長く生きるオブジェクトが不要なデータを握り続けることが原因になります。C/C++の手動管理では、解放する前にポインタを失うことも原因になります。代表的な原因と、握っている側を整理します。

原因 握っている側 典型例
リスナー・タイマーの解除漏れ イベント発行元・タイマー addEventListener、setInterval
上限のないキャッシュ グローバル変数・static Map、dict、static List
クロージャの抱え込み 登録されたコールバック 外側の巨大な配列を参照
デタッチDOM(ブラウザ) JSの変数・リスナー 画面から外した要素への参照
解放の書き忘れ(C/C++) なし(ポインタを失う) malloc後のfree漏れ、例外での早期return
ClassLoaderの解放漏れ(Java) スレッド・static・ドライバ登録 アプリの再デプロイの繰り返し

最初に疑うのはリスナーとタイマーです。発行元が処理関数を覚え続け、その関数がクロージャで外側の変数を抱えているため、1回の登録で抱える量が想像より大きくなります。

循環参照は、JavaScript・Java・PythonのGCであれば互いに参照し合うだけのオブジェクトも回収できます。問題になるのは、C++のstd::shared_ptr同士の循環のように参照カウントだけで管理している場合です。Javaのラムダ式や匿名クラスが、気づかないうちに外側のインスタンス(this)を抱え込む仕組みはキャプチャリングとは?Javaのラムダ式・匿名クラスと実質finalの制約で実測しています。

リークかメモリ不足かを見分ける方法

「メモリが足りない」ときに最初に決めるのは、リークを探すのか割り当てを増やすのかです。見るのは使用量の値ではなく、時間を追った形です。

GC直後のヒープ使用量によるリークの判定

対象はNode.jsのheapUsedやJavaのGCログに出るGC後のヒープ使用量など、GCが管理するヒープの量です。同じ負荷をかけ続け、グラフを数時間から数日ぶん並べます。GCのある言語ではGCのたびに下がる「のこぎり型」が正常で、見るのは山の高さではなくGC直後の谷の水準です。

形 判定 次の手
谷が毎回同じ水準に戻る ヒープ内の増加傾向なし 症状があればヒープ外を確認
谷が右肩上がりに上がる リークを疑う ヒープの差分を取る
負荷に応じて上下する 正常な変動の可能性 上限到達・障害の有無を確認
起動直後から高く、そのまま平ら 初期確保・キャッシュ 設定値の確認

負荷を止めても下がらず再起動で戻る、というだけでは、リークとキャッシュやランタイムの保持を区別できません。同じ負荷を繰り返し、GC直後の使用量や保持しているオブジェクトの数が増え続けるかで判断します。容量不足かどうかは、設定した上限と実際に必要な量を照らし合わせて決めます。OSのメモリ全体が足りずディスクへの退避が頻発している状態は別の問題で、その切り分けはスワッピングとは?ページングとの違いとスラッシングの判定手順を実装目線で解説で扱っています。

OSごとの確認手順

Windowsでは、Microsoftがシステムにメモリ リークがあるかどうかを判断する手順として、パフォーマンスモニター(perfmon)にMemory\Pool Nonpaged Bytes・Memory\Pool Paged Bytes・Paging File\% Usageの3つのカウンターを追加する方法を示しています。サンプル間隔は600秒(10分ごと)、期間は24時間なら86,400秒が例として挙がっています。値が落ち着くまで時間がかかるため、数時間待ってから判断するよう注意書きがあります。ユーザーモードのリークではPool Paged BytesとPaging File\% Usageが、カーネルモード(ドライバー)のリークではPool Nonpaged Bytesが増え続けます。アプリを特定したいときは、ユーザーモードのリークの調べ方にあるとおり、疑わしいプロセスごとにProcess\Private Bytes(他のプロセスと共有しないメモリ量)とProcess\Virtual Bytesを追加して、どのプロセスが増え続けているかを見ます。

Linuxでは、対象プロセスの常駐メモリ(RSS)を一定間隔で記録するのが手軽です。ps -o rss= -p <PID>をwatchやcronで回し、増え方の形を見ます。ただし、GCのある言語のランタイムは、一度確保したヒープをすぐにはOSへ返さないことがあるため、RSSが下がらないことだけではリークと断定できません。言語側の指標(JavaならGCログ、Node.jsならprocess.memoryUsage().heapUsed)と並べて判断します。

原因箇所を特定する手順:ヒープスナップショットの2点比較

GCが管理するヒープで不要な保持を探す場合は、言語を問わず次の手順で前後を比較します。Cの解放漏れは、後述のValgrindなど専用のツールで調べます。

  1. リークを起こしそうな操作を1つに絞り、何回でも同じ手順で再現できるようにする
  2. 操作の前にGCを走らせてから、ヒープの中身を記録する(ヒープスナップショット/ヒープダンプ)
  3. 同じ操作をN回繰り返し、もう一度GCを走らせてから記録する
  4. 2つの記録を比べ、N回に比例して増えたオブジェクトの「型」を探す
  5. その型のオブジェクトを「誰が保持しているか」(保持元・GCルートからの経路)をたどり、コードの該当箇所を見つける

Nは100や1,000のように切りのよい数にすると、差分の中から「ちょうどN個増えた型」がすぐ見つかります。Chromeの開発者ツールでは、Memoryパネルで操作の前後にHeap snapshotを取って比較表示にすると増えたオブジェクトが並び、選んだオブジェクトの保持元(Retainers)までたどれます。画面から外れたDOM要素は、Class filterに「Detached」と入力するか、Detached elementsプロファイルで探せます(Chrome DevToolsのメモリ問題の解説)。Java向けのEclipse Memory Analyzer(MAT)では、メモリを大きく占有している対象をDominator Treeで、回収されない理由をPath to GC Rootsで追えます。

この手順を、次の章のNode.jsの再現コードで実際に回した結果を先に示します。v8.writeHeapSnapshot()で前後2回スナップショットを書き出し、ノードを種別と名前ごとに数えて差を取りました。

// snap.js として保存し、node --expose-gc snap.js で実行する
// 1,000回の処理の前後でヒープスナップショットを書き出し、種別と名前ごとの件数差を出す
const v8 = require('node:v8');
const fs = require('node:fs');
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
bus.setMaxListeners(0);
function handleRequest() {
  const big = new Array(10_000).fill('x');
  const onConfig = () => big.length;
  bus.on('config', onConfig);
}
function count(file) {
  const s = JSON.parse(fs.readFileSync(file, 'utf8'));
  const f = s.snapshot.meta.node_fields, types = s.snapshot.meta.node_types[0];
  const n = f.length, ti = f.indexOf('type'), ni = f.indexOf('name'), si = f.indexOf('self_size');
  const m = new Map();
  for (let i = 0; i < s.nodes.length; i += n) {
    const key = types[s.nodes[i + ti]] + ':' + s.strings[s.nodes[i + ni]];
    const v = m.get(key) || [0, 0]; v[0]++; v[1] += s.nodes[i + si]; m.set(key, v);
  }
  return m;
}
global.gc(); const a = v8.writeHeapSnapshot('before.heapsnapshot');
for (let i = 0; i < 1000; i++) handleRequest();
global.gc(); const b = v8.writeHeapSnapshot('after.heapsnapshot');
const A = count(a), B = count(b);
const diff = [...B].map(([k, [c, sz]]) => [k, c - (A.get(k)?.[0] || 0), sz - (A.get(k)?.[1] || 0)])
  .filter(x => x[1] > 0).sort((x, y) => y[2] - x[2]).slice(0, 5);
for (const [k, c, sz] of diff) console.log(k.padEnd(28), '+' + c, 'objs', '+' + (sz / 1024 / 1024).toFixed(1), 'MiB');
array:(object elements)      +1001 objs +76.3 MiB
code:                        +7 objs +0.1 MiB
closure:onConfig             +1000 objs +0.1 MiB
object:system / Context      +1000 objs +0.0 MiB
object:Array                 +1001 objs +0.0 MiB

1,000回の処理でちょうど1,000個増えたclosure:onConfigが見つかり、外し忘れたリスナーの関数名がそのまま特定できました。76.3MiBを占めているのは配列の中身で、関数自体は0.1MiBしかありません。サイズ順だけで並べると配列が先頭に来るため、「大きい型」と「N回に比例して増えた型」を両方見るのがこつです。なお、約80MiBのヒープに対して書き出したスナップショットのファイルは254MBありました。Node.jsのドキュメントは、取得時にその時点のヒープの約2倍のメモリが必要で、OOM Killerに終了させられる危険があると注意しています。本番で取るときは、ディスクの空きと書き出し中の停止時間に加えてメモリの余裕を確かめ、止まっても復旧できるプロセスで実行してください。

4言語で再現したメモリリークと検出ツールの出力

各言語で典型的なリークを最小のコードで再現し、検出ツールが実際に何を表示するかを示します。実行環境はmacOS 26.6.2(x86_64)、Node.js v26.5.0、Temurin 25.0.4.1、Python 3.9.6(macOS標準)、Apple clang 21です。Valgrindが公式に対応するmacOSは11.0までで今回のmacOS 26.6.2は含まれないため、Debian 13のコンテナ(gcc 14.2.0、Valgrind 3.24.0)で実行しました。Valgrind本家の現行版は3.27.1です。Node.jsの出力のMiBは1024の2乗バイト単位で、測定値は実行ごとに多少変わります。

Node.js:外し忘れたイベントリスナー

アプリ全体で使い回すEventEmitterに、リクエストごとの処理がリスナーを登録して外さない例です。引数にfixを付けると、処理の最後でoffを呼びます。

// 長命なEventEmitterに、短命な処理がリスナーを登録して外さない
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();          // アプリ全体で使い回す長命オブジェクト
bus.setMaxListeners(0);                  // 警告を消すと気づけない(悪い例)
const fix = process.argv[2] === 'fix';

function handleRequest() {
  const big = new Array(10_000).fill('x'); // リクエストごとの作業データ(約80KB)
  const onConfig = () => big.length;       // クロージャがbigを抱える
  bus.on('config', onConfig);
  if (fix) bus.off('config', onConfig);    // 処理の終わりで外す
}

function mb() { global.gc(); return (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); }
console.log('start', mb(), 'MiB');
for (let round = 1; round <= 5; round++) {
  for (let i = 0; i < 1000; i++) handleRequest();
  console.log(`after ${round * 1000} req`, mb(), 'MiB', 'listeners=', bus.listenerCount('config'));
}
$ node --expose-gc leak_listener.js
start 3.6 MiB
after 1000 req 80.4 MiB listeners= 1000
after 2000 req 156.8 MiB listeners= 2000
after 3000 req 233.3 MiB listeners= 3000
after 4000 req 309.7 MiB listeners= 4000
after 5000 req 386.1 MiB listeners= 5000

$ node --expose-gc leak_listener.js fix
start 3.6 MiB
after 1000 req 4.1 MiB listeners= 0
...
after 5000 req 4.1 MiB listeners= 0

外し忘れた版はGC直後でも1,000回ごとに約76MiBずつ増え、5,000回で386MiBに達しました。offを呼ぶ版は4.1MiBのまま動きません。この例ではわざとbus.setMaxListeners(0)で上限を外しています。setMaxListeners(0)の行を消して実行すると、同じイベントに11個目のリスナーが登録された時点で次の警告が出ます。Node.jsのドキュメントにある既定の上限は10です。

(node:39881) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 config listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

この警告をsetMaxListeners(0)で消してよいのは、リスナーが設計上増え、しかも数の上限が決まっている場合だけです。本番で原因を追うときは、ヒープが上限に近づいたら自動でスナップショットを書き出す--heapsnapshot-near-heap-limitや、シグナルを受けて書き出す--heapsnapshot-signalを起動オプションに付けておくと、再現待ちの手間が省けます(Node.jsのコマンドラインオプション)。Node.js自体の構成や版の選び方はNode.jsとは?仕組み・版の選び方・標準機能で動かす手順を実装目線で解説を参照してください。

Java:staticなMapに溜まり続けるセッション

セッションごとのデータをstaticなHashMapに入れ、使い終わっても消さない例です。staticフィールドはクラスが読み込まれている間ずっと生きているため、中身は永遠に回収されません。

import java.util.*;

public class LeakCache {
    // static なMapは、クラスが生きている限り中身を手放さない
    static final Map<String, byte[]> CACHE = new HashMap<>();

    static byte[] load(String sessionId) {
        return CACHE.computeIfAbsent(sessionId, k -> new byte[100_000]);
    }

    public static void main(String[] args) throws Exception {
        long sleep = args.length > 0 ? Long.parseLong(args[0]) : 0;
        for (int i = 0; ; i++) {
            load("session-" + i);   // 使い終わったセッションを消していない
            if (sleep > 0 && i % 50 == 0) Thread.sleep(sleep);
        }
    }
}
$ javac -d . LeakCache.java
$ java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./oom.hprof LeakCache
java.lang.OutOfMemoryError: Java heap space
Dumping heap to ./oom.hprof ...
Heap dump file created [65475669 bytes in 0.183 secs]

# 動いているプロセスに対して、生きているオブジェクトをクラス別に数える
$ java -Xmx256m LeakCache 20 &
$ jcmd <PID> GC.class_histogram
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:          4988      125261592  [B ([email protected])
   ...
   5:          2227          71264  java.util.HashMap$Node ([email protected])
# 2秒後にもう一度
   1:          6788      215304792  [B ([email protected])

ヒープ上限を64MiB(-Xmx64m)にするとOutOfMemoryError: Java heap spaceで停止し、-XX:+HeapDumpOnOutOfMemoryErrorの指定により停止直前のヒープダンプ(約65MB)が自動で残りました。このファイルをEclipse MATで開き、Dominator Treeでbyte[]を大量に保持しているオブジェクトを探します。

動いているプロセスなら、jcmd <PID> GC.class_histogramを時間を空けて2回取るだけでも、増え続けるクラスが分かります。上の例ではbyte[](表示は[B)が2秒で4,988個から6,788個に増えました。このコマンドは既定では到達可能なオブジェクトだけを数えるので(到達不能なものも含めるには-all)、増えた数がそのまま回収されなかった数です。ヒープダンプはjcmd <PID> GC.heap_dump <ファイル名>で取れます。jcmdのヘルプはどちらの負荷も「High」としているので、本番では実行のタイミングを選んでください。

注意したいのは、Oracleのトラブルシューティング・ガイドが、Java heap spaceは必ずしもメモリリークを意味しないと明記している点です。ヒープの指定(または既定値)が処理量に対して小さいだけのこともあります。同じガイドは、GC overhead limit exceededを「GCに約98%以上の時間を使っても回収できるのが2%未満」の状態、Metaspaceをクラスのメタデータ領域の枯渇として区別しています。再デプロイのたびにMetaspaceが増えるなら、ClassLoaderリークを疑います。ヒープ外のネイティブメモリが増えている場合はNative Memory Tracking(NMT)とは?jcmdでJVMのネイティブメモリを調べる手順、ヒープサイズの決め方はJavaヒープとは?メモリ構造・サイズ設定・OutOfMemoryErrorの対処が参考になります。

直し方は、使い終わったセッションをremoveするか、キャッシュに上限を持たせるかです。上限付きにした次の版は、同じ64MiBの上限で100万件を処理してもsize=100のまま完走しました。

import java.util.*;

public class BoundedCache {
    static final int MAX = 100;
    // アクセス順のLinkedHashMapで、上限を超えたら最も古い1件を捨てる
    static final Map<String, byte[]> CACHE = new LinkedHashMap<>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
            return size() > MAX;
        }
    };

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            CACHE.computeIfAbsent("session-" + i, k -> new byte[100_000]);
        }
        System.out.println("size=" + CACHE.size());
    }
}

Python:上限のない辞書キャッシュとtracemalloc

Pythonでも、モジュール変数の辞書に結果を溜め続ければ同じことが起こります。標準ライブラリのtracemallocを使うと、2時点のスナップショットを比べて、増えたメモリを確保したソースの行まで表示できます。

import tracemalloc

_cache = {}                     # 上限も期限もないモジュール変数

def render(user_id):
    key = f"user:{user_id}"
    if key not in _cache:
        _cache[key] = "x" * 10_000   # 1件あたり約10KB
    return _cache[key]

tracemalloc.start()
before = tracemalloc.take_snapshot()
for uid in range(5_000):
    render(uid)
after = tracemalloc.take_snapshot()

for stat in after.compare_to(before, "lineno")[:3]:
    print(stat)
$ python3 leak_cache.py
leak_cache.py:8: size=48.1 MiB (+48.1 MiB), count=5001 (+5001), average=10076 B
leak_cache.py:6: size=282 KiB (+282 KiB), count=5000 (+5000), average=58 B
leak_cache.py:5: size=115 B (+115 B), count=2 (+2), average=58 B

5,000件の処理で、8行目("x" * 10_000)が48.1MiB増えたことが行番号つきで出ました。確保した行が直接分かるので、Pythonではまずtracemallocを試すのが近道です。キャッシュをfunctools.lru_cacheに置き換えて上限を256件にすると、同じ処理での増分は2.5MiBに収まりました。

from functools import lru_cache

@lru_cache(maxsize=256)          # 上限を超えたら、使われていない順に捨てる
def render(user_id):
    return "x" * 10_000

C:free漏れとValgrind・AddressSanitizer・leaks

Cでは、確保したメモリへのポインタを失った時点でリークが確定します。次のコードには、構造体の先頭ポインタを捨てるリーク、ループ内で毎回確保して解放しないリーク、終了時までstatic変数が指したまま残る確保の3種類を入れています。

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

typedef struct Node { char *name; struct Node *next; } Node;

static Node *make(const char *s) {
    Node *n = malloc(sizeof(Node));
    n->name = strdup(s);   /* 2つ目の確保 */
    n->next = NULL;
    return n;
}

int main(void) {
    Node *a = make("leaked");        /* 先頭を失う:definitely + indirectly */
    a = NULL;
    for (int i = 0; i < 3; i++) {
        char *buf = malloc(64);      /* ループ内で毎回確保して free しない */
        buf[0] = 'x';
    }
    static Node *keep;               /* プログラム終了時まで指している:still reachable */
    keep = make("kept");
    return 0;
}

ValgrindのMemcheckに--leak-check=fullを付けて実行すると、リークを4種類に分けて報告します。

$ gcc -g -O0 leak.c -o leak
$ valgrind --leak-check=full --show-leak-kinds=all ./leak
...
==4056== 192 bytes in 3 blocks are definitely lost in loss record 5 of 5
==4056==    at 0x4844818: malloc (vg_replace_malloc.c:446)
==4056==    by 0x1091C0: main (leak.c:17)
...
==4056== LEAK SUMMARY:
==4056==    definitely lost: 208 bytes in 4 blocks
==4056==    indirectly lost: 7 bytes in 1 blocks
==4056==      possibly lost: 0 bytes in 0 blocks
==4056==    still reachable: 21 bytes in 2 blocks
==4056==         suppressed: 0 bytes in 0 blocks

definitely lostは、どこからもポインタが見つからないブロックです。構造体の先頭(16バイト)とループの3回分(192バイト)がここに入りました。indirectly lostは、そのブロック自体を指すポインタはあるが、指している側がすでに失われているブロックで、構造体が持っていた文字列(7バイト)が該当します。still reachableは終了時点でもポインタが残っているブロックで、static変数が指していた2ブロックです。修正の優先度はdefinitely lostが最も高く、still reachableは終了時に解放していないだけのことが多いため後回しで構いません。

GCCやClangのAddressSanitizer(-fsanitize=address)は、Linuxでは既定でLeakSanitizerによるリーク検出も行います。出力は一部省略しています。

$ gcc -g -fsanitize=address leak.c -o leak_asan && ./leak_asan; echo $?
==4063==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 192 byte(s) in 3 object(s) allocated from:
    #1 0x5c4b5a1e3266 in main /w/leak.c:17
Direct leak of 16 byte(s) in 1 object(s) allocated from:
    #1 0x5c4b5a1e31be in make /w/leak.c:7
Indirect leak of 7 byte(s) in 1 object(s) allocated from:
    #1 0x5c4b5a1e31ce in make /w/leak.c:8

SUMMARY: AddressSanitizer: 215 byte(s) leaked in 5 allocation(s).
1

合計215バイトはValgrindのdefinitely lostとindirectly lostの合計と一致し、still reachableの21バイトは報告されません。この実行例ではリークを検出すると終了コードが1になるため、CIのテストで失敗として扱えば、テストが通る経路で検出したリークを含む変更をマージ前に止められます。Clangの公式文書によるとAddressSanitizerによる速度低下は典型的に2倍程度で、その代わりに専用のビルドが必要です。

macOSでは事情が異なります。LLVMのAddressSanitizerの文書はmacOSでもASAN_OPTIONS=detect_leaks=1で有効にできると書いていますが、Xcode付属のApple clang 21(x86_64)では、この指定を付けても「not supported on this platform」と表示されて何も検出されませんでした。Macでは標準のleaksコマンドを使います。

$ clang -g -O0 leak.c -o leak_mac
$ MallocStackLogging=1 leaks --atExit -- ./leak_mac
Process 36997: 4 leaks for 208 total leaked bytes.
STACK OF 2 INSTANCES OF 'ROOT LEAK: <malloc in main>':
1   leak_mac    main + 62  leak.c:17
...
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in make>':
2   leak_mac    main + 27  leak.c:14
1   leak_mac    make + 22  leak.c:7

# Apple clang では LeakSanitizer が動かない
$ clang -g -fsanitize=address leak.c -o leak_masan
$ ASAN_OPTIONS=detect_leaks=1 ./leak_masan
==37201==AddressSanitizer: detect_leaks is not supported on this platform.

leaksはループで確保した3ブロックのうち2つしか報告しませんでした。メモリ上にポインタらしき値が残っていると「まだ参照されている」とみなす保守的な走査をするため、最後の1回分のポインタがスタックやレジスタに残っていたと考えられます。表示サイズも80バイトや32バイトと、要求した64バイトや16バイトより大きくなっています。これはmacOSのアロケータが割り当てる単位に切り上げた値です。ツールが違えば件数もバイト数も変わるので、修正の前後は同じツール・同じ条件で比べてください。Cのメモリ不正アクセスで落ちる場合の調べ方はセグメンテーションフォルト(Segmentation fault)の原因と調べ方|core dumpedの意味・gdb・ASan、ポインタの基本はポインタとは?C言語での使い方とメリット・言語別の違いをわかりやすく解説で解説しています。

環境別の検出ツールの選び方

ここまでの実測を踏まえ、最初に使うツールを環境別にまとめます。どれも無料です。

環境 最初に使う 深掘り・自動化
ブラウザ(JS) Chrome DevTools Memory memlab
Node.js writeHeapSnapshot・起動オプション Chrome DevToolsで解析、memlab
Java jcmd GC.class_histogram ヒープダンプ+Eclipse MAT・VisualVM
Python tracemalloc gc.get_referrers で保持元を確認
C/C++(Linux) AddressSanitizer Valgrind Memcheck
C/C++(macOS) leaks Instruments(Xcode)
Windows全体 パフォーマンスモニター ドライバーはPoolMon等

memlabはMetaが公開しているJavaScript向けのメモリリーク検出ツールで、npmの最新版は2.0.5(2026年8月公開)です。ページを開く・操作する・元に戻す手順をシナリオに書くと、スナップショットの取得から増えたオブジェクトの抽出までを自動で回すので、画面遷移の多いSPAをCIで検査するのに向きます。ブラウザの操作に使っているPuppeteerの仕組みはPuppeteerとは?Chrome自動操作Nodeライブラリの仕組みと使い方【2026年最新】で解説しています。

メモリリークの直し方と予防

GCのある言語では不要な参照を切り、キャッシュには上限を設けます。C/C++の手動管理メモリでは、確保に対応する解放を、正常終了時とエラー時の両方で実行します。

  • 登録したものは必ず対で外す:addEventListenerとremoveEventListener、setIntervalとclearInterval、onとoff。ReactのuseEffectのように後始末の場所が決まっているフレームワークでは、そこにまとめて書く。
  • 終わりを待たなくてよい非同期処理は取り消す:fetchやイベント登録にはAbortControllerのシグナルを渡し、画面を離れるときにabort()する。
  • キャッシュには必ず上限か期限を付ける:JavaのLinkedHashMap+removeEldestEntry、Pythonのlru_cache(maxsize=…)など。オブジェクトをキーにする付帯情報なら、JavaScriptのWeakMapを使えばキーが不要になった時点で一緒に回収される。
  • C/C++では所有者を型で表す:C++ならstd::unique_ptrを基本にし、std::shared_ptrの循環はstd::weak_ptrで切る。Cでは確保と解放を同じ関数の入口と出口に置き、エラー時の早期returnで解放を飛ばさない。
  • CIで検出する:C/C++はAddressSanitizer付きのテスト、SPAはmemlabのシナリオを回し、リークを含む変更をマージ前に止める。

-XmxやNode.jsの--max-old-space-sizeを上げる、定期的に再起動する、という対応は、修正が間に合わないときの時間稼ぎに限るべきです。恒久対策にすると、増え方が速くなっても気づけません。再起動で凌ぐなら、再起動直前の使用量を記録し、増加速度を監視の対象に入れてください。社内システムのリークが長く解消しない、作り直しも含めて検討したいという場合は、フルスクラッチでのシステム開発として相談を受け付けています。

ChromeやPCのメモリを解放する手順(利用者向け)

リークそのものはアプリやWebページの不具合なので、利用者が直すことはできません。利用者側でできるのは、メモリを増やし続けているタブやアプリを見つけて、再読み込みや終了でいったん解放することです。

  1. Chromeのタスクマネージャーを開きます。WindowsとLinuxではShift+Escで開けます(Chromeのキーボードショートカット)。Macではメニューの[ウィンドウ]から開きます。「メモリ使用量」の列で並べ替え、極端に大きいタブや拡張機能を探します。
  2. 見つけたタブを再読み込みするか閉じます。タスクマネージャーで選んで[プロセスを終了]を押しても止められます。拡張機能が原因なら、その拡張機能を無効にします。
  3. Chromeのメモリセーバーをオンにします。設定の[パフォーマンス]にある[メモリセーバー]で、使っていないタブを非アクティブにして節約し、タブを開き直すと自動で再読み込みされます。節約の度合いは「適度」「バランス重視(推奨)」「最大」から選べます(Chromeのパフォーマンス設定のヘルプ)。
  4. PC全体が重い場合は、Windowsのタスクマネージャー(Ctrl+Shift+Esc)の[プロセス]タブでメモリ列を並べ替え、時間とともに使用量が増え続けるアプリを終了して起動し直します。数時間単位で記録したいときは、前述のパフォーマンスモニターで、そのアプリのProcess\Private Bytesを追加します。

同じサイトで繰り返し重くなる場合は、タスクマネージャーで見たメモリ使用量の増え方と再現手順を記録して、サイトの運営元に報告します。原因はリークとは限らず、ページがもともと大量のメモリを使う場合もあります。メモリ解放をうたうアプリで一時的に空きを作っても、リークしているプログラムは再び使用量を増やしていきます。

よくある質問

メモリリークは放置するとどうなりますか?

使用量が増え続け、応答の遅れ、スワップの多発、最終的には強制終了やクラッシュに至ります。JavaならOutOfMemoryError、LinuxならOOM Killerによるプロセスの強制終了として表れることが多いです。リークは自然には直らないため、不要な参照の除去、保持量の制限、C/C++なら解放漏れの修正といった、原因に応じた修正が必要です。

メモリリークとメモリ不足は同じですか?

違います。メモリリークは解放漏れという原因で、メモリ不足は枯渇したという結果です。メモリ不足の原因はリークのこともあれば、単に必要量が上限を超えただけのこともあります。GC直後の使用量が時間とともに上がり続けるかどうかで見分けます。

メモリリークの原因特定は何から始めればよいですか?

リークを起こす操作を1つに絞り、その前後でヒープの中身を記録して比べます。Node.jsならv8.writeHeapSnapshot()、JavaならjcmdのGC.class_histogram、Pythonならtracemallocのcompare_toが手軽です。

ガベージコレクションがある言語でもメモリリークは起こりますか?

起こります。不要になったオブジェクトでも、static変数・グローバル変数・登録したままのリスナーなどGCルートから強参照でたどれる間は回収されません。

「OutOfMemoryError: Java heap space」はメモリリークですか?

必ずしもそうとは限りません。Oracleのガイドも、ヒープの指定が処理量に対して小さいだけの場合があると明記しています。-XX:+HeapDumpOnOutOfMemoryErrorでダンプを残し、意図せず溜まっているオブジェクトがあればリーク、正当なデータで上限を超えているだけなら設定の見直しと判断します。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

ほか 4 件の記事からもリンクされています。

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.07.21 テックブログ Apache Tomcatの脆弱性一覧【2026年10月】最新版11.0.27・10.1.61・9.0.123とCVE-2026-59083ほかの影響確認・対応手順
  2. 2026.10.22 テックブログ Tomcatバージョン一覧とサポート期限【2026年10月】9.0系は2027年3月末で終了・9.1系と11.0系への移行判断
  3. 2026.10.21 テックブログ NEC委託先の不正アクセスとは|顧客システム暗号化・情報漏えいの経緯と委託元が点検する正規アカウント
  4. 2026.05.15 テックブログ scrcpyの最新版は5.0|ハードウェアデコード対応とインストール・使い方【2026年10月】
  5. 2026.10.21 テックブログ ふるさとチョイス不正アクセスで445万件流出:対象確認・パスワード変更と再開時期

RELATED POSTS 関連記事

目次