Windowsメモリの深層(第1回) ── 仮想アドレスが物理RAMに変わる瞬間:ページフォルトの一部始終

· 更新日: · · Windows, メモリ管理, VirtualAlloc, ページフォルト, VAD, パフォーマンス監視

VirtualAllocMEM_COMMIT を渡した直後、Commitは増えます。

しかし、Working Setは同じだけ増えるとは限りません。

では、そのメモリはどこにあるのでしょうか。

答えは、まだ対応する物理RAMがないページが大半である、です。

Windowsは、アプリケーションが本当にページへ触れるまで、物理ページの割り当てを遅らせます。最初のアクセスでCPUがページフォルトを発生させ、メモリマネージャーがVAD、PTE、保護属性、バックストアを調べ、必要ならRAMを1ページずつ結び付けます。1

本記事では、その「最初に触った1バイト」が物理RAMへ到達するまでを追います。

数字の意味を先に整理したい方は、導入編「Windowsの『メモリ使用量』は何を表しているのか ── Working Set・Private Bytes・Commit・ページファイルを正しく読む」をご覧ください。本連載は、そこで使った用語を再定義せず、なぜその数字になるのかを機構側から掘ります。

「Windowsメモリの深層」全3回

  1. 第1回(本記事):仮想アドレスとページフォルト
    VirtualAllocした領域が、いつ物理RAMを得るのかを追います。
  2. 第2回:物理ページの一生
    Working Setから外れたページが、Modified、Standby、Free、Zeroedをどう移動するかを追います。
  3. 第3回:セクションオブジェクトとコピーオンライト
    DLL、ファイルマッピング、共有メモリが物理ページを共有できる理由を追います。

第1回が答える疑問は、次の1つです。

Commit済みの仮想アドレスは、どの瞬間に物理RAMへ変わるのか。

対象読者は、Windowsアプリのメモリ使用量、起動直後のページフォルト、0xC0000005、VMMapやPerfMonの数字を仕組みから理解したい開発者・運用担当者です。

前提環境はWindows 10/11または現行のWindows Server、前提知識はポインターとVirtualAllocの基礎です。ページテーブルのビット配置やカーネルデバッガーの経験は不要です。

難易度は中級です。内部構造名を扱いますが、Windowsの特定ビルドに依存する非公開レイアウトは前提にしません。

1. まず結論

通常のプライベートメモリについて、流れを一行にすると次のようになります。

Reserveは仮想アドレス範囲を確保し、Commitは将来の保存先を保証するためのコミット料金を課し、最初のアクセスで起きるページフォルトが物理ページを割り当てる。

MEM_COMMITは「RAMを今すぐ確保する」という命令ではありません。

MicrosoftのVirtualAllocドキュメントは、Commit済みページの初期内容がゼロであることを保証する一方、実際の物理ページは仮想アドレスへアクセスされるまで割り当てられないと説明しています。1

ただし、「Reserve/CommitはVADへ書くだけ」と言い切るのも不正確です。

  • Reserveでは、主に仮想アドレス範囲と属性を表すVADが作られます。
  • Commitでは、システムのCommit Totalが増え、範囲のコミット状態も記録されます。
  • ページテーブルの中間階層や個々のPTEは、必要に応じて遅延構築されます。
  • 物理RAMとの最終的な結び付きは、通常、最初のアクセス時です。

Commitは空約束ではなく、将来その内容をRAMまたは適切なバックストアで保持できるというシステム全体の約束です。

その約束を1ページずつ実体化する入口が、ページフォルトです。

2. 仮想ページを追う3つの台帳

仮想アドレスから物理RAMまでを理解するには、3種類の台帳を分けます。

台帳 単位 役割
VAD 仮想アドレス範囲 何の領域か、Reserve/Commit、保護、セクション対応を管理
ページテーブル/PTE 仮想ページ 現在の物理ページへの変換、または未実体化状態を表現
PFNデータベース 物理ページ 各RAMページの所有、参照、状態を追跡

VADは範囲、PTEは仮想ページ、PFNデータベースは物理ページの情報です。

ページフォルトハンドラーは、これらを突き合わせてアクセスを続行できるか判断します。

