jcmd: the commands to know in production

A billing service runs in a container, with the eclipse-temurin:25-jdk image. Since this morning, its latency has been rising and its heap keeps growing. No profiler is installed in the image. We open a shell in the container. The JDK already contains the tool we need: jcmd.

$ jcmd
1 Billing
40 jdk.jcmd/sun.tools.jcmd.JCmd

Without an argument, jcmd lists the JVMs of the current user, with their pid and their main class. In a container, the JVM is often process 1. The second line is jcmd itself, which is also a JVM.

This article presents the jcmd commands that help on call, with their real output and their cost for the application. The tests were run with OpenJDK 25.0.4.1 (Corretto) on a Mac. The container tests ran in eclipse-temurin:25-jdk (Temurin 25.0.4.1+1). The test application, Billing, reproduces three problems. A cache never empties, eight threads compete for one lock, and fifty virtual threads wait.

How jcmd works

jcmd sends a diagnostic command to a running JVM, then prints the answer. The JVM runs the command itself, in a dedicated thread, the Attach Listener. There is nothing to install and no need to restart the JVM.

The general form is jcmd <pid> <command> [options]. The pid can be replaced by the name of the main class:

$ jcmd Billing VM.uptime
42500:
237.640 s
41959:
433.878 s

With a class name, the command runs on every JVM that has this name. Here, two instances of Billing were running, and both answered. On a machine that hosts several instances of the same service, use the pid.

jcmd accepts one command per call. jcmd <pid> VM.uptime VM.version answers Unknown argument 'VM.version' in diagnostic command. To run several commands in a row, write them in a file, one per line, and run jcmd <pid> -f commands.txt.

The list of commands comes from the JVM itself:

$ jcmd 41959 help
41959:
The following commands are available:
AOT.end_recording
Compiler.CodeHeap_Analytics
Compiler.codecache
...
GC.class_histogram
GC.finalizer_info
GC.heap_dump
GC.heap_info
GC.run
...
JFR.view
JVMTI.agent_load
...
VM.set_flag
VM.stringtable
VM.symboltable
VM.system_properties
VM.systemdictionary
VM.uptime
VM.version
help

On JDK 25, there are 54 of them on macOS and 57 on Linux, not counting help. Linux adds Compiler.perfmap, System.native_heap_info and System.trim_native_heap. Each command has a detailed help text, with an Impact line that says what it costs the application:

$ jcmd 41959 help GC.heap_dump
41959:
GC.heap_dump
Generate a HPROF format dump of the Java heap.

Impact: High: Depends on Java heap size and content. Request a full GC unless the '-all' option is specified.

Syntax : GC.heap_dump [options] <filename>

Arguments:
	filename :  Name of the dump file (FILE, no default value)

Options: (options must be specified using the <key> or <key>=<value> syntax)
	-all : [optional] Dump all objects, including unreachable objects (BOOLEAN, false)
	-gz : [optional] If specified, the heap dump is written in gzipped format using the given compression level. 1 (recommended) is the fastest, 9 the strongest compression. (INT, 1)
	-overwrite : [optional] If specified, the dump file will be overwritten if it exists (BOOLEAN, false)
	-parallel : [optional] Number of parallel threads to use for heap dump. The VM will try to use the specified number of threads, but might use fewer. (INT, 1)

Read the Impact line before running a command on a JVM under load.

Knowing what really runs

Four commands give the starting point: the version, the uptime, the command line and the flags.

$ jcmd 41959 VM.version
41959:
OpenJDK 64-Bit Server VM version 25.0.4.1+10-LTS
JDK 25.0.4.1

$ jcmd 41959 VM.uptime
41959:
433.878 s

$ jcmd 41959 VM.command_line
41959:
VM Arguments:
jvm_args: -Xmx1g -XX:+UseG1GC -Xlog:safepoint:file=/tmp/safepoint.log:uptime -Xlog:gc:file=/tmp/gc.log:uptime
java_command: Billing
java_class_path (initial): classes
Launcher Type: SUN_STANDARD

