OpenJDK Trivia: Class Unloading and JDWP
§1. Introduction to JDWP
The Java Debug Wire Protocol (JDWP) enables remote debugging of Java applications by allowing debuggers to connect to a running JVM and inspect its state. However, JDWP brings subtle implications for memory management, particularly around class lifecycle events like ClassPrepare, which can have unexpected consequences for garbage collection and class unloading.
To understand how this works, it’s helpful to know the key components:
- JDWP Agent: A native library running inside the debugged JVM that communicates with the debugger over a socket. It hooks into JVMTI (Java Virtual Machine Tool Interface) to receive VM events.
- JVMTI (Java Virtual Machine Tool Interface): The low-level interface that allows agents to monitor and interact with various JVM events, like class loading and method entry.
- JDI (Java Debug Interface): The client-side Java API that debuggers use to communicate with the JDWP agent and interact with the debugged VM.
The architecture is thus: JVM → JVMTI → JDWP Agent (native) ↔ JDWP Protocol ↔ JDI Client (debugger). When events occur in the JVM, they flow from JVMTI through the agent to the debugger client.
1 | ┌─────────────────────────────────────────────────────────────────┐ |
§2. The Class Preparing Event
When a class reaches the prepared phase of initialization, JVMTI fires a ClassPrepare event. The JDWP agent listens for this event so it can notify the attached debugger.
§2.1 Callback Registration
The JDWP agent registers its ClassPrepare callback during initialization, in classTrack_initialize (src/jdk.jdwp.agent/share/native/libjdwp/classTrack.c):
1 | /* |
This tells JVMTI to invoke the registered callback whenever a class is prepared.
§2.2 Callback Firing
On the HotSpot side, JvmtiExport::post_class_prepare (src/hotspot/share/prims/jvmtiExport.cpp) iterates over active JVMTI environments and invokes their registered ClassPrepare callbacks:
1 | void JvmtiExport::post_class_prepare(JavaThread *thread, Klass* klass) { |
§2.3 Callback Implementation
The JDWP agent’s callback is cbClassPrepare in src/jdk.jdwp.agent/share/native/libjdwp/eventHandler.c:
1 | /* Event callback for JVMTI_EVENT_CLASS_PREPARE */ |
event_callback() calls into eventHelper_recordEvent, which converts the local jclass reference into a JNI global reference via saveGlobalRef (NewGlobalRef) so the class remains reachable beyond this callback. The event command is then enqueued for asynchronous delivery.
The agent thread runs commandLoop (src/jdk.jdwp.agent/share/native/libjdwp/eventHelper.c), which dequeues and processes these commands:
1 | /* |
When processing a command, handleCommand calls handleEventCommandSingle, which writes the event to a JDWP packet and then calls tossGlobalRef (DeleteGlobalRef) to release the reference.
§3. The Problem: Global Reference Management
As described above, processing a ClassPrepare event involves two steps on two different threads: the application thread creates a JNI global reference to the class (so it remains live across the asynchronous handoff), and the agent thread deletes that reference after sending the event to the debugger.
The cleanup—tossGlobalRef (DeleteGlobalRef)—only happens when the agent thread dequeues and processes the command. Until then, the global reference keeps the class alive.
§3.1 The Race Condition
This creates a window between global ref creation and deletion:
- Application thread calls
saveEventInfoRefs→saveGlobalRef→NewGlobalRef, and enqueues the event command. - Agent thread dequeues the command, sends the JDWP packet, and calls
tossEventInfoRefs→tossGlobalRef→DeleteGlobalRef.
If the application drops all references to a class and triggers System.gc() while the event command is still in the queue, the global reference acts as a GC root, preventing the class (and its ClassLoader) from being collected.
On a loaded system, or if the agent thread is slow to drain the queue, this window can be significant, causing class unloading failure.
§4. The Issue in Action
Consider a scenario where you dynamically load a class via a custom URLClassLoader, use it, and then attempt to unload it by closing the loader and triggering garbage collection. You might expect the class to be collected when there are no more references to it. However, due to the presence of the JDWP agent, the JNI global reference created while emitting the ClassPrepare event may still be live, preventing the class from being unloaded.
Here’s a concrete example that demonstrates this:
1 | import java.io.*; |
§4.1 How the Example Works
The code example consists of two parts: a Debuggee (the application being debugged) and a Debugger (the debugger client).
The Debuggee:
- Listens for commands on a socket
- When it receives a
LOADcommand, it loads classGinto a customURLClassLoader - It stores a
PhantomReferenceto the loader (useful for detecting when the loader has been garbage collected) - When it receives an
UNLOADcommand, it closes the loader, nullifies theloadervariable, and callsSystem.gc() - It then checks if the loader was garbage collected using the phantom reference
The Debugger:
- Attaches to the debuggee using the JDWP Socket Attach connector
- This causes JDWP events to be monitored, triggering
ClassPrepareevents whenever new classes are loaded - It then sends 100 load/unload cycles to stress-test the class unloading process
§4.2 Running the Example
Running it with:
1 | javac test_jdwp.java; |
From time to time, the unloading will fail, especially when there are heavy background tasks on the same system. (Alternatively, one can place an artificial sleep(1) inside commandLoop to induce delays in the agent thread. That way, this unloading failure becomes almost always reproducible.)