Java 基础体系 · 第 52/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java Process API:启动子进程、流、超时、退出和命令注入防护
Java 的 Process API 用于让当前 Java 虚拟机启动并管理操作系统层面的子进程。子进程可以是另一个 Java 程序,也可以是操作系统命令、脚本解释器或外部工具。
它解决的不是“调用一个方法并拿到字符串”这么简单,而是一条完整的生命周期:
- 构造命令和进程属性;
- 创建子进程;
- 读写标准输入、标准输出和标准错误;
- 等待完成或设置超时;
- 获取退出状态;
- 在超时、异常和进程树残留时执行清理;
- 避免把不可信输入交给 shell,从而形成命令注入。
本文以 Java 25 LTS 的标准 API 为范围,重点介绍 ProcessBuilder、Process 和 ProcessHandle。
一、先区分 Java API、操作系统进程和 shell
1.1 子进程是什么
操作系统中的进程通常拥有独立的:
- 地址空间;
- 文件描述符或句柄;
- 标准输入
stdin; - 标准输出
stdout; - 标准错误
stderr; - 进程 ID;
- 环境变量;
- 当前工作目录;
- 退出状态。
Java 程序调用 ProcessBuilder.start() 后,操作系统创建一个新进程。Java 进程称为父进程,新创建的进程称为子进程。
Java 对子进程的抽象主要分成三层:
| Java 类型 | 作用 |
|---|---|
ProcessBuilder |
描述“如何启动”子进程 |
Process |
表示已经启动的子进程,并访问其输入输出流 |
ProcessHandle |
访问进程 ID、父子关系、存活状态、异步退出等信息 |
这里的“输入流”和“输出流”容易从方向上误解。站在父进程角度:
| Java 方法 | 实际含义 |
|---|---|
process.getOutputStream() |
父进程写入,数据进入子进程的标准输入 |
process.getInputStream() |
父进程读取,数据来自子进程的标准输出 |
process.getErrorStream() |
父进程读取,数据来自子进程的标准错误 |
也就是说,Process 的 getInputStream() 并不是“让父进程输入”,而是“读取子进程输出”。
1.2 启动程序不等于启动 shell
下面的代码通常表示直接启动 echo 程序:
Process process = new ProcessBuilder("echo", "hello").start();
参数列表中的每个字符串是一个参数。Java 不会因为参数中出现空格、*、; 或 >,就自动把它们解释为 shell 语法。
而下面的代码明确启动了 shell:
Process process = new ProcessBuilder(
"sh", "-c", "echo " + userInput
).start();
此时,userInput 被拼接进 shell 程序文本,sh 会解释其中的命令替换、管道、重定向、命令分隔符等语法。Windows 上类似的风险通常出现在:
new ProcessBuilder("cmd.exe", "/c", command).start();
因此需要建立一个基本判断:
ProcessBuilder本身不是 shell;只有当启动命令显式调用 shell,或者外部程序本身具有命令解释功能时,shell 语法才会参与解析。
但“不经过 shell”也不代表任意参数都安全。参数仍然可能触发目标程序自身的选项语义,例如把用户输入当成 --output、文件路径或正则表达式。
二、ProcessBuilder:构造子进程启动配置
2.1 命令列表与参数边界
最基本的构造方式是:
ProcessBuilder builder = new ProcessBuilder(
"java",
"-version"
);
Process process = builder.start();
列表中的第一个元素通常是可执行文件,其余元素是参数。
更重要的不是“字符串列表”这个形式,而是它保留了参数边界:
new ProcessBuilder("grep", "-F", "--", userText, "file.txt");
这里有五个参数:
grep-F--userTextfile.txt
如果 userText 是:
hello world
它仍然是一个参数,而不会因为包含空格被拆成两个参数。
这和拼接命令文本不同:
String command = "grep -F -- " + userText + " file.txt";
拼接文本后,参数边界必须由 shell 或目标程序自行解释,输入中的空格、引号和特殊字符会改变解释结果。
2.2 ProcessBuilder 的常用配置
ProcessBuilder 可以设置:
ProcessBuilder builder = new ProcessBuilder("some-program", "arg1");
builder.directory(new File("/tmp")); // 当前工作目录
builder.environment().put("MODE", "test"); // 环境变量
builder.redirectInput(...); // 标准输入重定向
builder.redirectOutput(...); // 标准输出重定向
builder.redirectError(...); // 标准错误重定向
builder.redirectErrorStream(true); // 合并 stdout 和 stderr
默认情况下:
- 子进程的标准输入连接到
Process.getOutputStream(); - 子进程的标准输出连接到
Process.getInputStream(); - 子进程的标准错误连接到
Process.getErrorStream(); - 环境变量通常继承父进程环境;
- 工作目录通常继承父进程当前目录。
environment() 返回的是可修改的环境变量映射:
Map<String, String> environment = builder.environment();
environment.remove("SECRET");
environment.put("LANG", "C");
修改它会影响即将启动的子进程,不会修改当前 Java 进程自己的环境变量。
directory(null) 表示使用父进程当前工作目录。工作目录和 Java 的 System.getProperty("user.dir") 有关联,但不应把它们当作完全等价的状态来源;真正传给子进程的是 ProcessBuilder 的工作目录配置。
2.3 启动时可能失败
start() 不是一个纯粹的内存操作,它需要请求操作系统创建进程,因此可能失败:
try {
Process process = new ProcessBuilder("does-not-exist").start();
} catch (IOException e) {
// 可执行文件不存在、权限不足、工作目录无效、系统资源不足等
}
常见失败原因包括:
- 可执行文件不存在;
- 当前用户没有执行权限;
- 指定的工作目录不存在;
- 参数或环境配置不符合平台要求;
- 操作系统拒绝创建新进程;
- 进程创建所需资源不足。
启动成功只表示进程已经创建,不表示目标程序已经正常执行,更不表示业务操作成功。
三、从启动到退出:进程生命周期
一个简化的状态模型如下:
stateDiagram-v2
[*] --> Configured: 创建 ProcessBuilder
Configured --> Running: start() 成功
Configured --> [*]: start() 失败
Running --> Running: 读写 stdin/stdout/stderr
Running --> Exited: 子进程自行退出
Running --> Terminating: destroy()
Terminating --> Exited: 正常终止成功
Terminating --> Running: 终止请求未生效
Running --> ForceTerminating: destroyForcibly()
Terminating --> ForceTerminating: 超时或仍存活
ForceTerminating --> Exited: 强制终止完成
几个状态之间不能混淆:
ProcessBuilder是启动配置,不代表进程已经存在;Process对象创建成功,通常表示已经有对应的 OS 进程;isAlive()表示进程当前是否仍然存活;exitValue()只能对已经退出的进程读取;waitFor()返回表示等待结束,但退出码仍需单独解释;- 进程退出不一定意味着它的所有后代进程也退出。
ProcessBuilder 可以被重新使用来启动多个相似进程,但每次 start() 得到的是不同的 Process 对象和不同的 OS 进程。
四、标准流:父子进程之间的数据通道
4.1 三条流的方向
假设子进程执行:
stdout: normal output
stderr: error output
stdin: input
父进程的视角如下:
父进程 子进程
------------------------------------------------------------
process.getOutputStream() ----------> stdin
process.getInputStream() <---------- stdout
process.getErrorStream() <---------- stderr
例如,父进程向子进程发送一行文本:
try (OutputStream stdin = process.getOutputStream()) {
stdin.write("hello\n".getBytes(StandardCharsets.UTF_8));
}
关闭 stdin 很重要。许多程序只有读取到 EOF,也就是输入流关闭后,才会开始处理剩余输入或结束。只调用 flush() 不等价于发送 EOF。
4.2 stdout 和 stderr 都必须处理
子进程的输出通常通过 OS 管道传输。管道容量有限,不是无限大的内存队列。
考虑以下父进程代码:
Process process = new ProcessBuilder("some-program").start();
int exitCode = process.waitFor(); // 先等待,不读取输出
如果子进程持续向标准输出或标准错误写数据,管道可能被写满。子进程随后阻塞在写操作上,也就无法退出;父进程则一直阻塞在 waitFor() 上。这形成了相互等待:
子进程:等待 stdout/stderr 管道有空间
父进程:等待子进程退出
因此,长时间运行或输出量不可控的程序应当并发消费输出流。
4.3 合并标准错误
如果不需要区分标准输出和标准错误,可以使用:
ProcessBuilder builder = new ProcessBuilder("some-program");
builder.redirectErrorStream(true);
Process process = builder.start();
String output = new String(
process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8
);
redirectErrorStream(true) 的效果是:
- 子进程的 stderr 被合并到 stdout;
getInputStream()能读到两者;getErrorStream()不再提供独立的 stderr 数据。
合并会损失输出来源信息,因此需要区分正常输出和诊断错误时,不应使用它。
4.4 并发读取两条流
下面的示例同时读取 stdout 和 stderr,避免其中一条管道阻塞子进程:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
public class ProcessStreams {
public static void main(String[] args) throws Exception {
Process process = new ProcessBuilder(
"java", "-version"
).start();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<byte[]> stdout = executor.submit(
() -> process.getInputStream().readAllBytes()
);
Future<byte[]> stderr = executor.submit(
() -> process.getErrorStream().readAllBytes()
);
int exitCode = process.waitFor();
String out = new String(stdout.get(), StandardCharsets.UTF_8);
String err = new String(stderr.get(), StandardCharsets.UTF_8);
System.out.println("exitCode = " + exitCode);
System.out.println("stdout = " + out);
System.out.println("stderr = " + err);
}
}
}
这个示例使用 Java 21 已标准化、在 Java 25 可用的虚拟线程执行器。虚拟线程适合这种阻塞 I/O 等待,但它不会改变管道容量,也不会自动限制输出大小。
示例中的字符集明确指定为 UTF-8,但这只是父进程的解码选择。子进程究竟使用哪种字符集,取决于子进程本身及其运行环境。生产代码应让协议明确规定编码,不应仅凭当前机器的默认字符集猜测。
readAllBytes() 会把全部输出保存在内存中。若子进程可能输出大量数据,应改为增量读取并写入文件、环形缓冲区或受限日志收集器,否则可能因为输出规模导致父进程内存压力。
五、等待进程与获取退出状态
5.1 waitFor() 的语义
int exitCode = process.waitFor();
waitFor() 会阻塞当前线程,直到子进程退出,然后返回退出码。
如果进程还没有退出,直接调用:
int exitCode = process.exitValue();
会抛出 IllegalThreadStateException。因此,正确的顺序通常是先等待,或者先判断:
if (process.isAlive()) {
// 仍然运行
} else {
int exitCode = process.exitValue();
}
退出码的具体含义由被执行程序约定。Java API 只提供整数结果,不保证某个非零值在所有程序中都有相同含义。常见约定是:
0:成功;- 非
0:失败或其他状态。
但这只是命令约定,不是所有操作系统程序的统一规范。被信号终止、发生参数错误或执行了业务级失败时,具体退出值也可能依赖平台和程序实现。
5.2 超时等待
可以使用带超时的等待:
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
if (finished) {
int exitCode = process.exitValue();
} else {
// 5 秒后仍然存活
}
返回值表示等待期间是否观察到进程退出:
true:进程已经退出;false:超时返回,进程通常仍然存活。
超时不是退出。超时后如果继续使用进程,必须明确决定是继续等待、请求终止,还是强制终止。
5.3 异步等待:onExit()
Process.onExit() 返回一个 CompletableFuture<Process>:
CompletableFuture<Process> completion = process.onExit();
completion.thenAccept(p -> {
int exitCode = p.exitValue();
System.out.println("process exited: " + exitCode);
});
它适合把“进程退出”接入异步流程:
CompletableFuture<Integer> exitCode =
process.onExit().thenApply(Process::exitValue);
但异步等待并不会自动解决标准流问题。即使使用 onExit(),父进程仍然必须及时消费 stdout 和 stderr。
一个常见错误是:
process.onExit().thenAccept(p -> {
// 进程退出后才读取输出
});
如果子进程在退出前写满管道,它可能永远无法退出,回调也就永远不会执行。流消费和进程等待是两个独立问题。
六、超时后的终止:destroy()、destroyForcibly() 和进程树
6.1 两种终止请求
Process 提供:
process.destroy();
process.destroyForcibly();
destroy() 请求正常终止,但“正常”如何实现取决于操作系统和 Java 实现。子进程可能捕获终止信号、延迟退出,或者根本不响应。
destroyForcibly() 请求强制终止。它也不是一个可以脱离状态检查的魔法操作:
process.destroyForcibly();
if (process.isAlive()) {
// 仍需继续等待或诊断
}
destroyForcibly() 返回 Process 本身,便于链式调用,但返回并不等价于“进程已经退出”。应使用 isAlive()、waitFor() 或 onExit() 确认结果。
6.2 一个完整的超时处理顺序
典型顺序是:
- 启动进程;
- 并发读取 stdout 和 stderr;
- 等待限定时间;
- 超时后先尝试
destroy(); - 给进程一个短暂的退出窗口;
- 仍然存活时使用
destroyForcibly(); - 最终等待退出;
- 收集输出并记录是否发生超时。
以下是一个可以直接运行的端到端示例。它同时包含父模式和子模式,因此不依赖系统中的 sleep、echo 等命令。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.concurrent.*;
public class ProcessApiDemo {
public static void main(String[] args) throws Exception {
if (args.length > 0 && args[0].equals("child")) {
childMain();
return;
}
runParent();
}
private static void childMain() throws InterruptedException {
System.out.println("child: stdout message");
System.err.println("child: stderr message");
// 让父进程有机会触发超时
Thread.sleep(1_500);
System.out.println("child: completed");
}
private static void runParent() throws Exception {
String javaExecutable = Path.of(
System.getProperty("java.home"),
"bin",
isWindows() ? "java.exe" : "java"
).toString();
String classPath = System.getProperty("java.class.path");
ProcessBuilder builder = new ProcessBuilder(
javaExecutable,
"-cp",
classPath,
ProcessApiDemo.class.getName(),
"child"
);
Process process = builder.start();
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Future<byte[]> stdout = executor.submit(
() -> process.getInputStream().readAllBytes()
);
Future<byte[]> stderr = executor.submit(
() -> process.getErrorStream().readAllBytes()
);
boolean completed =
process.waitFor(1, TimeUnit.SECONDS);
boolean timedOut = !completed;
if (timedOut) {
System.out.println("parent: timeout, requesting normal termination");
process.destroy();
if (!process.waitFor(200, TimeUnit.MILLISECONDS)) {
System.out.println("parent: forcing termination");
process.destroyForcibly();
process.waitFor();
}
}
int exitCode = process.exitValue();
String output = new String(
stdout.get(),
StandardCharsets.UTF_8
);
String error = new String(
stderr.get(),
StandardCharsets.UTF_8
);
System.out.println("timedOut = " + timedOut);
System.out.println("exitCode = " + exitCode);
System.out.println("--- stdout ---");
System.out.print(output);
System.out.println("--- stderr ---");
System.out.print(error);
}
}
private static boolean isWindows() {
return System.getProperty("os.name")
.toLowerCase()
.contains("win");
}
}
编译和运行:
javac ProcessApiDemo.java
java ProcessApiDemo
典型输出类似:
parent: timeout, requesting normal termination
timedOut = true
exitCode = ...
--- stdout ---
child: stdout message
--- stderr ---
child: stderr message
退出码在不同操作系统或不同终止方式下可能不同,因此示例不应依赖某个固定的非零值。重要的是:
- 子进程确实先输出了 stdout 和 stderr;
- 父进程在一秒后发现它仍未完成;
- 父进程发出终止请求;
- 最终读取并保存了已经产生的输出。
6.3 子进程和后代进程不是同一个对象
假设 Java 启动了脚本,脚本又启动了另一个长时间运行的程序:
Java 父进程
└── shell/script
└── worker
调用:
process.destroyForcibly();
通常针对的是 Process 代表的那个直接子进程。它不应被理解为“自动杀死整个进程树”。后代进程可能继续运行,并且可能继续持有 stdout/stderr 管道。
ProcessHandle 可以访问进程关系:
ProcessHandle handle = process.toHandle();
System.out.println("pid = " + handle.pid());
System.out.println("alive = " + handle.isAlive());
handle.children().forEach(child ->
System.out.println("child pid = " + child.pid())
);
handle.descendants().forEach(descendant ->
System.out.println("descendant pid = " + descendant.pid())
);
children() 和 descendants() 返回的是对当前关系的观察结果。进程树会随时变化,因此不能把一次枚举当作永久、原子的进程树快照。
如果必须清理进程树,应根据实际平台设计明确策略:
- 记录直接子进程 PID;
- 在终止时重新发现后代;
- 按后代到祖先的顺序终止;
- 再确认是否仍有残留;
- 必要时使用平台专用的作业对象、进程组或容器隔离机制。
仅依赖递归调用 destroyForcibly() 仍然存在竞态:在枚举和终止之间,原有进程可能创建新的后代。
七、ProcessHandle:PID、状态和退出通知
Process 关注的是当前 Java 程序与子进程之间的控制和流;ProcessHandle 关注操作系统进程身份和生命周期。
7.1 获取当前进程和子进程句柄
ProcessHandle current = ProcessHandle.current();
System.out.println("current pid = " + current.pid());
对于一个子进程:
Process process = new ProcessBuilder("some-program").start();
ProcessHandle handle = process.toHandle();
System.out.println("child pid = " + handle.pid());
ProcessHandle.Info 可以提供部分进程信息:
ProcessHandle.Info info = handle.info();
info.command().ifPresent(command ->
System.out.println("command = " + command)
);
info.commandLine().ifPresent(commandLine ->
System.out.println("commandLine = " + commandLine)
);
info.startInstant().ifPresent(start ->
System.out.println("start = " + start)
);
这些信息通过 Optional 返回,因为操作系统可能不提供、进程可能已经退出,或者当前用户没有足够权限读取。
不要把 commandLine() 当作安全日志格式。命令行可能包含密码、令牌或其他敏感参数;而且它是平台相关的展示信息,不是重新执行命令的可靠输入。
7.2 supportsNormalTermination()
if (handle.supportsNormalTermination()) {
handle.destroy();
} else {
handle.destroyForcibly();
}
这个方法表示当前实现是否支持“正常终止”操作。它不保证目标进程一定会及时退出,也不保证目标程序能完成清理逻辑。
ProcessHandle 的销毁方法返回布尔值,表示终止请求是否被接受,不应把它直接解释为进程已经死亡:
boolean accepted = handle.destroy();
if (accepted) {
handle.onExit().thenRun(() ->
System.out.println("process has exited")
);
}
八、标准输入输出的重定向
并非所有场景都需要 Java 直接读写管道。ProcessBuilder.Redirect 可以把流交给文件、继承当前进程,或丢弃。
8.1 输出写入文件
Path logFile = Path.of("tool-output.log");
Process process = new ProcessBuilder("some-tool")
.redirectOutput(logFile.toFile())
.redirectError(ProcessBuilder.Redirect.appendTo(logFile.toFile()))
.start();
int exitCode = process.waitFor();
这里 stdout 覆盖写入文件,stderr 追加到同一个文件。需要注意并发写入同一个文件时,输出的相对顺序不一定等同于两个独立流在子进程中的产生顺序。
8.2 继承父进程终端
调试命令行工具时可以:
Process process = new ProcessBuilder("some-tool")
.inheritIO()
.start();
int exitCode = process.waitFor();
inheritIO() 会让子进程直接使用父 Java 进程的标准输入、输出和错误。这适合交互式命令或诊断,但不适合需要程序化捕获输出的服务端任务。
8.3 丢弃输出
Java 9 及以后可以使用:
Process process = new ProcessBuilder("some-tool")
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
丢弃输出可以避免管道堆积,但也会丢失诊断信息。对生产任务而言,通常应把输出写入有大小上限的日志系统,而不是无条件丢弃。
九、命令注入:从字符串拼接到可控参数
9.1 直接把用户输入拼进 shell 命令
下面的代码是不安全的:
String fileName = request.getParameter("file");
Process process = new ProcessBuilder(
"sh",
"-c",
"cat " + fileName
).start();
如果 fileName 包含 shell 语法,例如命令分隔、管道或命令替换,shell 会把它解释为新的操作,而不是普通文件名。
风险的因果链是:
不可信输入
↓
拼接到 shell 程序文本
↓
shell 重新解析整个文本
↓
输入中的控制语法获得执行语义
↓
攻击者影响任意命令或参数
9.2 使用参数列表避免 shell 重新解析
如果目标程序可以直接启动,优先写成:
String fileName = request.getParameter("file");
Process process = new ProcessBuilder(
"cat",
"--",
fileName
).start();
这里没有 sh -c。fileName 作为一个独立参数传给 cat,其中的空格或 shell 元字符不会自动变成 shell 命令。
-- 的作用是告诉许多 Unix 命令:后面的内容不再按选项解析。例如用户输入:
--help
如果没有 --,目标程序可能把它当成选项,而不是文件名。这个问题叫作选项注入,即使没有 shell,也必须防护。
不过,-- 不是所有程序都支持;参数语义由目标程序决定。对于不支持 -- 的程序,应使用其文档规定的安全参数形式,或对输入采用允许列表和路径约束。
9.3 shell 不是唯一的危险来源
以下代码没有调用 shell,但仍可能危险:
new ProcessBuilder("rm", "-rf", userInput).start();
如果 userInput 被错误地允许为空、指向错误目录或包含目标程序认可的危险路径,仍可能造成严重破坏。
因此需要分别处理三类问题:
| 问题 | 例子 | 防护 |
|---|---|---|
| shell 注入 | sh -c 中拼接输入 |
不使用 shell;必须使用时采用严格的 shell 参数化方式 |
| 选项注入 | 输入变成 --recursive |
使用 -- 或程序特定的参数边界 |
| 业务语义危险 | 用户可指定任意路径或任意程序 | 允许列表、固定可执行文件、路径约束和权限隔离 |
9.4 固定可执行文件,不信任 PATH
下面的代码依赖操作系统搜索 PATH:
new ProcessBuilder("tool", "--input", file).start();
如果运行环境中的 PATH 被篡改,tool 可能不是预期程序。更严格的做法是固定可执行文件路径:
Path tool = Path.of("/opt/myapp/bin/tool").toAbsolutePath().normalize();
Process process = new ProcessBuilder(
tool.toString(),
"--input",
file.toString()
).start();
固定路径仍然需要检查:
- 文件是否属于预期部署目录;
- 文件权限是否正确;
- 运行用户是否有执行权限;
- 路径是否可能被符号链接替换;
- 部署过程是否可信。
路径规范化本身不能解决所有 TOCTOU,即“检查时”和“使用时”之间的竞态。高风险场景需要依赖更强的文件系统权限和隔离机制。
9.5 允许列表优于黑名单
如果业务只允许执行有限动作,不应接受任意命令字符串:
enum Operation {
COMPRESS,
CHECK
}
List<String> commandFor(Operation operation, Path input) {
return switch (operation) {
case COMPRESS -> List.of(
"/opt/app/bin/compressor",
"--",
input.toString()
);
case CHECK -> List.of(
"/opt/app/bin/checker",
"--",
input.toString()
);
};
}
这种设计使可执行文件和固定选项由程序控制,外部输入只进入被约束的参数位置。
如果参数是路径,还应额外限制它位于指定目录下。例如:
Path base = Path.of("/srv/uploads").toAbsolutePath().normalize();
Path candidate = base.resolve(userProvidedName).normalize();
if (!candidate.startsWith(base)) {
throw new SecurityException("path escapes upload directory");
}
这个检查可以防止普通的 ../ 路径穿越,但不能单独解决符号链接、挂载点和并发替换问题。安全边界还应由文件系统权限、运行用户和隔离环境共同提供。
十、一个更完整的命令执行封装
直接在业务代码中散落 start()、waitFor() 和流读取,容易遗漏超时或错误处理。可以先定义结果对象:
import java.time.Duration;
public record CommandResult(
int exitCode,
boolean timedOut,
String stdout,
String stderr
) {
public boolean succeeded() {
return !timedOut && exitCode == 0;
}
}
然后实现一个最小执行器:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.*;
public final class CommandRunner {
private CommandRunner() {
}
public static CommandResult run(
List<String> command,
Duration timeout
) throws IOException, InterruptedException, ExecutionException {
if (command == null || command.isEmpty()) {
throw new IllegalArgumentException("command must not be empty");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive");
}
Process process = new ProcessBuilder(command).start();
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Future<byte[]> stdout = executor.submit(
() -> process.getInputStream().readAllBytes()
);
Future<byte[]> stderr = executor.submit(
() -> process.getErrorStream().readAllBytes()
);
boolean completed = process.waitFor(
timeout.toNanos(),
TimeUnit.NANOSECONDS
);
boolean timedOut = !completed;
if (timedOut) {
process.destroy();
if (!process.waitFor(200, TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
process.waitFor();
}
}
int exitCode = process.exitValue();
String out = new String(stdout.get(), StandardCharsets.UTF_8);
String err = new String(stderr.get(), StandardCharsets.UTF_8);
return new CommandResult(exitCode, timedOut, out, err);
}
}
}
调用示例:
CommandResult result = CommandRunner.run(
List.of("java", "-version"),
Duration.ofSeconds(5)
);
if (result.timedOut()) {
throw new IllegalStateException("command timed out");
}
if (!result.succeeded()) {
throw new IllegalStateException(
"command failed: exit=" + result.exitCode()
+ ", stderr=" + result.stderr()
);
}
System.out.println(result.stdout());
这个封装的每一步有明确职责:
List<String>保留参数边界;ProcessBuilder.start()创建进程;- 两个读取任务分别消费 stdout 和 stderr;
waitFor(timeout)限制等待时间;- 超时后先正常终止,再强制终止;
exitValue()只在确认进程退出后调用;- 返回值同时表达退出码、超时状态和两个输出流。
这个示例仍有生产边界:readAllBytes() 不限制输出大小,而且没有递归清理后代进程。若命令来自不稳定或不可信环境,应增加输出上限、进程树策略、日志截断和资源配额。
十一、异常、取消和中断
11.1 启动异常与执行失败不是一回事
下面两种失败必须区分:
new ProcessBuilder("missing-command").start();
这通常是启动阶段 IOException,因为系统没有成功创建目标进程。
而目标程序启动后返回非零退出码:
Process process = new ProcessBuilder("some-program").start();
int exitCode = process.waitFor();
这是执行结果,不是 Java API 异常。业务代码必须根据退出码和 stderr 决定是否认为任务失败。
11.2 等待线程被中断
waitFor() 和带超时的等待都可能抛出 InterruptedException。被中断时,不应简单吞掉异常:
try {
process.waitFor();
} catch (InterruptedException e) {
process.destroyForcibly();
Thread.currentThread().interrupt();
throw e;
}
恢复中断标志可以让上层调度器知道取消已经发生。是否终止子进程取决于任务语义,但如果当前线程负责该子进程,通常不能在任务取消后任由它无限运行。
11.3 读取任务也可能失败
读取 stdout/stderr 的任务可能因:
- 进程被终止;
- 管道被关闭;
- 操作系统 I/O 错误;
- 父进程主动关闭流;
而抛出 IOException。因此,完整封装不应只检查退出码,还应检查读取任务的 Future 是否正常完成。
十二、常见误解和对应诊断
12.1 “调用 waitFor() 就能防止资源泄漏”
不能。waitFor() 只等待进程退出,不会:
- 限制执行时间;
- 读取 stdout/stderr;
- 自动终止后代进程;
- 关闭所有业务层资源;
- 判断退出码是否符合业务预期。
诊断时应同时查看:
System.out.println(process.isAlive());
System.out.println(process.pid());
System.out.println(process.toHandle().info().commandLine());
并确认 stdout 和 stderr 是否有独立读取者。
12.2 “进程退出了,读取流就一定不会阻塞”
通常直接子进程退出后,相关管道会关闭;但如果子进程启动了后代进程,而后代继承了管道句柄,父进程读取端仍可能等不到 EOF。
因此,出现“退出码已经拿到,但读取任务不结束”时,应检查:
- 是否存在后代进程;
- 后代是否继承 stdout/stderr;
- 是否有外部程序持有相同文件描述符;
- 是否需要显式关闭读取流或设置读取超时。
12.3 “destroyForcibly() 返回了,就已经终止”
不保证。它表示强制终止请求已经发出或被接受,最终状态仍要通过:
process.isAlive()
process.waitFor()
process.onExit()
确认。
12.4 “把输入包在引号中就安全了”
这种做法通常不可靠:
String command = "sh -c \"cat '" + input + "'\"";
引号规则会受到 shell、转义层数、换行、不同平台和命令替换语法影响。更可靠的结构是避免 shell:
new ProcessBuilder("cat", "--", input).start();
如果业务确实必须运行 shell 脚本,应把固定脚本作为受控文件,把用户输入作为独立参数传给脚本,并在脚本中使用对应 shell 的安全参数引用;不能把输入继续拼接为脚本文本。
十三、规范保证、平台差异和工程取舍
13.1 Java API 保证的范围
Java 标准 API 可以保证或提供:
- 使用
ProcessBuilder描述启动参数; - 通过
Process访问进程流; - 等待进程退出;
- 读取退出码;
- 请求终止;
- 通过
ProcessHandle查询部分进程信息和父子关系。
但以下内容通常不是 Java API 能统一保证的:
- 非零退出码的具体含义;
destroy()在目标程序中的实际终止行为;- 信号和控制台事件的精确映射;
- 进程树是否自动级联终止;
- 命令行展示格式;
- 外部程序的字符编码;
- shell 的语法和转义规则;
- OS 管道的具体容量。
13.2 启动外部进程还是使用 Java 库
外部进程有明确隔离边界,可以使用现成的命令行工具,但代价包括:
- 进程创建成本;
- 跨平台命令差异;
- 输出编码和错误格式不稳定;
- 需要处理超时、残留进程和流背压;
- 需要额外设计安全边界。
如果功能已有可靠的 Java 标准库或受信任的 Java 库实现,直接调用库通常更容易获得类型安全和可测试性。只有在确实需要操作系统工具、独立运行时或进程级隔离时,才应把外部进程作为接口边界。
13.3 生产执行器至少要明确这些合同
一个可维护的进程执行组件,应在接口层明确:
- 可执行文件是否固定路径;
- 参数是否全部采用独立列表元素;
- 是否允许 shell;
- stdout 和 stderr 的编码;
- 输出最大字节数;
- 连接超时、执行超时和终止宽限期;
- 超时后是否清理进程树;
- 哪些退出码算成功;
- 是否保留完整输出或仅保留截断结果;
- 子进程是否允许继承环境变量和标准输入。
这些不是 ProcessBuilder 自动提供的业务语义,而是调用方必须建立的执行协议。
Java Process API 的核心边界可以归纳为:ProcessBuilder 负责描述启动,Process 负责流和生命周期控制,ProcessHandle 负责进程身份与关系;而超时、输出规模、退出码含义、进程树清理和命令注入防护,都必须由应用程序显式设计。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 密码学 API:随机数、哈希、MAC、对称加密、签名和密钥
- 下一篇:Java 25 Foreign Function & Memory API:Arena、MemorySegment 和本地调用
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论