TypedArray・ArrayBuffer・DataViewのメモリレイアウト
バイナリで値が化ける謎を解消。生バイト列ArrayBufferにTypedArrayとDataViewがどう被さるか、エンディアン・アラインメント・detach・転送・SharedArrayBufferの違いまで原理から解説します。
- 1.ArrayBufferは生のバイト列(記憶域)そのもので、それ自体を直接読み書きはできない。TypedArrayやDataViewという「ビュー」を被せて初めてアクセスできる。
- 2.TypedArrayはホストのエンディアンに従い要素単位でアクセスするが、DataViewはバイトオフセット指定でエンディアンを明示できる。外部由来のバイナリはDataViewが安全。
- 3.ArrayBufferはdetach(転送やtransfer)で中身が切り離され、以降そのバッファのビューは長さ0になる。SharedArrayBufferは転送ではなく共有でき、複数スレッドから同じバイト列を見る。
なぜバイナリで値が化けるのか
ファイルやネットワークから来たバイナリをJavaScriptで扱うと、同じバイト列のはずなのに環境によって数値が変わったり、配列の途中を読むとずれたりします。原因のほとんどは、生のバイト列と、それをどう解釈するかを混同していることにあります。JSのバイナリ表現は、記憶域そのものである ArrayBuffer と、その上に被せるビュー(TypedArray と DataView)の二層構造です。この層の役割分担、特にエンディアンとアラインメント、そして detach/転送の挙動を押さえると、「なぜそう読めるのか」が一通り説明できるようになります。
ArrayBufferは「中身を直接触れない」記憶域
ArrayBuffer は、指定したバイト数の連続した固定長のメモリ領域を表すオブジェクトです。重要なのは、ArrayBuffer 自身には要素アクセスのAPIが無いことです。buf[0] のように添字で読み書きはできず、byteLength を問い合わせられるだけです。実際のデータを読み書きするには、その領域を「どの型の並びとして見るか」を決めるビューを作ります。
const buf = new ArrayBuffer(8); // 8バイトの生領域を確保
buf[0]; // undefined(添字アクセスは効かない)
const u8 = new Uint8Array(buf); // 同じ領域を「符号なし8bit整数の並び」として見る
u8[0] = 255; // ビュー経由で初めて書ける
ビューは領域をコピーしません。new Uint8Array(buf) と new Float64Array(buf) は同じ8バイトを別の解釈で共有するため、一方の書き込みが他方から見えます。これがバイナリ変換(同じバイト列を整数として見るか浮動小数として見るか)の基礎です。
TypedArray も DataView も、内部に buffer・byteOffset・byteLength の3点を持ちます。1つの ArrayBuffer の一部だけを切り出した窓を複数並べられ、それぞれが重なっていても構いません。値そのものはビューではなく、常に裏の ArrayBuffer に置かれています。
TypedArrayとDataView:解釈の二つの流儀
ビューには性格の異なる2系統があります。TypedArray(Uint8Array / Int32Array / Float64Array など)は、単一の要素型で領域を等間隔に区切り、arr[i] で配列のように読み書きします。一方 DataView は型を固定せず、getInt32(offset) のように呼び出しごとに型とオフセットを指定して読み書きします。
| 観点 | TypedArray | DataView |
|---|---|---|
| アクセス単位 | 固定した要素型・添字で連続アクセス | 呼び出しごとに型とバイトオフセット指定 |
| エンディアン | ホスト依存(多くはリトルエンディアン) | 引数で明示(既定はビッグエンディアン) |
| アラインメント | 要素境界に揃う前提 | 任意のバイト位置から読める |
| 向く用途 | 自前の数値配列・大量演算 | 外部仕様のバイナリ(フォーマット)解析 |
使い分けの原則は明快です。自分で生成して自分で消費する数値配列なら TypedArray が速くて簡潔です。外部仕様で「ここは32bitビッグエンディアン整数」と決まっているバイナリ(画像・音声・ネットワークプロトコル等)を読むなら、解釈を明示できる DataView が安全です。
エンディアン:TypedArrayが環境で化ける理由
エンディアンは、複数バイトの数値をメモリにどの順で並べるかの規約です。値 0x12345678(4バイト)を、低位バイトから並べるのがリトルエンディアン(78 56 34 12)、高位バイトから並べるのがビッグエンディアン(12 34 56 78)です。
TypedArray の多バイト要素(Uint16Array 等)は、動作環境(ホストCPU)のエンディアンで読み書きします。現在主流のCPUはリトルエンディアンですが、これは保証ではありません。だから外部から来たバイト列をそのまま Uint32Array で読むと、相手がビッグエンディアンで書いていた場合に値が化けます。DataView はこの曖昧さを排除し、第2引数で littleEndian を明示します(省略時はビッグエンディアン)。
const dv = new DataView(new ArrayBuffer(4));
dv.setUint8(0, 0x12); dv.setUint8(1, 0x34);
dv.setUint8(2, 0x56); dv.setUint8(3, 0x78);
dv.getUint32(0, false); // 0x12345678(ビッグエンディアンとして解釈)
dv.getUint32(0, true); // 0x78563412(リトルエンディアンとして解釈)
ネットワークやファイル由来のバイナリは、相手が定めたエンディアンで書かれています。これを Int32Array 等でそのまま読むとホスト依存になり、別環境で再現しないバグになります。仕様でエンディアンが決まっているデータは DataView で明示的に読むのが鉄則です。
アラインメント:TypedArrayにある境界制約
TypedArray を ArrayBuffer の途中から作るとき、開始オフセットは要素サイズの倍数でなければなりません。Float64Array(要素8バイト)を byteOffset=4 から作ろうとすると RangeError になります。これは要素を境界に揃える前提で実装が単純化・高速化されているためです。
const buf = new ArrayBuffer(16);
new Float64Array(buf, 4); // RangeError(8の倍数でない)
new Float64Array(buf, 8); // OK(8バイト境界)
new DataView(buf).getFloat64(4); // OK(DataViewは任意オフセットを許す)
DataView にはこの制約がなく、任意のバイト位置から多バイト値を読めます。各フィールドが必ずしも境界に揃っていない外部フォーマットを解析するとき、DataView の自由度が効いてきます。
detachと転送:中身が消えるバッファ
ArrayBuffer はdetach(切り離し)という状態を持ちます。detachされると、その領域は所有権を失い、byteLength は0、被せていた全ビューも長さ0になって読めなくなります。代表例が転送(transfer)です。postMessage の第2引数に ArrayBuffer を渡す、ArrayBuffer.prototype.transfer() を呼ぶ、といった操作で、領域はコピーされずに所有権ごと移動し、元のバッファはdetachされます。
const a = new ArrayBuffer(8);
const view = new Uint8Array(a);
const b = a.transfer(); // 領域をbへ移動、aはdetach
a.byteLength; // 0
view.byteLength; // 0(元ビューも無効化)
b.byteLength; // 8
転送はコピーを避ける高速な受け渡しですが、元側でそのバッファとビューが使えなくなる点が落とし穴です。これはWasmの線形メモリで memory.grow 後に古いビューが無効化されるのと同じ理屈で、「ビューはあくまで裏の領域への窓であり、領域が切り替われば窓も死ぬ」という共通原理です。Worker間でデータを移すときも、構造化クローン(コピー)になるか転送(ゼロコピー)になるかで性能が大きく変わるため、IndexedDBや構造化クローンの文脈でもこの区別が重要になります。
転送・grow・resizeのいずれでも、保持していたビューは無効化され得ます。ArrayBuffer を握り続ける設計を避け、操作後に裏のバッファからビューを作り直すか、参照を破棄してから新しいビューを生成します。グローバルにビューをキャッシュするのが最も事故りやすいパターンです。
SharedArrayBuffer:転送ではなく共有
SharedArrayBuffer は、名前は似ていても性格が逆です。ArrayBuffer の転送が「所有権の移動(移したら元は使えない)」なのに対し、SharedArrayBuffer は同じ領域を複数スレッドが同時に見る共有です。postMessage で渡しても元側はそのまま使え、detachも起きません。メインスレッドとWorkerが文字通り同じバイト列を読み書きできます。
ただし共有には代償があります。複数スレッドが同じ領域を同時に触ると競合が起き得るため、Atomics(Atomics.add / Atomics.load / Atomics.wait 等)で不可分操作やメモリ順序を制御する必要があります。また、SharedArrayBuffer の利用にはタイミング攻撃対策としてクロスオリジン分離(COOP/COEP ヘッダによる隔離環境)が必要で、どの文書でも無条件に使えるわけではありません。
| 観点 | ArrayBuffer | SharedArrayBuffer |
|---|---|---|
| スレッド間の受け渡し | 転送(ゼロコピー、元はdetach)/クローン(コピー) | 共有(同一領域を複数スレッドが参照) |
| detach | 起こる(転送時) | 起こらない |
| 同時アクセス | 同時には1スレッドのみ | 複数スレッド可、Atomicsで同期が必要 |
| 利用条件 | 制約なし | クロスオリジン分離環境が必要 |
detach(転送で領域が切り離され元が無効化)、resize(ArrayBuffer を maxByteLength 指定で作ると resize で長さ変更でき、縮小時は末尾が捨てられる)、share(SharedArrayBuffer による同一領域の共有)は別概念です。「コピーされるのか」「元が使えなくなるのか」「複数スレッドから見えるのか」の3軸で切り分けると取り違えません。
まとめ
JSのバイナリは、生のバイト列である ArrayBuffer と、その上に被せるビュー(TypedArray / DataView)の二層です。値は常に裏の領域にあり、ビューは窓に過ぎません。TypedArray はホストのエンディアンで要素単位に読む高速な道具、DataView はエンディアンとオフセットを明示できる外部バイナリ解析向けの道具で、アラインメント制約の有無も異なります。ArrayBuffer は転送でdetachされ元のビューが無効化される一方、SharedArrayBuffer は転送ではなく共有で、複数スレッドが同じ領域を Atomics 越しに触れます。「コピーか・移動か・共有か」「どの順でバイトを並べるか」を意識すれば、バイナリで値が化ける現象はほぼ説明・回避できます。
Web/フロントエンド Article
TypedArray・ArrayBuffer・DataViewのメモリレイアウトを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
TypedArray
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
TypedArrayはホストのエンディアンに従い要素単位でアクセスするが、DataViewはバイトオフセット指定でエンディアンを明示できる。外部由来のバイナリはDataViewが安全。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「TypedArray / ArrayBuffer」に近いか確認する。
- 強みである「ArrayBufferは生のバイト列(記憶域)そのもので、それ自体を直接読み書きはできない。TypedArrayやDataViewという「ビュー」を被せて初めてアクセスできる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。