3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GCがあるのになぜメモリリークは起きるのか【到達可能性と4つの典型パターン】

3
Posted at

Webサーバーを起動して数日、メモリ使用量が右肩上がりに増え続け、最後にはメモリ不足で落ちる。仕方なく、毎晩サーバーを再起動して延命している。あるいはフロントエンドで、画面を行き来するうちにブラウザがどんどん重くなる。

こうした症状に心当たりがある人は多いはずです。そしてここで、多くの人が不思議に思います。今どきの言語には、ガベージコレクタ(GC)があって、いらなくなったメモリは自動で片付けてくれるはずだ、と。

なのに、なぜ漏れるのか。実はGCがあってもメモリリークは起きます。そして、その理由を知らないと、永遠に原因不明の「再起動で延命」を続けることになります。

▶ アニメーションで見たい方はこちら(約14分)
https://youtu.be/ZXJiZLXP50o

この記事では、GCの判定基準から出発して、リークの本質、典型的な4つのパターン、参照カウント方式特有の弱点、そして対策となる弱参照まで、順を追って掘り下げます。

GCは「到達可能か」でしか判定しない

まず、GCがどう判断しているかを確認します。

GCは、「このオブジェクトはもう要らないか」を直接は判定できません。未来にそのオブジェクトが使われるかどうかは、プログラムの実行を先まで見なければ誰にも分からないからです。これは理論的にも決定不能な問題です。

そこでGCは、問いを言い換えます。「このオブジェクトに、まだ辿り着けるか」と。

プログラムが今アクセスできる出発点をルートと呼びます。グローバル変数、実行中の関数のローカル変数、CPUのレジスタなどです。ここから参照をたどっていって到達できるオブジェクトは、これから使われる可能性がある「生きている」オブジェクトとみなします。

逆に、どのルートからも辿り着けないオブジェクトは、プログラムから二度とアクセスできません。だから安全に回収できます。

ポイントはここです。GCが回収するのは、到達「不能」になったオブジェクトだけです。裏を返せば、ルートから辿れる限り、GCは絶対に回収しません。たとえプログラマがもう使うつもりがなくても、です。

リークの本質 — 「到達可能」と「論理的に必要」のズレ

ここに、メモリリークの本質があります。

GCが見ているのは「到達可能かどうか」です。一方、私たちが本当に知りたいのは「論理的に、もう必要かどうか」です。この2つは、同じではありません。

もう二度と使わないオブジェクトでも、どこかに参照が1本でも残っていれば、ルートから辿れてしまいます。GCは「まだ到達できる、生きている」と判断し、絶対に回収しません。

つまりメモリリークとは、論理的にはもう不要なのに、参照がうっかり残っているせいで到達可能なままになっているオブジェクトのことです。

これは、C言語の「解放し忘れ」とは性質が異なります。

原因 対策の方向
Cのリーク free() するコードを書き忘れた 解放処理を書く
GC言語のリーク 参照を切り忘れた 参照を断ち切る

GCは、参照を消してはくれません。参照を消すのは、あくまでプログラムの責任です。GCは、プログラマが切り忘れた参照の先まで、律儀に守り続けてしまうのです。

だから対策は、たった一つの問いに集約されます。「もう要らないオブジェクトへの参照を、ちゃんと断ち切っているか」。

ここから、典型的なリークのパターンを4つ見ていきます。すべて「参照の切り忘れ」の具体例です。

パターン1: 長命オブジェクトへの溜め込み

最も多いパターンです。長生きするオブジェクト(グローバル配列など)が、短命のはずのオブジェクトを掴み続けてしまいます。

例えば、グローバルな配列に、処理のたびにデータを追加していくコードを考えます。ログを溜める、履歴を残す、といった素朴な実装です。

# 悪い例: 際限なく溜め込むログ
request_log = []

def handle_request(req):
    result = process(req)
    request_log.append(req)  # 追加はあるが、削除が無い
    return result

個々のリクエストは、処理が終わればもう不要になります。ところが、グローバル配列がそれを参照し続けているので、ルートから到達可能なまま。GCは回収できません。配列は際限なく伸び続け、メモリは右肩上がりになります。

厄介なのは、コード自体は何も間違って見えないことです。追加するコードはあるのに、要らなくなったものを取り除くコードが、どこにもありません。

# 改善例: 上限を決めて古いものを追い出す
from collections import deque

request_log = deque(maxlen=1000)  # 最大件数を明示

def handle_request(req):
    result = process(req)
    request_log.append(req)  # 上限を超えると自動で古い要素が捨てられる
    return result

対策は、不要になった要素を明示的に取り除くこと、あるいはそもそも無制限に溜めない設計にすることです。

パターン2: リスナー・購読の解除漏れ

2つ目は、イベントリスナーや購読(subscribe)の解除漏れです。

