Java 基础体系 · 第 52/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java Process API:启动子进程、流、超时、退出和命令注入防护

Java 的 Process API 用于让当前 Java 虚拟机启动并管理操作系统层面的子进程。子进程可以是另一个 Java 程序,也可以是操作系统命令、脚本解释器或外部工具。

它解决的不是“调用一个方法并拿到字符串”这么简单,而是一条完整的生命周期:

  1. 构造命令和进程属性;
  2. 创建子进程;
  3. 读写标准输入、标准输出和标准错误;
  4. 等待完成或设置超时;
  5. 获取退出状态;
  6. 在超时、异常和进程树残留时执行清理;
  7. 避免把不可信输入交给 shell,从而形成命令注入。

本文以 Java 25 LTS 的标准 API 为范围,重点介绍 ProcessBuilderProcessProcessHandle


一、先区分 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() 父进程读取,数据来自子进程的标准错误

也就是说,ProcessgetInputStream() 并不是“让父进程输入”,而是“读取子进程输出”。

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");

这里有五个参数:

  1. grep
  2. -F
  3. --
  4. userText
  5. file.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 一个完整的超时处理顺序

典型顺序是:

  1. 启动进程;
  2. 并发读取 stdout 和 stderr;
  3. 等待限定时间;
  4. 超时后先尝试 destroy()
  5. 给进程一个短暂的退出窗口;
  6. 仍然存活时使用 destroyForcibly()
  7. 最终等待退出;
  8. 收集输出并记录是否发生超时。

以下是一个可以直接运行的端到端示例。它同时包含父模式和子模式,因此不依赖系统中的 sleepecho 等命令。

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() 返回的是对当前关系的观察结果。进程树会随时变化,因此不能把一次枚举当作永久、原子的进程树快照。

如果必须清理进程树,应根据实际平台设计明确策略:

  1. 记录直接子进程 PID;
  2. 在终止时重新发现后代;
  3. 按后代到祖先的顺序终止;
  4. 再确认是否仍有残留;
  5. 必要时使用平台专用的作业对象、进程组或容器隔离机制。

仅依赖递归调用 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 -cfileName 作为一个独立参数传给 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、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。