TL

ブラウザのメモリ表現(文字列インターンとSmallInt最適化)

数値も文字列も同じ箱なのに速い理由が腑に落ちる。V8のSMIタグ付けとConsString・SlicedString、boxingの回避まで内部表現を整理し、メモリ効率の良いデータ構造選択を仕組みから判断できるようにします。

応用V8JavaScriptメモリ文字列最適化最終更新: 2026-06-21
TL;DR要点だけ先に
  • 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の値域はワード幅で変わる

ポインタ圧縮を使う一般的な構成ではSMIは31bit(約 ±10.7億)ですが、圧縮なしの64bit構成では32bitぶんの値域を持ちます。いずれにせよ「JSの安全整数の上限である 2^53-1 とは別物」である点が重要です。SMIを外れた整数(たとえば10億を大きく超えるID)は、後述のHeapNumberへ落ちます。

boxing:SMIを外れた数はHeapNumberに箱詰めされる

SMIの値域を超える整数、そしてすべての小数3.140.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圧が上がります。整数で済む計算をうっかり小数化しない、配列を整数だけで詰める、といった配慮がここで効きます。

数値だけの配列はSMI要素として最適化される

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 の結果
ExternalStringV8外(C++側)のバッファを指すソースコード等の外部文字列

a + b は内容をコピーせず、ab を子に持つ ConsString という木のノードを1つ作るだけで済みます(O(1))。同様に s.substring(i, j) は親 s への参照とオフセットだけを持つ SlicedString になり、元の文字を複製しません。だからこそ巨大文字列の連結や切り出しが見かけ上「一瞬」で終わります。

文字へアクセスすると flatten(平坦化)が走る

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とboxingの境界を問われる

試験・面接では「小整数はヒープを使わずワードに即値で持つ(SMI)/小整数を外れた数と小数はHeapNumberにboxingされる」という対比、および「a + b は実体コピーせずConsStringを作り、文字アクセス時にflattenで平坦化される」という遅延コピーの仕組みが問われやすい点を押さえてください。SMIの値域(圧縮環境で約±10.7億)と安全整数 2^53-1 を混同しないことも頻出です。

まとめ

まとめ

V8は値をポインタサイズのワードに詰め、最下位ビットのタグで小整数(SMI:即値、確保なし)とヒープ参照を見分けます。SMIを外れる整数と全小数は HeapNumberboxingされ、確保と間接参照のコストを生みます。文字列はリテラルやキーがインターンで実体共有され、連結は 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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

V8JavaScriptメモリ文字列最適化V8JavaScriptメモリ
参考: 公式情報