Integrating thc
The guest API is jam.vm.Weak. It exposes the collector hooks through a Java JAR and a small JNI library. A finalizer is a JVM Runnable. It may enter whatever guest context it needs; jam-vm has no context IDs or routing table. As long as some caller pumps the queue, callbacks from every producer can run. In Native Image, the same API connects directly to the collector and the queue belongs to the current isolate.
The thc owner can lower weak primitives to this API and wrap Haskell finalizers in runnables that enter thc. The collector owns reachability, retirement and at-most-once claims. The runnable owns entering and executing the guest code.
Build the API
Use a JDK 25 installation and the platform C compiler:
python3 tools/build_bridge.py --java-home /absolute/path/to/jdk-25This does not rebuild jam or HotSpot. It writes:
| Output | Purpose |
|---|---|
build/bridge/jam-vm.jar |
Public jam.vm.Weak class; automatic module name jam.vm |
build/bridge/jam-vm-sources.jar |
API sources for the consumer's IDE |
build/bridge/lib/ |
JNI shim: libjam_bridge.dylib, libjam_bridge.so or jam_bridge.dll |
build/bridge/include/jam_vm_Weak.h |
JNI declarations generated by javac |
build/bridge/api/ |
Javadoc for the exported methods |
The shim resolves the JVM_JamWeak* exports in the running VM. It does not link thc to the collector's C++ modules. A stock JVM without these exports reports a linkage error; a patched JVM running another collector reports UnsupportedOperationException. Call Weak.checkAvailable() when initializing the integration so a missing collector is reported before registering work.
Add the JAR to the host class path and its native directory to the library path:
"$JAM_JAVA" -Xshare:off -Xms256m -Xmx256m \
-XX:+UnlockExperimentalVMOptions -XX:+UseJamGC \
--enable-native-access=ALL-UNNAMED \
-Djava.library.path=/absolute/path/to/jam-vm/build/bridge/lib \
-cp /absolute/path/to/jam-vm/build/bridge/jam-vm.jar:your-runtime.jar \
your.runtime.MainFor module-path use, enable native access for jam.vm instead. Load the API from a shared host class loader: the JVM associates a loaded native library with its loader. The JAR and shim are a pair for the pinned Jam-enabled JDK, not an independently versioned VM extension protocol. The current native build supports macOS 26 arm64, Linux x86_64 and Windows x86_64. On Windows, separate class-path entries with ; and use bin/ as the native library directory in a packaged JDK.
Register and dereference
First, install the weak association:
import jam.vm.Weak;
Weak.checkAvailable();
long token = Weak.create(key, value, finalizer);key must be nonnull. value is the object returned by a successful dereference. finalizer is a Runnable, or null when none is needed. Captures of the key or value inside the runnable participate in the conditional graph; the registry does not turn them into independent strong roots.
Now, you can dereference it:
Object result = Weak.deref(token);The returned value is an ordinary strong Java reference. Null means either a retired/unknown registration or an active null value. Use a nonnull guest value or box null if that distinction matters. For a GHC weak value represented by a managed closure, the closure itself should supply the nonnull representation.
A guest weak-handle wrapper should contain only the token. Storing its key, value or finalizer as ordinary strong fields in the wrapper would change the reachability problem. Tokens are monotonic IDs local to a JVM or native isolate, never reused. Do not persist them across runs or treat them as security capabilities. Dropping a wrapper does not cancel its finalizer.
Pumping finalizers
Any caller can drain currently pending work:
int completed = Weak.pump();The pump claims one runnable at a time, calls run() on its own thread, and releases the collector root in a finally block. The call to run() happens after leaving the VM entry and its heap lock. There is no guest callback inside the GC safepoint.
Callbacks can allocate, trigger collection or pump recursively. Different threads may also pump concurrently: each claim is atomic with respect to other pumps and explicit finalization. Finalizers have no promised execution order. The pump returns when a poll observes the queue empty; it neither requests GC nor waits for future work.
If a runnable throws, its claim is completed and the exception propagates to the caller. Remaining callbacks stay queued for a subsequent pump. The caller chooses whether to catch that exception and continue pumping. Jam-vm does not retry a callback that has already been claimed.
There is currently no wakeup notification. Arrange for a host thread or event loop to call pump() periodically. Context identity, attachment and shutdown remain inside the installed runnable and its owner, invisible to this API.
Explicit finalization and low-level claims
Explicit finalization shares the same at-most-once claim as the pump:
Runnable finalizer = Weak.finalizeNow(token);
if (finalizer != null) {
try {
finalizer.run();
} finally {
Weak.complete(token);
}
}This retires an active or queued association before returning its runnable. Dereference remains null even if the finalizer resurrects the key. An absent, already-running, completed or unknown registration returns null. An association without a finalizer is still retired.
Weak.take(long[] tokenOut) exposes the pump's nonblocking claim operation for callers that need to schedule execution elsewhere. It returns a runnable and writes its token to element zero, or returns null and writes zero when empty. The array must have at least one element.
Every successful take or finalizeNow claim must eventually be completed. For asynchronous execution, call complete after the runnable actually finishes, not immediately after submitting it. If submission fails, release the claim as part of handling that failure. Pending and running finalizers stay rooted across GC; losing a returned runnable without completing it leaks that retained graph. Repeated completion is harmless.
Runtime requirements
Use the Jam-enabled GraalVM for compiled Truffle execution. The packaged runtime includes the API at lib/jam/jam-vm.jar and its native libraries under lib/jam/. Keep thc's patched Truffle libraries on its normal runtime class path.
thc currently enables compact object headers by default. Build its launchers with -Pthc.compactObjectHeaders=false for Jam, which requires ordinary headers. Set JAVA_HOME to the packaged GraalVM and add these JVM options when launching thc:
export THC_OPTS="-Xshare:off -Xms256m -Xmx256m -XX:+UnlockExperimentalVMOptions -XX:+UseJamGC"This selects the collector. The thc runtime still needs to lower its weak primitives through this API and arrange to pump finalizers. See supported configurations for the remaining runtime work.