本記事はVADとPTEが主役です。PFNデータベースは第2回で物理ページ側から見ます。

3. Reserve、Commit、Touchは別の出来事

3.1. Reserve ── 住所を押さえる

次は256MiBの連続した仮想アドレス範囲を予約します。

void* base = VirtualAlloc(
    nullptr,
    256ull * 1024 * 1024,
    MEM_RESERVE,
    PAGE_NOACCESS);

この時点では、範囲を他の割り当てに使わせないよう、プロセスの仮想空間へ住所を確保しただけです。

MEM_RESERVEはRAMにもページファイルにも物理的な保存領域を割り当てません。1

64bitプロセスでは広い仮想空間を使えるため、大きな範囲をReserveして、必要な部分だけ後からCommitする設計ができます。

3.2. Commit ── 保存できることを約束する

予約済み範囲をCommitします。

void* committed = VirtualAlloc(
    base,
    256ull * 1024 * 1024,
    MEM_COMMIT,
    PAGE_READWRITE);

成功するとCommit Totalと、通常はプロセスのPrivate Bytesに反映される約束量が増えます。

それでも、256MiB分の物理ページが一度に並ぶわけではありません。通常のページは初回アクセスまで物理的に割り当てられません。12

Commitの意味は、システムが約束を引き受けられないなら、使っている途中ではなくCommit時点で失敗を返せることです。

3.3. Touch ── 物理ページが必要になる

次の代入が先頭ページへ初めて書き込みます。

static_cast<unsigned char*>(base)[0] = 1;

CPUは仮想アドレスを物理アドレスへ変換しようとしますが、PTEにはまだ有効な物理ページへの変換がありません。

そこでページフォルトが発生します。

メモリマネージャーは「Commit済みで書き込み可能なプライベートページへの初回アクセス」と判定し、ゼロ化済みの物理ページを得てPTEへ結び、Working Setへ加えます。

その後、失敗した書き込み命令を再実行します。

アプリから見えるのはただの代入ですが、内部では代入の途中でカーネルへ入り、物理ページを割り当て、同じ命令へ戻る処理が走っています。

4. VAD ── 仮想空間の範囲台帳

VADはVirtual Address Descriptorの略です。

Windowsはプロセスの利用中アドレス範囲をVADの木で管理します。WinDbgの!vadは、開始・終了VPN、Commit、保護属性、Private/Mapped、Control Areaなどを表示します。3

VADが表す代表的な情報は次のとおりです。

  • アドレス範囲の開始と終了
  • Private、Mapped、Imageなどの種類
  • Reserve/Commitの状態
  • 読み取り、書き込み、実行、Copy-on-Writeなどの保護
  • ファイルやセクションとの対応
  • ガードページなどの特殊属性

256MiBは4KiBページなら65,536ページです。最初から全ページへ完全な管理構造を作るより、「この連続範囲は1つの予約」とVADで持ち、必要になったページから具体化する方が効率的です。

4.1. VADが見つかれば必ず回復できるわけではない

「VADに載っていればフォルトを解決し、載っていなければアクセス違反」という説明は、入口としては便利ですが単純化されています。

VADが見つかっても、次なら通常のアクセスを続けられません。

  • Reserveのみで対象ページがCommitされていない
  • PAGE_NOACCESSである
  • 読み取り専用ページへ書き込んだ
  • 実行不可ページから命令を実行した
  • ガードページへ初めて触れた
  • セクションの有効範囲外へ触れた

反対にPTEが無効でも、VADとPTEのソフトウェア状態から正当なアクセスだと分かれば、デマンドゼロ、Transition復帰、ページイン、CoWとして解決できます。

正確には、VAD、PTE、保護属性、アクセス種別を合わせて判定する、です。

5. ページテーブルとTLB

アプリが持つポインターは仮想アドレスです。CPUがRAMへアクセスするには、仮想ページ番号を物理ページ番号へ変換する必要があります。

その階層的な変換表がページテーブル、末端のエントリーがPTE(Page Table Entry)です。

