OpenJDK Trivia: Class Unloading and CompileTask Retention
§The Problem
When all references to a class loader are released and System.gc() is invoked, the class loader should become eligible
for garbage collection. However, with JIT compilation enabled, CompileTask objects in the compiler pipeline retain
references to methods, which in turn reference their declaring classes and class loaders. Since a class loader can only
be collected when none of its classes are reachable, these retained references prevent unloading. Because background
compiler threads process these tasks asynchronously, class loader garbage collection becomes non-deterministic—unloading
may succeed or fail depending on the timing of compilation completion.
§Why This Happens
When the JIT compiler identifies a hot method, it instantiates a CompileTask and enqueues it for a background compiler
thread (C1 or C2). This task holds a reference to the Method*, which references its Klass, which references its
defining class loader. Even after application code drops all external references, the JVM-internal CompileTask might
still be awaiting cleanup. Until the task is actually reclaimed, the reference chain keeps the Klass and its class
loader reachable. GC cannot mark the class loader for unloading until all CompileTask objects referencing methods of
that loader’s classes are deallocated. The timing is unpredictable because of the scheduling of compiler threads.
We can either sleep to let compiler threads catch up. Note that repeatedly invoking full GC, especially stop-the-world full GC, makes matters worse because it prevents compiler threads from progressing.
Alternatively, we can use -XX:-BackgroundCompilation. This flag disables asynchronous compilation, causing
CompileTask objects to be reclaimed by the app thread upon completion. This breaks the reference chain
deterministically: once the calling thread finishes compilation, the CompileTask and its Method reference are
released, allowing the class loader to be collected in the next GC cycle.
Tradeoff: The application incurs latency spikes during method compilation, so this flag is primarily useful for debugging.
§CompileTask Reclamation Sites
The following code shows where CompileTask objects are freed:
§With -XX:+BackgroundCompilation
1 | CompileTaskWrapper::~CompileTaskWrapper() { |
§With -XX:-BackgroundCompilation
1 | void CompileBroker::wait_for_completion(CompileTask* task) { |
§Reproducing the Behavior
The following example demonstrates class loader unloading with default JIT settings (background compilation enabled).
The test creates a custom class loader, loads a class with hot methods to trigger compilation, drops all references, and
uses a PhantomReference to detect when the class loader is garbage collected:
1 | $ javac LoaderTest.java Target.java; java LoaderTest |
§LoaderTest.java
The harness creates a URLClassLoader with no parent (to isolate it), loads the target class, invokes methods to
trigger JIT compilation, then releases all strong references. It uses a PhantomReference to verify when the class
loader becomes eligible for collection:
1 | import java.io.File; |
§Target.java
This is the target class loaded by the custom class loader. It provides two hot methods (hotMethod and newHotMethod)
that loop heavily and are prime candidates for JIT compilation:
1 | import java.io.*; |