doc.md 22 KB

JAVA Process 使用教程

本教程通过 7 个可运行的示例,讲解 ProcessBuilder / Process 的核心用法。所有示例都以 src/main/resources/code/ 下的 .java 程序作为子进程,先编译再运行。 每个案例点由独立的 static method 演示,在 main() 中依次调用。

运行示例

mvn -q compile
java -cp "target/classes:$(mvn -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout | tail -1)" space.anyi.process.<类名>

各示例声明了 static Logger(slf4j),手工执行时 classpath 必须带上 logback/slf4j 依赖, 否则报 NoClassDefFoundError: org/slf4j/LoggerFactory。 示例的行为由 JUnit 5 测试守护:mvn test 直接驱动子进程程序做端到端校验。


零、概念铺垫(30 秒建立心智模型)

  • 进程(Process):操作系统里一个正在运行的程序实例。Java 代码可以启动一个外部程序(如 javajavac,或任意可执行文件)作为子进程,用一个逻辑句柄来操控它。
  • ProcessBuilder"如何启动" 的配置器。它只管描述命令、工作目录、环境变量、三路标准流走哪个 源头;配置完成后调用 start() 才真正启动子进程,并返回一个 Process 句柄。
  • Process"已启动的子进程" 的句柄。用它可以等待结束、读退出码、write/read 三路标准流、销毁进程。
  • 主进程与子进程通过三路管道交互,方向最容易搞反,记住这句:

            主进程                                 子进程
    getOutputStream()  ────────────▶  标准输入  stdin
    getInputStream()   ◀───────────  标准输出  stdout
    getErrorStream()   ◀───────────  标准错误  stderr
    

子进程程序固定放在 src/main/resources/code/<主题>/Main.java,示例先编译再运行, 公共类 Jdkjavac()/java() 绝对路径、compileFixture())负责这两件事。 这类预先写好的、供示例与测试反复使用的子进程程序,英文习惯称 fixture(测试夹具), 本教程统一使用"子进程程序"这一叫法。


一、通过 ProcessBuilder 构建 Process —— QuickStart

案例点:buildProcess()(示例类:space.anyi.process.QuickStart

// 1. 构建命令并启动子进程:可执行文件绝对路径 + Main 类名,directory() 指定工作目录
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
        .directory(workDir)
        .start();

// 2. 读取子进程的标准输出
new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))
        .lines()
        .forEach(line -> log.info("子进程标准输出: {}", line));

// 3. 等待子进程结束,返回退出状态码
int exitStatus = process.waitFor();

要点:

  • 命令以字符串列表传给构造器,start() 在子进程中执行该命令;可执行文件用 System.getProperty("java.home") 拼出的绝对路径(子进程的 PATH 常常没有 JDK 的 bin 目录,裸写 javac/java 会报 error=2)。
  • 必须先编译子进程程序:公共类 Jdk.compileFixture(workDir) 会用绝对路径的 javac 编译工作目录下的 Main.java

预期输出:

子进程标准输出: Hello from child process!
子进程标准输出: child pid = 12345
子进程退出状态码: 0

警告:ProcessBuilder 不经过 shell。 列表里每一项就是子进程收到的一个参数

  • 不要整个命令写成一个带空格的字符串,如 "java -version"(会被当作一个叫 java -version 的可执行文件);
  • 不要期待引号展开、$VAR、通配符、| 管道、重定向等 shell 语法生效;
  • 确实需要 shell 能力时,显式走 sh/cmdList.of("sh", "-c", "echo $HOME | wc -c")

平台差异:Windows 下 javac/java 的文件名带 .exe,目录分隔符是 \process.destroy()TerminateProcess(不产生 143 这种退出码)。


二、获取 Process 的输入和输出 —— ProcessIOExample

