Weak pointers
A generalized weak pointer associates a key K with a value V and an optional finalizer F. Keeping the weak pointer itself alive does not keep its key alive. If the key is live, the value and finalizer are retained. If the key is dead, the association dies and its finalizer becomes eligible to run.
The interesting part is deciding what live means. Both V and F may refer to K, and other weak associations may lead to it as well.
Start with a cycle
Suppose the only path to a key goes through its own weak value:
association A: K ⇒ V
▲ │
└───┘
Here ⇒ is conditional retention and the return edge is an ordinary strong reference. With no independent path to K, the association must die. Its own value cannot supply the evidence needed to make that value live.
Now add a strong root to K. The key is live, so the collector follows V. Everything strongly reachable from V is then live as usual. The difference is entirely in when that first conditional edge is followed.
This is why a Java WeakReference<K> paired with a strongly held V does not suffice. The strong value would retain the key through the return edge. Making V weak too would allow it to disappear while K remains live.
Close over live keys
One association's value can contain another association's key:
root → K₁ ⇒ V₁ → K₂ ⇒ V₂
Following V₁ makes K₂ live, which permits following V₂. Registration order must not determine whether that second step happens.
Let S be the objects retained before generalized weak processing, including ordinary roots, pending/running guest finalizers and Java soft references retained during discovery. Write strong(X) for closure under ordinary strong edges, with Java reference fields handled by the VM's reference policy. Then:
L₀ = strong(S)
Lₙ₊₁ = strong(Lₙ ∪ {V, F | an active (K, V, F) has K ∈ Lₙ})
L = the least fixed point of this sequence
Starting at L₀ gives us the least fixed point. In particular, a cycle of conditional edges with no live entry point does not keep itself alive. The implementation repeatedly scans unactivated registrations and drains jam's actual tracing frontier until no association activates.
During a minor collection, old keys are conservatively live. The collector does not prove old-generation death while collecting only young. A major uses the marks for both generations.
Freeze death before following finalizers
After closure, every remaining association dies as one batch. The registry clears its key and value before any finalizer from that batch is traced.
Consider two associations with the same dead key. The first finalizer might refer back to that key. If we traced it before deciding the second association, the second one would appear live. Reversing registration order could reverse the outcome.
Freezing the complete dead batch removes that dependency. A finalizer may keep the key's object around, or resurrect it later by storing it in a root, but the old weak registration stays dead:
retired(A) ⇒ deref(A) = null
claims_of_finalizer(A) ≤ 1
Retirement is irreversible. Registering another association after resurrection creates a new registration; it does not repair the old one. A dead key is kept only when an actual strong edge from retained data reaches it. The registry does not retain dead keys merely because they had finalizers.
Jam's original weak policy processes sorted registrations once and retains newly dead finalizers immediately. That is a different policy. jam-vm keeps jam's collection machinery and supplies the fixed-point and batch rules at the hosted phase boundary.
Java references in the same heap
HotSpot uses OpenJDK's ReferenceProcessor for Java soft, weak, final and phantom references. Generalized weak processing shares its tracing epoch:
- Trace ordinary VM roots and queued/running guest finalizers. Java discovery and soft-reference retention use the same jam frontier.
- Close the generalized live-key fixed point and freeze the remaining dead registrations. Tracing a live value can discover further Java references.
- Make Java soft/weak clearing and final-reference decisions.
- Trace the frozen guest finalizers, then perform Java final keepalive and phantom processing.
- Clear weak VM roots, prepare forwarding, repair roots and move objects.
An optional hook just before Java final keepalive supplies step four. Other collectors retain their existing call behavior. This order lets a Java weak reference be cleared before a guest finalizer retains or resurrects its referent. It also gives phantom processing the final retained graph.
Running two weak processors once in sequence would not establish this closure or ordering: following one association can expose another policy's edges.
Java and JNI access
Use java.lang.ref.WeakReference<T> for an ordinary Java weak pointer. Its get() returns a strong Java reference when the referent is still available. Use jam.vm.Weak when a live key must retain a separate value or finalizer, including values and finalizers that refer back to the key.
Native code can keep a JNI weak global created by NewWeakGlobalRef. Acquire a strong local with NewLocalRef before using its referent, and release that local with DeleteLocalRef when done. A separate null check does not keep the referent alive between JNI calls. NewLocalRef can return null after collection or an allocation failure. Check for a pending exception before treating null as a collected referent. Release the weak handle itself with DeleteWeakGlobalRef.
JNI weak globals have phantom-reference clearing semantics. A newly queued generalized finalizer can therefore keep a JNI weak global's referent available after a Java WeakReference to the same object has cleared. After the finalizer completes and its acquired strong references are released, a later collection can clear the JNI weak global too. This ordering applies on both HotSpot and Native Image.
Claiming a finalizer
Registration tokens are stable native IDs. They are not object addresses and do not serve as strong JNI handles to K, V or F. Dropping the token does not cancel finalization.
The public Java API exposes the protocol:
| Operation | Effect |
|---|---|
create(K, V, F) |
Register a conditional association with a JVM Runnable finalizer |
deref(token) |
Return its active value, or null after retirement |
take(tokenOut) |
Claim one queued finalizer and return its token |
finalizeNow(token) |
Retire and claim explicitly, using the same at-most-once state |
complete(token) |
Release the running-finalizer root |
pump() |
Claim, run and complete pending callbacks on the calling thread |
For example, a caller can claim a finalizer, run it outside the GC safepoint, and release its root even if execution throws:
long[] token = new long[1];
Runnable finalizer = jam.vm.Weak.take(token);
if (finalizer != null) {
try {
finalizer.run();
} finally {
jam.vm.Weak.complete(token[0]);
}
}pump() performs this loop for the caller. A Truffle runtime installs a runnable that enters and executes its guest closure. Context identity is invisible to jam-vm; any caller can pump the shared queue. Pending and running finalizers remain roots through subsequent collections, including a collection triggered by the finalizer itself. complete ends that retention. See integrating thc for the artifact and scheduling contract.
Remaining work
The registry currently scans registrations repeatedly and never reuses token IDs. Dead entries still occupy metadata. A key-indexed work queue and safe reclamation would improve weak-heavy workloads without changing the laws above.
thc still needs primitive lowering, finalizer scheduling and exception integration. GHC C finalizers and weak-thread resurrection require further runtime work. This is not yet end-to-end execution of System.Mem.Weak through thc.
The pinned GHC sources include System.Mem.Weak, MarkWeak.c and Weak.c. See supported configurations for the remaining runtime work.