A program running out of virtual memory could cause many passengers to miss their flights.

Airport departure board

This is a very complex subject, and parts of it go beyond my knowledge. For those who are curious and want to understand every bit, I recommend reading the book Windows Internals by Mark Russinovich.

With the VMMap tool from Windows Sysinternals, you can take a snapshot of a process’s virtual memory usage in fine detail:

VMMap snapshot with memory states as columns and memory types as rows
Windows VMMap snapshot.

Let’s refer to each column as a memory state and each row as a memory type.

Memory states

Each page in a process virtual address space can be in different states. Briefly:

  • Size, Reserved — total amount of memory reserved by the memory manager for the process.
  • Committed — reserved memory that is in use, and when accessed, ultimately translates to valid pages in physical memory (either RAM or the paging file).
  • Private — committed memory that is private, meaning it is only used by the owner process. This is what we refer to when we talk about private bytes.
  • Total Working Set (WS) — committed memory that is in physical RAM.
  • Private WS — memory that is in RAM and only used by the owner process. In Windows each process has its own private working set.
  • Shareable WS — memory that is in RAM and can be shared between processes.
  • Shared WS — memory that is in RAM and currently being shared between processes.

Memory that is committed but not in the working set is in the paging file (on disk), and the Memory Manager will copy it into RAM if a process needs it.

Memory types

1 — Private data

Private data is the memory allocated and used only by the owner process and cannot be shared between processes. It is also the main reason for most memory leaks and fragmentation. You can allocate it using the VirtualAlloc API, which is the lowest-level memory allocation API available with the Windows Memory Manager.

This example uses the VirtualAlloc function to reserve and commit memory:

// Example 1 : Allocate memory without specifying the base address
LPVOID MemPtr1 = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

