ブラウザのメモリ表現(文字列インターンとSmallInt最適化)
数値も文字列も同じ箱なのに速い理由が腑に落ちる。V8のSMIタグ付けとConsString・SlicedString、boxingの回避まで内部表現を整理し、メモリ効率の良いデータ構造選択を仕組みから判断できるようにします。
- 1.V8は値をポインタサイズのワードに詰め、最下位ビットのタグで小整数(SMI)とヒープ参照を区別する。SMIはヒープ確保なしで即値として持つため、整数演算が確保もGCも伴わず速い。
- 2.文字列はリテラルやプロパティ名がインターン(同一実体を共有)され、連結はConsString、部分文字列はSlicedStringで実体コピーを遅延する。文字へのアクセス時にflatten(平坦化)で連続配列へ変換される。
- 3.SMI範囲を超える数や小数はHeapNumberとしてboxing(ヒープに箱詰め)され、確保とポインタ追跡のコストが生じる。配列を整数で詰める・キーを安定させる等、SMIとインターンが効く形に寄せるとメモリと速度の両方が改善する。
なぜ「メモリ表現」を知る必要があるのか
JavaScriptは型を意識させない言語ですが、その下でV8はすべての値をポインタサイズのワードに詰め込むという強い制約の中で動いています。なぜ小整数の足し算はGCを起こさず、なぜ巨大文字列の連結が一瞬で終わり、なぜある書き方だけメモリが膨らむのか——これらは個別の事象ではなく、値をワードにどう詰めるかという表現方式から導かれる必然です。原理を押さえれば、どのデータ構造がアロケーションを誘発するかを推測でなく仕組みから判断できます。前提として数値の表現は JavaScriptの数値表現とIEEE754が生む落とし穴 を、オブジェクトの内部形は V8のオブジェクト表現と隠しクラスの遷移図 を押さえておくとつながります。
タグ付きポインタ:1ワードに値か参照かを詰める
V8では、変数が指す1つの値はポインタサイズのワード(ポインタ圧縮環境では32bit、非圧縮では64bit)で表されます。問題は、そのワードに「整数そのもの」と「ヒープ上のオブジェクトへの参照」のどちらも入れたいことです。V8はこれを最下位ビットのタグで見分けます。
| 種別 | 最下位ビット | ワードの意味 |
|---|---|---|
| SMI(小整数) | 0 | 残りビットに整数値そのものを格納(即値) |
| ヒープ参照 | 1 | 残りビットがヒープオブジェクトのアドレス |
ヒープオブジェクトは必ず偶数アドレスに整列されるため、アドレスの最下位ビットは常に0です。V8はそこに1を立てて「これは参照」と印を付け、SMIには印を付けません。結果、1ワードを見るだけで分岐でき、型タグを別の場所に持つ必要がありません。これがタグ付きポインタで、SMIとHeapObjectという二系統を1ワードに同居させる土台です。
SMI:ヒープを使わない小整数の即値表現
SMI(Small Integer)は、ワードのタグ以外のビットに整数値を直接埋め込んだ表現です。ポインタ圧縮環境では32bitワードの上位31bitが値域となり、-2^30 から 2^30 - 1(約 ±10.7億)の整数がSMIとして扱われます。この範囲の整数はヒープ確保を一切伴わず、ワードの中だけで足し算・比較ができます。
(ポインタ圧縮環境・32bitワードの概念図)
[ 31bit 整数値 ][tag=0] ← SMI:値そのもの、確保なし
[ 31bit アドレス ][tag=1] ← ヒープ参照:HeapNumberなどを指す
SMIが速いのは、メモリ確保もGC対象の増加も起きないからです。配列のインデックス、ループカウンタ、小さなIDなど、JavaScriptの整数の大半はこの範囲に収まるため、V8はそれらを実質「ネイティブの整数」のように扱えます。
ポインタ圧縮を使う一般的な構成ではSMIは31bit(約 ±10.7億)ですが、圧縮なしの64bit構成では32bitぶんの値域を持ちます。いずれにせよ「JSの安全整数の上限である 2^53-1 とは別物」である点が重要です。SMIを外れた整数(たとえば10億を大きく超えるID)は、後述のHeapNumberへ落ちます。
boxing:SMIを外れた数はHeapNumberに箱詰めされる
SMIの値域を超える整数、そしてすべての小数(3.14 や 0.1)は、1ワードに収まりません。これらはヒープ上に HeapNumber というオブジェクトとして確保され、IEEE754倍精度の64bitをそこに格納し、変数のワードにはその参照(tag=1)が入ります。値を箱に入れて参照で持つこの操作が boxing(ボクシング) です。
let a = 100; // SMI:確保なし、即値
let b = 3.14; // HeapNumber:ヒープに64bit確保+参照
let c = 2 ** 31; // SMI範囲外 → HeapNumber
a = a + 1; // SMIのまま、確保なし
b = b + 1; // 新しいHeapNumberを確保(数値はイミュータブル)
boxingのコストは二重です。第一に確保そのもの(GC対象が増える)、第二に値を読むたびにポインタを追ってヒープにアクセスする間接参照です。さらにループ中で小数を更新し続けると、その都度新しいHeapNumberが生まれ、GC圧が上がります。整数で済む計算をうっかり小数化しない、配列を整数だけで詰める、といった配慮がここで効きます。
V8の配列は要素の種類(elements kind)を追跡し、全要素がSMIならPACKED_SMI_ELEMENTSという最も軽い内部表現を使います。ここに小数を1つ混ぜるとDOUBLE要素へ、undefined で穴を空けるとHOLEY(穴あき)へ格上げされ、後戻りしません。数値配列は「整数だけ・穴を作らない」を保つと、要素ごとのHeapNumber確保を避けられます。
文字列インターン:同じ文字列の実体を1つに共有する
文字列側の最適化がインターン(interning)です。V8はリテラル文字列、オブジェクトのプロパティ名(キー)、識別子などをインターン化された文字列テーブルに登録し、内容が同一なら同じ実体を共有します。こうすると、同じキーが何度現れてもメモリは1つで済み、しかも比較がポインタ一致の確認だけで終わります(内容を1文字ずつ比べる必要がない)。
const k1 = "name";
const k2 = "na" + "me"; // コンパイル時に畳まれ、同じインターン実体になりうる
obj[k1] === obj[k2]; // キー比較がポインタ比較で高速
プロパティ名がインターンされることは、隠しクラス(hidden class)でプロパティを高速に解決できる前提にもなっています。動的に大量のユニークな文字列をキーにすると、インターンテーブルが膨らみ共有の利点が薄れるため、キーは安定した既知の集合に寄せるのが定石です。
ConsString と SlicedString:コピーを遅延する文字列表現
文字列はイミュータブルなので、連結や切り出しのたびに新しい連続バイト列を作ると、長い文字列ではコピーが致命的になります。V8はこれを実体コピーの遅延で回避し、文字列を複数の内部表現で持ちます。
| 内部表現 | 持ち方 | 生まれる場面 |
|---|---|---|
| SeqString | 文字を連続バッファに実体として持つ | リテラルや平坦化後の文字列 |
| ConsString | 左右2つの文字列への参照のペア(木構造) | a + b の連結結果 |
| SlicedString | 親文字列+開始位置+長さ | substring / slice の結果 |
| ExternalString | V8外(C++側)のバッファを指す | ソースコード等の外部文字列 |
a + b は内容をコピーせず、a と b を子に持つ ConsString という木のノードを1つ作るだけで済みます(O(1))。同様に s.substring(i, j) は親 s への参照とオフセットだけを持つ SlicedString になり、元の文字を複製しません。だからこそ巨大文字列の連結や切り出しが見かけ上「一瞬」で終わります。
ConsStringやSlicedStringのまま charCodeAt・正規表現・APIへの受け渡しなど個々の文字に触れる操作をすると、V8はその場で木をたどって連続バッファへ展開する**flatten(平坦化)**を行います。連結を大量に繰り返した直後の1回のアクセスで、まとめて重いコピーが発生し得ます。ループ内で文字列を += で積み上げ続けると深いConsStringの木ができ、後段の平坦化やGCで跳ね返ってくる点に注意してください。文字列がUTF-16コード単位の並びである前提は テキストエンコーディングの内部(UTF-16のサロゲートとコードポイント) を参照してください。
メモリ効率を意識したデータ構造選択の指針
ここまでの表現方式から、実務での選択指針が機械的に導けます。要点は「SMIとインターンが効く形に寄せ、boxingと無駄なConsStringの木を避ける」ことです。
| やりたいこと | メモリ効率の良い選択 | 避けたい形 |
|---|---|---|
| 大量の数値を並べる | 整数だけのArray/TypedArray(SMIまたは連続バイト) | 小数や穴を混ぜてDOUBLE/HOLEYへ格上げ |
| 固定構造のレコード集合 | 同じ形のオブジェクト(隠しクラス共有) | 毎回キー順や有無が違うオブジェクト |
| キー・ラベルの集合 | 既知の文字列リテラル(インターン共有) | 実行時生成のユニーク文字列を無限にキー化 |
| 長い文字列の組み立て | 配列にpush→最後にjoinで1回平坦化 | ループ内 += で深いConsStringを蓄積 |
数値が常にSMI範囲かつ小数を含まないなら、配列はSMI要素のまま軽量に保てます。固定長で型が一様な巨大データは、boxingもelements kindの格上げも起きない TypedArray・ArrayBuffer が最も密です(バイト列としての扱いは TypedArray・ArrayBuffer・DataViewのメモリレイアウト)。文字列の組み立ては、都度 += するより配列に貯めて最後に join で一度だけ平坦化するほうが、ConsStringの木とGCを抑えられます。
試験・面接では「小整数はヒープを使わずワードに即値で持つ(SMI)/小整数を外れた数と小数はHeapNumberにboxingされる」という対比、および「a + b は実体コピーせずConsStringを作り、文字アクセス時にflattenで平坦化される」という遅延コピーの仕組みが問われやすい点を押さえてください。SMIの値域(圧縮環境で約±10.7億)と安全整数 2^53-1 を混同しないことも頻出です。
まとめ
V8は値をポインタサイズのワードに詰め、最下位ビットのタグで小整数(SMI:即値、確保なし)とヒープ参照を見分けます。SMIを外れる整数と全小数は HeapNumber にboxingされ、確保と間接参照のコストを生みます。文字列はリテラルやキーがインターンで実体共有され、連結は ConsString、切り出しは SlicedString として実体コピーを遅延し、文字へ触れた時点で flatten が走ります。だからこそ「整数だけ・穴なしの配列」「安定したキー」「joinによる一括組み立て」が効くのです。「値をワードにどう詰めるか」を起点にすれば、どの書き方がアロケーションとGCを誘発するかを推測でなく仕組みから判断できます。確保された箱がいつ回収されるかは ガベージコレクションのブラウザ内動作(世代別GCとメモリリーク) と合わせて見ると、メモリ効率の全体像がつながります。
Web/フロントエンド Article
ブラウザのメモリ表現(文字列インターンとSmallInt最適化)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
V8
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
文字列はリテラルやプロパティ名がインターン(同一実体を共有)され、連結はConsString、部分文字列はSlicedStringで実体コピーを遅延する。文字へのアクセス時にflatten(平坦化)で連続配列へ変換される。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「V8 / JavaScript」に近いか確認する。
- 強みである「V8は値をポインタサイズのワードに詰め、最下位ビットのタグで小整数(SMI)とヒープ参照を区別する。SMIはヒープ確保なしで即値として持つため、整数演算が確保もGCも伴わず速い。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。