VM.command_line shows the arguments the JVM really received. The options coming from the JAVA_TOOL_OPTIONS variable appear there too, at the start of jvm_args. This helps when a startup script, an environment variable and a Docker image each add their own options.

VM.flags prints the flags whose value is not the default, including the ones the JVM chose itself:

$ jcmd 41959 VM.flags
41959:
-XX:CICompilerCount=4 -XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=9 ... -XX:InitialHeapSize=268435456 ... -XX:MaxHeapSize=1073741824 -XX:MaxNewSize=643825664 ... -XX:+UseCompressedOops -XX:+UseG1GC ...

It shows the real heap size, the number of GC threads and the number of compiler threads. Nobody wrote ConcGCThreads=2 on the command line: the JVM computed it from the visible CPUs. In a container, the CPU limit drives this computation.

VM.flags -all lists every flag, 505 on this JDK 25, with its value and its origin. A filter on manageable gives the ones that can be changed without a restart:

$ jcmd 41959 VM.flags -all | grep manageable
     uint FullGCHeapDumpLimit                      = 0                                      {manageable} {default}
    uintx G1PeriodicGCInterval                     = 0                                      {manageable} {default}
   double G1PeriodicGCSystemLoadThreshold          = 0.000000                               {manageable} {default}
     bool HeapDumpAfterFullGC                      = false                                  {manageable} {default}
     bool HeapDumpBeforeFullGC                     = false                                  {manageable} {default}
      int HeapDumpGzipLevel                        = 0                                      {manageable} {default}
     bool HeapDumpOnOutOfMemoryError               = false                                  {manageable} {default}
    ccstr HeapDumpPath                             =                                        {manageable} {default}
    uintx MaxHeapFreeRatio                         = 70                                     {manageable} {default}
    uintx MinHeapFreeRatio                         = 40                                     {manageable} {default}
     bool PrintClassHistogram                      = false                                  {manageable} {default}
     bool PrintConcurrentLocks                     = false                                  {manageable} {default}
     bool ShowCodeDetailsInExceptionMessages       = true                                   {manageable} {default}
   size_t SoftMaxHeapSize                          = 1073741824                             {manageable} {ergonomic}

There are fourteen of them, and six concern heap dumps. The last column says where the value comes from, for example default, command line, or ergonomic when the JVM computed it. After a VM.set_flag, it shows management.

Changing a flag without a restart

The service was not started with -XX:+HeapDumpOnOutOfMemoryError. If the heap overflows tonight, there will be nothing to analyze. VM.set_flag fixes this on the running JVM:

$ jcmd 41959 VM.set_flag HeapDumpOnOutOfMemoryError true
41959:
Command executed successfully

$ jcmd 41959 VM.set_flag HeapDumpPath /var/dumps
41959:
Command executed successfully

$ jcmd 41959 VM.flags | tr ' ' '\n' | grep HeapDump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/dumps

Only manageable flags accept this change. For the others, the answer is short:

$ jcmd 41959 VM.set_flag MaxHeapSize 2g
41959:
only 'writeable' flags can be set

The heap size and the GC choice are fixed at startup. A change made with VM.set_flag does not survive a restart. Write it down in the incident ticket, then add the flag to the configuration.

Turning on a GC log at runtime

We want to see the GC pauses in a separate file, without restarting the service. VM.log adds an output to the JVM logging system:

$ jcmd 41959 VM.log output=file=/tmp/gc-live.log what=gc decorators=uptime
41959:
Command executed successfully

$ head -3 /tmp/gc-live.log
[43.331s] GC(43) Pause Young (Normal) (G1 Evacuation Pause) 65M->20M(77M) 0.387ms
[43.636s] GC(44) Pause Young (Normal) (G1 Evacuation Pause) 65M->20M(77M) 0.405ms
[43.926s] GC(45) Pause Young (Normal) (G1 Evacuation Pause) 65M->20M(77M) 0.857ms

