DEV Community

Markus
Markus

Posted on • Originally published at myfear.substack.com on

JVM Inspector with AI: Build a Smart Diagnostic Tool with Quarkus, LangChain4j, and Dev Services

JVM inspector

Most observability tools speak machine. But what if you could ask your system a human question like:

“Show me all running JVMs. Is anything stuck or deadlocked?”

In this tutorial, you’ll build exactly that.

We’ll use:

  • Quarkus for building a fast, developer-friendly backend.

  • LangChain4j to integrate a local Large Language Model (LLM) using the @Tool interface.

  • Dev Services to run Ollama locally without needing Docker commands.

  • A real JVM inspection utility that lists Java processes and performs thread dumps.

This is also a great MCP (Model Context Protocol) use case. MCP allows tools and models to expose structured capabilities. Here, you expose JVM diagnostics as structured tools the model can invoke deterministically. This is the foundation of reliable AI agents.

Step 1: Project Setup

Generate a Quarkus project with the following:

  • Group: org.acme

  • Artifact: jvm-inspector-ai

  • Java: 17+

  • Extensions:

Create it at code.quarkus.io or with the CLI:

quarkus create app org.acme:jvm-inspector-ai --no-code \
  --extension=langchain4j-ollama,rest
cd jvm-inspector-ai
Enter fullscreen mode Exit fullscreen mode

And as usual, you can check out the complete project on my Github repository.

Step 2: Dev Services for Ollama

Quarkus Dev Services automates the startup of local services like Ollama by leveraging Testcontainers behind the scenes. When you include the quarkus-langchain4j-ollama extension in your project and run in development mode (quarkus:dev), Quarkus automatically detects the need for an Ollama instance. It then uses Testcontainers to find a local Podman or Docker installation, pull the required Ollama image, and start a container. It also injects the container's dynamic address and port into your application's configuration, so it connects seamlessly without any manual setup from you. This creates a "zero-config" experience, but it does rely on having a container engine installed on your machine.

In src/main/resources/application.properties:

quarkus.langchain4j.ollama.chat-model.model-id=qwen3:latest
quarkus.langchain4j.ollama.timeout=120s
Enter fullscreen mode Exit fullscreen mode

Qwen is a strong choice for building local AI agents, especially when tool integration and performance matter. The models use a mixture-of-experts (MoE) architecture, so you get large-model quality with a much smaller compute footprint

Step 3: JVM Tools Using @Tool

Let’s create a JvmTools class. The JvmTools class is a utility class that provides JVM monitoring and diagnostic capabilities through LangChain4J tool annotations.

package org.acme;

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.List;
import java.util.stream.Collectors;

import dev.langchain4j.agent.tool.Tool;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class JvmTools {

    record JvmProcess(String processId, String displayName) {
    }

    @Tool("Displays all the JVM processes available on the current host")
    public List<JvmProcess> getJvmProcesses() {
        return ProcessHandle.allProcesses()
                .filter(p -> p.info().command().map(cmd -> cmd.contains("java")).orElse(false))
                .map(p -> new JvmProcess(String.valueOf(p.pid()),
                        p.info().commandLine().orElse("unknown")))
                .collect(Collectors.toList());
    }

    @Tool("Performs a thread dump on a specific JVM process")
    public String threadDump(String processId) {
        long currentPid = ProcessHandle.current().pid();
        if (!String.valueOf(currentPid).equals(processId)) {
            return "Only the current process can be inspected in this example.";
        }

        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        StringBuilder dump = new StringBuilder();
        for (ThreadInfo ti : bean.dumpAllThreads(true, true)) {
            dump.append(ti.toString());
        }
        return dump.toString();
    }
}
Enter fullscreen mode Exit fullscreen mode

It offers two main functions:

  1. getJvmProcesses() - Discovers and lists all Java processes running on the current host, returning their process IDs and command line information as JvmProcess records.

  2. threadDump(String processId) - Generates a thread dump for a specified JVM process. However, it's currently limited to only inspect the current process (for security/permission reasons in this example implementation).

Step 4: Simulated JVM Workloads

Create two classes: one for a long sleep thread, and one that simulates a deadlock. These give our thread dump something meaningful to show.

package org.acme;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.runtime.Startup;

@ApplicationScoped
@Startup
public class LongRunningTask {
    @PostConstruct
    void run() {
        new Thread(() -> {
            try {
                Thread.sleep(300_000);
            } catch (InterruptedException ignored) {}
        }, "sleeping-thread").start();
    }
}

package org.acme;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.runtime.Startup;

@ApplicationScoped
@Startup
public class DeadlockSimulator {

    private final Object a = new Object();
    private final Object b = new Object();

    @PostConstruct
    void init() {
        new Thread(() -> {
            synchronized (a) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                }
                synchronized (b) {
                }
            }
        }, "deadlock-1").start();

        new Thread(() -> {
            synchronized (b) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                }
                synchronized (a) {
                }
            }
        }, "deadlock-2").start();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: AI Service with LangChain4j

The JvmInspector interface defines an AI service for JVM inspection and diagnostics.

package org.acme;

import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.Tools;
import dev.langchain4j.service.UserMessage;
import io.quarkus.langchain4j.RegisterAiService;

@RegisterAiService(tools = JvmTools.class)
public interface JvmInspector {

    @SystemMessage("""
        You are an AI JVM inspector. You can list JVMs and run thread dumps.
        Use the tools provided. Never fabricate PIDs or results.
        """)
    String chat(@UserMessage String userMessage);
}

Enter fullscreen mode Exit fullscreen mode

Here's what it does:

  1. AI Service Registration - The @RegisterAiService(tools = JvmTools.class) annotation registers this as a LangChain4J AI service that has access to the JVM inspection tools from the JvmTools class.

  2. System Instructions - The @SystemMessage provides the AI with specific instructions about its role as a "JVM inspector" that can list JVMs and run thread dumps, with explicit guidance to use the provided tools and never fabricate results.

  3. Chat Interface - The chat() method provides a conversational interface where users can send natural language messages about JVM inspection tasks, and the AI will respond using the available JVM tools.

Essentially, this creates an AI-powered chatbot that can understand natural language requests about JVM monitoring (like "show me all Java processes" or "give me a thread dump of process 1234") and automatically use the appropriate tools from JvmTools to fulfill those requests. It's a conversational wrapper around the JVM diagnostic capabilities.

Step 6: REST Endpoint

The REST endpoint finally glues all of it together and let’s us talk with the llm.

package org.acme;

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;

@Path("/inspect")
public class InspectorResource {

    @Inject
    JvmInspector ai;

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String inspect(@QueryParam("query") String query) {
        return ai.chat(query);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Run and Inspect

Start Quarkus in dev mode:

quarkus dev
Enter fullscreen mode Exit fullscreen mode

Then try:

curl "http://localhost:8080/inspect?query=list+running+java+processes"
Enter fullscreen mode Exit fullscreen mode

Potential output:

I found the following JVMs:
- PID: 12345, displayName: java -jar myapp.jar
- PID: 56789, displayName: io.quarkus.runner.GeneratedMain
Enter fullscreen mode Exit fullscreen mode

Then:

curl "http://localhost:8080/inspect?query=thread+dump+process+56789"
Enter fullscreen mode Exit fullscreen mode

And the LLM will invoke the threadDump tool, showing the full state of all threads, including the ones we intentionally deadlocked.

### **1. Deadlock Between Threads**
**Thread 1 (`deadlock-1`)**:
- **Blocked on** : `java.lang.Object@639e8881` (owned by `deadlock-2`)
- **Locked** : `java.lang.Object@6ad7c7f`

**Thread 2 (`deadlock-2`)**:
- **Blocked on** : `java.lang.Object@6ad7c7f` (owned by `deadlock-1`)
- **Locked** : `java.lang.Object@639e8881`
Enter fullscreen mode Exit fullscreen mode

Why This Would be a Great MCP Use Case

This example shows Tool calling for the LLM. But it is a generic use-case for all JVMs and an ideal candidate to be implemented via the Model Context Protocol (MCP). With MCP, tools like getJvmProcesses and threadDump can be self-described , composable , and deterministically invoked by the model. And more importantly, they can be re-used. Learn more about how to create Quarkus MCP server.

What You’ve Built

You now have a fully working AI-powered JVM inspector built in Quarkus. It uses Dev Services to run an LLM, exposes real diagnostics as tools, and wraps the whole thing in a simple REST interface.

From here, you can:

  • Extend it to use jstack via ProcessBuilder to inspect other JVMs.

  • Add memory metrics, GC logs, or flight recorder dumps.

  • Deploy it into a Kubernetes-native observability dashboard.

  • Create an MCP Server from it.

You’re no longer just logging observability metrics — you’re conversing with your infrastructure.

Top comments (0)