V8のオブジェクト表現と隠しクラスの遷移図
同じ形のオブジェクトを使い回すコードが速い理由が腑に落ちる。V8の隠しクラスがプロパティ追加でどう枝分かれし、インラインキャッシュが単型から多型過多へ劣化するまでを遷移ツリーで読み解きます。
- 1.V8のオブジェクトは「形(隠しクラス=Map)」とプロパティ値の配列に分離され、参照は固定オフセットの読み出しで済む。
- 2.プロパティ追加のたびに隠しクラスは遷移し、追加順序が違うと別の枝に分かれる。空Mapを根とする遷移ツリーが共有される。
- 3.各アクセス箇所のインラインキャッシュは観測した形の数で単型→多型→多型過多と劣化し、最後はキャッシュを捨てて汎用探索へ落ちる。
なぜ「形」を図で追うのか
V8がオブジェクトを速く扱える鍵は、値そのものと「どんなプロパティがどの位置にあるか」という形(shape)を分けて持つ点にあります。形は内部メタデータの隠しクラス(V8の用語ではMap、Shapeとも)として表現され、プロパティを足すたびに別の隠しクラスへ遷移します。この遷移は単なる連鎖ではなく、追加順序や型で枝分かれするツリーを成します。本稿はその遷移ツリーと、参照箇所ごとの**インラインキャッシュ(IC)**が劣化していく過程を、擬似コードと表で図解します。前提となるエンジン全体像は JavaScriptエンジンの内部(JITとインラインキャッシュ) と JavaScript に譲り、ここでは「形」の構造に集中します。
オブジェクトの物理レイアウト
V8のオブジェクトは1枚岩ではなく、複数の領域に分かれて配置されます。
- Map(隠しクラス)へのポインタ:先頭ワード。形・サイズ・プロトタイプ・要素種別などを指す。
- プロパティのバッキングストア:名前付きプロパティの値。固定数までは**オブジェクト内(in-object)に直接埋め込み、あふれた分は外部のプロパティ配列(out-of-object)**へ追い出す。
- 要素(elements)のバッキングストア:
arr[0]のような数値添字プロパティ。名前付きとは別管理。
肝心なのは、値の置き場所(オフセット)はMapが知っていることです。obj.xは「Mapを見てxのオフセットを引き、そのワードを読む」だけになり、毎回のハッシュ探索を回避できます。Mapはプロパティ名→オフセットの記述子(descriptor)の表を持ち、同じ形のオブジェクトすべてで1つのMapを共有します。
オブジェクト生成時に確保される枠を超えてプロパティを足すと、値は外部のプロパティ配列へ移ります。in-objectのほうが間接参照が1段少なく速いので、最終的な形が読めるならコンストラクタで主要プロパティを先に確保しておくと、あふれを抑えられます。
遷移ツリー:根は空Map
空オブジェクト {} は「プロパティ0個」を表す根のMapを指します。プロパティを足すと、V8はまず「このMapからそのプロパティを足した先のMap」が既にあるか遷移テーブルを引きます。あれば再利用し、なければ新しいMapを作って遷移を登録します。こうして同じ手順を踏むオブジェクト群は、まったく同じMap列を共有します。
(遷移ツリー:矢印はプロパティ追加。M0 が根=空オブジェクト)
M0 {} ──+x──> M1 {x} ──+y──> M2 {x,y} ──+z──> M3 {x,y,z}
│
└──+y──> M4 {y} ──+x──> M5 {y,x}
・makePoint(a,b){ o={}; o.x=a; o.y=b } を通る全オブジェクトは M2 を共有
・先に y を足すと M4→M5 という別の枝に入り、{x,y} とは別クラス扱い
各Mapは親へのバックポインタを持ち、ツリーは根から葉へ向かう遷移と、葉から根へ戻る逆引きの両方をたどれます。重要なのは、プロパティ集合が同じでも追加順序が違えば別のMapになる点です。{x,y}(M2)と{y,x}(M5)はプロパティ名の集合こそ同じでも、オフセット割り当てが異なる別の形として扱われます。
function makePoint(x, y) {
const o = {};
o.x = x; // M0 -> M1
o.y = y; // M1 -> M2 ← この経路を通る全オブジェクトが M2 を共有
return o;
}
条件分岐で if (cond) o.flag = true のように後付けすると、flagの有無でツリーが二股になり、下流のICが複数の形を観測してしまいます。使うプロパティは生成時に同じ順序で一度に宣言し、未使用でも null で埋めて形を一本化するのが定石です。
形が壊れるとき:辞書モードへの転落
遷移ツリーで管理できるのは「素直に育つ形」だけです。次のような操作は固定オフセット表現を諦めさせ、オブジェクトを辞書モード(dictionary / slow mode)——プロパティ名をキーにしたハッシュテーブル——へ落とします。
| 操作 | 形への影響 | 推奨 |
|---|---|---|
| delete obj.p で物理削除 | 辞書モードへ転落しオフセットを失う | 値を null/undefined にする |
| プロパティ数が極端に多い | 記述子表が肥大し辞書化 | Map や別構造を検討 |
| 数値添字を飛び飛びに代入 | 要素が疎(sparse)表現へ劣化 | 添字を 0 から詰める |
| 順序が不定なプロパティ追加 | 遷移ツリーが過剰に枝分かれ | 追加順序を固定する |
辞書モードに落ちたオブジェクトは、参照のたびにハッシュ探索を行い、後述のICも効きません。一度落とすと自動では戻りにくいため、deleteを避け値を空にするだけでも体感差が出ます。
インラインキャッシュの劣化過程
ICは obj.x のようなアクセス箇所ごとに、「直前に来たオブジェクトのMapと、そのときのxのオフセット」を記憶します。次に同じ箇所を通ったとき、来たオブジェクトのMapが記憶と一致すれば探索を飛ばして即読みします。ICの状態は、その箇所が観測したMapの種類数で次のように遷移します。
| IC状態 | 観測したMap数 | ふるまい | 速度 |
|---|---|---|---|
| uninitialized | 0(初回) | まだ何も学習していない | — |
| monomorphic(単型) | 1種類 | 1つのMapとオフセットを直読み | 最速 |
| polymorphic(多型) | 2〜4種類 | 数個のMapを線形に分岐照合 | やや遅 |
| megamorphic(多型過多) | おおむね5種類以上 | 個別キャッシュを諦め汎用探索へ | 遅い |
劣化は一方向に進みます。初回アクセスで uninitialized → monomorphic となり1つの形を覚え、別の形が来るたびに分岐を増やしてpolymorphic化、種類が上限を超えるとmegamorphicへ落ちてキャッシュ自体を放棄します。
(同一アクセス箇所 obj.x の IC 状態遷移)
uninitialized ──初の形 A──> monomorphic{A}
monomorphic{A} ──別の形 B──> polymorphic{A,B}
polymorphic{A,B,C,D} ──5つ目の形 E──> megamorphic(学習を放棄)
function readX(o) { return o.x; } // この o.x が1つのIC箇所
readX({ x: 1 }); // monomorphic:形 {x} を学習
readX({ x: 1 }); // ヒット、最速で読む
readX({ x: 1, y: 2 }); // 形が {x,y}(別Map)→ polymorphic 化
// …さらに {x,a}, {x,b}, {x,c} と異なる形を流すと megamorphic へ
monomorphic / polymorphic / megamorphic はICの状態を表し、判定基準は観測したMap(形)の種類数です。numberやstringといった言語上の型ではなく、V8内部の隠しクラスである点が要注意です。同じ{x}でも追加順序が違えば別Mapになり、ICから見れば別の形として数えられます。種類が増えるほど遅い、と押さえれば十分です。
図解からの実務指針
遷移ツリーとIC劣化を1枚に重ねると、速いコードの条件が見えてきます。
- 同じアクセス箇所には同じ形だけを通す:そのICを単型に保てる。多種の形を1つの関数に流すほど多型過多へ近づく。
- プロパティは同順で一度に初期化する:遷移ツリーが一本化し、下流のICが学習する形が1種類で済む。
deleteせず値を空にする:辞書モードへの転落を避け、固定オフセットとICを維持できる。- 配列は単一型で詰めて使う:要素表現の劣化(疎配列・型混在)を防ぐ。
これらが効くのはホットパス(ループ内側や毎フレーム呼ばれる関数)に限られます。まず計測でホットスポットを特定し、効く場所だけ形を整えるのが費用対効果の高いやり方です。計測の考え方は Webパフォーマンス、JavaScriptがいつ実行されるかの土台は イベントループの内部 を参照してください。
まとめ
V8はオブジェクトを Map(隠しクラス)+値の配列 に分け、参照を固定オフセットの読み出しに落とします。Mapは空オブジェクトを根とする 遷移ツリー を成し、プロパティの追加順序が違えば別の枝=別の形になります。各アクセス箇所の インラインキャッシュ は観測した形の数で 単型→多型→多型過多 と一方向に劣化し、deleteなどで形が壊れると 辞書モード へ転落します。だから速いコードとは——同じ箇所に同じ形を通し、同順で初期化し、deleteを避ける——エンジンに「次も同じ形のはず」と賭けさせ続けるコードです。エンジン全体の最適化パイプラインは JavaScriptエンジンの内部 で押さえると、形の話が最適化・脱最適化へとつながります。
Web/フロントエンド Article
V8のオブジェクト表現と隠しクラスの遷移図を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
プロパティ追加のたびに隠しクラスは遷移し、追加順序が違うと別の枝に分かれる。空Mapを根とする遷移ツリーが共有される。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「JavaScript / V8」に近いか確認する。
- 強みである「V8のオブジェクトは「形(隠しクラス=Map)」とプロパティ値の配列に分離され、参照は固定オフセットの読み出しで済む。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。