The lines arrive right away, in the usual format of the GC logs. what accepts the same tags as -Xlog. VM.log list prints the full list of tags, then the current outputs, numbered. Here, outputs 0 and 1 are stdout and stderr, and outputs 2 and 3 come from the command line. The new output received number 4. To stop it:

$ jcmd 41959 VM.log output=#4 what=all=off
41959:
Command executed successfully

After this command, VM.log list no longer shows output 4. The file stays on disk. The help text of the command announces Impact: Low: No impact.

Threads and locks

The symptom is rising latency. We look at the threads first. Thread.print gives the same result as jstack, and the -l option adds the java.util.concurrent locks:

$ jcmd 41959 Thread.print -l
41959:
2026-10-20 15:33:48
Full thread dump OpenJDK 64-Bit Server VM (25.0.4.1+10-LTS mixed mode, sharing):
...
"invoice-worker-1" #27 [41731] daemon prio=5 os_prio=31 cpu=49682.82ms elapsed=403.22s tid=0x000000093cd79800 nid=41731 runnable  [0x00000001720e2000]
   java.lang.Thread.State: RUNNABLE
	at Billing.lambda$main$1(Billing.java:44)
	- locked <0x0000000440000708> (a java.lang.Object)
	...

"invoice-worker-2" #28 [41219] daemon prio=5 os_prio=31 cpu=49654.15ms elapsed=403.22s tid=0x000000093cd7a000 nid=41219 waiting for monitor entry  [0x00000001722ee000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at Billing.lambda$main$1(Billing.java:41)
	- waiting to lock <0x0000000440000708> (a java.lang.Object)
	...

One thread holds the lock 0x0000000440000708 and computes. The seven other invoice-worker threads wait for this same lock, in the BLOCKED state. The cpu= field confirms this finding: each of the eight threads consumed about 50 s of CPU in 403 s. Together, the eight threads use only one CPU.

A single dump proves nothing. Take three, a few seconds apart. If the same lock blocks the same threads every time, the contention is confirmed.

Waiting virtual threads do not appear in Thread.print. The application has fifty of them, and the dump above shows none. Thread.dump_to_file includes them, and its JSON format groups them by executor:

$ jcmd 41959 Thread.dump_to_file -format=json -overwrite /tmp/threads.json
41959:
Created /tmp/threads.json

$ jq -r '.threadDump.threadContainers[] | "\(.container): \(.threads | length)"' /tmp/threads.json
<root>: 20
java.util.concurrent.ThreadPoolExecutor@6f94fa3e: 0
java.util.concurrent.ThreadPerTaskExecutor@35f021a2: 50
ForkJoinPool.commonPool/jdk.internal.vm.SharedThreadContainer@8dcaabe: 0
ForkJoinPool-1/jdk.internal.vm.SharedThreadContainer@3c157736: 1

The fifty virtual threads are in the ThreadPerTaskExecutor. Each entry contains "virtual": true, its state and its stack. Here, they are all in TIMED_WAITING inside LinkedBlockingQueue.poll. Two other commands, Thread.vthread_scheduler and Thread.vthread_pollers, describe the scheduler and the network polling threads:

$ jcmd 41959 Thread.vthread_scheduler
41959:
java.util.concurrent.ForkJoinPool@48f602f3[Running, parallelism = 10, size = 0, active = 0, running = 0, steals = 60, tasks = 0, submissions = 0, delayed = 50]

parallelism = 10 is the maximum number of carriers, one per CPU. size = 0: no carrier exists at the moment. delayed = 50: fifty timed waits. These are the fifty poll calls with a timeout. For a detailed reading of a dump that mixes platform threads and virtual threads, see virtual threads in production.

The content of the heap, with or without a dump

Three commands cover the heap. Their cost increases from one to the next.