ボタンや要素に、クリックされたら動く処理(リスナー)を登録するとします。このとき、リスナーの内部から、周りのオブジェクトを参照することがよくあります。

問題は、その要素を画面から取り除いたときです。見た目は消えても、リスナーがまだ登録されたままだと、内部のシステムがその要素を参照し続けます。結果、画面から消えたはずの要素と、それが掴んでいるデータ一式が、まるごとメモリに残り続けます。これを「デタッチされたノードのリーク」と呼びます。

// 悪い例: リスナーを解除しないコンポーネント
function mountWidget(el, bigData) {
  const onClick = () => console.log(bigData.summary());
  el.addEventListener("click", onClick);
  // el を DOM から外しても、onClick がスコープに bigData を捕まえたまま
  // かつ el への参照がどこかに残っていると、両方とも回収されない
}

同じことが、データの購読でも起きます。サブスクライブしたままアンサブスクライブを忘れると、発行側がコールバックを参照し続け、コールバックが捕まえているオブジェクトも解放されません。

// 改善例: 破棄タイミングで必ず解除する
function mountWidget(el, bigData) {
  const onClick = () => console.log(bigData.summary());
  el.addEventListener("click", onClick);

  return function cleanup() {
    el.removeEventListener("click", onClick); // 登録と解除をペアにする
  };
}

画面を行き来するたびに、解除されないリスナーが積もっていきます。ブラウザがどんどん重くなる症状は、これが原因であることが多いです。

対策はシンプルです。登録したものは必ず解除する。コンポーネントが消えるタイミングで、リスナーや購読を後始末する。「登録と解除を、必ずペアにする」のが鉄則です。

パターン3: クロージャによる意図しない捕捉

3つ目は、クロージャによる意図しない捕捉です。

クロージャとは、自分が作られた場所の周りの変数を覚えている関数のことです。便利な仕組みですが、リークの温床にもなります。

例えば、巨大なデータを使って、小さな関数を作って返すとします。関数の中で、その巨大データを少しでも参照していると、クロージャは巨大データ全体を捕捉します。

// 悪い例: 小さな関数が巨大データ全体を握り続ける
function makeLogger(hugeDataset) {
  return function log(id) {
    // hugeDataset の中の 1 要素しか使っていなくても、
    // クロージャは hugeDataset 全体への参照を保持する
    console.log(hugeDataset.find(x => x.id === id)?.name);
  };
}

const logger = makeLogger(loadHugeDataset()); // logger を長期保持すると…

その小さな関数をどこかに登録して保持し続ける限り、もう使わない巨大データもクロージャ経由で到達可能なままです。GCは回収できません。見た目には小さな関数を1つ持っているだけなのに、その裏で巨大なデータが丸ごと生き続けているとは気づきにくいものです。

// 改善例: 必要な値だけを切り出して捕捉する
function makeLogger(hugeDataset) {
  const idToName = new Map(hugeDataset.map(x => [x.id, x.name])); // 必要分だけコピー
  return function log(id) {
    console.log(idToName.get(id));
  };
  // hugeDataset 本体への参照はここで手放される
}

対策は、クロージャが本当に必要な値だけを捕捉するように切り出すことです。必要な一部だけをコピーして渡し、巨大なデータ本体への参照は残さないようにします。

パターン4: 無制限に育つキャッシュ

4つ目は、無制限に育つキャッシュです。

高速化のために、計算結果をマップや辞書に溜めておく。キャッシュは強力なテクニックです。しかし、上限も有効期限も追い出しの仕組みもないキャッシュは、ただのリークです。

キーが増え続ける限り、エントリは際限なく積み上がります。一度キャッシュに入れたものは、マップがルートから到達可能なので、GCは永遠に回収しません。

たちが悪いのは、これが「性能を上げる良いコード」の顔をしていることです。レビューでも見逃されやすく、動き始めは速いのに、時間とともにメモリを食いつぶしていきます。

対策は、キャッシュに必ず「終わり」を設けることです。最大サイズを決めて、あふれたら古いものから追い出すLRU(Least Recently Used)。あるいは、一定時間で期限切れにするTTL(Time To Live)。

from functools import lru_cache

# 悪い例: 無制限に育つ辞書キャッシュ
cache = {}
def expensive(key):
    if key not in cache:
        cache[key] = compute(key)  # 上限も期限もない
    return cache[key]

# 改善例: 上限付きキャッシュ (LRU)
@lru_cache(maxsize=1000)  # 最大 1000 件、あふれたら最古のものを追い出す
def expensive_v2(key):
    return compute(key)

さらに、キーが他から参照されなくなったら自動で消える、弱参照を使ったキャッシュという選択肢もあります。

循環参照 — 参照カウント方式の弱点

