What's new with CoreCLR GC handles in .NET 9 and .NET 10
.NET 9 and 10 were big releases for GC handles in the CoreCLR garbage collector. We have new public APIs for working with GC handles. For the first time in CoreCLR's open source history1, new types of GC handles have been introduced to the CoreCLR GC. These new GC handle types are implementation details of the CoreCLR runtime that you won't directly use but are nevertheless fascinating.
Background
I previously covered CoreCLR GC handles in my 2024 post. That post discusses the 10 different types of GC handles implemented in CoreCLR's garbage collector at that time. To summarize, the GC in CoreCLR is a tracing garbage collector, which means the GC determines which objects are alive by walking the object graph, starting at GC roots like static variables and local variables2. Not all code patterns fit into this model, particularly cases involving interop with native code. When it is not possible to express object lifetimes through the normal system, the GC Handle abstraction provides a way to explicitly control and track object lifetimes.
New Public GC Handle APIs
Since the dawn of .NET, the
GCHandle struct
has been the main way of interacting with GC handles. .NET 6 added the
DependentHandle struct.
.NET 10 added four new types
for using GC handles. Compared to the old GCHandle struct, these types have better type safety
by using separate types for different types of GC handles. For example, on the old GCHandle struct
calling AddrOfPinnedObject()
on a non-pinned handle results in an exception at runtime. The new GC handle prevents the problem at compile time: only the
PinnedGCHandle<T> exposes a method to get the address of the pinned object.
The new types use generic types, so you don't have to cast the result of reading a handle's target.
These new GC handle types also have slightly better performance.
GCHandle<T>- a strong GC handle. This replaces usingGCHandleType.Normalwith the oldGCHandle.WeakGCHandle<T>- a weak GC handle. This replaces usingGCHandleType.WeakorGCHandleType.WeakTrackResurrectionwith the oldGCHandle.PinnedGCHandle<T>- a GC handle that pins its object. This replaces usingGCHandleType.Pinnedwith the oldGCHandle.GCHandleExtensions- this adds extension methods toPinnedGCHandle<T>for getting pointers to the data of pinned arrays and strings.
I wrote a micro benchmark to illustrate that performance difference:
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<HandleBenchmarks>(args: args);
[StructLayout(LayoutKind.Sequential)]
public class IntBox
{
public int SomeInt;
}
public class HandleBenchmarks
{
private static readonly GCHandle s_standardHandle = GCHandle.Alloc(new IntBox());
private static readonly GCHandle<IntBox> s_newHandle = new GCHandle<IntBox>(new IntBox());
[Benchmark(Baseline = true)]
public IntBox StandardGcHandle() => (IntBox)s_standardHandle.Target!;
[Benchmark]
public IntBox NewGcHandle() => s_newHandle.Target;
}
When I run this benchmark on my computer using .NET 10.0.9, the new GC handle takes 74% as much time to access its target compared to the old GC handle:
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|---|---|---|---|---|---|
| StandardGcHandle | 1.0260 ns | 0.0572 ns | 0.0477 ns | 1.00 | 0.06 |
| NewGcHandle | 0.7580 ns | 0.0634 ns | 0.0929 ns | 0.74 | 0.09 |
To explain the difference, let's run the following code through Compiler Explorer.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
static class Example
{
static IntBox StandardGcHandle(GCHandle hand) => (IntBox)hand.Target!;
static IntBox NewGcHandle(GCHandle<IntBox> hand) => hand.Target;
}
[StructLayout(LayoutKind.Sequential)]
public class IntBox
{
public int SomeInt;
}
We can see that the new handle type merely dereferences the handle value:
Example:NewGcHandle(System.Runtime.InteropServices.GCHandle`1[IntBox]):IntBox (FullOpts):
mov rax, gword ptr [rdi]
ret
The old handle has a lot of extra checks. I've added comments explaining them below.
Example:StandardGcHandle(System.Runtime.InteropServices.GCHandle):IntBox (FullOpts):
push rbp
mov rbp, rsp
// Test for a zero handle value and throw `InvalidOperationException` if zero.
test rdi, rdi
je SHORT G_ThrowInvalidOperationException
// Clear IsPinned bit from the bottom of the handle value.
and rdi, -2
// Dereference handle value.
mov rsi, gword ptr [rdi]
// Check for null and return early if null.
mov rax, rsi
test rax, rax
je SHORT G_Return
// Check if the object is an instance of IntBox and branch to fallback more complicated
// casting logic if it is not an exact match.
mov rdi, 0x740999C5B800 ; IntBox
cmp qword ptr [rax], rdi
je SHORT G_Return
call CORINFO_HELP_CHKCASTCLASS_SPECIAL
G_Return: ;; offset=0x002C
// Now that we have performed all the checks, we can return the object reference in rax.
nop
pop rbp
ret
G_ThrowInvalidOperationException: ;; offset=0x002F
call [System.ThrowHelper:ThrowInvalidOperationException_HandleIsNotInitialized()]
int3
New Internal GC Handle Types
When CoreCLR was open-sourced in 2015, its GC supported 10 types of handles. See my previous post or the original source code for more details
In .NET 9 and .NET 10, two additional internal GC handle types were added.
Weak Interior Pointer Handles
.NET 9 added the weak interior pointer handles (HNDTYPE_WEAK_INTERIOR_POINTER). These handles have
an extra pointer value associated with them. This extra pointer points a location in unmanaged memory that contains
a pointer to the object in the GC heap. The pointer to the object may be to the beginning of the object or to a location
in the interior of the object. Whenever the object is moved, the location pointed to by the associated
pointer is
updated
to continue pointing at the object's new location.
This diagram shows how this is laid out in memory.
GC Handle Object in GC heap
┌────────────────────────────┐ ┌──────────────┐
│ Primary handle value ├──────►│Method Table │
│ │ │ │
┼────────────────────────────┤ ├──────────────┤
│ │ │Field 1 │◄──────────────┐
│ Secondary pointer value ├──┐ │ │ │
│ │ │ ├──────────────┤ │
└────────────────────────────┘ │ │Field 2 │ │
│ └──────────────┘ │
│ │
│ │
│ Unmanaged or pinned memory │
│ │
│ ┌───────────────────┐ │
│ │... │ │
│ ├───────────────────│ │
└───►│Interior Pointer ├──────────┘
├───────────────────┤
│... │
└───────────────────┘
This handle type was added in pull request #100446.
It was initially used to keep a pointer to the managed System.Type object for collectable types
updated in the unmanaged MethodTable and MethodDesc data structures.
Pull request #99183 changed how memory for static variables is allocated (and freed for collectable assemblies). It made the code for managing this memory simpler and accessing this memory more performant. The weak interior pointer handle is used when allocating memory for static variables in collectable assemblies. See the PR description and the Book of the Runtime section on static variables for more details.
Cross-reference Handles
.NET 10 added experimental support
for using the CoreCLR instead of Mono when publishing MAUI
applications for Android. To support this feature, cross-reference handles (HNDTYPE_CROSSREFERENCE) were added
in pull request #116310. This added the classes in the
System.Runtime.InteropServices.Java
namespace.
A full description of .NET's Java interop system is out of the scope of this post3. The interesting property of the system we are focusing on in this post is that the system is able to pair a .NET and a Java object. These objects have to share the same lifetime. Neither the Java GC nor the CoreCLR GC has a full picture of whether or not an object is being used. Thus the interop system uses a .NET GC handle to keep the .NET object alive indefinitely. It also uses Java's version of a strong GC handle, a global reference4, to keep the Java object alive indefinitely.
Here is a diagram of the lifetime dependencies between the Java and .NET objects.
The problem with creating strong references to the objects in both GC heaps is immediately apparent: The objects will never be collected even if the objects are unreachable in both GC heaps.
The solution to this Gordian knot of GCs is the cross-reference handle introduced in .NET 10. These are used to coordinate the collection of objects on both GC heaps. For most purposes they act like strong GC handles. Where it gets interesting is when a collection occurs the following happens:
- The .NET GC makes a list of all the cross-reference handles whose targets are not reachable in the .NET GC. The GC extends the lifetime of the objects so they continue to live.
- .NET's Java interop system takes this list of handles and mirrors the object reference graph on the Java side.
- The interop system downgrades its references to the Java objects to weak references.
- A Java garbage collection is triggered.
- The cross-reference handles that correspond to collected Java objects are freed, allowing the corresponding .NET object to be collected the next time the .NET GC runs.
The next section describes the algorithm in more detail.
Details about how the .NET GC and the Java GC are bridged
To keep the Java side alive, a global reference is created for the Java object and is stored in a
HandleContext struct
on the unmanaged heap. This keeps the Java object alive indefinitely.
On the .NET side, the .NET object and the HandleContext* is passed to
JavaMarshal.CreateReferenceTrackingHandle,
which creates a handle of type HNDTYPE_CROSSREFERENCE.
The .NET GC will keep the .NET object alive indefinitely.
Here we have some objects in the .NET and Java heaps. A black edge between
objects indicates that an object has reference to the other object. Objects of the same number on
each heap are paired with each other. For example .NET object d0 is paired with Java object j0.
In this diagram all objects are garbage. To find these dead objects, the .NET GC scans all handles of type
HNDTYPE_CROSSREFERENCE and makes a list of the garbage objects. It then promotes the dead .NET
objects so they remain alive for the time being.
Before we attempt to garbage collect objects on the Java side, we need to replicate the .NET object graph on the paired objects. This is necessary to ensure that objects on the Java side remain alive as long as there is a way to reach them through the .NET object graph. To reduce the amount of information about references between .NET objects that needs to be replicated on the Java side, .NET uses Tarjan's strongly connected components algorithm to find the strongly connected components (SCC) of the .NET object graph. This processing happens in gcbridge.cpp. In the following diagram, the boxes around objects in the .NET heap represent the strongly connected components and the green arrow between the SSCs represents cross references between strongly connected components.
The .NET GC packages the information about the strongly connected components and cross references
into a
MarkCrossReferencesArgs struct.
The corresponding Java objects are represented by the HandleContext* that was passed to CreateReferenceTrackingHandle.
The .NET GC passes a pointer to the MarkCrossReferencesArgs struct to the callback registered with
JavaMarshal.Initialize.
This is the
GCBridge::mark_cross_references function.
This kicks off the processing of the .NET object graph on a separate thread and returns quickly
so that the .NET GC can continue running and resume execution of .NET threads.
BridgeProcessingShared::process
is the entrypoint to the core GC bridge logic. For each strongly connected component with more than
one object in it5, it connects the corresponding Java objects together in a cycle with normal Java object references6.
Cross references between SCCs are also represented with normal Java object references.
In this graph we can see how the SCCs were replicated on the Java side. The blue edges are the circular references and the green edges are cross references between the SCCs.
At this point the GC bridge converts all the global references to Java objects to weak references.
The GC bridge
calls
the java.lang.Runtime.gc() method
to trigger a GC. It then checks all of its weak references to Java objects. If the object was collected,
the weak reference is freed. If the Java object is still alive, the weak reference is upgraded to a global reference
so that the Java object is once again kept alive indefinitely.
The Java interop side will
make a list
of all the GC Handles that were not reachable from neither
the .NET side nor the Java side. It will pass these handles to the .NET GC via the
JavaMarshal.FinishCrossReferenceProcessing function.
This function
nulls any weak GC handle7 that points to the same .NET object as any of the handles. It will then
free all the GC handles.
Conclusion
The new generic GC handle structs are a welcome improvement in developer experience. I hope you find the details of these new GC handle types as fascinating as I do. Usually as software engineers we live within the constraints of a language or language runtime to express solutions to problems. The developers of languages and runtimes build the worlds we live in. These new GC handles are a reminder that by changing the abstractions available in a runtime we gain new vocabulary to express solutions to problems, making the impossible possible. This is the joy of systems software.
Footnotes
The initial
open sourcing of CoreCLR
in 2015 there were 10 GC handle types. The oldest at that time, HNDTYPE_WEAK_WINRT, was likely added in .NET Framework 4.5 in 2012.
.NET 9 in 2024 was the next time a GC handle type was introduced.
So it has been a while since new types of handles were added.
See the CoreCLR code here
and the NativeAOT code here.
Besides the static variable and local variable scanning, these runtimes use these
IGCToCLR::GcScanRoots callbacks to extend the lifetime of other types of objects that don't neatly
fit the GC handle model.
See these official docs
for some description of how the interop system works, but nothing about the GC bridge at the
time of writing. The
old documentation for Xamarin.Android
contains a description of the function of the GC bridge used with the Mono runtime.
The API proposal for JavaMarshal provides some hints.
See also the pull request consuming this new API in dotnet/android.
Before this feature was implemented in CoreCLR, Filip Navara
described in detail
how the feature was implemented in Mono and options for implementing it in CoreCLR.
The paper "Collecting Cyclic Garbage across Foreign Function Interfaces" 8
gives an academic description of the algorithm for coordinating two different garbage collectors.
Understanding how JNI works would be helpful. I don't know much about JNI, but the documentation about
local and global references
is helpful for understanding the terms used in the interop code.
Tetsuro Yamazaki, Tomoki Nakamaru, Ryota Shioya, Tomoharu Ugawa, and Shigeru Chiba. 2023. Collecting Cyclic Garbage across Foreign Function Interfaces: Who Takes the Last Piece of Cake? Proc. ACM Program. Lang. 7, PLDI, Article 130 (June 2023), 24 pages. https://doi.org/10.1145/3591244
See the JNI docs for more details.
SCCs with one Java object in them need no special processing here. SCCs with zero Java objects
are possible. These occur when the .NET objects in a SCC have no Java peers. These are
represented in the Java object graph with an instance of the
GCUserPeer class.
The references between Java objects are
created
by calling the
monodroidAddReference method
on the Java object. This method is
added
to all .NET callable wrappers generated by the build process.
Attempts to read objects from WeakReference or WeakReference<T> will
block
while GC Bridge processing takes place. This prevents resurrecting a .NET object whose Java peer
may have been collected.