18
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

“見えてるけど見えてない”メモリリークの実体と 5 分で防ぐテクニック

Last updated at Posted at 2025-11-19

はじめに

現場で「なんだかメモリが増えている気がする」「GC(ガベージコレクション)があるから大丈夫だと思ったら落ちた…」「原因がよく分からないままサービス再起動してごまかしている」という経験はありませんか?
本記事では、そんな“見えてるけど見えていない”存在、つまり 「メモリリーク」 をテーマに、現場のエンジニアがつまづきがちなポイントを整理していきます。

対象読者は、たとえば以下の方々です。

  • 実務で Java/C#/Go/JavaScript 等を使っていて、「メモリが増えてる?」と漠然と思ったことのある方
  • GCあり・なし問わず「なぜメモリリークが起こるのか」を原理から理解したい方
  • コードレビューや設計段階で「このコード、リークしてないかな?」とチェックできるようになりたい方

なぜ「見えてるけど見えていない」のでしょう?
それは、メモリリークが即座にエラーになるわけではなく、長時間の稼働や累積的な影響で初めて影を落とすことが多いから。
そのため、原因と挙動をキチンと押さえておかないと、思わぬ時間経過のあとに「なんで落ちたんだ…」となってしまいがちです。

本記事の構成は次のとおりです。

  1. メモリリークとは何か(定義・なぜ気付きにくいか・影響)
  2. メモリリークの動作メカニズムを言語横断的に整理
  3. 各言語/環境でよくある典型パターンと再現コード
  4. “5分で防ぐ”実践テクニック
  5. 実践クイズ:このコード、メモリリークしています。どこが原因でしょう?

それでは、まず「メモリリークとは何か」から紐解いていきましょう。

弊社Nucoでは、他にも様々なお役立ち記事を公開しています。よかったら、Organizationのページも覗いてみてください。
また、Nucoでは一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。

メモリリークとは何か

メモリリークの定義・発生メカニズム 

「メモリリーク」という言葉を聞くと難しそうに感じるかもしれませんが、シンプルに言えば 「プログラムが確保したメモリを使い終わったのに、解放/再利用できずに残り続ける」現象 です。 
たとえば C/C++ のように手動で確保・解放を行う言語では、mallocしたあと freeを忘れると典型的。
一方でGC(ガベージコレクション)がある言語でも、「使われなくなったオブジェクトに参照が残ってしまっている」 と、GCが回ってもメモリが戻らずリークになるケースがあります。

なぜ“見えてない”(気付きにくい)のか

なぜ「見えてるけど見えていない」のか。それは以下のような理由があります。

  • メモリ使用量が時間をかけてゆっくり増えていくため、一気にクラッシュするわけではない。
  • GCありの環境では「解放はGCがやってくれるだろう」と安心しがち。だから“参照を残してしまっている”ことに気付きにくい。
  • 長時間稼働するサーバー/サービスでは、小さなリークが累積して大きな問題になるが、テストでは見つけにくい。

リークがシステムに与える影響

メモリリークを放置すると、次のような影響が出ることがあります。

  • プログラムやサービスのメモリ使用量がじわじわ増加し、余裕が少なくなっていく。
  • パフォーマンスが低下する(GCが頻繁に動く、ページングが増えるなど)。
  • 最悪の場合は Out Of Memory(OOM) によるクラッシュや強制再起動。特に長時間稼働・高可用性が求められる環境では致命的です。 

以上を踏まると、メモリリークは「ちょっとの油断が長期的な事故につながる」タイプのバグだと捉えることができます。
次の章では、GCあり/なしという観点で、どういう仕組みでリークが起きるかを整理していきます。

メモリリークの動作メカニズムを言語横断的に整理

システムで「メモリリークが起きているかも」と感じたとき、その本質的なしくみを理解しておくと、対策もぐっと楽になります。
ここでは、GC(ガーベジコレクション)あり・なしといった環境の違いを押さえながら、なぜリークが発生するのか、「何が見えていないのか」を整理します。

