You have a .NET 8 dump open. !dumpheap -stat shows a block of System.String and System.RuntimeType objects that do not sit in any generation you can name. !gcwhere on one of them returns something that does not look like gen0, gen1, or gen2. Before you call it a leak, decide which heap the object lives on. That decision is separate from whether the object is reachable, and conflating the two is what produces the false verdict.
What the NonGC heap is, precisely
The .NET 8 runtime introduced a heap that is not managed by the garbage collector. The design document states it plainly: “a specialized heap that is not managed by the Garbage Collector (GC) and is designed to store immortal objects with certain benefits for GC and code generation (codegen)” (NonGC Heap design doc).
The name is new. The mechanism is not. The same document notes the feature “is based on pre-existing Frozen Segments which were added long time ago to serve a similar purpose and were used on CoreRT/NativeAOT.” What changed in .NET 8 is that the functionality was “exposed to users through public profiling and debugging APIs” and is now “heavily leveraged for several codegen optimizations in RyuJIT.”
The rename was deliberate. The diagnostics issue that tracked the work records the decision: “we landed on disclosing the Frozen Object Heap as a new memory region that holds managed objects, but is distinct from the GC entirely. It has also been proposed that we stop calling it the Frozen Object Heap because it does support dynamic allocations at runtime. The current suggestion is to call it the Non-GC heap” (Diagnostic support for NonGC heap).
That rename has a consequence you will feel in every dump triage. The same issue states: “previously terms ‘GC heap’, ‘Managed heap’, ‘.NET heap’, ‘.NET Managed heap’ all meant the same thing and that is no longer true.” When a command says “GC heap,” it now means the GC-managed portion only. The NonGC heap is managed memory that is not in it.
Which objects qualify
The design doc lists what is commonly found on the NonGC heap: string literal objects; RuntimeType objects (CoreCLR-only at the time of writing); boxed statics; and certain static readonly fields that can be pre-initialized on NativeAOT or recognized by CoreCLR, such as Array<>.Empty.
The exclusions matter more for triage, because they tell you what will not be there and therefore what you should still expect to find on the GC heap:
- Non-immortal objects. The doc is explicit: “Only immortal objects should be allocated on NonGC heap to avoid ‘dead weight’ in that heap.”
- Objects from unloadable contexts. “NonGC heap should never be used for objects belonging to unloadable contexts. Doing so could lead to potential memory leaks. As a consequence, all string literals associated with unloadable Assembly Load Contexts (ALCs) won’t be placed in the NonGC Heap.”
- Objects that reference GC-heap objects. “NonGC heap must never contain references to GC heaps’ objects. However, it may contain references to other NonGC objects and GC heaps are allowed to reference NonGC objects.” The reference direction is one-way.
- Large objects and unusual alignment. “The current design of CoreCLR’s FrozenObjectHeapManager has certain design limitations such as: Large objects are not supported. Objects requiring unusual data alignment are not supported.”
The runtime source confirms the size limit at the allocator. FrozenObjectHeapManager::TryAllocateObject carries the comment: “May return nullptr if object is too large (larger than FOH_COMMIT_SIZE) in such cases caller is responsible to find a more appropriate heap to allocate it” (frozenobjectheap.cpp). The same file defines the commit chunk as 64 KB and the default reserved segment size as 4 MB, and doubles the reserved size of each new segment “to reduce the number of frozen segments in apps with lots of frozen objects.”
Why they never move
Three properties follow from the heap not being GC-managed, and each one changes what you see in a dump.
No mark-and-sweep participation. The design doc: “Since the objects in the NonGC Heap are always alive and the heap itself is not managed by the GC, there’s no need for the heap to be involved in the mark-and-sweep procedure. This also eliminates the need to root objects to a specific AppDomain or Assembly Load Context (ALC) to maintain their persistence.”
No write barriers. “Since the NonGC heap is not managed by the GC we no longer need to emit the write-barriers for objects in it.” The doc shows the before and after for a field assignment: pre-.NET 8 codegen loads a pinned handle, then the object, then calls CORINFO_HELP_ASSIGN_REF for the write barrier; the .NET 8 version moves a direct object reference into the field and returns.
Address embedding in RyuJIT. “The JIT compiler can ‘bake’ direct addresses into the code generation, removing the need for redundant indirect loads.” For a string literal, the pre-.NET 8 sequence loads the address of a pinned handle and then dereferences it; the .NET 8 sequence loads the object address directly. For typeof(int), the pre-.NET 8 sequence calls CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE; the .NET 8 sequence moves the RuntimeType address directly.
That is the mechanism behind “never move.” The JIT has embedded the address in generated code. If the object moved, the code would be wrong. The heap exists in part so the object cannot move.
Reading it in a dump
dotnet-dump analyze gives you an interactive shell that accepts SOS commands. The tool is not a native debugger: the documentation states it “allows you to run SOS commands to analyze crashes and the garbage collector (GC), but it isn’t a native debugger so things like displaying native stack frames aren’t supported” (dotnet-dump). For heap work that limitation is usually irrelevant.
The SOS command surface you already use is documented in the SOS debugging extension reference. DumpHeap “displays information about the garbage-collected heap and collection statistics about objects.” EEHeap “displays information about process memory consumed by internal runtime data structures,” with -gc limiting output to garbage collector data structures.
On the diagnostics issue, a contributor reported the state of tooling support: “As of now, SOS (in the main branch of dotnet/diagnostics) should fully support frozen objects in all commands. Feel free to raise a specific issue there if a command is misbehaving. For example, !dumpheap will enumerate those objects, !eeheap reports frozen segments, !gcwhere will report frozen objects, etc.”
Treat that as a statement about a specific branch at a specific time, not a permanent guarantee. Verify against the SOS build in front of you.
One caveat is worth carrying into every triage. In the same issue thread, a runtime engineer noted: “!eeheap -gc does include non-GC heap segments currently, just because they are threaded onto gen2 (for GC bookkeeping purposes for segments; for regions I’m thinking of making that a no op so it wouldn’t show up in sos by accident and instead will be tracked on the VM side which has this knowledge anyway).”
So a segment appearing under a GC-oriented view is not by itself proof that the objects in it are GC-managed. Cross-check with a command that reports the object’s location before drawing a conclusion. The GC design document also describes the physical layout: “There is also a NonGC heap that contains ro (readonly) segments” (Garbage Collection Design).
Telling NonGC from POH
Both heaps hold long-lived data. They are not interchangeable, and the design doc gives the reason the POH was rejected as the home for interned literals.
“The POH has a public API, allowing users to use it for both long- and short-living objects. This flexibility can create complications when these objects are mixed with immortal ones.” The doc walks through an interleaving of user buffers and JIT-created string and RuntimeType objects, then notes that because immortal objects are not all allocated at once, the arrangement “leads to fragmentation in case if e.g. buffer3 is no longer needed.”
The second reason is mechanical: “placing the objects in the POH wouldn’t allow us to omit write-barriers, and these objects would still need to be scanned, reducing the efficiency gains the NonGC Heap is designed to provide.”
For dump triage, the practical distinction is this. The POH is a GC heap. Its objects participate in collections, they have generations, and dotnet-counters reports its size alongside the other GC generations. The NonGC heap is not a GC heap. Its objects have no meaningful generation, and the GC counters do not cover it.
You can see the POH in counter output. The dotnet-counters documentation shows dotnet.gc.last_collection.heap.size broken out by gen0, gen1, gen2, loh, and poh. There is no nongc line in that output. The diagnostics issue is explicit about the intent: “All of our existing performance counters for gc-* are not intended to include non-gc heap information.” The same paragraph covers configuration: “All of our GC* environment variables solely control/constrain the behavior of the GC portion of the heap and have no impact on the Non-GC heap.”
The generation sentinel
If you call GC.GetGeneration on a NonGC object, you get a value that is not a generation. The design doc states: “The most visible change that now GC.GetGeneration(object) API returns int.MaxValue for such objects. This change serves to emphasize that these objects are not managed by the garbage collector.”
The issue that tracked the design proposed a different value: “we propose to return -1 to indicate that these objects do not belong to any generation.” The two sources disagree on the exact sentinel. Do not hard-code either value into a triage script. Test the return value against the runtime you are actually running, or avoid the API in diagnostic code and use the heap-location commands instead.
What matters for the dump is the concept: generation is a GC-heap property, not a universal object property. An object with no generation is not an object with a broken generation.
A triage checklist
Two passes, in order.
Pass one: which heap. For each suspicious object, establish its location before you reason about its lifetime. If it is enumerated by heap-walking commands but has no generation and does not move between collections, treat it as NonGC. Do not assume that a segment listed under a GC-oriented view contains only GC-managed objects; the bookkeeping caveat above applies.
Pass two: is it reachable. Only after you have placed the object on a heap should you ask whether anything roots it. For NonGC objects, the design doc adds a warning that changes the answer: “these objects should never be considered as ‘dead’ if no one references them since it’s possible that they’re actually kept alive by unmanaged data structures in VM itself.” Absence of a managed root is not evidence of a leak for these objects.
Record the runtime version and the SOS or ClrMD version alongside the dump. Diagnostic support for this heap landed incrementally, and the contributor statement above is scoped to a branch. A command that behaves one way in one SOS build may behave differently in another. The version is part of the evidence.
FAQ
Is the Frozen Object Heap the same thing as the NonGC heap? The NonGC heap is the current name for the concept. The design doc says the feature “is based on pre-existing Frozen Segments,” and the diagnostics issue records the rename because the heap “does support dynamic allocations at runtime.” Older material and some tool output still say FOH.
Can I allocate on it directly? No public allocation API is described in the sources here. The design doc contrasts it with the POH, which “has a public API.” The NonGC heap is populated by the runtime for the object categories listed above.
Will a NonGC object show up in !dumpheap -stat? The contributor report on the diagnostics issue says !dumpheap enumerates those objects. Confirm against your SOS build.
Why does !eeheap -gc show frozen segments? Because they are threaded onto gen2 for GC bookkeeping, per the runtime engineer’s comment in the diagnostics issue. The same comment describes a possible change for regions. Do not read the presence of a segment under -gc as proof of GC management.
Do GC counters include it? No. The diagnostics issue states the existing gc-* counters are not intended to include non-GC heap information, and the dotnet-counters output shows no NonGC line.
Why not just put interned strings in the POH? Because the POH has a public API and mixed lifetimes, which fragments when immortal objects are interleaved with user allocations, and because POH objects would still need to be scanned and would still require write barriers. The design doc gives both reasons.