有効なPTEは、概念的にPFN、読み書き実行の保護、ユーザーモード可否、Accessed/Dirtyなどを持ちます。実際のビット配置はCPUとWindowsのバージョンに依存します。

毎回ページテーブルをたどるのは遅いため、CPUは最近の変換をTLB(Translation Lookaside Buffer)へキャッシュします。

  1. TLBに変換があれば、その結果を使います。
  2. なければ、CPUがページテーブルをたどります。
  3. 有効なPTEがあれば、TLBへ登録して続行します。
  4. 有効な変換がない、または保護違反ならページフォルトの入口へ進みます。

したがって、TLBミスとページフォルトは別物です。

TLBにないだけでPTEが有効なら、ページテーブルウォークで終わります。

5.1. 無効なPTEは単なる空欄ではない

Windowsは無効PTEのソフトウェア状態から、たとえば次を区別します。

  • 一度も実体化していないデマンドゼロページ
  • RAMに残るTransitionページ
  • Prototype PTEを参照する共有ページ
  • ページファイルに保存されたプライベートページ
  • 保護違反や無効領域

CPUは「通常の有効変換ではない」と判断してカーネルへ渡し、その意味付けをメモリマネージャーが行います。

6. ページフォルトの一部始終

Commit済みプライベートページへの初回書き込みを、6段階で追います。

  1. CPUが書き込もうとする。
    TLBとページテーブルを調べますが、対象PTEに有効なPFNがありません。
  2. CPUがページフォルトを発生させる。
    フォルトした仮想アドレス、読み書き実行の種別、ユーザー/カーネル、変換不在か保護違反かをカーネルへ渡します。
  3. メモリマネージャーがVADとPTEを調べる。
    Commit済みか、保護に合うか、デマンドゼロ・Transition・共有・ページイン・CoW・例外のどれかを決めます。
  4. デマンドゼロならゼロ化済み物理ページを得る。
    他プロセスのデータを漏らさないため、新規に渡すページはゼロでなければなりません。
  5. PTEとPFN管理情報を更新する。
    PTEへPFNと保護を設定し、物理ページをActiveにして、プロセスのWorking Setへ加えます。
  6. 失敗した命令を再実行する。
    正常に解決したためユーザーモード例外は届かず、アプリは通常の代入として処理を続けます。

ETWのページフォルトイベントも、Transition、Demand Zero、Copy-on-Write、Guard Page、Hard Page Fault、Access Violationを別種として記録します。4

ページフォルトは、最初から「異常」を意味する言葉ではありません。

CPUが通常経路で変換できなかったため、OSへ判断を依頼する共通入口です。

7. デマンドゼロ ── ディスクを読まないソフトフォルト

デマンドゼロは、Commit済みプライベートページへ初めて触れたときの代表的なソフトフォルトです。

MicrosoftのWorking Set解説も、「割り当て済み仮想ページをプロセスが初めて参照する」場合をソフトフォルトの例に挙げています。5

特徴は次のとおりです。

  • 元データをディスクから読む必要がない
  • 初期内容はゼロ
  • 利用可能な物理ページを結び付ける
  • Working Setと累積Page Fault Countは増える
  • この処理だけならMemory\\Pages Input/secは増えない

起動直後にPage Faults/secが跳ねても、それだけでストレージが詰まっているとは言えません。

256MiBをCommitして実際に使うのが8MiBだけなら、残り248MiBをRAMへ置かない遅延割り当ては合理的です。その代わり、初回アクセスにはフォルト処理のコストが入ります。

レイテンシが厳しい処理では、開始前に各ページへ触れてプリフォールトする設計もあります。ただし、これはRAM常駐量を先に増やす選択です。

8. ソフトフォルトとハードフォルト

8.1. ソフトフォルト

バックストアへの読み取りI/Oなしで解決できるフォルトです。

  • デマンドゼロ
  • Standby/Transitionに残るページの再接続
  • 他プロセスのWorking Setにある共有ページの接続
  • 先読み済みページの接続
  • 元ページが常駐しているCopy-on-Write

カーネル遷移、ロック、PTE/PFN更新、TLB整合などのCPUコストはありますが、ストレージ待ちはありません。5