案例点:standardOutput() / standardError() / standardInput()(示例类:space.anyi.process.ProcessIOExample

子进程(code/io/Main.java)启动时打印一行 stdout,从标准输入逐行读取并回显,读到 EOF 后向标准错误打印统计信息。三个案例点:

  • standardOutput():启动子进程、后台读 getInputStream(),看到启动横幅即标准输出 ✓
  • standardError():关闭 stdin 后读 getErrorStream(),看到 EOF 统计行即错误输出 ✓
  • standardInput():向 getOutputStream() 写两行,子进程回显到 stdout ✓

    // 标准输出、标准错误分别在后台线程读取,避免阻塞主线程、也避免管道写满导致子进程卡死
    Thread stdoutDrain = drain("stdout", process.getInputStream());
    Thread stderrDrain = drain("stderr", process.getErrorStream());
    
    // 向子进程标准输入写入数据;关闭输入流即表示 EOF
    try (OutputStream stdin = process.getOutputStream()) {
    stdin.write("第一行输入\r\n".getBytes(StandardCharsets.UTF_8));
    stdin.write("第二行输入\r\n".getBytes(StandardCharsets.UTF_8));
    }
    

对应关系:

子进程侧 主进程侧 API 说明
标准输入 (stdin) process.getOutputStream() 主进程向该流写入,即子进程的输入
标准输出 (stdout) process.getInputStream() 子进程 println 的内容
标准错误 (stderr) process.getErrorStream() 子进程 err.println 的内容

要点:

  • 标准输出/错误要用后台线程读取BufferedReader(...).lines().forEach(...) 是同步阻塞的, 会一直读到 EOF 为止,而 EOF 只有在子进程退出时才出现——在限时 waitFor 之前这样读, 会把主线程钉住整个子进程生命周期,静默破坏超时语义(本仓库的踩坑点)。
  • 若不读子进程输出且输出量超过管道缓冲区(Linux 约 64KB),子进程会因管道写满而阻塞、永不退出。 后台异步读取 + 超时后 destroy() 的设计可以兜住这种情况。

预期输出(线程日志与主线程日志顺序可能交错):

[stdout] child: started, waiting for stdin
[stdout] child: echo 第一行输入
[stdout] child: echo 第二行输入
[stderr] child: stdin EOF after 2 line(s)
子进程是否已退出: true
退出状态码: 0

二-1、标准输出与错误输出的重定向 —— ProcessRedirectExample(进阶)

进阶章节:掌握前几节后再看。重定向用于"子进程输出不进管道"的常见场景。

案例点:errorStreamMerged() / redirectToFile() / redirectEnum()(示例类:space.anyi.process.ProcessRedirectExample

子进程复用 code/io/Main.java。三个案例点:

相互重定向 —— errorStreamMerged()

// stderr 合并写入 stdout 管道,合并后的整体从 getInputStream() 读取,不再有独立 stderr 流
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
        .directory(WORK_DIR)
        .redirectErrorStream(true)
        .start();

演示结果里 child: started...(stdout)与 child: stdin EOF...(stderr)都出现在同一路 [merged] 日志中:

[merged] child: started, waiting for stdin
[merged] child: stdin EOF after 0 line(s)
退出码: 0

重定向到文件 —— redirectToFile()

.redirectInput(input.toFile())    // stdin 从文件读
.redirectOutput(stdout.toFile())  // stdout 写到文件
.redirectError(stderr.toFile())   // stderr 写到文件

子进程结束后读回文件校验:

退出码: 0
stdout 文件内容: [child: started, waiting for stdin, child: echo 来自文件的输入行, child: echo 第二行来自文件]
stderr 文件内容: [child: stdin EOF after 2 line(s)]

可用于把子进程输出落盘、日志归档等场景。

Redirect 枚举 —— redirectEnum()

枚举值 含义 演示
ProcessBuilder.Redirect.PIPE 默认值,子进程输出进入管道,父进程用 getInputStream() 读取 逐行读回子进程输出
ProcessBuilder.Redirect.INHERIT 子进程 stdout/stderr 直接打印到父进程的控制台,不进管道 两行内容直接出现在父控制台
ProcessBuilder.Redirect.DISCARD 子进程输出被直接丢弃 进程正常退出但读不到任何输出

Redirect 还提供 to(File)(输出重定向到文件,覆盖)与 appendTo(File)(追加)两个工厂方法, 可用于 redirectInput/Output/Error(Redirect)


二-2、异步处理输入和输出 —— ProcessAsyncExample(进阶)

进阶章节:需要掌握 JDK 8+ 的 CompletableFuture 再阅读。

案例点:asyncByThread() / asyncByCompletableFuture()(示例类:space.anyi.process.ProcessAsyncExample

多线程 —— asyncByThread()

// 每个流一个独立线程读取,主线程不阻塞在读取上,也避免管道写满导致子进程卡死
Thread stdoutDrain = drain("thread-stdout", process.getInputStream());
Thread stderrDrain = drain("thread-stderr", process.getErrorStream());
...
stdoutDrain.join();  // 读到 EOF(子进程退出)后汇合
stderrDrain.join();

CompletableFuture —— asyncByCompletableFuture()

// stdout / stderr 读取各封装成一个异步任务
CompletableFuture<Void> stdoutFuture = CompletableFuture.runAsync(() ->
        new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))
                .lines().forEach(line -> log.info("[cf-stdout] {}", line)));
CompletableFuture<Void> stderrFuture = CompletableFuture.runAsync(() -> ...);

// 进程结束时 onExit() 自动完成该 Future,注册回调即可在退出时拿到退出码
CompletableFuture<Void> exitFuture = process.onExit()
        .thenAccept(p -> log.info("onExit 回调: 退出码 {}", p.exitValue()));

// 等待所有异步任务结束
CompletableFuture.allOf(stdoutFuture, stderrFuture, exitFuture).join();

要点:process.onExit() 返回 CompletableFuture<Process>,不依赖 destroy()/阻塞读取, 适合与函数式回调、并发编排(allOf/thenAccept)结合。

预期输出:

[thread-stdout] child: started, waiting for stdin
[thread-stdout] child: echo hello thread
[thread-stderr] child: stdin EOF after 1 line(s)
退出码: 0
== CompletableFuture 异步读取 ==
[cf-stdout] child: started, waiting for stdin
[cf-stdout] child: echo hello future
[cf-stderr] child: stdin EOF after 1 line(s)
onExit 回调: 退出码 0

三、获取 Process 执行的状态码 —— ExecuteStatusExample

案例点:blockingWaitFor() / timedWaitFor()(示例类:space.anyi.process.ExecuteStatusExample

子进程(code/executeStatus/Main.java)睡眠 10 秒后退出,用来对比两种获取退出码的方式:

// 方式一:阻塞式,一直等到子进程结束,返回退出码(0 正常,非 0 异常)
int exitValue = process.waitFor();

// 方式二:限时等待,返回 boolean(超时时间内是否退出),不是退出码
boolean flag = process.waitFor(2, TimeUnit.SECONDS);
if (!flag) {
    process.destroy();   // 超时后子进程仍存活,先发终止信号
    process.waitFor();   // 再阻塞等待它真正结束
}
int exitCode = process.exitValue();  // 被信号终止时通常为 143(SIGTERM)

要点:

  • waitFor() 无参:返回 int 退出码。
  • waitFor(long timeout, TimeUnit unit):返回 boolean。超时返回 false 时子进程仍然存活, 此时调用 process.exitValue() 会抛 IllegalThreadStateException;必须先 destroy() + 阻塞 waitFor() 才能读取退出码。
  • process.destroy() 在 Linux 上发送 SIGTERM,属于优雅终止;可用 destroyForcibly() 直接 SIGKILL。

预期输出:

== 方式一:waitFor() 阻塞等待 ==
阻塞等待退出码: 0        # 大约 10 秒后出现(子进程睡眠 10 秒)
== 方式二:waitFor(2, SECONDS) 限时等待 ==
2 秒内是否退出: false
超时未退出,destroy 销毁子进程后阻塞等待
销毁后退出码: 143        # SIGTERM 信号终止的退出码

四、Process 的核心对象及专属 API 详解

ProcessBuilder

作用:描述“如何启动一个进程”,配置完成后调用 start() 生成 Process

方法 参数 返回 说明
ProcessBuilder(List<String> command) 命令及参数的字符串列表 构建器
ProcessBuilder(List<String>) / List<String> command() 变体 命令列表/单个命令+参数数组 构建器自身 设置或读取命令
directory(File dir) 子进程工作目录 构建器自身 不设置则继承父进程目录
environment() Map<String,String> 返回可变的子进程环境变量视图(增删改影响子进程)
redirectInput/Output/Error(File) 文件 构建器自身 把子进程标准流重定向到文件
redirectInput/Output/Error(Redirect) Redirect 枚举 构建器自身 PIPE/INHERIT/DISCARD/to(file)/appendTo(file)
redirectErrorStream(boolean) 是否合并 stderr 到 stdout 构建器自身 true 时子进程 stderr 写入 stdout 管道
start() Process 启动子进程;目录不存在/命令找不到抛 IOException

环境变量示例见 ProcessBuilderExampleprocessBuilder.environment()System.getenv() 内容一致,向返回的 Map 里 put 即可为子进程注入环境变量。

Process

作用:表示一个已启动的子进程,负责等待结束、读取退出码、与进程交互、销毁进程。

方法 参数 返回 说明
getOutputStream() OutputStream 子进程的标准输入
getInputStream() InputStream 子进程的标准输出
getErrorStream() InputStream 子进程的标准错误
waitFor() int 阻塞直到子进程结束,返回退出码
waitFor(long, TimeUnit) 等待时长、单位 boolean 限时等待;超时未退出返回 false
exitValue() int 退出码;进程未结束调用抛 IllegalThreadStateException
destroy() 终止进程(Linux 为 SIGTERM,优雅)
destroyForcibly() Process 强制终止(Linux 为 SIGKILL)
isAlive() boolean 进程是否仍在运行
onExit() CompletableFuture<Process> 进程结束时自动完成
toHandle() ProcessHandle 转为 JDK 9+ 的进程句柄,可查询元信息、遍历进程树、按句柄销毁(见第六章)

五、综合运用:管道式调用外部工具 —— ProcessPipelineExample

案例点:runPipeline()(示例类:space.anyi.process.ProcessPipelineExample

把子进程当作一条处理管道,完整走一遍真实调用的流程:启动 → 异步采集输出 → 喂入数据 → 取回结果 → 校验。

// 1. 启动子进程(外部工具)
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
        .directory(WORK_DIR)
        .start();

// 2. 异步把 stdout 收集成 List<String>、异步消费 stderr
CompletableFuture<List<String>> resultFuture = CompletableFuture.supplyAsync(() ->
        new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))
                .lines().collect(Collectors.toList()));
CompletableFuture<Void> stderrDrain = CompletableFuture.runAsync(() -> ...);

// 3. 向子进程 stdin 喂入数据,关闭输入流表示 EOF
try (OutputStream stdin = process.getOutputStream()) {
    for (int i = 1; i <= 3; i++) writeLine(stdin, "待处理数据-" + i);
}

// 4. 等待退出码,取回处理结果并校验行数
int exit = process.waitFor();
List<String> result = resultFuture.get();
long echoed = result.stream().filter(line -> line.startsWith("child: echo")).count();

预期输出:

[stderr] child: stdin EOF after 3 line(s)
退出码: 0
回显结果: [child: started, waiting for stdin, child: echo 待处理数据-1, child: echo 待处理数据-2, child: echo 待处理数据-3]
回显行数: 3,符合预期: true

要点:这一段把前面四章的知识串了起来——ProcessBuilder 构建(一)、三路管道(二)、 异步读取 stdout/stderr(二-2)、waitFor() 拿退出码(三)。实际项目中把第 2 步的"异步收集 stdout"换成业务处理(如逐行解析、透传到日志),子进程就成了你程序里的一级生产/消费管道。


六、使用 ProcessHandle 管理进程(进阶) —— ProcessHandleExample

进阶章节:ProcessHandle 是 JDK 9 新增的进程管理 API,需要 JDK 9+ 环境(本教程 target 17,没问题)。

案例点:handleFromProcess() / resolveByPid() / inspectProcessTree() / terminateViaHandle()(示例类:space.anyi.process.ProcessHandleExample

子进程(code/processHandle/Main.java)启动后打印自身 pid 与 args,然后睡眠 30 秒等父进程检查与销毁。四个案例点各演示 ProcessHandle 的一个能力:

从 Process 获取句柄并查询元信息 —— handleFromProcess()

ProcessHandle handle = process.toHandle();   // Process → ProcessHandle
handle.pid();      // 与 process.pid() 一致
handle.isAlive();  // 进程是否存活

ProcessHandle.Info info = handle.info();     // 全部 Optional
info.command();            // 如 Optional[/path/to/java]
info.commandLine();        // 如 Optional[.../java -Dfile.encoding=UTF-8 Main]
info.startInstant();       // 启动时间
info.totalCpuDuration();   // CPU 累计耗时
info.user();               // 所属用户
handle.supportsNormalTermination();  // Linux: true,Windows: false

通过 PID 解析句柄 —— resolveByPid()

Optional<ProcessHandle> child = ProcessHandle.of(process.pid());  // 存在 → 有值
child.isPresent();   // true
ProcessHandle.of(99999999L).isPresent();   // 不存在的 PID → false

遍历父子进程树 —— inspectProcessTree()

ProcessHandle current = ProcessHandle.current();   // 当前 JVM 句柄
current.children().forEach(h -> log.info("{}", h.pid()));     // 直接子进程
current.descendants().count();                                // 所有后代(含间接)
current.parent();                                             // 当前 JVM 的父进程
process.toHandle().parent();                                  // 子进程的父 = 当前 JVM
ProcessHandle.allProcesses().count();   // 注意是静态方法:当前用户可见的活跃进程总数

通过句柄销毁进程 —— terminateViaHandle()

ProcessHandle handle = process.toHandle();

handle.destroy();          // 与 process.destroy() 同义:Linux 发 SIGTERM,Windows 走 TerminateProcess
handle.onExit().join();    // 阻塞到进程真正退出(返回 CompletableFuture<ProcessHandle>)
handle.isAlive();          // false(已退出)
process.exitValue();       // 被 SIGTERM 终止常见 143

要点与陷阱:

  • ProcessHandleProcess 的关系:Process 是"已启动子进程"的句柄,管三路流、等待、销毁; ProcessHandle 是 JDK 9 新增的通用进程句柄,不依赖 ProcessBuilder/Process 对象也能按 pid 拿到 任意可访问进程并查询/销毁。process.toHandle() 只是两者间的桥。
  • ProcessHandle.allProcesses()静态方法,必须用接口名调用(ProcessHandle.allProcesses(), 不能写成 handle.allProcesses(),否则编译报错);可能受系统权限限制(Linux 非 root 看不到其他用户进程)。
  • ProcessHandle.onExit() 返回 CompletableFuture<ProcessHandle>,与 Process.onExit() 返回的 CompletableFuture<Process> 不同。
  • info() 的字段都是 Optional,进程退出后部分字段可能不再可读。
  • Windows 下 supportsNormalTermination()falsedestroy() 行为等同强杀,无 143 这种信号退出码。

预期输出(pid、时间、进程总数随环境变化):

== 从 Process 获取 ProcessHandle ==
[stdout] child: pid=242593
[stdout] child: args=[]
[stdout] child: sleeping 30s, waiting for parent to inspect
[stderr] child: ready for ProcessHandle inspection
process.pid()      = 242593
handle.pid()      = 242593
pid 一致: true
handle.isAlive()  = true
command()         = Optional[/path/to/java]
commandLine()     = Optional[/path/to/java -Dfile.encoding=UTF-8 Main]
startInstant()    = Optional[2026-09-11T10:52:25.210Z]
totalCpuDuration()= Optional[PT0.05S]
user()            = Optional[yangyi]
supportsNormalTermination() = true
== 通过 PID 解析 ProcessHandle ==
ProcessHandle.of(242617) 存在: true
ProcessHandle.of(99999999) 存在: false
== 进程树与 allProcesses ==
当前 JVM pid: 242488
子进程 PID 242693 在 children() 中: true
子进程 parent 存在: true
  parent 就是当前 JVM: true
descendants() 数量: 1
当前用户可见活跃进程总数: 455
== 通过句柄销毁进程 ==
进程存活: true
destroy() 已发起: true
进程已退出,handle.isAlive() = false
退出码 (process.exitValue()): 143

子进程睡眠 30 秒,示例中所有案例点检查完后立即 destroy(),不会真的等 30 秒。


常见踩坑小结

  1. 编译/运行子进程的 javacjavajava.home绝对路径,不要裸写命令名。
  2. user.dir 相对的工作目录要跟 src/main/resources/code/ 下的子进程程序目录保持一致,否则 error=2
  3. 同步 lines().forEach(...) 读子进程输出会阻塞到子进程退出,限时 waitFor 前要在后台线程读取。
  4. waitFor(timeout, unit) 返回 boolean;超时后取退出码要 destroy() + waitFor()exitValue()
  5. ProcessBuilder 不经 shell:参数数组每一项就是一个参数,引号/$VAR/通配符/| 都不生效,需要时用 sh -c "..."
  6. ProcessHandle.allProcesses()静态方法:必须 ProcessHandle.allProcesses() 调用,不能写成 handle.allProcesses()
  7. ProcessHandle.destroy()Process.destroy() 语义相同(Linux SIGTERM),Windows 走 TerminateProcesssupportsNormalTermination()false