はじめに
現場で「なんだかメモリが増えている気がする」「GC(ガベージコレクション)があるから大丈夫だと思ったら落ちた…」「原因がよく分からないままサービス再起動してごまかしている」という経験はありませんか?
本記事では、そんな“見えてるけど見えていない”存在、つまり 「メモリリーク」 をテーマに、現場のエンジニアがつまづきがちなポイントを整理していきます。
対象読者は、たとえば以下の方々です。
- 実務で Java/C#/Go/JavaScript 等を使っていて、「メモリが増えてる?」と漠然と思ったことのある方
- GCあり・なし問わず「なぜメモリリークが起こるのか」を原理から理解したい方
- コードレビューや設計段階で「このコード、リークしてないかな?」とチェックできるようになりたい方
なぜ「見えてるけど見えていない」のでしょう?
それは、メモリリークが即座にエラーになるわけではなく、長時間の稼働や累積的な影響で初めて影を落とすことが多いから。
そのため、原因と挙動をキチンと押さえておかないと、思わぬ時間経過のあとに「なんで落ちたんだ…」となってしまいがちです。
本記事の構成は次のとおりです。
- メモリリークとは何か(定義・なぜ気付きにくいか・影響)
- メモリリークの動作メカニズムを言語横断的に整理
- 各言語/環境でよくある典型パターンと再現コード
- “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がない、または手動でメモリ管理を行う環境では、基本的には「確保したメモリを開放し忘れる」「外部リソースを閉じない」が典型的です。
-
malloc→freeを忘れる、new→deleteを忘れる。 - ファイルハンドル、ソケット、GUIリソース、OSリソースなどを開け放しにする。
- ポインタがどこかに残っていて「もう使わない」と思ったけど解放できない状況。
手動管理型のリークは比較的“明らかにミス”ですが、規模の大きなシステム・長時間稼働の組み込み系では、どこで忘れたか/どれが生き残っているかを追うのが難しいこともあります。
共通して起こる典型パターン
言語/環境が異なっても、メモリリークが発生しやすい構造・パターンには共通項があります。以下はよくあるものです。
- 静的コレクション・キャッシュが肥大化:無制限に
MapやListにオブジェクトを溜め続ける。 - イベント/コールバック登録解除忘れ:観察者を登録しっぱなしで放置。
- タイマー/スレッド/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されない可能性あり
このパターンでは、購読を解除する処理が無い限り、Producer が Consumer のインスタンスを保持し続けているかのような状態になり、参照が切れずリークになります。
解説ポイント
- 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対象にならない。
- タイマー(
setInterval/setTimeout)をクリアしない、またはクロージャで大量データを囲んでしまう。 - 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あり環境でも、参照が残っている限り解放されません。 -
コレクション/キャッシュ/マップに“無限増大”を許さない
静的なListやMap、キャッシュ構造などが増え続けると、それ自体がメモリリークの温床になります。例えば、要素数上限を設けたり、LRU(最少使用)方式で削除を行ったりする工夫を。 -
イベント・タイマー・スレッド/Goルーチンの登録解除を必ずペアで実装する
リスナーを登録したら解除、タイマーを開始したら停止、Goルーチンを起動したら終了条件を用意、など。解除忘れが参照を残し、リークを招きやすい典型です。 -
ツールを使って“戻るべきところが戻っているか”を定期チェックする
長時間動くアプリなら、ヒープスナップショット、メモリ/Goルーチンプロファイル、GCログなどを定期的に取得して「メモリ使用量が戻っているか?」「Goルーチン数が減っているか?」などを確認。 -
コードレビュー&チェックリスト化
新規実装・変更時に「静的コレクションに値を追加しっぱなしになっていないか」「イベント/タイマーに解除処理があるか」「キャッシュに上限があるか」などをチェックリスト化しておくと、未然防止につながります。
5分チェックリスト(実践用)
- 長時間稼働するプロセス/サービスで、メモリ使用量がじわじわ増えていないか?
- 静的/グローバル変数に大量のオブジェクトを溜めていないか?
- イベントリスナー/タイマー/Goルーチンなどの「開始に対して終了(解除・停止・終了条件)」が実装されているか?
- 不要になった参照を
nullにする/スコープ外に出すなど、明示的に手放す仕組みがあるか? - プロファイルツール・ヒープダンプ・ログ等を使って「増えて終わり」でないこと(=戻ること)を確認しているか?
言語別ワンポイント
ここからは、主要言語・環境別に「今日から一歩改善できる小ネタ」を紹介します。自分のプロジェクトの言語にあわせてチェックしてください。
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 対象になりづらい。
- イベントリスナー/タイマー (
setInterval/setTimeout) の解除忘れに注意。コンポーネントのアンマウント時/プロセス終了前には必ずremoveEventListener/clearIntervalを。 - クロージャで大きなデータ構造を囲んでしまったら要注意。必要な部分だけを取り出して使う、またはデータ参照を薄く保つようにする。
実践クイズ:このコード、メモリリークしています。どこが原因でしょう?
問題 1
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);
}
}
上記コードは長時間稼働するサーバー上で使われています。このコード、どこがメモリリークの原因になり得るでしょうか?
解答/解説
解答
静的変数 cache(static final Map<String, Data>)に、使い終わったと想定される Data オブジェクトを 無制限に追加し続けている点 がリークの原因です。
この cache がアプリケーションのライフタイムと同じくらい生きており、かつ不要になった Data を削除/クリアしていなければ、GC はその Data を「参照されていない」と判断できず、メモリを解放できません。
解説+改善点
このパターンは、GCありの環境における典型的な「静的コレクションにオブジェクトを追加し続けてしまう」メモリリークです。 
改善点としては 「キャッシュ/静的コレクションに上限を設け、不要になった要素を削除する」 の一つに絞ると明快です。例えば
-
cacheに最大件数を設け、古いDataを削除する(LRU方式など) - あるいは
WeakReferenceを使って参照が薄くなるようにする
この改善により、参照のライフサイクルが明示され、GCが正しく「もう使われていないオブジェクト」として扱えるようになります。
問題 2
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では一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。