8.2. ハードフォルト

必要なページがRAMのどこにもなく、バックストアから読む必要があるとハードフォルトです。

読み取り元はページファイルだけではありません。

  • ページファイルへ退避されたプライベートページ
  • メモリマップトファイル
  • EXEやDLLのイメージ
  • ファイルキャッシュが参照するデータファイル

ETWのHardFaultイベントにはFileObject、ReadOffset、ByteCountがあり、読み取り元を追跡できます。6

したがって、Hard Fault = pagefile.sysを読んだではありません。

バックストア読み取りが必要になると、要求はWindowsのI/Oスタックへ入ります。IRPと発行・完了の流れは「Windows I/Oの深層(第1回)」、ファイルキャッシュとの合流は「Windows I/Oの深層(第4回)」で扱っています。

ページがRAMにあればメモリマネージャーだけで戻れます。なければI/Oを発行し、完了までフォルトしたスレッドを待たせます。

9. 解決できないフォルトは例外になる

VADとPTEを調べても正当な割り当て・ページイン・CoWとして解決できなければ、ユーザーモードへ例外を届けます。

代表がSTATUS_ACCESS_VIOLATION、例外コード0xC0000005です。無効なアドレスの読み取り、書き込み、実行で発生し、第1例外パラメーターがアクセス種別、第2パラメーターが違反アドレスを示します。7

典型例は次のとおりです。

  • NULL、解放後、配列外のアドレスを読む
  • 読み取り専用ページへ書く
  • DEP/NXで実行不可のページから命令を実行する
  • CommitされていないReserve範囲へ触る

PAGE_GUARDは別の意味を持ちます。アクセスを一度だけ通知する仕組みで、STATUS_GUARD_PAGE_VIOLATIONを発生させ、スタック拡張などに使われます。8

正常な遅延割り当て、ページイン、CoW、ガード通知、最終的なアクセス違反は、CPUから見れば同じページフォルト入口へ集まります。

結末を決めるのは、VAD、PTE、保護属性、アクセス種別です。

10. 自分の目で確かめる

次のC++プログラムは256MiBをReserveし、Commitし、各ページへ1バイト書き、最後にReleaseします。

各段階でEnterキーを待つため、VMMapとPerfMonで変化を観測できます。

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <psapi.h>

#include <cstdio>
#include <cstdlib>

#pragma comment(lib, "Psapi.lib")

constexpr SIZE_T kSize = 256ull * 1024 * 1024;

void PrintMemory(const char* stage)
{
    PROCESS_MEMORY_COUNTERS_EX c{};
    c.cb = sizeof(c);
    if (!GetProcessMemoryInfo(
            GetCurrentProcess(),
            reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&c),
            sizeof(c))) {
        std::printf("GetProcessMemoryInfo failed: %lu\n", GetLastError());
        return;
    }

    std::printf(
        "%-10s WS=%zu MiB  Private=%zu MiB  Faults=%lu\n",
        stage,
        c.WorkingSetSize / 1024 / 1024,
        c.PrivateUsage / 1024 / 1024,
        c.PageFaultCount);
}

void Pause(const char* message)
{
    PrintMemory(message);
    std::puts("Press Enter...");
    (void)std::getchar();
}

int main()
{
    SYSTEM_INFO si{};
    GetSystemInfo(&si);
    std::printf("PID=%lu, page=%lu bytes\n",
                GetCurrentProcessId(), si.dwPageSize);

    void* base = VirtualAlloc(nullptr, kSize, MEM_RESERVE, PAGE_NOACCESS);
    if (!base) {
        std::fprintf(stderr, "Reserve failed: %lu\n", GetLastError());
        return EXIT_FAILURE;
    }
    Pause("reserved");

    if (!VirtualAlloc(base, kSize, MEM_COMMIT, PAGE_READWRITE)) {
        std::fprintf(stderr, "Commit failed: %lu\n", GetLastError());
        VirtualFree(base, 0, MEM_RELEASE);
        return EXIT_FAILURE;
    }
    Pause("committed");

    auto* bytes = static_cast<volatile unsigned char*>(base);
    for (SIZE_T offset = 0; offset < kSize; offset += si.dwPageSize) {
        bytes[offset] = 1;
    }
    Pause("touched");

    if (!VirtualFree(base, 0, MEM_RELEASE)) {
        std::fprintf(stderr, "Release failed: %lu\n", GetLastError());
        return EXIT_FAILURE;
    }
    Pause("released");
}

