JRE vs JDK
The problem. A Java service misbehaves and ps, top and strace only show you "a process called java". The JDK ships tools that ask the JVM itself what is going on inside - if they are installed, and if you run them as the right user.
What you need to know already: users and sudo -u (4.3), signals and SIGQUIT (3.6), UNIX sockets and /proc/PID/root (3.14), systemd's PrivateTmp (2.26), JDK vs JRE (20.1).
The runtime (JRE) runs programs. The diagnostic tools - jcmd, jstack, jmap, jstat, jinfo, jps - ship with the JDK. On Ubuntu:
openjdk-21-jre-headless java only what servers usually get
openjdk-21-jdk-headless java + jcmd jstack jmap jstat ... what you want when debugging
$ jcmd
Command 'jcmd' not found, but can be installed with:
sudo apt install openjdk-21-jdk-headless
$ sudo apt install -y openjdk-21-jdk-headless
The same problem, worse, in containers: eclipse-temurin:21-jre and every distroless Java image have no jcmd at all. You have shipped an image you cannot look inside. Decide before 3am which of these you use:
- A JDK-based runtime image - bigger, but
kubectl exec -it pod -- jcmd 1 ...works. - A debug sidecar or ephemeral container with a JDK, sharing the process namespace:
kubectl debug -it pod --image=eclipse-temurin:21-jdk --target=app. The target's PID 1 is visible from the debug container; attach needs the same UID and a shared/tmp, which is why this sometimes needs extra care. - Actuator over HTTP (next chapter):
/actuator/threaddumpand/actuator/heapdumpneed no tools in the image at all.
How attaching works - and why it fails
jcmd, jstack, jmap and jinfo use the attach mechanism: the tool creates .attach_pid<PID>, sends the JVM SIGQUIT, the JVM opens a UNIX socket /tmp/.java_pid<PID> and the tool sends it a command. That imposes rules:
- Same user as the JVM, or root. Sending a signal to another user's process is not allowed:
$ jcmd $(pgrep -f orders.jar) VM.flags
1210:
java.io.IOException: Operation not permitted
at jdk.attach/sun.tools.attach.VirtualMachineImpl.sendQuitTo(Native Method)
at jdk.attach/sun.tools.attach.VirtualMachineImpl.checkCatchesAndSendQuitTo(VirtualMachineImpl.java:398)
...
The fix is to become the JVM's user: sudo -u appuser jcmd 1210 VM.flags. Root also works.
- The same /tmp. A service with
PrivateTmp=yes, or a container, has its own/tmp; the tool looks under/proc/<PID>/root/tmpfor exactly that reason. - A JVM that does not answer (hung in a GC, out of memory, or not a HotSpot JVM) gives:
com.sun.tools.attach.AttachNotSupportedException: Unable to open socket file /proc/1210/root/tmp/.java_pid1210: target process 1210 doesn't respond within 10500ms or HotSpot VM not loaded
jstat is different: it reads the JVM's shared performance counters in /tmp/hsperfdata_<user>/<pid> (mode 0600) without attaching. Another user just gets:
$ jstat -gcutil $(pgrep -f orders.jar)
1210 not found
And jcmd with no arguments lists only the JVMs you can see:
$ jcmd
4410 jdk.jcmd/sun.tools.jcmd.JCmd
$ sudo jcmd
1210 /opt/app/orders.jar
4411 jdk.jcmd/sun.tools.jcmd.JCmd
jcmd: one tool for everything
$ sudo -u appuser jcmd $(pgrep -f orders.jar) help
1210:
The following commands are available:
Compiler.CodeHeap_Analytics
...
GC.class_histogram
GC.heap_dump
GC.heap_info
GC.run
JFR.start
Thread.print
VM.command_line
VM.flags
VM.native_memory
VM.system_properties
VM.uptime
VM.version
help
For more information about a specific command use 'help <command>'.
The ones you will use weekly:
jcmd PID VM.version which JDK build is actually running
jcmd PID VM.command_line the real flags, including JAVA_TOOL_OPTIONS
jcmd PID VM.flags every non-default flag, ergonomic ones included
jcmd PID VM.system_properties -D properties, user.dir, java.io.tmpdir
jcmd PID GC.heap_info heap committed/used, metaspace
jcmd PID GC.class_histogram live objects by class (forces a full GC)
jcmd PID GC.heap_dump FILE HPROF dump for Eclipse MAT
jcmd PID Thread.print [-l] thread dump
jcmd PID VM.native_memory summary native memory (needs NMT at start)
jcmd PID JFR.start duration=60s filename=/tmp/rec.jfr flight recording
You can also target by name instead of PID - handy in scripts:
$ sudo -u appuser jcmd orders.jar VM.uptime
1210:
8226.493 s
VM.flags, decoded
$ sudo -u appuser jcmd $(pgrep -f orders.jar) VM.flags
1210:
-XX:CICompilerCount=2 -XX:ConcGCThreads=1 -XX:G1ConcRefinementThreads=2 ... -XX:G1HeapRegionSize=1048576 ... -XX:InitialHeapSize=96468992 ... -XX:MaxHeapSize=536870912 -XX:MaxNewSize=321912832 ... -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:SoftMaxHeapSize=536870912 ... -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseG1GC
What to read out of it: the collector (+UseG1GC), the heap bounds (InitialHeapSize 92 MB, MaxHeapSize 512 MB), NativeMemoryTracking if it is on, HeapDumpOnOutOfMemoryError if it is set. -XX:+PrintFlagsFinal is the same information for a JVM that is not running yet.
The paths are the target's, not yours
GC.heap_dump is performed by the target JVM, as the target's user, with a path relative to the target's working directory:
$ sudo -u appuser jcmd $(pgrep -f orders.jar) GC.heap_dump heap.hprof
1210:
Dumping heap to /opt/app/heap.hprof ...
Heap dump file created [301451712 bytes in 0.793 secs]
$ sudo -u appuser jcmd $(pgrep -f orders.jar) GC.heap_dump /home/learner/heap.hprof
1210:
Dumping heap to /home/learner/heap.hprof ...
Unable to create /home/learner/heap.hprof: Permission denied
Use an absolute path in a directory the JVM's user can write, on a filesystem with room for a file the size of the heap - /tmp on a container is often a small overlay. The dump is created mode 0600, owned by the JVM's user.
jmap -dump:live,format=b,file=heap.hprof PID resolves the path on your side instead. And jmap -heap is gone since JDK 9:
$ sudo jmap -heap $(pgrep -f orders.jar)
Error: -heap option used
Cannot connect to core dump or remote debug server. Use jhsdb jmap instead
kill -3
The oldest trick: kill -3 PID (SIGQUIT) makes the JVM print a thread dump to its own stdout. For a systemd service that is the journal; in Kubernetes it is kubectl logs. No tools needed, but you have to go and find it.
$ sudo kill -3 $(pgrep -f orders.jar)
$ journalctl -u orders --since '-1min' | grep -c 'java.lang.Thread.State'
46