Windows VirtualAlloc: Reserve, Commit, Decommit, and Release Correctly
Understand Windows virtual-address reservation, commit charge, page faults, protection changes, and VirtualFree release rules before diagnosing memory growth.
VirtualAlloc manages regions in a process’s virtual address space. It is useful for custom allocators, large arenas, memory-mapped data structures, and runtime systems, but its state transitions are easy to misread. Reserving an address range does not commit storage; committing does not mean every page is already resident in physical RAM; and changing page protection does not allocate the page. Diagnosing a memory problem requires separating virtual size, system commit, working set, and application-owned allocation policy.
Three page states and two alignment scales
Windows describes virtual pages as free, reserved, or committed. Free addresses are available for allocation. Reserved pages are unavailable to other allocations in that process but have no physical or paging-file storage committed for them and cannot be accessed. Committed pages have system commit charge and a protection state; physical memory is generally supplied as a page is first touched. Thus an application can reserve a large contiguous region for future use without immediately making its full size resident, while committing the entire range raises commit usage even if most pages remain untouched.
Two alignment rules matter. A reservation start is rounded according to allocation granularity, while committed addresses are rounded to page boundaries. dwSize is a byte count, but the operation affects whole pages covering the requested range. Query the system’s page size and allocation granularity rather than hard-coding values. If an exact address range is required, calculate alignment and overflow safely before calling the API.
Reserve first, commit on demand
A common arena pattern reserves a range and then commits smaller portions as the application needs them:
constexpr SIZE_T arenaBytes = 256u * 1024u * 1024u;
void* arena = VirtualAlloc(
nullptr,
arenaBytes,
MEM_RESERVE,
PAGE_NOACCESS);
if (arena == nullptr) {
const DWORD error = GetLastError();
// Report reservation failure; no range is owned by this allocator.
}
void* committed = VirtualAlloc(
arena,
commitBytes,
MEM_COMMIT,
PAGE_READWRITE);
if (committed == nullptr) {
const DWORD error = GetLastError();
// Keep the reservation intact or release it using the allocator policy.
}
The sample omits allocator metadata and alignment checks. For a page within a previously reserved range, pass an address in that range and commit only the pages needed. A commit to a specific non-null range that has not first been reserved fails with ERROR_INVALID_ADDRESS; asking to commit already-committed pages is allowed. If the simpler design is appropriate, MEM_RESERVE | MEM_COMMIT performs both operations in one call, but it loses the ability to keep unused capacity merely reserved.
VirtualAlloc returns the base address on success and NULL on failure; call GetLastError immediately after failure. Do not assume a large reservation will always succeed just because the process has a 64-bit address space: address fragmentation, architecture, policy, and competing reservations matter. A reservation is an address-space commitment by the allocator, not a guarantee of future physical capacity for every possible workload.
Decommit versus release
VirtualFree distinguishes returning committed pages to the reserved state from returning an entire reservation to the free address space. Decommitting releases commit charge for pages while preserving the address range for later recommit. Releasing returns the reservation itself and makes its address range available again. The exact flag and size rules differ: MEM_RELEASE requires the original base address and a size of zero, while a decommit can specify a page-aligned range. Track the original reservation base and every committed subrange in allocator metadata so partial cleanup cannot accidentally release a neighbor’s allocation.
// Return committed pages to the reserved state.
if (!VirtualFree(committed, commitBytes, MEM_DECOMMIT)) {
const DWORD error = GetLastError();
// Retain allocator bookkeeping until failure is handled.
}
// Release the complete reservation only when the arena is no longer used.
if (!VirtualFree(arena, 0, MEM_RELEASE)) {
const DWORD error = GetLastError();
}
Do not free a region while another thread can still dereference it. Synchronize the allocator’s publication and reclamation, and wait for readers or outstanding I/O to finish before decommit/release. The memory API cannot infer application ownership or prevent a use-after-free in an address range that remains mapped.
Protection changes are not allocation
VirtualProtect changes the protection of committed pages in the calling process. It does not commit reserved pages and it does not authorize access to an unrelated reservation. Keep protection transitions narrow and page-aligned, restore the old protection when appropriate, and avoid making large mutable regions executable. If a runtime must generate code, use a controlled write-then-execute transition consistent with the process mitigation policy and call FlushInstructionCache after writing instructions before executing them. A writable-and-executable mapping increases attack surface and should not be a default allocator mode.
Guard pages are useful for detecting stack or arena boundary overruns, but they have a specific exception-driven behavior and require a recovery path. A guard page is typically cleared after its first access violation; it is not a permanent memory protection policy. For ordinary data, use no-access or read/write protections matching the intended lifecycle.
MEM_RESET is not a secure zeroing operation. It indicates that the data is no longer of interest and does not guarantee that the range will read back as zero. If the application needs deterministic clearing, explicitly overwrite sensitive bytes using an appropriate non-elidable secure-zero primitive before reuse or release, and define the threat model for copies in other buffers.
Committing memory is a system resource decision, not a promise that the process can always grow later. The commit limit is related to physical memory and paging files; a successful reserve-only operation does not reserve future commit capacity. If an arena will eventually need a hard upper bound, decide whether the product should commit its expected working set at startup and fail early with a clear diagnostic, or grow gradually and handle commit failure while preserving a consistent allocator state. Those are different availability policies and should not be left to accidental allocation timing.
Interpreting memory measurements
Reserved virtual bytes can be large while working-set usage stays small. Committed memory consumes system commit capacity even before every page is resident. Working set measures pages currently resident for a process, and can shrink under memory pressure without the application freeing its virtual allocation. A rising private commit with stable working set can still represent a leak in application ownership; a large reserved address range by itself may be a normal arena strategy.
Use VirtualQuery to inspect state and protection regions in the current process, and correlate that with allocator-level allocation tags or ETW traces. A snapshot cannot explain why the code reserved a range; track allocation owner, source call site, requested size, commit size, and lifetime in debug telemetry. In a 64-bit process, examine address-space fragmentation separately from system commit pressure. In a 32-bit process, address exhaustion can occur long before physical RAM is exhausted.
For large-page allocations, AWE, NUMA placement, or cross-process allocation, use the specialized API and privilege requirements documented for that feature. Do not add MEM_LARGE_PAGES as a generic speed flag: it requires particular size/alignment and system support. VirtualAllocEx targets another process but introduces additional process-access rights and security considerations; it is not a substitute for shared-memory design between cooperating components.
Verification cases for a custom allocator
Test reserve-only memory and confirm access is rejected; commit a page and read/write it; decommit and verify the reserved range remains unavailable to other allocations; recommit and ensure initialization assumptions hold; then release the original base with the required zero size. Exercise allocation failure and commit failure paths while checking that bookkeeping matches the API’s actual state. Add concurrent-reader shutdown tests so no consumer can retain a pointer past decommit.
Measure reserved bytes, committed bytes, private commit, working set, allocation count, and high-water marks independently. Alert on the metric that matches the resource you intend to protect. When a process is near its commit limit, lowering only its working set may not solve the problem; when virtual space is fragmented, freeing unrelated physical pages may not provide a suitable contiguous address range.
The core rule is to model every region’s state explicitly. Reserve, commit, decommit, protect, and release are separate operations with distinct accounting and lifetime effects. Once those are tracked separately, VirtualAlloc becomes a predictable foundation instead of a mysterious source of “memory usage.”
Related:
- Fixing High Memory Usage from Memory Compression and the System Process
- Windows WER LocalDumps: Capturing User-Mode Crashes for Diagnosis
Sources: