Implementation notes / C++ and Win32

A small Windows
heap manager,
explained.

A C++ allocator that reserves Windows virtual memory, manages blocks with intrusive free lists and boundary tags, and caches frequently requested small sizes.

Real native snapshots. No browser simulation.
No payload contents. No process addresses.

01 First-fit + coalescing02 Intrusive free lists03 Demand-driven caching04 Read-only inspection

01 / The problem

Turn raw pages into reusable allocations.

Windows gives a process virtual memory in pages. Applications usually need much smaller, differently sized allocations. The problem here is to divide one reserved region into usable blocks, remember which bytes are free, reuse them safely, and keep existing payload addresses stable when committing more memory.

This is an implementation exercise, not an attempt to replace the CRT or Windows heap. The useful outcome is understanding the metadata, ownership rules, and tradeoffs behind the familiar allocation API.

  1. 01Client requestmy_malloc(heap, size)
  2. 02Heap bookkeepingCheck size, lock, select a block
  3. 03Reserved regionReuse blocks or commit more pages
  4. 04Caller payloadReturn aligned, writable bytes

The same region backs both the general free list and the small-block buckets. A bucket is not another OS heap.

02 / Architecture

Four projects, with explicit boundaries.

CLIENT

ClientApp

Owns test heaps, exercises the public API, runs named scenarios, and writes validated snapshots to JSON.

ALLOCATOR

HeapManager

A DLL containing the first-fit backend, split/coalesce logic, 51 size classes, heap lock, counters, and snapshot API.

LINKS

Intrusive_LinkedList

Defines the embedded NODE and reusable list operations. HeapManager uses the layout but manages its own lists under its heap lock.

DIAGNOSTICS

Debugger

A static logging library with source context and synchronized output. It is diagnostic support, not an interactive debugger.

The client calls the DLL; the DLL calls VirtualAlloc and VirtualFree. Snapshot records travel in the other direction: native state → caller-owned records → JSON → this static page. The browser cannot invoke the DLL or change its heap.

Detailed architecture · Client responsibilities

03 / Allocation lifecycle

Create. Allocate. Reuse. Release.

my_heap_create reserves at least one 64 KiB chunk, commits the first chunk, stores the HEAP metadata at the front, and places the remaining committed span in one backend free block. Destruction releases the entire region; callers must first stop every heap user.

Allocation path

  1. Check the request. Reject zero/invalid inputs and arithmetic overflow before adding header, guard, and alignment costs.
  2. Lock and consider the bucket. Retire cold caches when due, classify the total block size, and try a reusable block from an active class.
  3. Fall back to first-fit. Search the backend. If no block fits, reclaim cold caches, trim warm caches, then try commit growth.
  4. Split only a useful remainder. Otherwise return the whole block. Record the requested size, set state, stamp a Debug guard, and unlock.

Free and resize paths

  1. Find the real payload boundary. Reject interior, foreign, and still-cached pointers; Debug also rejects damaged guards.
  2. Cache or coalesce. Eligible frees park in an active class below its cap. Others merge with free physical neighbors and return to the backend list.
  3. Resize without losing data. Realloc retains sufficient existing capacity, or allocates and copies the old requested prefix. Allocation failure leaves the original block intact.
  4. Do not decommit on free. Reusable committed memory stays in this heap until destruction. This is a deliberate limitation.

Free-list neighbors are not necessarily memory neighbors. Boundary tags locate physical neighbors; intrusive links only maintain list membership.

04 / Memory layout

Bookkeeping lives beside—and inside—the bytes.

The header stores a packed total size and state flags, plus the exact requested length. A free block reuses its idle payload for list links and a footer. Cached blocks keep the physical used flag and add a cached flag, preventing the backend from coalescing through them.

Illustrative x64 Release layout — sizes below are aligned storage, not a scale drawing.
Live
Requested payload + any slack
Backend free
NODE · 16 BRemaining idle bytes
Cached
NODE in the former payload; no caller-owned data
Current native layouts, in bytes
BuildAlignmentAligned headerMinimum block
x64 Release161648
x64 Debug163264
Win32 Release8824
Win32 Debug81632

Debug adds magic/allocation-ID fields and an eight-byte guard immediately after the requested payload. Class rounding can make an allocation larger than the minimum block. The flags are MH_USED = 1, MH_PREV_USED = 2, and MH_CACHED = 4; masking alignment bits recovers the total size.

Pointer arithmetic, worked layouts, and snapshot contract

05 / LFH-style buckets

Reuse hot sizes without retaining everything.

The frontend groups total block sizes into 51 classes, from 32 bytes through 16 KiB. A class activates after its request counter reaches 16. Freed blocks can then be parked and reused without a first-fit search. This is a custom LFH-style cache, not the Windows LFH API.

  1. DEMANDRepeated requestsActivation counter reaches 16
  2. REUSEPark and popKeep USED + CACHED while parked
  3. PRESSUREReclaim firstCold classes → warm trimming
  4. FALLBACKGrow if neededCommit within the same reservation

Three different questions

Occupancy is the number of parked blocks. Demand is the recent request count. Recency is when the class was last pushed or popped. They are separate fields because a cache can be full but no longer useful.

A warm drain retains cap / 8 blocks. A cold retirement can empty the class completely. Cold age uses 4,096 heap-operation ticks, not wall-clock seconds. Periodic cleanup is checked at allocation entry; there is no background sweeper.

A demand reset can lower the cap below existing occupancy. The lower cap restricts new pushes; it does not instantly evict every excess block.

Formula illustration / not a native snapshot

Explore the cap / keep policy

Cap16 blocks
Warm keep2 blocks

cap = clamp(4 + demand, 4, 256)
keep = floor(cap / 8)
This calculator changes neither the captured heap nor any native allocator setting. The scale represents a maximum of 256 cached blocks, not bytes.

06 / Technical decisions

The benefit comes with a cost.

Implementation choices and their tradeoffs
DecisionWhy it helpsWhat it costs or does not solve
Intrusive free-list nodesNo separate allocation to record each free block; known nodes unlink cheaply.Does not index blocks by size. First-fit still scans the list.
Split and coalesceA useful remainder is reusable; adjacent backend-free blocks become one range.Live blocks can still separate free space. Rounding and unsplittable tails still waste bytes.
Demand-driven bucketsRepeated sizes can reuse a parked block without searching.Retains committed bytes and delays coalescing until reclamation.
One critical section per heapKeeps multi-step metadata updates simple and consistent.Contended metadata operations serialize; no claim of linear thread scaling.
Physical-boundary validationRejects bad payload boundaries before trusting a caller's pointer.Free/realloc lookup is O(B); complete list validation can be O(B²), for B physical blocks.

Coalescing reduces external fragmentation; it does not eliminate it. Caching improves a reuse path; it does not guarantee a faster allocator. These distinctions matter more than presenting a favorable score.

07 / Debugging and validation

Check invariants, not just non-null pointers.

What is checked

  • Block boundaries, minimum sizes, state flags, and physical coverage.
  • Previous-used flags, free footers, and absence of adjacent backend-free blocks.
  • Free-list links, bucket membership/lengths, and live/allocation/free counters.
  • Debug magic and payload guards. A damaged guard is rejected rather than silently freed.

What can be observed

  • Source-aware logger messages with automatic synchronization and optional file output.
  • Heap reports that refuse an invalid layout instead of walking it blindly.
  • Read-only snapshots copied under the heap lock, then serialized outside the lock.
  • Offsets, states, and counts—not payload contents or raw process addresses.

The validator is diagnostic support, not a security boundary against arbitrary metadata writes. Raw pointers also have a reuse limitation: an old alias can look valid after its address is assigned to a new live allocation. The API still requires live heap handles and correct ownership.

08 / Test scenarios and evidence

Small examples with explicit expectations.

Named native scenarios available in the explorer
ScenarioWhat the check expects
split-mergeThree large allocations eventually coalesce into one backend-free block.
bucket-reuseA hot class parks one block; the next request reuses its offset.
changing-workloadWarm reclamation reduces 24 cached blocks to 3 without growth, then cold retirement empties the cache.
growthA 96,000-byte request increases commitment; freeing does not decommit pages.
concurrentFour workers hold 16 blocks each at a barrier, then verify and free their payloads.

The original eight-thread workload remains. Additional regressions cover alignment, overflow, realloc failure, invalid pointers, repeated frees, snapshot capacity, controlled corruption, list operations, and logger lifecycle. Local verification has passed on x64 and Win32 in Debug and Release. The repository includes Windows CI; a hosted CI result must be checked separately after publishing.

View a captured heap-state screenshot
Recorded heap state showing cached blocks, backend free space, counters, and snapshot comparisons in the explorer
This is a screenshot of real exported state. Use the interactive explorer below to change scenarios and inspect individual records.

