こんにちは!フロントエンドからNode.jsの深部まで、JavaScriptのランタイムと日々格闘しているシニアアーキテクトです。
今回は、JavaScriptの学習を進める中で誰もが一度は頭を悩ませる「非同期処理とスコープ、そしてメモリの寿命」についてお話ししていきますね。
「Promiseを使った非同期処理を書いたはいいけれど、メモリってどう管理されているの?」「なぜかメモリリークが起きるんだけど……」そんな疑問を持ったことはありませんか?
ここをクリアすれば、JavaScriptの変数と非同期処理の裏側の動きが手に取るように分かり、ワンランク上のエンジニアにグッと近づけますよ。それでは、一緒に本質の世界を覗いてみましょう!
—
1. なぜ「非同期処理」を実行すると変数がメモリに残るのか?
JavaScriptはシングルスレッドで動く言語ですが、ネットワーク通信やタイマー処理などの「非同期処理」を巧みに操ることで、裏でたくさんの仕事を同時にこなしていますよね。
ここで、JavaScriptのエンジン(V8など)がメモリをどのように管理しているか、少しイメージしてみましょう。
JavaScriptでは、関数の中で変数を宣言すると、その変数はメモリ(ヒープ領域など)に保管されます。通常、関数が実行を終えてreturnすると、「もうこの変数は使わないよね」ということで、ガベージコレクター(GC)というお掃除ロボットがやってきてメモリを回収してくれます。
しかし、「Promiseチェーン」や「クロージャ」が絡むと話が変わります。
クロージャと参照の保持
非同期処理のコールバック関数(`.then()` や `async/await` の中身など)は、その外側にある変数を「キャプチャ(記憶)」します。この仕組みをクロージャと呼びます。
非同期処理が完了する(Promiseが解決または拒否される)までの間、JavaScriptエンジンは「あとでこの非同期処理が動いたとき、あの変数を参照するかもしれないな」と判断し、変数をメモリ上にしっかりと保持し続けます。
つまり、「非同期処理が終わるまで、変数はガベージコレクションの対象外になり、メモリに居座り続ける」ということなんです。
—
2. 実際のコードで見るメモリ保持のメカニズム
百聞は一見にしかず。まずは、よくあるPromiseチェーンのコードを見てみましょう。ここでは、重たいデータを一時的に保持する変数が、非同期処理の完了までどのように生き続けるのかを追ってみます。
// 【良い例:適切なスコープ管理がされているパターン】
function fetchUserData(userId) {
// 1. ローカル変数として巨大なデータを定義
const heavyCache = new Array(1000000).fill(‘🚀 宇宙船のデータ’);
console.log(`[開始] ユーザー ${userId} のデータを取得中…`);
// 2. 非同期処理(Promiseチェーン)を開始
return fetch(`https://api.example.com/users/${userId}`)
.then(response => response.json())
.then(userData => {
// 非同期処理の中で heavyCache を利用する
console.log(`[成功] 取得完了:`, userData.name);
console.log(`キャッシュのサイズ: ${heavyCache.length}`);
// 処理が完了すれば、このスコープ全体の寿命が尽き、
// heavyCache もガベージコレクションの回収対象になります。
return userData;
})
.catch(error => {
console.error(‘[エラー]’, error);
});
}
このコードでは、`fetchUserData`関数が呼び出されると`heavyCache`がメモリ上に展開されます。そして、`fetch`通信が終わり、`.then()`の処理がすべて完了するまでの間、`heavyCache`は大切にメモリに保持されます。そして処理が終わると、無事にメモリから解放されます。ここまでは理想的な流れですね。
—
3. 陥りがちな罠:意図しないメモリリークを生むコード
問題は、「本当はもう必要ないのに、参照が残り続けてしまうケース」です。
初学者の方がやりがちなのが、グローバルスコープや、長生きするオブジェクト(シングルトンやイベントリスナーなど)のプロパティに、Promise内の重い変数をうっかり紐づけてしまうことです。
// 【危険な例:メモリリークを引き起こしやすいパターン】
// アプリケーション全体で共有されるグローバルなキャッシュコンテナ
const globalAppCache = {
activeRequests: []
};
function registerLeakProneTask(taskId) {
// 非常に大きなデータ構造
const massivePayload = new Array(5000000).fill(‘⚠️ メモリを圧迫するデータ’);
const asyncTask = new Promise((resolve) => {
// わざと遅延させる非同期処理
setTimeout(() => {
resolve(`タスク ${taskId} 完了`);
}, 10000); // 10秒かかる重い処理
});
// 【落とし穴】
// グローバルな配列に、重いデータを含んだタスクやオブジェクトをプッシュしてしまう
globalAppCache.activeRequests.push({
taskId,
task: asyncTask,
payload: massivePayload // ← ここが原因で非同期処理中ずっとメモリを圧迫!
});
asyncTask.then(result => {
console.log(result);
});
}
なぜこれが問題なのか?
1. `massivePayload` は、10秒間の `setTimeout` が終わるまでメモリに残り続けます。
2. もし仮に、この関数が何度も呼び出され、かつ `globalAppCache.activeRequests` から不要になったタスクを削除し忘れた場合、データが永遠にメモリから消えない「メモリリーク」が発生します。
3. 結果として、ブラウザタブがクラッシュしたり、Node.jsサーバーが `FATAL ERROR: Ineffective mark-compacts near heap limit`(ヒープメモリ上限エラー)で突然落ちたりする原因になります。
—
4. 不要な参照を断ち切る!プロのスコープ管理術
では、こうしたメモリリークを防ぎ、安全に非同期処理を扱うにはどうすればよいのでしょうか。ここで、私たちプロが実践している3つのスコープ管理術をお伝えします。
① 変数のスコープをできる限り狭くする(`let` / `const` の徹底)
グローバル変数や不必要に広いスコープに変数を置くのをやめ、関数のローカルスコープ閉じ込めることで、スコープを抜けた瞬間に自動でGCの対象にさせます。
② 使い終わった変数や参照を明示的に「null」にする(あるいはスコープで自然消滅させる)
Promiseチェーンや `async/await` の中で、大きなデータを扱った後は、不要になったタイミングで変数を再代入して参照を切る意識を持ちましょう。
③ アプリケーションの状態管理から古い参照を確実に削除する
先ほどの `globalAppCache` のような配列やMapでタスクを管理している場合は、「非同期処理が終わったら(`.finally()`などを使って)、必ず配列からその要素を削除する」という後始末を徹底します。
// 【改善された安全なパターン】
function registerSafeTask(taskId) {
const massivePayload = new Array(5000000).fill(‘✨ 安全なデータ’);
const asyncTask = new Promise((resolve) => {
setTimeout(() => {
resolve(`タスク ${taskId} 完了`);
}, 5000);
});
const taskItem = {
taskId,
task: asyncTask,
payload: massivePayload
};
globalAppCache.activeRequests.push(taskItem);
// 非同期処理が成功しても失敗しても、必ず実行される .finally() を使う
asyncTask.finally(() => {
// 処理が終わったら、グローバルな参照から自分自身を切り離す!
const index = globalAppCache.activeRequests.indexOf(taskItem);
if (index !== -1) {
globalAppCache.activeRequests.splice(index, 1);
console.log(`[ガチッと解放] タスク ${taskId} の参照をメモリから削除しました`);
}
});
}
`.finally()` を使うことで、非同期処理がどのような結末を迎えても(成功してもエラーになっても)、確実にメモリ上の参照を断ち切ることができます。この一手間が、プロダクション環境で安定して動くアプリケーションの秘訣です。
—
まとめ
いかがでしたでしょうか? 今回のポイントをギュッとまとめておきますね。
- 非同期処理(Promiseやasync/await)が完了するまで、そこで参照されている変数はメモリ上に保持される。
- スコープを適切に設計しないと、意図せぬところで変数が居座り続け、メモリリークを引き起こす。
- グローバルな配列やオブジェクトに非同期の文脈やデータを保存する場合は、.finally()などを活用して、処理終了後に必ず参照を断ち切る(削除する)ことが重要。
ここをクリアすれば、JavaScriptの変数管理と非同期処理の挙動はもうバッチリマスターです!
メモリの寿命とスコープの繋がりを意識しながら、ぜひご自身のコードを見直してみてくださいね。快適でパフォーマンスの高いコードを一緒に書いていきましょう!