ここで、特定のGC方式に固有のリークを見ておきます。循環参照です。

参照カウントという方式は、各オブジェクトが「自分は今いくつから参照されているか」を数えます。カウントがゼロになった瞬間に解放する、素朴で素早い方式です。Swiftの ARC(Automatic Reference Counting)が代表例です。

ところが、この方式には弱点があります。AがBを参照し、BがAを参照する。互いに参照し合う循環ができるとどうなるでしょうか。

外から誰も使わなくなっても、Aのカウントは Bによって1、Bのカウントは Aによって1のままです。決してゼロになりません。両者とも永遠に解放されません。これが循環参照によるリークです。

一方、ルートからの到達可能性で判定するマークアンドスイープ方式(JavaScript、Java、Goなどが採用)は、ルートから切り離された循環をまるごと到達不能と判定して回収できます。循環に強いのです。

ちなみにPythonは、参照カウントを基本としつつ、循環を見つけて回収する専用の仕組み(サイクル検出器)を別に持っています。だからPythonでは循環も最終的には回収されます。純粋な参照カウントのみの言語(Swiftなど)とは事情が異なる点に注意してください。

方式 代表言語 循環参照
参照カウントのみ Swift (ARC) 回収できない(weak/unownedが必須)
マークアンドスイープ JavaScript, Java, Go 回収できる
参照カウント + サイクル検出 Python 最終的に回収される

参照カウント方式の言語では、循環を断ち切るために片方を弱参照にするのが定石です。

弱参照 — 到達可能性に数えない参照

リーク対策の強力な道具、それが弱参照です。

ふつうの参照(強参照)は、GCに「これは生きている、回収するな」と伝えます。ここまで見てきたリークは、すべて強参照が残っていたせいで起きていました。

弱参照はその逆です。「参照はするが、これを生かす理由にはしない」。GCは、弱参照しか残っていないオブジェクトを到達不能とみなして回収します。

例えば、あるオブジェクトに付随する情報を別のところで覚えておきたい。でも、その情報のために本体を生かし続けたくはない。こういうとき、弱参照のマップ(JavaScriptなら WeakMap)を使います。

// 通常の Map: キーへの参照が強参照になり、キーが GC されなくなる
const strongCache = new Map();

// WeakMap: キーへの参照は弱参照。キーが他から参照されなくなれば
// エントリごと自動的に GC される
const weakCache = new WeakMap();

function attachMetadata(obj, meta) {
  weakCache.set(obj, meta); // obj 本体を生かし続ける理由にはならない
}

本体が他から参照されなくなれば、WeakMapの中身も自動で消えます。リークになりません。先ほどの循環参照も、片方を弱参照にすればカウントが増えず、解放できるようになります。

ただし、弱参照は万能の特効薬ではありません。いつ消えるかをGCに委ねるので、確実に保持したいデータには使えません。「保持したいなら強参照、付随情報や逆参照なら弱参照」と、役割で使い分けるのが正解です。

見つけ方・直し方

最後に、リークを実際に見つけて直す方法です。

第一に、再現と観測。 同じ操作を何度も繰り返して、メモリ使用量が操作のたびに階段状に増えていくなら、それはリークのサインです。減らずに積み上がるかどうかを見ます。

第二に、ヒープスナップショット。 ある時点と、操作を繰り返した後の、2つのメモリの状態を撮って差分を取ります。増え続けているオブジェクトの種類が、犯人の手がかりになります。ブラウザやランタイムの開発者ツールに、この機能が備わっています(Chrome DevToolsのMemoryタブなど)。

第三に、参照をたどる。 見つかった「消えないオブジェクト」が、どのルートから到達可能なのか、その参照の鎖をたどります。鎖の根本にある、切り忘れた参照こそが修正すべき箇所です。

そして、設計での予防。 これが一番効きます。登録したリスナーは解除する、キャッシュには上限を設ける、長命オブジェクトに短命なものを溜めない。オブジェクトの「寿命」を意識して、不要になったら参照を断つ。

まとめ

GCがあっても、メモリリークは起きます。

GCが回収するのは、到達不能になったものだけです。ところが、論理的にはもう不要なのに、参照が1本でも残っていると、到達可能なまま居座り続けます。これがリークの正体です。

典型は4つ。長命オブジェクトへの溜め込み、リスナーや購読の解除漏れ、クロージャの巨大データ捕捉、無制限キャッシュ。どれも共通するのは「切り忘れた参照」です。

参照カウント方式では、さらに循環参照が漏れます。その対策が、到達可能性に数えない弱参照でした。

GCは、参照を消してはくれません。要らなくなったオブジェクトへの参照を、自分で断ち切る。それだけで、「再起動で延命」の日々から卒業できます。


チャンネル: https://www.youtube.com/@base-technology (根本解説シリーズ)

3
4
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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?