GCあり言語の場合(Java/C#/Go 等)

なぜ"メモリ解放忘れ"とは少し違うのか

GCありの言語では、たとえば Java Virtual Machine(JVM) や .NET CLR が「もう使われていないオブジェクト(参照が到達できないオブジェクト)」を自動的に検出して解放してくれます。
そのため「new したけど delete/free を書き忘れた」という典型的な手動メモリ管理言語の“明示的解放忘れ”型とは少し事情が異なります。
つまり、GCあり環境でのリークは多くの場合、「プログラム上はもう使わないと思っているオブジェクトに“実は参照が残ってしまっている”」 という状態が原因です。
たとえば、あるオブジェクト A が不要になっても、静的コレクションやイベントリスナー登録などを通じて B が A を参照していると、A は「生きている」とGCから見なされ、解放されません。

典型的なメカニズム

  • 参照チェーン:GCは「ルート(スタック変数、静的変数、スレッドなど)から参照可能なオブジェクト」を“生きている”とみなします。逆にどこからも参照されないオブジェクトは“ゴミ”として回収されます。
  • ルートからのチェーンが切れない:参照が残っていると「もう使っていない」と思っていても解放されない。たとえば静的リスト、キャッシュ、イベント購読などにより参照が残ったまま。
  • リスナ/コールバック登録の解除漏れ:典型的なパターンとして、観察者パターン (Observer) で登録したイベントを解除し忘れ、リスナオブジェクトがずっと残り続ける「ラプスト・リスナ問題(Lapsed Listener Problem)」があります。
  • Goルーチン・チャネル・スレッドの放置(Go など):たとえば Go では、Goルーチンがチャネルでブロックされ続けていたり、参照を保持し続けていたりすると、GCありでも実質的にリークになることがあります。

補足ポイント

  • GCあり言語だから「メモリ管理は安心」と思いがちですが、参照のライフサイクル設計が甘いとリークは普通に起こります。
  • リークを発見・解析するには、ヒープダンプを取り「GC root(ルート)からどのオブジェクトがたどられているか(Path to GC Root)」を調べるのが有効です。

GCなしまたは手動管理環境(C/C++/組み込み系など)

GCがない、または手動でメモリ管理を行う環境では、基本的には「確保したメモリを開放し忘れる」「外部リソースを閉じない」が典型的です。

  • mallocfree を忘れる、newdelete を忘れる。
  • ファイルハンドル、ソケット、GUIリソース、OSリソースなどを開け放しにする。
  • ポインタがどこかに残っていて「もう使わない」と思ったけど解放できない状況。

手動管理型のリークは比較的“明らかにミス”ですが、規模の大きなシステム・長時間稼働の組み込み系では、どこで忘れたか/どれが生き残っているかを追うのが難しいこともあります。

共通して起こる典型パターン

言語/環境が異なっても、メモリリークが発生しやすい構造・パターンには共通項があります。以下はよくあるものです。

  • 静的コレクション・キャッシュが肥大化:無制限に MapList にオブジェクトを溜め続ける。
  • イベント/コールバック登録解除忘れ:観察者を登録しっぱなしで放置。
  • タイマー/スレッド/Goルーチンが停止しないまま放置:生成され続けて終了しない、参照を残す。
  • 外部リソースを開けっぱなし:ファイル、ソケット、DB接続などを閉じ忘れる。
  • 参照のライフサイクルを設計できていない:「いつまでこのオブジェクトを使うか」が曖昧。
  • GCあり環境ならでは:参照が残っているが「使っていない」と思っている状態。これは “見えてない” に繋がる重要なポイントです。

各言語/環境でよくある典型パターンと再現コード

ここでは、実務でよく使われる言語(GCあり・GCなし双方)を取り上げ、「こういう書き方をするとメモリリークしやすい」という典型パターンと簡単な再現コードを紹介します。
コードを見ることで「あ、これは見たことがある/やってるかも」と気づき、以降の防止策がグッと活きてきます。

Java(GCあり)

典型パターン

  • 静的なコレクション(例:static List<>static Map<>)にオブジェクトを追加し続け、削除やクリアの仕組みが無い。
  • イベントリスナ/コールバック登録を解除せず、長寿命オブジェクトが不要になったあとも参照を持ち続ける。
  • リソース(ファイル・ストリーム・DB接続など)を開いたままにすることで「参照残り+ネイティブ領域喰い」が起きる。

簡易的再現コード

public class Cache {
    private static final List<byte[]> cache = new ArrayList<>();
    public void addData() {
        cache.add(new byte[1024 * 1024]);  // 1MBずつ追加
    }
}

上記では、Cache をインスタンス化して addData() を呼ぶたびに 1MB の配列が static cache に積み増しされます。cache がアプリ終了まで生きてしまうため、GC が「この領域もう使ってない」と判断できず、メモリがどんどん増えていきます。実際、静的参照を使った典型的な Java のメモリリーク例として挙げられています。 

解説ポイント

  • GC付き言語だからと言って安心してはいけません。オブジェクトが「参照されていない」=「メモリ解放可能」ではなく、「GC root(スタック、静的変数、スレッド等)から参照されていない」状態でないと解放されません。
  • 「使い終わったから参照を捨てよう」あるいは「静的コレクション/キャッシュには上限・クリア机制を設けよう」が鍵です。
  • ヒープダンプを取って「どのオブジェクトが GC root から参照されているか?」を調べるのが有効です。

C#(GCあり・でもアンマネージドリソース注意)

典型パターン

  • イベント/デリゲート(event)登録したままで解除を忘れる。イベント発行元が長寿命の場合、購読者が常に参照されてしまい GC の対象にならない。
  • IDisposable を実装したクラスで Dispose() を呼ばず、アンマネージドリソース(ファイルハンドル、ネイティブメモリ)を放置。

簡易的再現コード

public class Producer {
    public event EventHandler SomethingHappened;
    public void Trigger() {
        SomethingHappened?.Invoke(this, EventArgs.Empty);
    }
}
public class Consumer {
    public Consumer(Producer p) {
        p.SomethingHappened += Handler;
    }
    void Handler(object s, EventArgs e) {
        // 何らかの処理
    }
}
// Consumer インスタンスを破棄しても、Producer が生きていると
// Handler が参照され続け、Consumer が GCされない可能性あり

このパターンでは、購読を解除する処理が無い限り、ProducerConsumer のインスタンスを保持し続けているかのような状態になり、参照が切れずリークになります。

解説ポイント

  • C# も GCありですが、実装がアンマネージドリソースやイベントのライフサイクルに依存するため、参照の残存に気を付ける必要があります。
  • Dispose()using パターンの徹底、イベント購読解除の実装が“見えていない”原因を排除します。
  • コードレビュー時には「イベント登録+登録解除」の対ペアを意識することで防止精度が上がります。

Go(GCあり)

典型パターン

  • Goルーチン(goroutine)を起動したまま終了条件を用意しておらず、チャネル待機・select 文でブロックしっぱなしの状態になる。これは「メモリリーク」だけでなく、Goルーチンリークとも呼ばれ、長時間稼働アプリケーションでは致命的なパフォーマンス劣化を招きます。
  • 長時間の接続やチャネルを閉じないまま放置。
  • リファレンスチェーンの設計が甘く、GC が解放できないものが残る。

簡易的再現コード

func worker(ch chan struct{}) {
    <-ch  // 永遠に待ってしまう – ch が使い終わっても閉じられなければここから出られない
}
func main() {
    for i := 0; i < 1000; i++ {
        go worker(make(chan struct{}))
    }
    time.Sleep(time.Minute)
}

このコードでは、worker がそれぞれのチャネルからの受信でブロックされ、「終了しない状態」に置かれています。チャネルが外部から閉じられなければ、Goルーチンも終了せず、スタック/変数が残り続けてメモリを消費し続けます。

解説ポイント

  • Go においては「GCがあるから参照がクリアされるだろう」という見込みで動くと、Goルーチン・チャネル・リソースの停止制御設計を忘れることが多いです。
  • 「このゴルーチンはいつ・どうやって終わるか?」を明文化することが重要です。
  • プロファイラ(pprof)、メモリプロファイルやゴルーチン数モニタリングを使って「Goルーチンが増え続けていないか?」をチェックできます。

JavaScript(GCあり/ブラウザ・Node.js)

典型パターン

  • DOM 要素やグローバル変数に大量のデータを保持し、イベントリスナーを解除しないまま残す。結果、不要なオブジェクトでも参照が残って GC対象にならない。
  • タイマー(setIntervalsetTimeout)をクリアしない、またはクロージャで大量データを囲んでしまう。
  • Node.js では大きなバッファオブジェクトをグローバルに持ち続けてしまう。

簡易的再現コード

function create() {
  const huge = { data: new Array(1000000).fill("leak") };
  window.addEventListener('resize', () => {
    console.log(huge);
  });
}
for (let i = 0; i < 1000; i++) {
  create();
}

この例では、huge オブジェクトを持った関数 create() を複数回呼び、各回でイベントリスナーを登録しています。リスナーが解除されず huge をクロージャ経由で参照し続けるため、GC が抜け出せない状態が続きます。

解説ポイント

  • ブラウザ環境でも同様に “参照が残ってしまう=GCされない” という構造が典型です。
  • 開発者ツール(Chrome DevTools 等)で「ヒープスナップショットを撮る」「初期状態と操作後と戻った状態を比較する」が有効です。
  • Node.js 環境では長時間稼働プロセスなので「リークしているかも?」の疑いを持ったら、ヒープダンプを定期的に取得して増加傾向を観察すべきです。

まとめて比較

以下の表は、異なる言語/環境で“どこに落とし穴があるか”を整理したものです。

環境 GC の有無 よくあるパターン 主な防止ヒント
Java/C#/Go あり 参照を残し続ける(静的コレクション・イベント・Goルーチン) 参照の解放設計、弱参照(WeakReference)活用など
C/C++/組込み系 なし 明示的な解放忘れ、リソース放置 RAII、スマートポインタ、明確な解放コード
JavaScript あり グローバル・DOM・タイマー・クロージャで不要データ保持 イベント解除、タイマー停止、ヒープ分析

以上、各言語/環境ごとの典型パターンと再現コードを紹介しました。次章では「”5分で防ぐ”実践テクニック」に移り、実務で今日から使える防止策を解説します。

“5分で防ぐ”実践テクニック

この章では、現場のコードレビューや実装段階ですぐ使える「5分でできる」メモリリーク防止のテクニックを、共通ルール+言語別ワンポイントに分けて紹介します。
軽く「今日からこれだけは意識しよう」という項目として活用してください。

共通テクニック

どんな言語・環境であっても、次のような習慣を持っておくとリークの「怖さ」をかなり軽くできます。

  • 参照のライフサイクルを明確にする
    「このオブジェクトは誰が使っていて、いつまで使うのか?」を意識する。使い終わったら参照をクリア/スコープから抜けるように設計する。
    GCあり環境でも、参照が残っている限り解放されません。
  • コレクション/キャッシュ/マップに“無限増大”を許さない
    静的な ListMap、キャッシュ構造などが増え続けると、それ自体がメモリリークの温床になります。例えば、要素数上限を設けたり、LRU(最少使用)方式で削除を行ったりする工夫を。
  • イベント・タイマー・スレッド/Goルーチンの登録解除を必ずペアで実装する
    リスナーを登録したら解除、タイマーを開始したら停止、Goルーチンを起動したら終了条件を用意、など。解除忘れが参照を残し、リークを招きやすい典型です。
  • ツールを使って“戻るべきところが戻っているか”を定期チェックする
    長時間動くアプリなら、ヒープスナップショット、メモリ/Goルーチンプロファイル、GCログなどを定期的に取得して「メモリ使用量が戻っているか?」「Goルーチン数が減っているか?」などを確認。
  • コードレビュー&チェックリスト化
    新規実装・変更時に「静的コレクションに値を追加しっぱなしになっていないか」「イベント/タイマーに解除処理があるか」「キャッシュに上限があるか」などをチェックリスト化しておくと、未然防止につながります。

5分チェックリスト(実践用)

  1. 長時間稼働するプロセス/サービスで、メモリ使用量がじわじわ増えていないか?
  2. 静的/グローバル変数に大量のオブジェクトを溜めていないか?
  3. イベントリスナー/タイマー/Goルーチンなどの「開始に対して終了(解除・停止・終了条件)」が実装されているか?
  4. 不要になった参照を null にする/スコープ外に出すなど、明示的に手放す仕組みがあるか?
  5. プロファイルツール・ヒープダンプ・ログ等を使って「増えて終わり」でないこと(=戻ること)を確認しているか?

言語別ワンポイント

ここからは、主要言語・環境別に「今日から一歩改善できる小ネタ」を紹介します。自分のプロジェクトの言語にあわせてチェックしてください。

Java

  • 静的変数・静的コレクションには慎重に。特に static List<>, static Map<> などに注意。不要になったオブジェクトを残しておくと、GCでは解放されません。
  • キャッシュを使うなら、WeakReference/SoftReference を活用したり、明確な削除ルールや上限を設けましょう。
  • イベントリスナーや内部クラス(特に非静的内部クラス)は、外部クラスの参照を暗に保持してしまうため、設計時に注意。

C#

  • IDisposable を実装しているクラスを使ったら、using/Dispose()を必ず。アンマネージドリソースを置き去りにしないようにしましょう。
  • イベント(event)の += 登録に対して、必ず -= 登録解除がペアになっているかをコードレビュー時に確認。

Go

  • Goルーチンを起動したら「いつ終わるか」「チャネルは閉じるか」「selectで逃げ道を作るか」を設計。ブロック状態のまま放置するとGoルーチンリークになります。
  • プロファイラ pprof を使って「Goルーチン数」「メモリ使用量」をモニタリング。増えっぱなしになっていないかをチェック。
  • チャネルを make(chan struct{}) で作るなら、使用後に close() を忘れずに。

JavaScript(ブラウザ/Node.js)

  • グローバル変数をむやみに使わない。グローバルに保持されたオブジェクトは GC 対象になりづらい。
  • イベントリスナー/タイマー (setIntervalsetTimeout) の解除忘れに注意。コンポーネントのアンマウント時/プロセス終了前には必ず removeEventListenerclearInterval を。
  • クロージャで大きなデータ構造を囲んでしまったら要注意。必要な部分だけを取り出して使う、またはデータ参照を薄く保つようにする。

実践クイズ:このコード、メモリリークしています。どこが原因でしょう?

問題 1

java
public class CacheManager {
    private static final Map<String, Data> cache = new HashMap<>();

    public void load(String key) {
        Data d = new Data(key);
        // 何らかの処理…
        cache.put(key, d);
    }
}

上記コードは長時間稼働するサーバー上で使われています。このコード、どこがメモリリークの原因になり得るでしょうか?

解答/解説

解答

静的変数 cachestatic final Map<String, Data>)に、使い終わったと想定される Data オブジェクトを 無制限に追加し続けている点 がリークの原因です。
この cache がアプリケーションのライフタイムと同じくらい生きており、かつ不要になった Data を削除/クリアしていなければ、GC はその Data を「参照されていない」と判断できず、メモリを解放できません。

解説+改善点

このパターンは、GCありの環境における典型的な「静的コレクションにオブジェクトを追加し続けてしまう」メモリリークです。 
改善点としては 「キャッシュ/静的コレクションに上限を設け、不要になった要素を削除する」 の一つに絞ると明快です。例えば

  • cache に最大件数を設け、古い Data を削除する(LRU方式など)
  • あるいは WeakReference を使って参照が薄くなるようにする

この改善により、参照のライフサイクルが明示され、GCが正しく「もう使われていないオブジェクト」として扱えるようになります。

問題 2

js
function setup() {
  const elem = document.getElementById('button');
  function clickHandler() {
    console.log('clicked');
  }
  elem.addEventListener('click', clickHandler);
}
for (let i = 0; i < 1000; i++) {
  setup();
}

この JavaScript コード(ブラウザ環境想定)で、メモリリークが起こる可能性があります。どこが原因でしょうか?

解答/解説

解答

addEventListener によるイベントリスナー登録を行なっていますが、登録解除(removeEventListener)をしていない点 が原因です。
elem が参照されたまま clickHandler 関数がクロージャとして保持され、不要になった setup() 呼び出し分の elem/clickHandler が解放されず、メモリに残り続ける可能性があります。

解説+改善点

このパターンは、ブラウザ/JavaScript環境で典型的な「イベントリスナー解除漏れ」によるメモリリークです。
改善点としては 「イベントリスナーを解除する」 ことに絞ればシンプルです。例えば

  • setup() 内でリスナー登録+解除のペアを必ず実装する
  • またはボタンや要素が不要になったタイミングで elem.removeEventListener('click', clickHandler) を呼ぶ

こうすることで、不要なクロージャや DOM 要素の参照を残さず、GC/ブラウザのメモリ管理が適切に働くようになります。

まとめ

メモリリークは「確保したメモリが使われなくなったのに解放されず残り続ける」現象であり、GCあり/なしを問わず、長時間稼働システムや大規模アプリケーションでは致命的な問題に繋がり得ます。

本稿では、リークが「何が見えていないのか」「なぜ起こるのか」を言語横断的に整理し、実践的な防止策を提示しました。
日々の実装やコードレビューに「この参照、本当に不要か」「キャッシュ/コレクションは無制限じゃないか」「イベント・タイマー・Goルーチンの解除忘れてないか?」と立ち止まるだけで、リークの芽をかなり摘むことが可能です。

ぜひ、紹介したチェックリストや言語別ワンポイントを、自分の開発プロジェクトに落とし込み、「数か月後にじわじわ増えるメモリ使用量」に悩まされない設計・実装を習慣にしてください。

弊社Nucoでは、他にも様々なお役立ち記事を公開しています。よかったら、Organizationのページも覗いてみてください。
また、Nucoでは一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。

18
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?