Inspect the captured scenarios ↓

09 / Supporting visualization

Inspect the recorded heap state.

No trace loaded

Loading the bundled native trace…

Local files stay in your browser · 8 MiB maximum · Structure is validated, not producer identity. Bundled loading requires HTTP; local selection also works when opening this HTML directly.

Awaiting capture

Select a trace to begin

RECORDED / NOT LIVE
Reserved address space
Committed
Live requested bytes
Live blocks
Backend free bytes
Cached block bytes
Largest backend free block
Live overhead + rounding
MetadataLiveBackend freeCachedUncommitted

Reserved address space

— committed

A reservation is address space, not resident RAM. The hatched area has not been committed.

Inside the committed region

— blocks
OFFSET 0

Widths are proportional. Focus filters dim other ranges without rescaling memory or changing counters.

Between recorded frames

What changed?

Baseline
Live blocks
Requested bytes
Cached bytes
Committed bytes
  • Load a trace to inspect its recorded transitions.

Compared with the preceding frame in this scenario, even when you jump backward. Range changes are observations—not allocation identities or a log of every operation between captures.

Block inspector

Selection
Select a block in the map or table.

Size-class cache

Class sizes include allocator overhead. An active bucket can be empty.

Inspect the block records / follows the focus filter

Offsets are relative to the heap base, not process addresses. An empty filter does not mean the heap is empty.
BlockOffsetTotal bytesRequested bytesStateBucket

10 / Reproduce the behavior

Run the native checks and capture a trace.

  1. Open HeapManager/HeapManager.slnx in Visual Studio with the C++ workload. Build ClientApp.
  2. Run the tests and export the named scenarios from the repository root:
From the repository root / PowerShell
bin\x64\Debug\ClientApp.exe --test
bin\x64\Debug\ClientApp.exe --export trace.json

Open the exported file in the explorer above. For GitHub Pages, publish this repository's root from your chosen branch. The page needs no server-side code, account, or frontend build step.

README · Heap design · Snapshot contract and internals · Complexity and tradeoffs

11 / Benchmark plan

Compare honestly, including where it is slower.

Not measured yet

No comparative allocator benchmark has been run for this page. Correctness tests and snapshot playback are not performance measurements, and no speedup is claimed. The following is the planned experiment—not a results table.

Planned comparison targets; no measurements published
AllocatorAPI familyStatus
This heap managermy_malloc / my_free / my_reallocPending measurement
C runtimemalloc / free / reallocPending measurement
Windows heapHeapAlloc / HeapFree / HeapReAllocPending measurement
  1. Use the same Release architecture, compiler settings, allocation sizes, operation sequence, and seed. Record the OS, CPU, toolchain, warm-up, and repetitions.
  2. Cover fixed-size reuse, mixed sizes, a changing workload, and single-threaded versus contended access. Record failures as well as successful operations.
  3. Keep console output, snapshot capture, and validation-only passes outside timed regions. Document allocator safety checks that remain on the normal path; do not remove them just to improve a result.
  4. Report throughput and latency distributions across repeated runs. Distinguish committed memory from resident RAM and explain fragmentation measurements rather than using one ambiguous memory score.
  5. Publish raw results and commands, including cases where the custom allocator loses. Linear pointer checks and one heap-wide lock are reasons to expect tradeoffs, not results by themselves.

12 / Limitations and future work

A small allocator with deliberate boundaries.

Current limitations

  • Windows only; not a drop-in CRT replacement or a general over-aligned allocator.
  • No compaction, page decommit on free, or growth beyond the original reservation.
  • One metadata lock and no per-thread caches. Diagnostic validation can be expensive.
  • Exposed heap internals and raw-pointer reuse limit misuse detection. Release has no payload guard.
  • Tests cover concrete cases, not every OS failure, thread schedule, or corruption pattern.

Useful next experiments

  • Reproducible benchmarks against the standard allocators above.
  • Seeded model-based tests with saved failing operation histories and more boundary cases.
  • An opaque public handle and a separate test-only corruption interface.
  • Measure ownership-index or cache-policy alternatives before changing the design.
  • Explore decommit or finer-grained locking only with tests and evidence of a real benefit.

13 / What I learned

The important lessons are in the invariants.

These are the technical takeaways visible in this implementation, rather than claims about performance or production readiness.

The goal is a design that can be explained, reproduced, and questioned—not a claim that a small custom allocator is better than a mature standard one.