Skip to content

[API Proposal]: Java Marshalling API #115506

Closed

Description

@AaronRobinsonMSFT

Background and motivation

For interop with JVM based languages, an interaction model must be made to facilitate lifetime managements through GC coordination. This support is primary for Android for .NET, but there is no technical limitation to this support being enabled for non-Android scenarios.

See #114184 and https://github.com/BrzVlad/runtime/tree/feature-clr-gcbridge

API Proposal

namespace System.Runtime.InteropServices.Java;

[SupportedOSPlatform("android")]
public struct ComponentCrossReference
{
    public nint SourceGroupIndex;
    public nint DestinationGroupIndex;
}

[SupportedOSPlatform("android")]
public unsafe struct StronglyConnectedComponent
{
    public nint Count;
    public System.IntPtr* Contexts;
}

[SupportedOSPlatform("android")]
public unsafe struct MarkCrossReferences
{
    public nint ComponentsLen;
    public StronglyConnectedComponent* Components;
    public nint CrossReferencesLen;
    public ComponentCrossReference* CrossReferences;
}

[SupportedOSPlatform("android")]
partial static class JavaMarshal
{
    public static unsafe void Initialize(delegate* unmanaged<MarkCrossReferences*, void> markCrossReferences);

    public static GCHandle CreateCrossReferenceHandle(object obj, IntPtr context);

    public static IntPtr GetContext(GCHandle obj);

    public static unsafe void FinishCrossReferenceProcessing(
        MarkCrossReferences* crossReferences,
        Span<GCHandle> unreachableObjectHandles);
}

API Usage

// Early in application start-up, a callback is provided.
JavaMarshal.Initialize(...);

object javaProxy = ...
var userContext = (IntPtr)NativeMemory.Alloc(...);
// Store data in context.

GCHandle handle = JavaMarshal.CreateCrossReferenceHandle(javaProxy, userContext);
// Store handle in a map from JNI handle to Object for fast retrieval

// Get GCHandle associated with JNI handle
GCHandle handle = ...
var userContext = JavaMarshal.GetContext(handle);
// Extract data from context.

// Free memory passed to callback registered in JavaMarshal.Initialize().
MarkCrossReferences* markRefsMem = ...;

// Collect all GCHandles marked as "unreachable"
Span<GCHandle> unreachableObjects = ...;

JavaMarshal.FinishCrossReferenceProcessing(markRefsMem, unreachableObjects);

Case example can be found at #114184

Alternative Designs

Place under System.Runtime.InteropServices.GCBridge

Do not limit to SupportedOSPlatform("android")

Risks

Risk is missing some scenario/semantic in order to ensure Android for .NET is successful. This would be a new interop stack and be narrow in scope, following our Swift, ObjectiveC and WinRT interop solutions in .NET Core.

