すべてのプログラミング言語において、変数が宣言されると、システムはその変数のためにメモリブロックを確保する必要があります。変数が不要になると、そのメモリを回収する必要があります。C言語では、メモリ管理を補助するためにmallocとfreeがあります。JSでは、開発者が手作業でメモリ管理をする必要はありません。しかし、これはJSでコーディングするときにメモリを気にする必要がないという意味ではありません。
JSのメモリ割り当てと変数
メモリ宣言のサイクルは以下の通り:
- 必要なメモリの割り当て
- 割り当てメモリの使用
- 不要になったら解除
JSでは、これらの3つのステップはすべて、開発者にとっては無意味なものであり、それほど注意を払う必要はありません。
変数を宣言してメモリ・ブロックを取得する際には、変数が基本型か参照型かを正しく区別する必要があることに注意してください。
基本型:文字列、数値、ブール値、Null、未定義、記号
参照型:オブジェクト、配列、関数
基本型変数の場合、システムはその変数のためにメモリ・ブロックを割り当て、このメモリ・ブロックに格納されるのが変数の内容です。
参照変数の場合、格納されるのはアドレスだけで、そのアドレスが指すメモリのブロックが変数の本当の中身です。参照変数の代入も、アドレスを渡すだけです。例を挙げましょう:
var c = [
0x1,
0x2,
0x3
];
var obj = c;
c.push(0x4);
console.log(obj);
もうひとつ注意しなければならないのは、JSにおける関数渡しは、実際には値渡しであるということです。例を挙げましょう:
function c(bar) {
bar.b = 0x1;
}
var obj = { 'a': 0x1 };
c(obj);
console.log(obj);
普段の開発では、JSにどのように変数が格納されているかを十分に理解することがとても重要です。私自身は、参照型の変数を渡さないようにしています。ある場所で誤って変数を変更してしまい、別の場所のロジックがうまく判断できない場合、バグが発生しやすくなります。これが、私が純粋な関数を好んで使う理由です。
const foo = [];
foo.push('1');
console.log(foo);
JSゴミ収集
ゴミ収集アルゴリズムは参照の概念に大きく依存しています。メモリ管理環境では、他のオブジェクトにアクセスできるオブジェクトを、他のオブジェクトへのオブジェクト参照と呼びます。例えば、Javascriptのオブジェクトはそのプロトタイプへの参照とプロパティへの参照を持っています。
この文脈では、「オブジェクト」という概念はJavaScriptのオブジェクトだけでなく、関数のスコープも指します。変数が不要になると、JSエンジンはその変数が占有していたメモリを回収します。しかし、変数が不要になったときをどのように定義するのでしょうか?主に2つの方法があります。
参照カウントアルゴリズム
オブジェクトが不要になったかどうか」の定義は、「そのオブジェクトが他のオブジェクトから参照されているかどうか」に簡略化されます。オブジェクトへの参照がない場合、そのオブジェクトはゴミ回収メカニズムによって回収されます:
var obj = { 'a': { 'b': 0x2 } };
var foo = obj;
obj = 0x1;
var bar = foo.a;
foo = 'yo';
bar = null;
この方法の限界の一つは、循環参照を扱えないことです。次の例では、2つのオブジェクトが作成され、互いに参照され、ループを形成しています。これらのオブジェクトは関数スコープの外に呼び出されているので、もはや役に立たず、リサイクルすることができます。しかし、参照カウントアルゴリズムは、2つのオブジェクトが少なくとも1回は互いに参照し合っていることを考慮に入れているので、リサイクルされることはありません。
function obj() {
var c = {};
var foo = {};
c.a = foo;
foo.a = c;
return 'azerty';
}
obj();
マーククリアアルゴリズム
このアルゴリズムは、ルートと呼ばれるオブジェクトが設定されていると仮定します。ごみコレクタは、定期的にルートから始まり、ルートから参照されるすべてのオブジェクトを見つけ、それらのオブジェクトから参照されるオブジェクトを見つけます......ルートから始まり、ごみコレクタは、取得できるすべてのオブジェクトを見つけ、取得できないすべてのオブジェクトを収集します。
JSのゴミ収集アルゴリズムについては、インターネット上に多くの解説記事がありますので、ここでは繰り返しません。
JSのメモリリーク
JSはメモリの割り当てと再利用を自動的に処理するように設計されていますが、特定のシナリオでは、JSのゴミ収集アルゴリズムが、使用されなくなったメモリの削除を支援しないことがあります。このような現象はメモリリークと呼ばれます。
メモリ使用量が増加し、システムパフォーマンスに影響を与えたり、プロセスのクラッシュを引き起こしたりする可能性があります。
グローバル変数、DOM イベント、タイマーなど、メモリリークが発生する可能性のあるシナリオは非常に多くあります。
以下はメモリリークのあるサンプルコードです:
class Page1 extends React.Component {
events= []
componentDidMount() {
window.addEventListener('scroll', this.handleScroll.bind(this));
}
render() {
return <div>
<div><Link to={'/page2'}> Page2</Link></div>
<p>page1</p>
....
</div>
}
handleScroll(e) {
this.events.push(e);
}
}
Page2にジャンプするボタンをクリックし、Page2でスクロールを続けると、メモリ使用量が増え続けることがわかります:
このメモリリークの原因は、Page1がアンマウントされたとき、Page1が破棄されても、Page1のスクロールコールバック関数はeventListenerを通して到達可能なため、ゴミ収集されないからです。Page2 に入った後、スクロールイベントロジックはまだ有効で、内部変数を GC することができません。 Page2 でユーザーが長時間スワイプすると、ページが徐々にラグくなります。
上記のような例は、開発中によくあることです。イベントバインディングだけでなく、ロジックのタイミングアップなども考えられます。解決方法は?アンマウント時に適切なキャンセル操作を行うことを忘れないでください。
通常のプロジェクト開発では、メモリリークのシナリオは他にもたくさんあります。ブラウザのページは、結局のところ、あまり多くのユーザが常に特定のページを開いているわけではありません。Node.jsでのメモリリークの結果はより深刻で、サービスがクラッシュすることもあります。JSの変数格納方法、メモリ管理メカニズムをマスターし、良いコーディング習慣を身につけることで、メモリリークの発生を減らすことができます。
JSの弱い参照
前述したように、JSのゴミ収集の仕組みでは、オブジェクトへの参照を保持していれば、そのオブジェクトはゴミ収集されません。ここでいう参照とは、強い参照という意味です。
コンピュータ・プログラミングでは、弱参照という概念もあります。弱参照によってのみ参照されるオブジェクトは、アクセス不可能とみなされるため、いつでもリサイクルされる可能性があります。
JSでは、WeakMapとWeakSetが弱い参照を提供する機能を提供します。
WeakMap WeakSet
Mapオブジェクトはキーと値のペアを保持し、キーの元の挿入順序を記憶します。どんな値でもキーや値として使うことができます。
マップはオブジェクトへの強い参照です:
const b = new Map();
let bar = { 'a': 0x1 };
b.set(bar, 'a');
bar = null;
WeakMapは、キーが弱く参照されるキーと値のペアのコレクションです。WeakMap はオブジェクトへの弱い参照です:
const a = new WeakMap();
let c = { 'b': 0x2 };
a.set(c, '2');
c = null;
この弱い参照のため、WeakMap のキーは列挙できません。もしキーが列挙可能であれば、リストはゴミ収集の対象となり、不確定な結果となります。
WeakSet は、すべての値がブール値である WeakMap の特殊なケースとみなすことができます。
JavaScriptのWeakMapは実際には弱参照ではありません。実際、キーが生きている間はキーの内容を強く参照し、キーがガベージコレクションされた後にのみWeakMapはその内容を弱参照します。この関係はより正確にはephemeron呼ばれます。
WeakRef
WeakRef は、真の弱参照を提供する、より高度な API です。上のメモリリークの例を参考に、WeakRef の効果を直接見てみましょう:
import React from 'react';
import { Link } from 'react-router-dom';
// WeakRefを使用してコールバック関数を「ラップ」し、コールバック関数への弱い参照を形成する。
function addWeakListener(listener) {
const weakRef = new WeakRef(listener);
const wrapper = e => {
if (weakRef.deref()) {
return weakRef.deref()(e);
}
}
window.addEventListener('scroll', wrapper);
}
class Page1 extends React.Component {
events= []
componentDidMount() {
addWeakListener(this.handleScroll.bind(this));
}
componentWillUnmount() {
console.log(this.events);
}
render() {
return <div>
<div><Link to={'/page2'}> Page2</Link></div>
<p>page1</p>
....
</div>
}
handleScroll(e) {
this.events.push(e);
}
}
export default Page1;
ページ2にジャンプするボタンをクリックした後のメモリの動作を見てみましょう:
ページ2にジャンプした後、一定期間スクロールを続けるとメモリが滑らかになることが直感的にわかります。これは、page1がアンマウントされることで、実際のスクロールコールバック関数がGCされるからです。その内部変数も最終的にGCされます。
weakRef.deref()によってhandleScrollスクロールコールバック関数が利用できなくなったとしても、removeEventListenerが実行されないため、ラッパー関数は実行されます。スクロールリスナーもキャンセルされるのが理想的です。
これはFinalisationRegistryの助けを借りて実現できます。以下のサンプルコードをご覧ください:
// FinalizationRegistryコンストラクターは引数としてコールバック関数を受け取り、サンプルを返す。サンプルをあるオブジェクトに登録すると、そのオブジェクトがGCされたときにコールバック関数が起動する。
const gListenersRegistry = new FinalizationRegistry(({ window, wrapper }) => {
console.log('GC happen!!');
window.removeEventListener('scroll', wrapper);
});
function addWeakListener(listener) {
const weakRef = new WeakRef(listener);
const wrapper = e => {
console.log('scroll');
if (weakRef.deref()) {
return weakRef.deref()(e);
}
}
// この行を追加すると、リスナーがGCになったとき、コールバック関数がトリガーされる。コールバック関数は、あなたのコントロール下にあるパラメータを渡す。
gListenersRegistry.register(listener, { window, wrapper });
window.addEventListener('scroll', wrapper);
}
WeakRefとFinalizationRegistryは、Chrome v84とNode.js 13.0.0からサポートされている高レベルのAPIです。一般的にこれらを使用することは推奨されません。間違った使い方をすると、より多くの問題を引き起こしやすくなります。
裏面に記載
この記事では、JSのメモリ管理からJSの弱い参照について説明します。JSエンジンはメモリ管理に対処するのに役立ちますが、ビジネス開発、特にNode.js開発ではメモリの問題を完全に無視することはできません。