【VB.NET】配列とList(Of T)の真実:メモリ構造とGCから読み解くパフォーマンス最適化の鉄則
文脈を問わず「動的なデータ処理=`List(Of T)`」「固定長=配列」という理解だけで設計を行っていないでしょうか。
長年、VBAやVB6で構築された巨大なレガシーシステムの再構築や、Windows APIを極限まで駆使する基幹システムの性能改善に携わってきたアーキテクトの視点から言えば、この安易な選択がガベージコレクション(GC)のスパイク、メモリ断片化、不必要なLOH(Large Object Heap)の発生源となっています。
本記事では、単なる文法の違いを超え、.NETランタイムにおけるメモリ割り当ての低レイヤ構造、P/Invoke(Windows API呼び出し)におけるメモリ固定(Pinning)、そしてレガシーコード(`ReDim Preserve`)からの安全な移行アプローチについて徹底解説します。
—
1. メモリ構造の解剖:ヒープ領域における実体
配列と `List(Of T)` の違いを決定づけるのは、.NET Managed Heap(管理ヒープ)におけるデータの配置方法です。
【配列 (System.Array)】 連続したメモリブロック
[ Header ][ Length ][ Elem 0 ][ Elem 1 ][ Elem 2 ][ Elem 3 ]
※ メモリ領域は完全に一塊で確保される
【List(Of T)】 インスタンス + 内部配列の二重構造
[ List(Of T) Header ][ _items ポインタ ][ _size ][ _version ]
│
└─► [ Inner Array Header ][ Length ][ Elem 0 ][ Elem 1 ]…
配列(`System.Array`)の物理構造
.NETにおいて、配列は単一の連続したオブジェクト(`System.Array`)として管理ヒープ上に直接割り当てられます。
- メモリ効率: オブジェクトヘッダ(8バイト/x64)、メソッドテーブルポインタ(8バイト)、要素数(8バイト)のオーバーヘッドのみ。
- データ参照: インデックスアクセスの計算式は `BaseAddress + (Index ElementSize)` となり、CPUキャッシュラインの局所性(Locality of Reference)を最大限に活用できます。
`List(Of T)` の物理構造
`List(Of T)` は独立したクラスインスタンスであり、内部にT型の配列(`T()`)を隠蔽して保持するラッパーに過ぎません。
- メモリ効率: `List(Of T)` 自体のオブジェクトヘッダに加え、内部配列のヘッダ、さらに容量を追跡するフィールドが含まれます。
- 再割り当ての代償: 要素を追加(`Add`)した際、現在の内部配列の容量(`Capacity`)を超えると、.NETランタイムは現在の2倍のサイズの新しい配列をヒープ上に確保し、旧配列の要素を全コピーします。
この「2倍の再割り当て」が、大規模ループ内で多発したとき、大量の短寿命オブジェクトが発生し、Gen 0/Gen 1 GCを極度に圧迫します。
—
2. 内部再割り当ての物理コストとLarge Object Heap (LOH)
`List(Of T)` の初期化時に `Capacity` を指定しない場合、初期容量は `0` または `4` からスタートし、拡張のたびに `8 -> 16 -> 32…` と再割り当てを繰り返します。
[初期状態: Capacity=4]
[ A ][ B ][ C ][ D ] (4 elements)
[5個目を追加時: 新しい配列(Capacity=8)を確保し全コピー]
旧: [ A ][ B ][ C ][ D ] –> (旧配列はゴミとなりGCへ)
新: [ A ][ B ][ C ][ D ][ E ][ ][ ][ ]
LOH(Large Object Heap)断片化の恐怖
.NETにおいて、85,000バイトを超えるオブジェクトは、通常のヒープではなくLOHに割り当てられます。
LOHはコンパクション(メモリの詰めての再配置)が原則行われないため、メモリの断片化(Fragmentation)を引き起こす最大の原因となります。
- 例:`Integer` (4バイト) の `List(Of Integer)` を運用する場合
要素数が 21,250 を超えた時点での配列再割り当て(21,250 × 4バイト ≈ 85KB)により、内部配列がLOH領域に直行します。
不適切な `List.Add` の繰り返しは、LOH内に大量のゴミ配列を撒き散らし、最終的にOut of Memory(OOM)例外を発生させます。
—
3. Win32 API / PInvoke 連携における絶対的境界
Windows API(Native Code)との連携において、`List(Of T)` を直接渡すことは不可能です。アンマネージド領域は、.NETのガベージコレクタがメモリを勝手に移動させることを許容しないためです。
ネイティブAPI(例: `ReadFile`, `DeviceIoControl`, `GetWindowText` 等)にバッファを渡す場合は、メモリ上で完全に連続しており、固定(Pinning)可能な「配列」の使用が必須となります。
Win32 API呼び出しにおけるデータパスの比較
1. 配列(Byte() 等): `GCHandle.Alloc(array, GCHandleType.Pinned)` により、ポインタを直接アンマネージドAPIに通過可能。零コピー(Zero-copy)。
2. `List(Of T)`: `.ToArray()` を呼ぶ必要があり、配列の完全なディープコピーが発生。メモリとCPUの無駄遣い。
—
4. 極限の最適化を実装した実践コード
以下に、レガシーな `ReDim Preserve` の完全撤廃、大規模データの高速処理における `List(Of T)` の適正化、および Win32 API(`GetSystemDirectory`)へのメモリ固定アプローチを網羅した極限の実践VB.NETコードを示します。
Imports System.Collections.Generic
Imports System.Runtime.InteropServices
Imports System.Text
”’
”’
Public Module MemoryOptimizedArchitecture
‘ Win32 APIのP/Invoke定義
‘ アンマネージドコードへバッファを渡すため、Char配列(StringBuilder)またはByte配列を使用する
Private Function GetSystemDirectory(
ByVal uSize As UInteger
) As UInteger
End Function
”’
”’
Public Function GetWindowsSystemPath() As String
‘ 期待される最大バッファサイズをあらかじめ固定長で確保 (MAX_PATH = 260)
Const MAX_PATH As UInteger = 260
Dim buffer As New StringBuilder(CInt(MAX_PATH))
‘ ManagedバッファをNative APIへ渡す (内部的にメモリ領域がピン留めされる)
Dim result As UInteger = GetSystemDirectory(buffer, MAX_PATH)
If result = 0 Then
Throw New System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error())
End If
Return buffer.ToString()
End Function
”’
”’
”’ 読み込み予定のデータ総数
Public Function ProcessLargeDataSet(ByVal itemCount As Integer) As List(Of Long)
‘ 【重要】Capacityをあらかじめ明示的に指定することで、
‘ 内部配列の再割り当て(Reallocation)とLOHへのゴミ蓄積を完全に回避する
Dim resultList As New List(Of Long)(capacity:=itemCount)
‘ CPUキャッシュ効率を極限まで高めたループ処理
For i As Long = 0 To itemCount – 1
‘ メモリ再割り当てなしで O(1) 追加
resultList.Add(i 100)
Next
Return resultList
End Function
”’
”’ 悪名高い ReDim Preserve (O(N^2)のメモリコピー) からの脱却
”’
Public Sub AntiPatternReDimLegacy()
‘ — 避けるべきアンチパターン (VBA/VB6時代の名残り) —
‘ Dim legacyArray() As String
‘ For i As Integer = 0 To 10000
‘ ReDim Preserve legacyArray(i) ‘ 毎回配列を生成して全要素コピーを行う悲劇
‘ legacyArray(i) = “Data ” & i
‘ Next
‘ — 最適化されたモダン実装 —
Const DataSize As Integer = 10000
‘ 1. サイズ変更が確定していない処理フェーズでは List(Of T) に Capacity を与えて収集
Dim temporaryList As New List(Of String)(capacity:=DataSize)
For i As Integer = 0 To DataSize – 1
temporaryList.Add(“Data ” & i)
Next
‘ 2. 収集完了後、読み取り専用データとして確定させるために固定長配列へ変換
‘ (これ以降、要素数が変化しない場合にメモリ参照効率が最大化する)
Dim immutableArray As String() = temporaryList.ToArray()
‘ 処理の完了
Console.WriteLine($”Processed {immutableArray.Length} items safely.”)
End Sub
End Module
—
5. 決定マトリクス:アーキテクトのための選択基準
プログラムの設計時にどちらを選択すべきか迷った場合は、以下の厳密な意思決定チャート(Decision Matrix)に従ってください。
| 判断要素 | `Array` (配列) を選択 | `List(Of T)` を選択 |
| :— | :— | :— |
| 要素数 (Size) | 完全固定、または初期化時に確定 | 動的に追加・削除が発生する |
| 用途 (Usage) | 読み取り専用のルックアップテーブル、固定バッファ | ビジネスロジック処理中のデータ収集 |
| Win32 API連携 | 必須 (P/Invoke用バッファとして利用) | 不可 (ToArray時のコピーコストが発生) |
| メモリ効率 | 最高 (オーバーヘッド最小、LOH回避が容易) | 条件付き高 (Capacity指定が必須) |
| マルチスレッド | `Array.AsReadOnly` 等で完全保護しやすい | 同期構造がないため `ConcurrentBag` 等を検討 |
| レガシー移行 | `ReDim` (Preserve無し) の置き換え | `ReDim Preserve` の完全代替 |
意思決定フローチャート
1. データ長は処理開始時に確定しているか?
- YES $\rightarrow$ 【配列】 を使用せよ。(`New T(length – 1)`)
- NO $\rightarrow$ Step 2 へ。
2. 最大要素数の概算(上限値)が見積もれるか?
- YES $\rightarrow$ 容量(`Capacity`)を明示して 【`List(Of T)`】 を生成せよ。
- NO $\rightarrow$ 【`List(Of T)`】 を使用するが、要素数が数万件を超える場合はLOH断片化に留意し、カスタムのチャンク化バッファ(または `LinkedList(Of T)` / `Memory(Of T)`)を検討せよ。
3. Windows APIやアンマネージドDLLとのメモリ共有を行うか?
- YES $\rightarrow$ 迷わず 【配列】(バイト配列や構造体配列)を使用し、必要に応じて `GCHandle` で固定せよ。
—
おわりに
「使いやすいから」という理由だけで `List(Of T)` を無条件に選択し、`Capacity` も指定せずに大きなループ内で `.Add()` を繰り返す実装は、システムの応答性を損なう潜在的なボトルネックとなります。
- 配列は、メモリ領域そのものを直接操作するための「最速のコンテナ」である。
- `List(Of T)` は、内部配列の管理を安全かつ効率的に行うための「動的ラッパー」である。
この低レイヤにおけるメモリ構造とGCの挙動を深く理解し、明瞭な根拠を持ってデータ構造を選択することこそが、堅牢でエンタープライズ品質に耐えうるVB.NETシステムを構築する極意です。