Activity

  1. added this to the 10.0.0 milestone on May 13, 2025
  2. dotnet-policy-service commented on May 13, 2025

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/interop-contrib
    See info in area-owners.md if you want to be subscribed.

  3. AaronRobinsonMSFT commented on May 13, 2025

    @AaronRobinsonMSFT
    MemberAuthor
  4. BrzVlad commented on May 13, 2025

    @BrzVlad
    Member

    In addition to the computation and passing of the SCCs and CCRs, that are handled by this API, there are a few implementation details currently present on mono.

    1. After java gc, we iterate over all weak refs and zero the refs (even long / finalization tracking ones ?) for the objects that had their java counterpart collected. The runtime gets information about which SCCs are alive from the callback into the android processing part.
    2. During java - C# marshalling, we have a mechanism to wait for bridge processing to finish. In theory, this would prevent mutations of the object graph that would lead to collecting of java objects with live C# peers.

    After the original rounds of discussions around design, the conclusion was that we will have the fetching of a WeakReference target wait for bridge processing and the finishing step of the bridge processing would clear the weak refs for bridge objects with dead java peers. It seems that the only way to achieve this would to fully iterate over the weakrefs and null them, every single time a ReferenceTrackingHandle is freed. I don't think we can do this when we get back via ReleaseMarkCrossReferenceResources since we have no information about which objects were collected and which not.

    Given this might be somewhat expensive, it raises the question on how relevant this nulling of weakrefs really is, considering that, even with the current design, resurrection could still happen and we can end up, in theory, with a C# object that had its java peer collected. I couldn't find a detailed reason for its existence, it seems to have been added in https://github.com/mono/mono/commit/572bd8b01721c4cf423ce1d66f96a195c54f983a but the reference to what exact kind of bug it was fixing is lost. It would surely be cleaner if the WeakReference would only have knowledge of the C# side of things. The WeakReference would then be nulled at the following collection. Calling the Target method could bring the object back alive, but is it really a problem ? I would assume the object would have its own state indicating whether the java peer is collected or not anyway, doesn't seem like it needs to rely on the weak refs for this.

    So as potential changes for this api:

    • If we still do the weakref clearing, would it make sense for ReleaseMarkCrossReferenceResources to also receive the set of to be freed crossref handles so we can iterate over the weakrefs only once ?
    • If we would no longer do weakref clearing, would we need an explicit WaitForBridgeProcessing api here ? And not have the WeakReference.Target block.
  5. filipnavara commented on May 13, 2025

    @filipnavara
    Member

    The WaitForBridgeProcessing barrier is necessary for reintroducing references. We must prevent any mutations on the C# side that would invalidate the compressed graph that was sent to the Java side until it is fully processed.

    For a more visual explanation, please watch https://youtu.be/h57uvjMQMgU?t=534 which describes the same problem albeit with a slightly more eleborate (and less blocking) solution.

  6. added a commit that references this issue on May 13, 2025
  7. jonpryor commented on May 13, 2025

    @jonpryor
    Contributor

    @AaronRobinsonMSFT: what should the markCrossReferences callback do? This should be documented.

    I had thought that it would be"mirror" MonoGCBridgeCallbacks::cross_references callback, along with:

    However, one field is missing in this world view: MonoGCBridgeSCC::is_alive:

    mono_bool is_alive; /* to be set by the cross reference callback */

    Thus, how does markCrossReferences tell the GC "this object is dead/still alive"? Do we need to add a StronglyConnectedComponent.IsAlive field?

    partial struct StronglyConnectedComponent {
        public int IsAlive; // 0 if dead, non-0 if alive
    }
  8. jonpryor commented on May 13, 2025

    @jonpryor
    Contributor

    Naming: StronglyConnectedComponent.Context should probably be StronglyConnectedComponent.Contexts (plural), is it contains StronglyConnectedComponent.Count Context values (plural).

  9. AaronRobinsonMSFT commented on May 13, 2025

    @AaronRobinsonMSFT
    MemberAuthor

    @BrzVlad

    If we still do the weakref clearing, would it make sense for ReleaseMarkCrossReferenceResources to also receive the set of to be freed crossref handles so we can iterate over the weakrefs only once ?

    That seems reasonable to me. @jkotas and I also previously talked about another callback, but replacing the "Release" with a broader "Finished" that serves both for memory clean-up and passing along the freed handles works too.

    If we would no longer do weakref clearing, would we need an explicit WaitForBridgeProcessing api here ? And not have the WeakReference.Target block.

    An explicit WaitForBridgeProcessing API shouldn't be needed as we can handle this as an implementation detail where the aforementioned "Finished" API must be called to track the bridge is now done.

    @jonpryor

    This should be documented.

    Yep, it will be.

    Thus, how does markCrossReferences tell the GC "this object is dead/still alive"?

    The markCrossReferences callback's job is to do the actual freeing of the associated GCHandles in the JNI value map. See the proof-of-concept's map clean-up here and corresponding bridge implementation here.

  10. jonpryor commented on May 13, 2025

    @jonpryor
    Contributor

    @AaronRobinsonMSFT wrote:

    The markCrossReferences callback's job is to do the actual freeing of the associated GCHandles in the JNI value map. See the proof-of-concept's map clean-up here and corresponding bridge implementation here.

    which doesn't actually quite make sense to me: the bridge impl has a JVMTriggerGC() invocation, i.e. it contains JNI calls, after which it can check to see if the the Java GC collected the JNI weak ref.

    #114184 , meanwhile, has no Java System.gc() invocations at all.

    I thus don't fully understand how it works.

  11. AaronRobinsonMSFT commented on May 13, 2025

    @AaronRobinsonMSFT
    MemberAuthor

    #114184 , meanwhile, has no Java System.gc() invocations at all.

    Right. Any calls to JVM APIs are the domain of the GC Bridge (2nd stage) and not needed in the dotnet/runtime repo. Only the SCC building (1st stage) is handled by runtime and that is passed to the 2nd stage which will do the JVM interaction.

  12. AaronRobinsonMSFT commented on May 13, 2025

    @AaronRobinsonMSFT
    MemberAuthor

    @BrzVlad

    If we still do the weakref clearing, would it make sense for ReleaseMarkCrossReferenceResources to also receive the set of to be freed crossref handles so we can iterate over the weakrefs only once ?

    That seems reasonable to me. @jkotas and I also previously talked about another callback, but replacing the "Release" with a broader "Finished" that serves both for memory clean-up and passing along the freed handles works too.

    It sounds like the workflow you're invisioning goes something like this:

    1. GC builds (1st stage) the SCCs/XRefs and collects interesting WeakReference instances.
    2. Passes the SCCs/XRefs to the GC Bridge (2nd stage), which reifies the graph in the JVM and triggers the JVM GC.
    3. The JVM GC completes and the 2nd stage now knows the dead objects.
    4. The 2nd stage passes the keys to the dead objects to they can be looked up in the JNI value map in the Android for .NET runtime.
    5. The Android for .NET runtime uses the keys to remove the GCHandles to the dead objects from some hashmap (perhaps, Dictionary<nint, GCHandle>?).
    6. The removed GCHandle instances are collected and passed, along with the MarkCrossReferences*, to some "Finished" API that can then free the GCHandle and null out the interesting WeakReference instances collected in (1).

    Steps (2) through (6) would all occur under what is now considered the "bridge processing" lock.

  13. 15 remaining items

  14. AaronRobinsonMSFT commented on May 20, 2025

    @AaronRobinsonMSFT
    MemberAuthor

    @BrzVlad The overall flow/architecture look good. There are two issues that need to be addressed.

    1. There is a GC hole in the call to FinishCrossReferenceProcessing. We're passing a void* to the byref contained within a Span<T>. That should really be an ObjectOnStack instance and extracted in a cooperative block.

    2. The GCHandleInternalGetBridgeWait will need a fast/slow split instead of a single FCall that switches to preemptive mode. The FCall should return immediately with "true" if it is free, otherwise it should return "false" and then call a QCall that does the actual work in preemptive mode.

    Once the branch is in a PR, I can make a few changes on the VM side. Otherwise, this looks like what we've talked about.

  15. added a commit that references this issue on May 23, 2025
  16. added
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    and removed
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on May 23, 2025
  17. bartonjs commented on Jun 3, 2025

    @bartonjs
    Member

    Video

    • Since this is heavy interop and already using pointers, IntPtr => void*
    • Non-generic GCHandle is better here, because of FinishCrossReferenceProcessing
    • FinishCrossReferenceProcessing should take ReadOnlySpan, not writable Span.
    • MarkCrossReferences => MarkCrossReferencesArgs
    • All of the nint counts/lenghts changed to nuint.
      • If the indexes are unsigned, change them to nuint, too.
    • sLen suffixes became Count
    namespace System.Runtime.InteropServices.Java;
    
    [SupportedOSPlatform("android")]
    [StructLayout(LayoutKind.Sequential)]
    public struct ComponentCrossReference
    {
        public nint SourceGroupIndex;
        public nint DestinationGroupIndex;
    }
    
    [SupportedOSPlatform("android")]
    [StructLayout(LayoutKind.Sequential)]
    public unsafe struct StronglyConnectedComponent
    {
        public nuint Count;
        public void** Contexts;
    }
    
    [SupportedOSPlatform("android")]
    [StructLayout(LayoutKind.Sequential)]
    public unsafe struct MarkCrossReferencesArgs
    {
        public nuint ComponentCount;
        public StronglyConnectedComponent* Components;
        public nuint CrossReferenceCount;
        public ComponentCrossReference* CrossReferences;
    }
    
    [SupportedOSPlatform("android")]
    partial static class JavaMarshal
    {
        public static unsafe void Initialize(delegate* unmanaged<MarkCrossReferencesArgs*, void> markCrossReferences);
    
        public static unsafe GCHandle CreateCrossReferenceHandle(object obj, void* context);
    
        public static unsafe void* GetContext(GCHandle obj);
    
        public static unsafe void FinishCrossReferenceProcessing(
            MarkCrossReferencesArgs* crossReferences,
            ReadOnlySpan<GCHandle> unreachableObjectHandles);
    }
  18. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Jun 3, 2025
  19. NinoFloris commented on Jun 4, 2025

    @NinoFloris
    Contributor

    IMO void* should stay as nint.

    Changing context to void pointer is a narrowing change to the communicated semantics. For example, an implementation might really want to store an index, and now it needs to check how this void* value is used. If the marshaling infra only ever considers it opaque data it should not be void* typed (C# does not, and has no need to, mirror historical C-isms around void* vs intptr_t).

  20. agocke commented on Jul 28, 2025

    @agocke
    Member

    Can this be closed as done?

  21. filipnavara commented on Jul 28, 2025

    @filipnavara
    Member

    Can this be closed as done?

    I believe so.

    For CoreCLR both the dotnet/runtime and dotnet/android support has landed. I tried it on a local build on a real-world app and it passed the smoke tests.

    For NativeAOT the dotnet/runtime part has landed. Now we are waiting for the proper runtime packs to be produced so the workload part can be finished.

  22. locked and limited conversation to collaborators on Aug 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions