TIL: 用进程树展开定位被脚本包装的 JVM 进程
用 jstack 调试被脚本包装的 JVM 进程(比如 lein test 启动的 Java 进程)时, jps 经常显示不出有用的信息:你看到的是一串主类名( main 、 GradleDaemon ),但没法把这些名字和你的启动命令对应起来。 jps 只告诉你"现在有哪些 JVM 进程在跑",不告诉你"它们分别是谁启动的"。Kyle Kingsbury 在 博客 上分享了解法:用 pgrep 递归展开进程树,再和 jps 取交集定位目标进程。
是什么
运行 lein run 时程序卡住了,想用 jstack 看线程栈,但 jps 的输出是这样的:
136086 Jps 2777 NutstoreGUI 136062 main 121004 GradleDaemon
main 是 Leiningen 启动 Java 时用 -m 指定的主类名。你从这两个字完全看不出"这是 lein run 启动的那个"。 jps 能列出所有 Java 进程,但没法把进程和你的启动命令关联起来。
为什么值得关注
传统做法是在 jps 输出里猜,或者用 ps aux | grep java 人工辨认。但当进程链很长(脚本 → Java → Java)时,这些方法都不可靠。
Kyle 的思路:从匹配命令行的根进程开始,用 pgrep -P <pid> 递归展开整棵进程树,再和 jps 列出的 JVM 进程取交集,取最大 PID(通常是最后启动的)执行 jstack 。
怎么用
Kyle 写的是 Ruby 脚本,这里翻译成 Shell:
#!/bin/bash # jstack+ - 对匹配命令的进程树中的 JVM 执行 jstack # 用法: jstack+ lein test expand() { local pid=$1 echo "$pid" for child in $(pgrep -P "$pid" 2>/dev/null); do expand "$child" done } roots=$(pgrep -f "$*") [ -z "$roots" ] && { echo "No process matching: $*"; exit 1; } all_pids="" for root in $roots; do all_pids="$all_pids $(expand "$root")" done jvm_pids=$(jps -q 2>/dev/null) fav="" for pid in $all_pids; do [ -z "$pid" ] && continue for jpid in $jvm_pids; do [ "$pid" = "$jpid" ] && { [ -z "$fav" ] || [ "$pid" -gt "$fav" ]; } && fav=$pid done done [ -z "$fav" ] && { echo "No JVMs with that command line in their ancestry"; exit 1; } echo "jstack $fav ($(tr '\0' ' ' < /proc/$fav/cmdline))" echo exec jstack "$fav"
pattern 是 pgrep -f 的参数,匹配进程的完整命令行。用项目目录名或启动命令中的关键词都行,关键是这个词要出现在进程的命令行里。
使用效果:
$ jstack+ "jstack-test" jstack 141649 (java -classpath /tmp/jstack-test/test:/tmp/jstack-test/src:... clojure.main ...) 2026-05-06 23:20:42 Full thread dump OpenJDK 64-Bit Server VM (25.492-b09 mixed mode): "Attach Listener" #9 daemon prio=9 os_prio=0 ... java.lang.Thread.State: RUNNABLE "Service Thread" #8 daemon prio=9 os_prio=0 ... java.lang.Thread.State: RUNNABLE ...
这里有个坑:PID 回绕的时候(系统 PID 用完了,重新从低编号分配),最大的 PID 不一定是最后启动的。这种情况可以改成按启动时间选: /proc/<pid>/stat 的第 22 字段是进程启动时的 clock ticks,值越大越晚。
#!/bin/bash # jstack++ - 按 starttime 选进程的版本 # 用法: jstack+ lein test expand() { local pid=$1 echo "$pid" for child in $(pgrep -P "$pid" 2>/dev/null); do expand "$child" done } roots=$(pgrep -f "$*") [ -z "$roots" ] && { echo "No process matching: $*"; exit 1; } all_pids="" for root in $roots; do all_pids="$all_pids $(expand "$root")" done jvm_pids=$(jps -q 2>/dev/null) fav="" fav_st=0 for pid in $all_pids; do [ -z "$pid" ] && continue for jpid in $jvm_pids; do if [ "$pid" = "$jpid" ]; then st=$(awk '{print $22}' /proc/$pid/stat) [ "$st" -gt "$fav_st" ] && fav=$pid fav_st=$st fi done done [ -z "$fav" ] && { echo "No JVMs with that command line in their ancestry"; exit 1; } echo "jstack $fav ($(tr '\0' ' ' < /proc/$fav/cmdline))" echo exec jstack "$fav"
验证:搭一个 Clojure 测试实例
要验证 jstack+ 能不能找到被脚本包装的 JVM 进程,可以搭一个简单的测试用例:让 lein run 启动后挂住,然后用 jstack+ 定位它。
创建一个 Leiningen 项目:
mkdir -p /tmp/jstack-test/src/jstack_demo cd /tmp/jstack-test cat > project.clj <<'CLJ' (defproject jstack-demo "0.1.0-SNAPSHOT" :description "jstack+ 测试项目" :main jstack-demo.core :dependencies [[org.clojure/clojure "1.11.1"]]) CLJ cat > src/jstack_demo/core.clj <<'CLJ' (ns jstack-demo.core) (defn -main [& args] (println "挂住 300 秒,另开终端跑 jstack+ ...") (Thread/sleep (* 300 1000))) CLJ
然后在终端 1 启动:
cd /tmp/jstack-test && lein run
挂住 300 秒,另开终端跑 jstack+ ...
终端 2 运行 jstack+ :
bash jstack+.sh "jstack-test"
应该能看到 jstack 的线程 dump 输出,说明脚本在进程树中找到了被 lein run 启动的 JVM 进程。