§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
2
3
4
5
6
7
8
9
10
CompileTaskWrapper::~CompileTaskWrapper() {
if (task->is_blocking()) {
...
} else {
task->mark_complete();

// By convention, the compiling thread is responsible for deleting
// a non-blocking CompileTask.
delete task;
}

§With -XX:-BackgroundCompilation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void CompileBroker::wait_for_completion(CompileTask* task) {
...
if (free_task) {
assert(task->is_complete(), "Compilation should have completed");
assert(task->next() == nullptr && task->prev() == nullptr,
"Completed task should not be in the queue");

// By convention, the waiter is responsible for deleting a
// blocking CompileTask. Since there is only one waiter ever
// waiting on a CompileTask, we know that no one else will
// be using this CompileTask; we can delete it.
delete 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
2
3
$ javac LoaderTest.java Target.java; java LoaderTest
Loading from: /home/albert/.
✅ ClassLoader unloaded

§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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.io.File;
import java.lang.ref.PhantomReference;
import java.net.URL;
import java.net.URLClassLoader;

public class LoaderTest {
private static final String TARGET_CLASS = "Target";

public static void main(String[] args) throws Exception {
// current dir
File clsDir = new File(".");
System.out.println("Loading from: " + clsDir.getAbsolutePath());

URLClassLoader loader = new URLClassLoader(
new URL[]{ clsDir.toURI().toURL() },
null // critical: no parent
);

// ---- set up PhantomReference ----
PhantomReference<ClassLoader> pr = new PhantomReference<>(loader, null);

Class<?> c = loader.loadClass(TARGET_CLASS);
Object hotObj = c.getDeclaredConstructor().newInstance();

// ---- provoke compilation ----
c.getMethod("entryMethod").invoke(hotObj);
c.getMethod("entryNewMethod").invoke(hotObj);

// ---- drop strong refs ----
hotObj = null;
c = null;
loader = null;

// wait for compiler threads to catch up
Thread.sleep(1000);
System.gc();

if (!pr.refersTo(null)) {
System.err.println("❌ ClassLoader NOT unloaded");
System.exit(-100);
}

System.out.println("✅ ClassLoader unloaded");
}
}

§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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.io.*;
import java.math.*;
import java.util.*;

public class Target {
StringBuffer strBuf;
BigInteger bigInt = BigInteger.ONE;
int iter;

public Target() {
strBuf = new StringBuffer(iter);
this.iter = 100000;
}

public void entryMethod() {
for (int i=0; i<iter; i++)
hotMethod(i);
}

void hotMethod(int i) {
strBuf.append(Integer.toString(i) + " ");
}

public void entryNewMethod() {
BigInteger bigInt2 = new BigInteger("100");

for (int i=0; i<iter; i++)
newHotMethod(bigInt2);
}

void newHotMethod(BigInteger bigInt2) {
bigInt = bigInt.add(bigInt2);
}
}