Visual Studioのx64 Native Tools Command Promptなら、次でビルドできます。

cl /std:c++20 /EHsc /W4 memory_fault_demo.cpp

10.1. VMMapで見るもの

VMMapは、予約済み仮想メモリ、Commit、Working Set、Private、Shareableを種類別に表示します。9

段階 期待する変化
Reserve Address SpaceのSizeは増えるが、Commit/WSは同量増えない
Commit PrivateのCommitが約256MiB増える
Touch Working SetとPrivate WSが大きく増え、Fault Countも増える
Release 対象範囲が消え、CommitとWSが下がる

数値はランタイム、セキュリティ製品、メモリ圧力、観測タイミングで変わります。256MiBちょうどになるかではなく、段階間の方向を見ます。

10.2. PerfMonでソフト/ハードを分ける

次のカウンターを同じ時間軸へ置きます。

  • Process(<対象>)\\Page Faults/sec
  • Memory\\Pages Input/sec
  • Memory\\Page Reads/sec
  • Memory\\Available MBytes
  • Process(<対象>)\\Working Set - Private
  • Process(<対象>)\\Private Bytes

Process\\Page Faults/secにはソフトとハードの両方が入ります。

Memory\\Pages Input/secは、ハードフォルト解決のためディスクから読み込まれたページ数です。10

Touch段階ではPage Faults/secが跳ねても、Pages Input/secは大きく増えないはずです。新規Commitページはデマンドゼロで、元データをディスクから読む必要がないためです。

同名プロセスが複数ある場合、PerfMonのprocess#1などの番号は再起動で変わり得ます。PIDを表示するカウンターと突き合わせるか、Process V2やETW/WPAでPIDを使って識別します。

11. 実務で避けたい3つの誤読

11.1. 「Commitが増えたからRAMリーク」

Commitは内容を保持する約束量で、未TouchページはRAMへ常駐していない場合があります。

リーク判定ではPrivate Bytesの時間推移、割り当ての内訳、処理後に基準値へ戻るかを見ます。

11.2. 「Page Faults/secが高いからディスクが遅い」

ソフトフォルトはディスクI/Oを伴いません。

Page Faults/secPages Input/sec、ストレージ待ちを分け、必要ならETWのHardFaultイベントで読み取り元ファイルとスタックを追います。

11.3. 「Working Setを空にすればリークが直る」

Working Setから外しても、Commitや所有権は解放されません。

ページはStandbyやModifiedへ移り、後でフォルトして戻ります。リークを直すには、割り当て元がVirtualFree、ヒープ解放、オブジェクト破棄などを行う必要があります。

その物理ページの行き先は第2回で追います。

12. まとめ

  • MEM_RESERVEは仮想アドレス範囲を押さえますが、RAMやページファイルの物理領域を割り当てません。1
  • MEM_COMMITはCommitを消費し、将来内容を保持できることを保証しますが、通常の物理ページは初回アクセスまで割り当てられません。12
  • VADは範囲、PTEは仮想ページ、PFNデータベースは物理ページの台帳です。
  • TLBミスはページフォルトではありません。有効なPTEがあればページテーブルウォークだけで解決します。
  • デマンドゼロ、Transition復帰、共有ページ接続は、ディスクI/Oなしで解決できるソフトフォルトです。5
  • ページファイル、DLL、EXE、マップドファイルから読む必要があればハードフォルトです。6
  • VAD、PTE、保護属性を検査して解決不能なら、0xC0000005などの例外になります。7
  • 性能判断ではPage Faults/sec単独ではなく、Pages Input/sec、Available、Working Set、Private Bytes、ストレージ待ちを同じ時間軸で見ます。

続きは第2回「物理ページの一生:5つのリストとページファイルの真実」です。

