Adds a Memory tab to the Debug tool window for JVM sessions: loaded classes with instance counts and shallow sizes (with deltas between refreshes), read over the HotSpot attach listener (`GC.class_histogram`), falling back to counting reachable objects through the debugger on JDKs that cannot answer while suspended. While paused, a class's instances, an instance's fields and its referrers are listed through a JDWP link (the Kotlin adapter's path, and a jdtls bundle `java/jvm-inspect` for java-debug). Debug launches started by the IDE get `-XX:+StartAttachListener` unless the user's VM options decide otherwise (not on Windows). New crates: ide-jdwp, ide-jvm-attach, ide-jvm-inspect. The bundled jar rebuilds byte-for-byte from build.py against the pinned JDT LS plugins. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
6.1 KiB
JVM inspection inside java-debug
An OSGi bundle loaded into JDT LS next to microsoft java-debug. While a Java debug session is suspended, it lists the instances of a class, the fields of an object and the objects that refer to it, and counts the instances of every loaded class. It works on java-debug's own JDI connection to the debuggee, so no second debugger connects to the JVM.
Commands
Every command but openidea.jvm.vms takes target first:
{"pid": n|null, "host": s|null, "port": n|null, "generation": n}. A launch session is found by
pid (the process java-debug started); an attach session by the remote port of java-debug's socket,
then by host when several VMs share that port. Two JVMs running the same program have the same
thread ids and names, so threads are never used to tell sessions apart.
| Command | Arguments after target |
ok |
|---|---|---|
openidea.jvm.vms |
none (no target) |
[{pid, remoteHost, remotePort, localPort, version, canGetInstanceInfo, timeout}] |
openidea.jvm.classes |
none | [{name, instances}], classes with no instances left out |
openidea.jvm.instances |
className, limit |
[JvmValue], at most 1000 |
openidea.jvm.fields |
objectId |
[{name, value}]; the first 100 elements, as "[i]", of an array |
openidea.jvm.referrers |
objectId, limit |
[{value, via}], at most 200; via lists the fields or "[i]" slots |
A reply is {"ok": payload} or {"error": code, "detail": text}, code being one of
unsupported, vmNotFound, stale, collected, classNotLoaded, notSuspended, timedOut.
Anything else (a malformed argument, a JDI error the bundle does not expect) is an ordinary
executeCommand error. Values have the JSON shape of ide_jvm_inspect::JvmValue. JDT LS writes
replies with Gson, which leaves out null members: an attached VM has no pid, and a long string
no text.
- Data is read only while some thread of the VM is suspended (
notSuspendedotherwise), one command per VM at a time. - An
objectIdis valid only in thegeneration(the session's stop) it was returned in; any other isstale. Each VM keeps a table of the objects the bundle handed out, emptied when the generation changes or when it grows past 100 000 entries. - Previews follow the JDWP link's rules: arrays give only their length, boxed types the primitive in
their
valuefield, aStringthe length of its backing array (bycoder) and its text only up to 1000 characters. Nothing is invoked: notoString, noinvokeMethod. - A referrer array longer than 10 000 elements is not searched for the slot (
viais empty). - An object collected before its preview is left out of
instancesandreferrers.
classes and referrers can take seconds on a large heap. Eclipse JDI fails a request after
getRequestTimeout() (3000 ms by default), and the timeout is per VM: java-debug's own requests
waiting behind the command time out too. These two commands raise it to at least 60 000 ms and,
when done, put back the old value unless something else changed it meanwhile (java-debug applies its
timeout setting to every VM when it changes).
Internals it depends on
java-debug is not pinned: the app installs the latest vscode-java-debug. The bundle reaches the VMs through these non-API members, by reflection:
com.microsoft.java.debug.plugin.internal.JdtVirtualMachineManagerProvider#getVirtualMachineManager()AdvancedVirtualMachineManager.connectedVMs(a synchronized list)org.eclipse.jdi.internal.VirtualMachineImpl#packetReceiveManager()org.eclipse.jdi.internal.connect.PacketManager.fConnectionorg.eclipse.jdi.internal.connect.SocketConnection.fSocket
and, as API of org.eclipse.jdt.debug, org.eclipse.jdi.VirtualMachine#getRequestTimeout /
#setRequestTimeout and org.eclipse.jdi.TimeoutException. When any of these is missing or
changes shape, every command answers unsupported; nothing throws into java-debug.
Checked with JDT LS 1.63.0.202610191322 (org.eclipse.jdt.debug 3.27.0) and java-debug plugin
0.53.2 (vscode-java-debug 0.60.0).
Rebuild
Build dependency: JDT LS 1.63.0 build 202610191322,
the same as java/java-usages. Check the extracted distribution from its root
(sha256sum -c /path/to/build-dependencies.sha256), then with Java 25 on PATH:
python3 build.py /path/to/jdtls # writes openidea-jvm-inspect.jar
python3 build.py /path/to/jdtls --test # compiles tests/SelfTest.java with the sources and runs it
The build uses the distribution's ECJ and plugin jars plus the jdimodel.jar nested in
plugins/org.eclipse.jdt.debug_*.jar, sorted inputs and fixed zip timestamps; bytecode targets
Java 21. Build twice and compare: two builds of the checked-in jar gave
ab53d0e6b9e492b5c3be1179c793c879db94ba44a421bdbd4f2f33319e03464a openidea-jvm-inspect.jar
Change Bundle-Version whenever the jar changes: OSGi caches bundles by version.
Real JDT LS check
tests/inspect.py starts JDT LS with java-debug and this jar, debugs a small Heap program (a launch
and two attaches to the same program) and prints OK/FAIL/SKIP per check:
PATH=/path/to/jdk25/bin:$PATH \
OPENIDEA_TEST_JAVA=/path/to/jdk11/bin/java:/path/to/jdk21/bin/java \
python3 -I tests/inspect.py /path/to/jdtls /path/to/com.microsoft.java.debug.plugin-0.53.2.jar --heap 10000000
OPENIDEA_TEST_JAVA lists debuggee JDKs (:-separated; ; on Windows). The attaches use the
first; classes is checked on the JDK 11 one. With --heap N, a JDK 21 JVM holds N objects and a
DAP evaluate sent 0.5 s after classes starts must succeed, with the timeout back to 3000 after.
It stops every process it started.