.NET で学ぶ “オブジェクトのメモリ配置” 超入門

― ターン制バトル RPG サンプルを題材に―

TL;DR

  • スタックには「参照値」だけ、ヒープに“本体”という二段構造をまず押さえよう。
  • 継承を使っても オブジェクトは 1 個。基底→派生のフィールドが “連結” されるだけで、ヘッダーは増えない。
  • 「継承はメモリ的に重い」という誤解は、“設計論” と “物理配置” を混同しがちなことが原因。

1. .NET 実行時のメモリ 3 レイヤー

領域役割本記事の例
スタックスレッドごとの作業台。メソッド呼び出しごとにフレームを積み、ローカルには 参照値 を置くbattle, actor, target などの変数(中身はヒープ上のアドレス)
マネージドヒープnew した 参照型本体、配列、string が並ぶ。GC が世代別に回収Battle / List<Character> / Player / “勇者アリス" など
値型の実体「置かれた場所」にインライン配置。フィールドなら親オブジェクトの内部、ローカルならスタックHP, Atk, Level などの int

2. RPG サンプルで辿るメモリ確保

var battle = new Battle(
    new Player("勇者アリス"),  // HP40 Atk10
    new Enemy("スライム"));   // HP30 Atk 8±2
  1. string “勇者アリス" → ヒープに確保
  2. Player 本体(ヘッダー16 B + Name参照8 B + HP4 B + Atk4 B + Level4 B)
  3. Enemy も同様
  4. Battle 本体(List<Character> 参照のみ)
  5. List 本体 + 内部 Character[] 配列(2 要素ぶんの参照を書き込み)

結果: スタックにあるのは battle 参照 1 つだけ。

“ヒープ連鎖” がゲーム全体の状態を保持する。


3. 継承 vs. コンポジション ― 実サイズの比較

モデルヒープ上のブロック数追加ポインタ64-bit での概算サイズ*
継承class Player : Character1 個0 本32 B(ヘッダー16 + Name参照8 + HP4 + Atk4 + Level4)
コンポジションclass Player { Character core; int Level; }2 個1 本64 B(外側32 B + 内側32 B)

* ポインタ 8 B、int 4 B、8 B アライン想定。

結論 : 継承の方が ヘッダーも間接参照も少なく、“密” でキャッシュフレンドリー

コンポジションが優れるのは柔軟性・疎結合・テスト容易性など 設計面 の話であり、

物理メモリ効率では継承が有利 なのが CLR の仕様。


4. オブジェクトヘッダーとフィールド並び

Player object (64-bit)
┌─ SyncBlockIndex   (4B)
│  GC ロック等
├─ MethodTablePtr   (8B)
│  vtable への参照
├─ Name 参照        (8B)
├─ HP (int)         (4B)
├─ Atk (int)        (4B)
├─ Level (int)      (4B)
└─ padding          (4B)  ← 8B 境界合わせ
  • ヘッダー 16 B は全オブジェクト共通
  • フィールドは IL 宣言順 → JIT が8 B境界にパディング
  • 派生しても MethodTablePtr は 1 本。仮想呼び出しはポインタ間接 1 回のみ

5. よくある誤解と整理

誤解実際
「継承すると基底オブジェクトが別に確保される」× C++ の sub-object のような“入れ子ブロック”は作られない。CLR では 1 ブロックに直結
「継承はメモリが重い」× ヘッダーは増えず むしろ参照 1 本ぶんだけ軽い。重いのは“設計依存” の話
「値型は常にスタック」× フィールドに置けば ヒープ上の親オブジェクト内部 に埋め込まれる

6. 計測して納得しよう

// .NET 8 なら sizeof が参照型も OK
Console.WriteLine(Marshal.SizeOf(typeof(Player)));   // 32
Console.WriteLine(Marshal.SizeOf(typeof(Character))); // 24

CLR バージョンやビルド構成(Debug / Release, x86 / x64)で数値は変わる。

“早すぎる最適化” を避けたいならまず計測 が鉄則。


7. まとめ & 次の一歩

  1. スタック = 参照値ヒープ = 本体 を最初に頭に入れる
  2. 継承しても オブジェクトは増えず、コンポジションよりメモリ局所性が良い
  3. 「継承は重い」は 設計論的デメリット。メモリ効率ではむしろ有利
  4. 気になったら Marshal.SizeOf や GC.GetGeneration で可視化
  5. ここを押さえておくと、GC チューニング や 高負荷ゲームのプロファイルにすぐ活かせる

学習課題

  • Enemy に Inventory (構造体 or クラス) を加えて 値型 / 参照型 のレイアウト差 を観察
  • struct をむやみに増やすと コピーコストで CPU が飽和 するケースも計測
  • –gcserver –gcconcurrent などランタイムスイッチで GC ログ を取得し、世代別サバイバル率をグラフ化してみる

このブログを足がかりに、“オブジェクトとメモリ” の裏側を意識してプログラミングを楽しんでみてください。

訪問数 4 回, 今日の訪問数 4回

C#,メモリ管理,継承

Posted by hidepon