This article outlines the development of a lightweight, non-intrusive Java diagnostic tool similar to Arthas. It is packaged as a standalone JAR that can be attached to any running JVM to perform runtime inspections. The core features include:
- Inspecting heap and non-heap memory usage
- Checking direct buffer allocation
- Capturing heap dumps
- Dumping thread stack traces
- Listing loaded ClassLoaders
- Decompiling and printing class source code
Attaching to a Target JVM
The client component leverages the Attach API to connect to a running Java process. It enumerates active JVMs using the jps command, prompts the user for a PID, and dynamically loads the agent JAR.
public class DiagnosticClient {
public static void main(String[] args) throws Exception {
// List active Java processes
Process jpsProcess = Runtime.getRuntime().exec("jps");
BufferedReader reader = new BufferedReader(
new InputStreamReader(jpsProcess.getInputStream()));
reader.lines().forEach(System.out::println);
reader.close();
// Prompt user for target PID
System.out.print("Enter PID: ");
String targetPid = new Scanner(System.in).next();
// Attach to the target virtual machine
VirtualMachine vm = VirtualMachine.attach(targetPid);
// Load the agent which triggers agentmain
vm.loadAgent("/path/to/diagnostic-agent.jar");
}
}
Leveraging JMX for Runtime Metrics
Since Java 1.5, the Java Management Extensions (JMX) framework provides a standardized way to monitor and manage applications. The JVM inherently populates various MXBean objects with runtime metrics such as memory consumption, thread states, and class metadata. Tools like VisualVM rely on JMX to expose these metrics remotely. In our agent, we directly invoke the JMX APIs locally to extract the required information.
Inspecting Heap and Non-Heap Memory
We can retrieve memory pool details via ManagementFactory.getMemoryPoolMXBeans() and categorize them into HEAP and NON_HEAP regions.
public class MemoryInspector {
public static void displayMemoryMetrics() {
List<MemoryPoolMXBean> pools = ManagementFactory.getMemoryPoolMXBeans();
System.out.println("=== HEAP MEMORY ===");
filterAndPrint(pools, MemoryType.HEAP);
System.out.println("=== NON-HEAP MEMORY ===");
filterAndPrint(pools, MemoryType.NON_HEAP);
System.out.println("=== DIRECT BUFFER MEMORY ===");
displayDirectBufferMetrics();
}
private static void filterAndPrint(List<MemoryPoolMXBean> pools, MemoryType type) {
pools.stream()
.filter(pool -> pool.getType().equals(type))
.forEach(pool -> {
MemoryUsage usage = pool.getUsage();
System.out.printf("Pool: %s | Used: %dMB | Committed: %dMB | Max: %dMB%n",
pool.getName(),
usage.getUsed() / (1024 * 1024),
usage.getCommitted() / (1024 * 1024),
usage.getMax() / (1024 * 1024));
});
}
}
Inspecting Direct Buffer Memory
NIO direct memory and mapped buffers are tracked by BufferPoolMXBean. Because this MXBean was introduced later, we load it dynamically using ManagementFactory.getPlatformMXBeans().
public static void displayDirectBufferMetrics() {
try {
Class<?> bufferPoolClass = Class.forName("java.lang.management.BufferPoolMXBean");
List<BufferPoolMXBean> bufferPools = ManagementFactory.getPlatformMXBeans(bufferPoolClass);
for (BufferPoolMXBean pool : bufferPools) {
System.out.printf("Buffer: %s | Used: %dMB | Total Capacity: %dMB%n",
pool.getName(),
pool.getMemoryUsed() / (1024 * 1024),
pool.getTotalCapacity() / (1024 * 1024));
}
} catch (ClassNotFoundException e) {
System.err.println("BufferPoolMXBean unavailable: " + e.getMessage());
}
}
Generating Heap Dumps
The HotSpotDiagnosticMXBean allows triggering a heap dump programmatically. Passing true as the second argument to dumpHeap ensures only live objects are included.
public static void captureHeapDump() {
HotSpotDiagnosticMXBean diagnosticBean = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
String timestamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date());
String dumpFilePath = timestamp + "-dump.hprof";
try {
diagnosticBean.dumpHeap(dumpFilePath, true);
System.out.println("Heap dump saved to: " + dumpFilePath);
} catch (IOException e) {
System.err.println("Failed to generate heap dump: " + e.getMessage());
}
}
Dumping Thread Stack Traces
Thread metadata and stack trace are accessible through the ThreadMXBean. The dumpAllThreads method requires two boolean flags indicating whether the JVM supports object monitor and synchronizer usage tracking.
public class ThreadInspector {
public static void dumpThreadStacks() {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
boolean monitorSupported = threadBean.isObjectMonitorUsageSupported();
boolean synchronizerSupported = threadBean.isSynchronizerUsageSupported();
ThreadInfo[] threads = threadBean.dumpAllThreads(monitorSupported, synchronizerSupported);
for (ThreadInfo info : threads) {
System.out.printf("Thread: %s (ID: %d) State: %s%n",
info.getThreadName(),
info.getThreadId(),
info.getThreadState());
for (StackTraceElement element : info.getStackTrace()) {
System.out.println(" at " + element);
}
}
}
}
Listing Loaded ClassLoaders
The Instrumentation interface provided to the agent supplies methods to query all loaded classes. By iterating over these classes and extracting their ClassLoaders, we can build a distinct list. The Bootstrap ClassLoader is represented as null in Java and requires special handling.
public class ClassInspector {
public static void listClassLoaders(Instrumentation instrumentation) {
Class<?>[] loadedClasses = instrumentation.getAllLoadedClasses();
Set<String> loaderNames = Arrays.stream(loadedClasses)
.map(clazz -> {
ClassLoader loader = clazz.getClassLoader();
return loader == null ? "BootstrapClassLoader" : loader.getName();
})
.filter(Objects::nonNull)
.collect(Collectors.toCollection(TreeSet::new));
System.out.println("Active ClassLoaders: " + String.join(", ", loaderNames));
}
}
Decompiling Class Source Code
To view the source code of a loaded class, we first retrieve its bytecode using a ClassFileTransformer, then pass the bytecode to the jd-core decompiler library.
Maven dependency for jd-core:
<dependency>
<groupId>org.jd</groupId>
<artifactId>jd-core</artifactId>
<version>1.1.3</version>
</dependency>
Implementation logic:
public static void decompileClass(Instrumentation instrumentation) throws UnmodifiableClassException {
System.out.print("Enter fully qualified class name: ");
String targetClassName = new Scanner(System.in).nextLine();
Optional<Class<?>> targetClass = Arrays.stream(instrumentation.getAllLoadedClasses())
.filter(c -> c.getName().equals(targetClassName))
.findFirst();
if (targetClass.isEmpty()) {
System.out.println("Class not found in loaded classes.");
return;
}
ClassFileTransformer bytecodeExtractor = new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String internalName, Class<?> classBeingRedefined,
ProtectionDomain domain, byte[] classfileBuffer) {
if (internalName.replace('/', '.').equals(targetClassName)) {
renderSourceCode(classfileBuffer, internalName);
}
return null; // Do not modify the bytecode
}
};
try {
instrumentation.addTransformer(bytecodeExtractor, true);
instrumentation.retransformClasses(targetClass.get());
} finally {
instrumentation.removeTransformer(bytecodeExtractor);
}
}
private static void renderSourceCode(byte[] bytecode, String internalName) {
Loader bytecodeLoader = new Loader() {
@Override
public byte[] load(String name) { return bytecode; }
@Override
public boolean canLoad(String name) { return true; }
};
StringBuilder sourceBuilder = new StringBuilder();
Printer sourcePrinter = new Printer() {
private static final String INDENT = " ";
private static final String LINE_BREAK = "\n";
private int depth = 0;
@Override public String toString() { return sourceBuilder.toString(); }
@Override public void start(int maxLineNumber, int majorVersion, int minorVersion) {}
@Override public void end() { System.out.println(sourceBuilder); }
@Override public void printText(String text) { sourceBuilder.append(text); }
@Override public void printNumericConstant(String value) { sourceBuilder.append(value); }
@Override public void printStringConstant(String value, String owner) { sourceBuilder.append(value); }
@Override public void printKeyword(String keyword) { sourceBuilder.append(keyword); }
@Override public void printDeclaration(int type, String internalTypeName, String name, String descriptor) { sourceBuilder.append(name); }
@Override public void printReference(int type, String internalTypeName, String name, String descriptor, String owner) { sourceBuilder.append(name); }
@Override public void indent() { depth++; }
@Override public void unindent() { depth--; }
@Override public void startLine(int lineNumber) { sourceBuilder.append(INDENT.repeat(Math.max(0, depth))); }
@Override public void endLine() { sourceBuilder.append(LINE_BREAK); }
@Override public void extraLine(int count) { sourceBuilder.append(LINE_BREAK.repeat(count)); }
@Override public void startMarker(int type) {}
@Override public void endMarker(int type) {}
};
try {
new ClassFileToJavaSourceDecompiler().decompile(bytecodeLoader, sourcePrinter, internalName);
} catch (Exception e) {
System.err.println("Decompilation failed: " + e.getMessage());
}
}
Packaging the Complete Tool
All features are orchestrated within the agentmain method using a interactive console menu. To package the agent as a fat JAR with a specified main class, use the maven-shade-plugin.
public class AgentEntry {
public static void agentmain(String args, Instrumentation inst) {
Scanner inputReader = new Scanner(System.in);
while (true) {
System.out.println("\n--- Diagnostic Menu ---");
System.out.println("1. Display Memory Metrics");
System.out.println("2. Capture Heap Dump");
System.out.println("3. Dump Thread Stacks");
System.out.println("4. List ClassLoaders");
System.out.println("5. Decompile Class");
System.out.println("6. Exit");
String selection = inputReader.nextLine();
switch (selection) {
case "1" -> MemoryInspector.displayMemoryMetrics();
case "2" -> MemoryInspector.captureHeapDump();
case "3" -> ThreadInspector.dumpThreadStacks();
case "4" -> ClassInspector.listClassLoaders(inst);
case "5" -> {
try { ClassInspector.decompileClass(inst); }
catch (Exception e) { e.printStackTrace(); }
}
case "6" -> { return; }
default -> System.out.println("Invalid selection.");
}
}
}
}
Maven Shade plugin configuration:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>diagnostic-agent</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.DiagnosticClient</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>