// Example 2 : Allocate memory at the specified address
LPVOID desiredAddress = (LPVOID)0x00400000;
LPVOID MemPtr2 = VirtualAlloc(desiredAddress, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

The first parameter of the VirtualAlloc function is the base address of the memory block you want to allocate, and NULL means that the Memory Manager will decide. Choosing the address yourself is occasionally useful, but usually the Memory Manager has more information about the address space than you do.

Important notes about VirtualAlloc:

  • If you try to reserve memory that is already reserved, the operation will fail.
  • If you try to use memory that is reserved but not committed, the operation will also fail.
  • Allocated memory is always initialised with 0 for Windows security reasons. A snapshot from Visual Studio confirms that.
Visual Studio memory window showing freshly allocated memory filled with zeros
Allocated memory using the VirtualAlloc function is always initialised.

Committing memory doesn’t necessarily mean that it is in RAM. Committed memory means that Windows guarantees it will be able to provide you with RAM when needed. You can force the Memory Manager to allocate RAM by using the memory (touching it), as demonstrated in this example:

// 1
for (int i = 0; i < 100; i++)
    *((int*)(MemPtr) + i) = i;

// 2
for (int i = 0; i < 100000000; i++)
    *((int*)(MemPtr) + i) = i;

// 3
for (int i = 0; i < 200000000; i++)
    *((int*)(MemPtr) + i) = i;

After each for loop, the working set of the process (and so the RAM it uses) grows, because we access more memory each time. Reserved and committed memory don’t move, because both were allocated and committed in advance.

VMMap showing the working set growing across three runs while reserved and committed stay constant
The process working set grows when memory is used.

Query private memory. You can determine if a specific address is private by using the VirtualQuery function and checking if the data member type of the output structure MEMORY_BASIC_INFORMATION is equal to MEM_PRIVATE.

2 — Stack

The stack is a special private memory allocated by the Memory Manager for each thread, used to store function parameters, local variables, and so on.

When a thread is created, the memory manager automatically reserves a predetermined amount of virtual memory, typically 1 MB by default. This amount can be configured when creating the thread.

When the Memory Manager allocates the stack, memory is initially only reserved, and it is later committed only when needed. To show how stack memory gets committed, this example allocates a static array of a different size in each case.

// 1
const SIZE_T size = 100;
int Buffer[size];

// 2
const SIZE_T size = 100000;
int Buffer[size];

// 3
const SIZE_T size = 200000;
int Buffer[size];

We can observe how the committed memory size of the stack increases:

VMMap showing stack committed memory increasing with larger local arrays
Stack committed memory grows only when needed.

When you create a thread dynamically at run time, its stack is also created at run time.

Query stack memory. To determine if a specific virtual address is in the stack, you can start by checking if the address is MEM_PRIVATE (refer to private memory above). If so, you can then collect the base address for each stack of each thread using Windows functions such as OpenThread and GetThreadContext. Next, you can check whether it belongs to one of the thread’s stacks.

3 — Heap

Windows aligns each region of reserved process address space to begin on an integral boundary defined by the value of the system allocation granularity; this value is 64 KB.

Most applications allocate smaller blocks than the 64 KB minimum allocation granularity possible using page-granularity functions such as VirtualAlloc. Allocating such a large area for relatively small allocations is not optimal from a memory usage and performance standpoint. To address this, Windows provides a component called the heap manager, which manages allocations inside larger memory areas reserved using the page-granularity memory-allocation functions. The allocation granularity in the heap manager is relatively small: 8 bytes on 32-bit systems, and 16 bytes on 64-bit systems.

Diagram of the Windows heap API layers above the virtual memory allocator
Heap API layer.

You can create many heaps for a process using the HeapCreate function, and you can use the HeapWalk function to enumerate the allocated memory blocks in a specified heap.

Note. I didn’t find an easy way to determine if a virtual address is in the heap, except by enumerating all heap blocks and checking if the specific address is within one of the blocks.

Example with C++ STL containers. When you call the reserve memory function of a std::vector container, memory is both reserved and committed in the heap. This example calls both reserve and resize:

// 1
std::vector<int> Vector1;
Vector1.reserve(10);

// 2
std::vector<int> Vector2;
Vector2.reserve(10000000);

// 3
std::vector<int> Vector3;
Vector3.resize(10000000);
VMMap showing heap reserved and committed memory growing with vector reserve and resize
Both reserve and resize functions of std::vector commit memory.

4 — Mapped files

A mapped file is a file that has been mapped into virtual memory such that it looks like it has been loaded into memory. The operating system will then transparently load parts of the file into physical memory as the application accesses them, and release them again if not needed anymore. The API that is provided by Windows is called the File Mapping API, and it is the same API used to share memory between processes.

Raymond Chen said: don’t forget that CreateFileMapping is used for creating both memory-mapped files and for creating plain old shared memory. The name of the function is misleading.

Diagram of the Windows memory management API layers
Windows memory management API layers.

Shared memory can be defined as memory that is visible to more than one process, or that is present in more than one process virtual address space.

The underlying primitives in the memory manager used to implement shared memory are called section objects. A section object can be connected to an open file on disk (called a mapped file) or to committed memory (to provide shared memory).

In this example I will use the File Mapping API to modify the content of this file:

Text file contents before the memory mapping example runs
Text file before file memory mapping.
HANDLE hFile = CreateFile(
    L"C:\\testfile.txt",          // File name
    GENERIC_READ | GENERIC_WRITE, // Desired access
    0,                            // Share mode (0 for no sharing)
    NULL,                         // Security attributes
    OPEN_ALWAYS,                  // Open existing or create new
    FILE_ATTRIBUTE_NORMAL,        // File attributes
    NULL                          // Template file
    );

// Get the size of the file
DWORD fileSize = GetFileSize(hFile, NULL);

// Create a file mapping object
HANDLE hMapping = CreateFileMapping(
    hFile,                     // File handle
    NULL,                      // Security attributes
    PAGE_READWRITE,            // Protection mode
    0,                         // High-order DWORD of the maximum size
    fileSize,                  // Low-order DWORD of the maximum size
    NULL                       // Name of the file mapping object
    );

// Map the file into memory
LPVOID pMappedData = MapViewOfFile(
    hMapping,                  // File mapping handle
    FILE_MAP_WRITE,            // Access mode (write)
    0,                         // High-order DWORD of the file offset
    0,                         // Low-order DWORD of the file offset
    fileSize                   // Number of bytes to map (entire file)
    );

// Modify the content in the memory-mapped file
char* pData = static_cast<char*>(pMappedData);
const char* newText = "My age is 30";
memcpy(pData, newText, strlen(newText));

// Unmap the file and clean up
UnmapViewOfFile(pMappedData);
CloseHandle(hMapping);
CloseHandle(hFile);

After running this code, the text file is modified:

Text file contents after the memory mapping example has written to it
Text file after file memory mapping.

5 — Image

Images refer to DLLs and executables (.exe) that are loaded into memory. They can be shared between processes. When an image is loaded, the memory space is divided into many sub-regions with different protections (read, write, execute, copy-on-write, and so on).

VMMap showing a loaded DLL split into sub-regions with different page protections
Memory sub-regions of a DLL loaded into a process virtual address space.

When the data of an image is intended to be shared between processes and cannot be modified, it is marked as read. If the data can be modified by a specific process, it is marked as copy-on-write.

In this example, page 2 is marked as copy-on-write. Therefore, when Process B wants to modify it, the memory manager creates a copy of page 2 accessed only by Process B and marks it as read-write.

Diagram of two processes sharing pages, with one page copied on write for the second process
Pages marked as copy-on-write are copied only when modified by the process, to optimise memory.

A mystery. It remains unclear to me why certain pages of an image that are marked as read end up in the private working set and are effectively added to the private bytes counter. I couldn’t find a definitive answer to this question, even after consulting the book.

VMMap showing read-only DLL pages counted as private
Memory pages of a DLL marked as private, even when they are marked as read-only.

6 — Unused regions

When allocating a block of memory, the specified allocation base address is rounded down to the nearest multiple of the allocation granularity, which is currently 64 KB on Windows. This results in some regions that cannot be used.

For 32-bit machines, unused regions caused a significant issue as an application could run out of virtual memory due to memory fragmentation. However, for 64-bit machines this is no longer a concern, because the virtual memory space is very large, approximately 18.4 million terabytes of memory.

7 — Managed heap

Managed heap is memory allocated and managed by .NET’s garbage collector. I won’t cover it in this article.

MemoryTracer

In the previous article I introduced the MemoryTracer project, an open-source C++ library that records where a process’s virtual memory goes over time.

MemoryTracer output side by side with a VMMap snapshot showing matching figures
MemoryTracer showing the same results as VMMap.

MemoryTracer tries to reproduce VMMap’s results exactly. However, there is no guarantee of accuracy: VMMap appears to query virtual memory through interfaces that are not public, so the two won’t always agree.

Future improvements:

  • There is no heap tracing, so all data in the heap will be in the private data row. An efficient way to traverse the heap and tell whether a virtual address falls within it is still open.
  • There are some pages marked as private data in VMMap, but the reasons are unclear. For instance, there are pages in DLLs with the MEM_IMAGE flag and protection PAGE_READONLY, suggesting they should be shareable, but they appear as private.

Sources