$ jcmd 41959 GC.heap_info
41959:
garbage-first heap   total reserved 1048576K, committed 263168K, used 224410K [0x0000000440000000, 0x0000000480000000)
 region size 1024K, 70 young (71680K), 4 survivors (4096K)

The JVM reserves 1 GB, that is the -Xmx1g. It asked the system for 257 MB, the committed value. It uses 219 MB, and this figure includes the dead objects the GC has not freed yet. This command causes no pause.

GC.class_histogram counts live objects per class:

$ jcmd 41959 GC.class_histogram | head -6
41959:
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:        165364      148930832  [B (java.base@25.0.4.1)
   2:         18748        3584704  [Ljdk.internal.vm.FillerElement; (java.base@25.0.4.1)
   3:         74953        2398496  java.util.concurrent.ConcurrentHashMap$Node (java.base@25.0.4.1)

We see 142 MB of byte arrays and 74,953 ConcurrentHashMap nodes. The number of nodes matches the cache: a little under 200 entries per second for 400 s. Each entry contains a 2 KB array, which gives the order of magnitude of the 142 MB. We find the cause without a heap dump.

To count only live objects, the command first triggers a Full GC. The GC log shows it: Pause Full (Heap Inspection Initiated GC) 154M->153M(259M) 7.958ms. The -all option counts everything, including dead objects, and avoids this Full GC.

When the histogram is not enough, the full dump is needed:

$ jcmd 41959 GC.heap_dump -gz=1 /tmp/billing.hprof.gz
41959:
Dumping heap to /tmp/billing.hprof.gz ...
Heap dump file created [8275050 bytes in 0.098 secs]

The compressed file is 7.9 MB for 153 MB of live objects. -gz=1 compresses while writing, which matters when the container disk is small. In a container, write the file to /tmp, then fetch it with docker cp or kubectl cp. Reading the file is the subject of diagnosing a memory leak with a heap dump.

The commands in the table below stop the application while they run. The durations come from -Xlog:safepoint, on this heap of 219 MB used and 153 MB live, with 42 threads in the dump:

CommandApplication pauseNote
Thread.print -l17.7 ms
GC.class_histogram20.2 msincluding a Full GC of 8.0 ms
GC.heap_dump -gz=196.5 msincluding a Full GC of 8.6 ms, 7.9 MB file
GC.heap_dump -all -gz=1110.7 msno Full GC, 10.7 MB file

On a heap of several GB, the dump takes seconds, and every thread is stopped during that time. Take it on an instance removed from the load balancer when you can.

GC.run forces a Full GC. On the same JVM, earlier, at 202 s of uptime: Pause Full (Diagnostic Command) 97M->77M(132M) 7.624ms. This tells you whether the used memory is live or dead, before you talk about a leak.

Native memory

The RSS of the container exceeds the heap. VM.native_memory breaks native memory down by use, on one condition:

$ jcmd 41959 VM.native_memory summary
41959:
Native memory tracking is not enabled

Tracking must be turned on at startup, with -XX:NativeMemoryTracking=summary. Without this flag, the command cannot say anything. On a JVM started with the flag, the output begins like this:

$ jcmd 42500 VM.native_memory summary
42500:

Native Memory Tracking:

(Omitting categories weighting less than 1KB)

Total: reserved=2662507KB, committed=413403KB
       malloc: 88043KB #12010, peak=91207KB #12037
       mmap:   reserved=2574464KB, committed=325360KB

-                 Java Heap (reserved=1048576KB, committed=264192KB)
                            (mmap: reserved=1048576KB, committed=264192KB, at peak)

-                     Class (reserved=1048674KB, committed=226KB)
                            (classes #901)
...
-                    Thread (reserved=92303KB, committed=719KB)
                            (threads #45)
                            (stack: reserved=92160KB, committed=576KB, peak=576KB)
...
-                      Code (reserved=249828KB, committed=7812KB)
...
-                        GC (reserved=72515KB, committed=57219KB)

The committed column is the one that counts. Here, the heap takes 258 MB, the GC 56 MB for its structures, the compiled code 8 MB. The 45 threads reserve 90 MB of stacks but use only 576 KB of it.

To see what grows, take a reference point, wait, then ask for the difference:

$ jcmd 42500 VM.native_memory baseline
42500:
Baseline taken

$ jcmd 42500 VM.native_memory summary.diff
42500:
...
Total: reserved=2642000KB -20506KB, committed=413456KB +54KB
...
-                    Thread (reserved=71789KB -20515KB, committed=765KB +45KB)
                            (threads #35 -10)

Each line gives the delta since the reference point. Here, ten threads stopped and gave back 20 MB of reserved memory. A category that rises at every measurement points to the suspect. The full analysis is in JVM off-heap memory.

JFR without copying a file

JFR is driven entirely with jcmd. We start a recording, check that it runs, and read it in place:

$ jcmd 41959 JFR.start name=oncall settings=profile filename=/tmp/oncall.jfr
41959:
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 41959 JFR.dump name=oncall to copy recording data to file.

$ jcmd 41959 JFR.check
41959:
Recording 1: name=oncall maxsize=250.0MB (running)

JFR.view reads the running recording without writing a file. After twenty seconds:

$ jcmd 41959 JFR.view hot-methods
41959:

                            Java Methods that Execute the Most

Method                                                                     Samples Percent
-------------------------------------------------------------------------- ------- -------
Billing.lambda$main$1()                                                      1,739  94.05%
java.util.concurrent.locks.LockSupport.parkNanos(long)                          97   5.25%
Billing.lambda$main$2()                                                          4   0.22%
...

$ jcmd 41959 JFR.view contention-by-site
41959:

                               Contention by Site

StackTrace                                                  Count   Avg.   Max.
----------------------------------------------------------- ----- ------ ------
Billing.lambda$main$1()                                     1,015 139 ms 143 ms

The hot-methods view shows where the CPU goes: to lambda$main$1, the invoice-worker loop. The contention-by-site view gives what the thread dump did not. In twenty seconds, the threads waited for this lock 1,015 times, 139 ms on average. JFR.view without an argument lists 79 views, for example gc, allocation-by-class, exception-by-site or pinned-threads.

When the analysis needs to go further, we write the file and stop the recording:

$ jcmd 41959 JFR.dump name=oncall filename=/tmp/oncall-dump.jfr
41959:
Dumped recording "oncall", 960.4 kB written to:

/tmp/oncall-dump.jfr

$ jcmd 41959 JFR.stop name=oncall
41959:
Stopped recording "oncall", 1.3 MB written to:

/tmp/oncall.jfr

JFR.dump copies the recording without stopping it. JFR.stop stops it and writes the file given at the start.

Loading async-profiler without a restart

async-profiler has its own command, asprof. When only the library is present, JVMTI.agent_load loads it into the running JVM. The option string contains = signs. The JVM command parser cuts it at the first =, as it would a key=value option. Without quotes, the profiler receives start,event, without the value:

$ jcmd 41959 JVMTI.agent_load /opt/async-profiler/lib/libasyncProfiler.dylib start,event=cpu,file=/tmp/profile.html
41959:
return code: 100

The JVM writes [ERROR] event must not be empty on its standard output. Single quotes around the string solve the problem:

$ jcmd 41959 JVMTI.agent_load /opt/async-profiler/lib/libasyncProfiler.dylib "'start,event=cpu,file=/tmp/profile.html'"
41959:
return code: 0

$ jcmd 41959 JVMTI.agent_load /opt/async-profiler/lib/libasyncProfiler.dylib "'stop,file=/tmp/profile.html'"
41959:
return code: 0

Return code 0 comes from the agent. The JVM writes Profiling started on its standard output, and the flamegraph is in /tmp/profile.html after the stop. The test was done with async-profiler 4.1.

In a container

Three things change when the JVM runs in a container.

The first is the user. jcmd must run as the same user as the JVM. At the first attach, the JVM creates a socket /tmp/.java_pid<pid> to receive commands. jcmd refuses to use it if it belongs to another user:

$ docker exec --user 1001:1001 billing jcmd 1 VM.version
1:
java.io.IOException: well-known file /tmp/.java_pid1 is not secure: file should be owned by the current user (which is 1001) but is owned by 1000

With docker exec --user 1000:1000, or kubectl exec in a pod that declares the right runAsUser, the command works. root can always connect to every JVM.

The second is the image. eclipse-temurin:25-jre does not contain jcmd:

$ docker run --rm eclipse-temurin:25-jre ls /opt/java/openjdk/bin
java
jfr
jrunscript
jwebserver
keytool
rmiregistry

With a JRE image, none of the commands in this article is available in the container. If you pick a JRE image for its size, decide at the same time how you will diagnose an incident.

The third is the -XX:+DisableAttachMechanism flag. Some teams add it for security. With it, jcmd answers AttachNotSupportedException: The VM does not support the attach mechanism, and jcmd can do nothing without restarting the JVM. The -XX:-UsePerfData flag only removes the JVM from the list that jcmd prints. With the pid, the commands work.

On Linux, two of the three extra commands help here. System.trim_native_heap asks the libc to give freed native memory back to the system:

$ docker exec billing jcmd 1 System.trim_native_heap
1:
Trim native heap: RSS+Swap: 147M->144M (-3176K)

On this small application, the gain is 3 MB. When the RSS of a JVM rises and neither the heap nor VM.native_memory explains it, this is a quick test. Compiler.perfmap writes /tmp/perf-<pid>.map, the file that lets perf name the compiled Java methods.

The other commands that help

CommandWhat it gives
VM.infoA report close to an hs_err file, without a crash: version, command line, machine, heap, threads, libraries, environment variables. In a container, a container (cgroup) information section with the limits the JVM sees.
VM.system_propertiesEvery system property, including file.encoding.
VM.metaspaceTotal Metaspace usage. Here 35 loaders, 2214 classes (1361 shared).
VM.classloader_statsOne line per class loader, with its number of classes. A number of loaders that keeps rising signals a class leak.
Compiler.codecacheCode cache occupancy: used=3884Kb out of size=245776Kb, and Compilation: enabled.
VM.eventsThe last internal JVM events: compilations, deoptimizations, heap history.
ManagementAgent.start_localStarts the local JMX agent without a restart, to attach a graphical tool from the same machine.
GC.finalizer_infoThe number of objects waiting for finalization.

In summary

jcmd ships with every JDK. It talks to the running JVM, without a restart and without an agent. Without an argument, it lists the JVMs. With help, it lists its commands, and each command announces its impact.

VM.command_line, VM.flags and VM.set_flag show the real configuration and let you fix a manageable flag at runtime, such as HeapDumpOnOutOfMemoryError. VM.log turns on a GC log without a restart.

Thread.print gives the thread dump, and Thread.dump_to_file -format=json adds the virtual threads. GC.heap_info, GC.class_histogram and GC.heap_dump cover the heap. GC.heap_info causes no pause. The other two stop the application for 20 ms to 111 ms on a 219 MB heap. GC.class_histogram and GC.heap_dump trigger a Full GC, except with -all.

VM.native_memory requires -XX:NativeMemoryTracking at startup. JFR.start, JFR.view and JFR.dump drive JFR, and JFR.view reads the recording in place. JVMTI.agent_load loads async-profiler, with single quotes around the options.

In a container, you need the same user as the JVM, a JDK image and no -XX:+DisableAttachMechanism.

What next?

To read in detail what Thread.print prints, see reading a thread dump with jstack. To go further with JFR.view and continuous recordings, see JFR in production.