JavaScriptエンジンの内部(JITとインラインキャッシュ)
同じコードでも書き方しだいで数倍速くなる理由が腑に落ちる。V8のIgnition/TurboFan、隠しクラスとインラインキャッシュ、脱最適化の原理を内部動作から解説します。
- 1.V8はまずIgnitionでバイトコードを実行し、何度も通る“ホット”な関数だけをTurboFanがネイティブコードへJITコンパイルする二段構え。
- 2.オブジェクトの形は隠しクラス(Hidden Class)で表現され、同じ隠しクラスが続く前提でインラインキャッシュ(IC)がプロパティ参照を高速化する。
- 3.型や形の前提が崩れると脱最適化(deopt)が起き、最適化コードを捨ててバイトコードに戻る。形を揃え単型を保つコードが速い。
なぜエンジンの内部を知るのか
JavaScriptは仕様上は動的型付けのインタプリタ言語ですが、ChromeやNode.jsが使うV8をはじめ、現代のエンジンは実行時に機械語まで踏み込んで最適化します。同じアルゴリズムでも「書き方」で速度が数倍変わるのは、エンジンが実行中に集めた型情報を前提に最適化コードを生成し、その前提が崩れると遅い経路へ落ちるからです。原理を押さえると、なぜそのコードが速い/遅いのかを推測ではなく仕組みから説明できます。基礎は JavaScript を前提に、ここでは内部動作に踏み込みます。
Ignition と TurboFan:二段構えのパイプライン
V8はソースを直接機械語にはしません。流れはおおむね次の通りです。
- パース → AST:ソースを抽象構文木に変換する。遅延パースで未使用関数は最小限だけ読む。
- Ignition(バイトコードインタプリタ):ASTからバイトコードを生成し、それを解釈実行する。起動が速く、メモリも小さい。
- プロファイリング:実行中に呼び出し回数・ループ回数・各所で観測した型(フィードバック)を蓄える。
- TurboFan(最適化JITコンパイラ):頻繁に通る“ホット”な関数だけを、フィードバックを前提にネイティブコードへコンパイルする。
ポイントは、全コードをコンパイルしないことです。一度しか呼ばれない関数を最適化しても、コンパイル費用を回収できません。だからまずIgnitionで安く動かし、元が取れる箇所だけをTurboFanへ昇格させます。これが「JIT(Just-In-Time)」=実行しながら必要な所だけコンパイルする戦略です。
起動時に全部を機械語化(AOT寄り)すると、初回表示が遅くメモリも膨らみます。Webは「すぐ動き始める」ことが重要なので、起動の速いIgnitionでまず手早く動かし、回収できる関数だけ後からTurboFanで本気を出す——この使い分けが体感速度と省メモリを両立させます。
隠しクラス(Hidden Class):動的オブジェクトに“形”を与える
JavaScriptのオブジェクトはいつでもプロパティを足せます。ナイーブに実装すると、プロパティ参照は毎回ハッシュ探索になり遅い。そこでV8は、同じ**形(shape)**を持つオブジェクトをまとめる内部メタデータ=隠しクラス(V8の用語ではMap、Shapeとも)を導入します。
隠しクラスは「どのプロパティが、どのオフセットに入っているか」を記録します。{x, y} を持つオブジェクトなら、xはオフセット0、yはオフセット1、というように固定配置に対応づけられます。プロパティ参照は「隠しクラスを確認し、決まったオフセットを読む」だけになり、ハッシュ探索より格段に速くなります。
重要なのは、隠しクラスはプロパティを追加するたびに遷移(transition)することです。空オブジェクトにxを足すと隠しクラスC0→C1、続けてyを足すとC1→C2、という遷移チェーンが作られます。
function makePoint(x, y) {
const o = {};
o.x = x; // 隠しクラス C0 -> C1
o.y = y; // 隠しクラス C1 -> C2
return o;
}
// 同じ順序で同じプロパティを足す限り、全オブジェクトが C2 を共有する
ここで効くのが追加順序です。x→yとy→xでは到達する隠しクラスが別物になります。同じ「形」のつもりでも、順序が違えばエンジンから見れば別クラスとして扱われ、後述の最適化が効きにくくなります。
オブジェクトはコンストラクタやオブジェクトリテラルで全プロパティを同じ順序で一度に初期化するのが鉄則です。後から条件分岐でif (cond) o.z = 1のように足すと、zの有無で隠しクラスが枝分かれします。使わないプロパティもnullやundefinedで先に宣言しておくと、形が一本化されてエンジンが推論しやすくなります。
インラインキャッシュ(IC):前回と同じ前提で読む
隠しクラスを土台に、プロパティ参照そのものを高速化するのがインラインキャッシュ(IC)です。obj.xのようなアクセス箇所ごとに、エンジンは「前回ここに来たオブジェクトの隠しクラスは何で、xはどのオフセットだったか」をその場所に記憶します。
次に同じ箇所を通ったとき、来たオブジェクトの隠しクラスが前回と同じなら、探索を飛ばして記憶したオフセットを直接読みます。これが「キャッシュ」の意味です。ICは観測した形の種類数で状態が変わります。
| IC状態 | 観測した隠しクラス | 速度 | 意味 |
|---|---|---|---|
| monomorphic(単型) | 1種類 | 最速 | 毎回同じ形。理想 |
| polymorphic(多型) | 2〜4種類 | やや遅 | 数種を分岐して対応 |
| megamorphic(多型過多) | 5種類以上 | 遅い | キャッシュを諦め一般探索へ |
つまり同じ箇所には同じ形のオブジェクトだけを通すのが最速です。1つの関数に何十種類もの形を流し込むと、ICはmegamorphic化してキャッシュを放棄し、汎用の(遅い)プロパティ探索に戻ります。TurboFanの最適化も、ICが集めた「この箇所はこの形」という前提に強く依存しています。
monomorphic / polymorphic / megamorphicはICの状態を指す用語で、観測した隠しクラスの種類数で決まります。「型」と訳されがちですが、ここでの“型”はnumber/stringのような言語型ではなく、V8内部の**形(隠しクラス)**である点が要注意です。種類が増えるほど遅くなる、と覚えれば十分です。
脱最適化(Deoptimization):前提が崩れたとき
TurboFanが生成するネイティブコードは、観測した前提が今後も成り立つという賭けの上に作られます。たとえば「この引数は常に小整数(Smi)」「このオブジェクトは常に隠しクラスC2」といった前提です。これらを保証するため、最適化コードには随所に**ガード(型チェック)**が埋め込まれます。
実行中にガードが破れる——たとえば整数だった引数に突然オブジェクトが渡る、想定外の隠しクラスが来る——と、脱最適化(deopt)が発生します。エンジンは最適化コードを捨て、その関数の実行をIgnitionのバイトコードへ巻き戻し、安全な経路で続行します。巻き戻し(フレームの再構築)自体にコストがかかるうえ、同じ関数が何度も最適化と脱最適化を往復する最適化のばたつきに陥ると、コンパイル費用ばかり払って一向に速くなりません。
function add(a, b) {
return a + b;
}
add(1, 2); // 整数として最適化される(前提: a,b は Smi)
add(3, 4); // 高速な最適化コードで実行
add("x", "y"); // 前提が崩れる -> deopt -> バイトコードへ巻き戻し
deoptを誘発する代表例には、a + bの型が途中で変わる、配列に数値と文字列を混在させる、要素を飛ばしてarr[100] = 1のような疎(sparse)配列を作る、argumentsを関数外へ漏らす、withやdeleteで形を不安定にする、などがあります(try/catchは古いV8では最適化を妨げましたが、現在のTurboFanは最適化できます)。
最適化を壊すコードパターン
仕組みから逆算すると、避けたいパターンは「形・型・配列を不安定にするもの」に集約されます。
| やりがちなコード | 何が起きるか | 対策 |
|---|---|---|
| 条件次第でプロパティを足す | 隠しクラスが枝分かれ | 初期化時に全プロパティを同順で宣言 |
| 同じ関数に多種の形を渡す | IC が megamorphic 化 | 形ごとに処理を分ける/形を揃える |
| 1配列に数値と文字列を混在 | 要素の型が不安定で deopt | 配列は単一型で保つ |
| arr[1000]=x など疎配列 | 高速な要素表現を失う | 添字は詰めて連続させる |
| delete obj.prop で削除 | 隠しクラスが辞書モードへ転落 | 値を null/undefined にする |
deleteは特に効きが大きく、プロパティを物理削除すると、そのオブジェクトは固定オフセットを諦めて辞書モード(dictionary mode)——ハッシュテーブル表現——へ落ちます。一度落ちると以後の参照が一律に遅くなるため、不要なら値を空にするほうが安全です。
これらはホットパス(ループの内側、毎フレーム呼ばれる関数など)でこそ意味を持ちます。年に数回しか通らないコードの形を神経質に揃えても、体感は変わりません。まず計測でホットスポットを特定し(Webパフォーマンス の考え方が役立ちます)、効く場所だけ形を整えるのが費用対効果の高いやり方です。
まとめ
V8は Ignition でバイトコードをすぐ実行し、ホットな関数だけを TurboFan がフィードバックを前提にネイティブコードへJITします。オブジェクトの形は 隠しクラス で表され、参照箇所ごとの インラインキャッシュ が「前回と同じ形」を当てにして高速化します。前提が崩れると 脱最適化 で最適化コードを捨ててバイトコードに戻る——だから速いコードとは、プロパティの形を揃え、型を単一に保ち、配列を詰めて使うコードです。エンジンに「次も同じはず」と賭けさせ続けられれば、最適化は外れません。実行の土台は ブラウザのレンダリングの仕組み と合わせて押さえると、フロントエンド全体の速度感がつながります。
Web/フロントエンド Article
JavaScriptエンジンの内部(JITとインラインキャッシュ)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
オブジェクトの形は隠しクラス(Hidden Class)で表現され、同じ隠しクラスが続く前提でインラインキャッシュ(IC)がプロパティ参照を高速化する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / V8」に近いか確認する。
- 強みである「V8はまずIgnitionでバイトコードを実行し、何度も通る“ホット”な関数だけをTurboFanがネイティブコードへJITコンパイルする二段構え。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。