EagleLand

2017.02.17

JavaScriptで起こるメモリリークのパターン

2014年1月25日に Frontrend in Fukuoka というイベントが開催された(もう3年前か…)。その時に Browser Computing Structure というタイトルで、ブラウザの仕組みやらスクリプト処理について発表している。

たまたま当時の資料を掘り起こす機会があったので、メモリリークのサンプルを直したついでにリークする JavaScript のパターンについて書き起こしてみる。サンプルは 1000ch/memory-leak に公開してあり、手順通り操作するとメモリリークを再現できるようになっている。

GCで回収されないオブジェクト

JavaScript はランタイム上で動的にメモリを確保する GC(ガベージコレクション)を採用しているので、JavaScript の書き手がメモリの確保・開放を意識することは少ない。しかしプログラムの書き方によっては、確保したメモリが GC によって開放されずにメモリが肥大化し、内部処理を圧迫していくことになる。

開放されないメモリは、対象の変数への参照が残っている場合である。どこかで使われている変数であれば GC で回収してはまずいが、不要になった変数が回収されないのはプログラム上のミスと言える。

class Leaker {}

let leaker = new Leaker();

この JavaScript を実行してみる。実行は Chrome DevTools の Console 上で問題ない。実行したら Memory パネルを開いて Take Heap Snapshot を選択して実行すると、ヒープのスナップショットを保存できる。保存したスナップショットの内部を Leaker で検索すると、オブジェクトが見つかる。グローバルに存在する Leaker インスタンスが GC によって回収されていないためだ。

次に leaker = null; を Console で実行すると leaker は参照元がなくなり GC による回収対象となる。再度ヒープのスナップショットを保存してみてみると、先程検出された Leaker オブジェクトはいなくなっているはず。これがメモリリークの単純な例、もとい GC の基本的な仕組みである。

解除されないタイマーやイベントリスナー

null を代入して GC による回収を促しても、実行したタイマーや登録したイベントリスナは暗黙的に解除されない。次の Leaker はインスタンスを作成した時点でタイマーが発動するが、そのインスタンスに null を代入してもタイマーは実行され続ける。

class Leaker {
  constructor() {
    this.timerId = setInterval(this.onInterval, 1000);
  }

  onInterval() {
    console.log('interval');
  }
}

let leaker = new Leaker();
leaker = null;

先の例と同じように leaker = null; を Console で実行した後にヒープのスナップショットを取ってみると、ヒープには Leaker オブジェクトが残っていないことが確認できる。デストラクタの機構が JavaScript にあればその中で解除処理を入れれば良さそうだが、残念ながらない。自前の dispose() 関数などを用意して呼び出すことをルール化するなどの工夫が必要になる。

循環参照

自分で自分を参照してしまったり、2つのオブジェクトが互いに参照しあうなど、オブジェクトの参照がループすることを循環参照と言う。

次のコードは Registry というオブジェクトを保持するクラスと、 Leaker という親・子への参照と Registry のインスタンスを持つクラス。 Leaker オブジェクトは親が存在しない場合のみ、新たに Leaker のインスタンスを作成して子供として参照する。

class Registry {
  constructor() {
    this.subscribers = [];
  }

  add(subscriber) {
    if (!this.subscribers.includes(subscriber)) {
      this.subscribers.push(subscriber);
    }
  }

  remove(subscriber) {
    if (this.subscribers.includes(subscriber)) {
      this.subscribers.splice(this.subscribers.indexOf(subscriber), 1);
    }
  }
}

class Leaker {
  constructor(parent, registry) {
    this.parent = parent;
    this.registry = registry;
    this.child = null;
    this.registry.add(this);

    if (!this.parent) {
      this.child = new Leaker(this, registry);
    }
  }
}

let registry = new Registry();
let leaker = new Leaker(null, registry);
leaker = null;

RegistryLeaker のそれぞれを初期化すると次のようになる。

次に [2] の Leaker インスタンスに null を代入するが、Registry インスタンスや Leaker 子インスタンスなどから再帰的に参照されているので、GC によって回収されない。これは複雑かつ露骨な例だが、ヒープのスナップショットなどに開放されているはずのオブジェクトが残っている場合は要注意。

コンソールから参照されるオブジェクト

console.log(object) などで出力しているオブジェクトは、コンソールから参照していることで GC によってメモリが開放されないケースがあった。しかし最新の Chrome では確認できず、どうやら解消されたようだ。