Commitという約束を物理ページへ変えた後、そのページがWorking Setから外れたらどこへ行くのかを、PFNデータベースとページリストから追います。

関連記事

関連する相談領域

合同会社小村ソフトでは、Windowsアプリケーションのメモリ使用量調査、アクセス違反、起動遅延、ページング、ネイティブコードの不具合解析を扱っています。

参考リンク

  1. Microsoft Learn, VirtualAlloc function. MEM_RESERVEが物理ストレージを割り当てずに仮想アドレス範囲を予約すること、MEM_COMMITがシステム全体のメモリとページファイルに対するコミット料金を課すこと、Commit済みページの初期内容がゼロであること、実際の物理ページはアクセスされるまで割り当てられないことについて。  2 3 4 5 6

  2. Microsoft Learn, PERFORMANCE_INFORMATION structure. CommitTotalが現在のシステムCommitページ数、CommitLimitがページファイルを拡張せずにCommitできる上限であることについて。  2

  3. Microsoft Learn, !vad (WinDbg). !vadがVADツリーを表示し、開始・終了VPN、Commit、Mapped/Private、保護属性、Control Areaなどを確認できることについて。 

  4. Microsoft Learn, PageFault_TypeGroup1 class. ETWがTransition Fault、Demand Zero Fault、Copy-on-Write、Guard Page Fault、Hard Page Fault、Access Violationを区別して記録することについて。 

  5. Microsoft Learn, Working Set. ソフトフォルトがバックストアへのアクセスなしに解決でき、他プロセスのWorking Set、Transition、初回参照のデマンドゼロなどで起きることについて。  2 3

  6. Microsoft Learn, PageFault_HardFault class. HardFaultイベントがFileObject、ReadOffset、ByteCount、VirtualAddress、Thread IDを含み、読み取り元を追跡できることについて。  2

  7. Microsoft Learn, Access Violation C0000005. 0xC0000005が無効なメモリアドレスの読み取り・書き込み・実行で発生し、例外パラメーターがアクセス種別と違反アドレスを示すことについて。  2

  8. Microsoft Learn, Creating Guard Pages. PAGE_GUARDがページアクセスのワンショット通知を提供し、STATUS_GUARD_PAGE_VIOLATIONを発生させることについて。 

  9. Microsoft Learn, VMMap - Sysinternals. VMMapがCommit済み仮想メモリを種類別に分解し、各種類のWorking Setと詳細アドレスマップを表示できることについて。 

  10. Microsoft Learn, Performance Analysis of Logs (PAL) Tool. Memory\\Pages Input/secがハードページフォルト解決のためディスクから読み込まれたページ数であることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

VirtualAllocでMEM_COMMITした時点でRAMは確保されますか?
通常のプライベートメモリでは、Commit時にシステムのコミット余力は消費しますが、対応する物理ページは最初にアクセスされるまで割り当てられません。書き込みで初めて触れたページは、デマンドゼロフォルトの処理中に物理ページを得ます。
ページフォルトは異常や性能問題を意味しますか?
いいえ。デマンドゼロやStandbyからの復帰など、ディスクI/Oを伴わないソフトフォルトは通常動作です。性能判断ではPage Faults/secだけでなく、Pages Input/sec、ストレージ待ち、Available MBytesも合わせて見ます。
TLBミスとページフォルトは同じですか?
別物です。TLBに変換結果がなくても、ページテーブルのPTEが有効ならCPUが表をたどって変換を再登録するだけです。PTEが無効、または保護違反があるときにページフォルトの入口へ進みます。
VADにアドレス範囲があればアクセス違反にはなりませんか?
そうとは限りません。VADの有無に加え、ReserveかCommitか、読み書き実行の保護属性、ガードページ、PTEの状態などをメモリマネージャーが評価します。解決不能なら0xC0000005などの例外になります。
Page Faults/secが多いとRAM不足ですか?
それだけでは判断できません。Page Faults/secには大量のソフトフォルトも含まれます。Memory\Pages Input/sec、Memory\Page Reads/sec、Available MBytes、ディスク待ち時間と同じ時間軸で相関を見る必要があります。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る