Java ProcessBuilder & Process API: Running OS Commands from Java

Java's Process API lets you launch external OS processes, capture their output, supply input, and monitor their lifecycle from within JVM code. ProcessBuilder configures the command, working directory, environment variables, and I/O redirection. ProcessHandle (Java 9+) lets you query and kill processes by PID, list descendants, and react when a process terminates via a CompletableFuture. This article covers both APIs with practical examples.

Why the Process API Exists

Some tasks simply cannot be done from within the JVM: running a shell script, invoking git, transcoding a video with ffmpeg, converting a document with pandoc, or executing a platform-specific native tool. The Process API bridges the gap: it lets Java code start, communicate with, and wait for external OS processes without leaving the JVM.

The original mechanism was Runtime.exec(String command), introduced in Java 1.0. It had two significant problems: it invoked the system shell for argument splitting, making shell-injection exploits trivially easy, and it returned a Process object with no way to configure working directory or environment before starting. ProcessBuilder, added in Java 5, replaced it with a proper builder pattern. ProcessHandle, added in Java 9, added the ability to introspect, monitor, and control processes by their OS PID, including processes the JVM did not start itself.

ProcessBuilder: Configuring and Starting a Process

A ProcessBuilder is configured before it is started. You provide the command as a list of strings: the first element is the executable, the rest are arguments. Passing each argument as a separate list element is important: ProcessBuilder does not use a shell for parsing, so it passes arguments directly to the OS without any splitting or quoting ambiguity. Additional configuration includes directory() for the working directory, environment() for environment variables, and redirectErrorStream(true) to merge stderr into stdout.

Calling start() forks the process and returns a Process handle. Use waitFor() to block until the process exits and retrieve its exit code, or use waitFor(timeout, unit) to add a deadline.

Java
import java.io.*;
import java.util.List;

// Run: git log --oneline -5
ProcessBuilder pb = new ProcessBuilder(
    "git", "log", "--oneline", "-5"
);
pb.directory(new File("/path/to/repo"));   // working directory
pb.redirectErrorStream(true);              // merge stderr into stdout

Process process = pb.start();

// Read all output before calling waitFor() — see next section for why
String output;
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(process.getInputStream()))) {
    output = reader.lines()
                   .collect(java.util.stream.Collectors.joining("\n"));
}

int exitCode = process.waitFor();
System.out.println("Exit code: " + exitCode);
System.out.println(output);
ProcessBuilder does not invoke a shell, so shell features like |, >, * globbing, and ~ expansion do not work. To use them, explicitly invoke the shell: new ProcessBuilder("sh", "-c", "ls *.java | wc -l"): but be careful about shell injection when building the command string dynamically.

Reading stdout and stderr: Avoiding Deadlock

A child process writes its output to two streams: stdout and stderr. Each stream has a fixed-size OS pipe buffer (typically 64 KB on Linux). If your Java code calls waitFor() before consuming the streams, and the process tries to write more than the buffer holds, the process blocks waiting for the buffer to drain. Meanwhile, the Java code is blocked in waitFor() waiting for the process to exit. Both sides wait for each other: a classic deadlock.

The safest solution is to consume stdout and stderr in separate threads, or redirect stderr to a file/INHERIT/DISCARD before calling waitFor(). If you set redirectErrorStream(true), both streams are merged into one and you only need one reader thread.

Java
ProcessBuilder pb = new ProcessBuilder("javac", "BadCode.java");
pb.directory(new File("/tmp"));

// Redirect stderr to a file — no need to consume it manually
pb.redirectError(new File("/tmp/compile-errors.txt"));

Process proc = pb.start();

// Read stdout in the calling thread (stderr goes to file)
String stdout;
try (var reader = new BufferedReader(
        new InputStreamReader(proc.getInputStream()))) {
    stdout = reader.lines().collect(java.util.stream.Collectors.joining("\n"));
}

int exit = proc.waitFor();
System.out.println("Compiler exit: " + exit);

// --- Alternative: consume both streams concurrently with threads ---
Process proc2 = new ProcessBuilder("some-verbose-tool").start();

Thread stdoutReader = Thread.ofVirtual().start(() -> {
    try (var r = new BufferedReader(new InputStreamReader(proc2.getInputStream()))) {
        r.lines().forEach(System.out::println);
    } catch (IOException e) { /* handle */ }
});
Thread stderrReader = Thread.ofVirtual().start(() -> {
    try (var r = new BufferedReader(new InputStreamReader(proc2.getErrorStream()))) {
        r.lines().forEach(System.err::println);
    } catch (IOException e) { /* handle */ }
});

proc2.waitFor();
stdoutReader.join();
stderrReader.join();
Never call waitFor() before consuming the process streams if the command can produce significant output. The result is a deadlock that is invisible in tests with small output but fails in production.

Sending Input to a Process

The stdin of a child process is exposed as an OutputStream via process.getOutputStream(). Anything you write to this stream appears as standard input to the process. After you have finished sending input, you must close the stream to signal EOF: otherwise the child process may block forever waiting for more input and your program will deadlock waiting for the process to exit.

Java
// Pipe text through a command that reads stdin (e.g., wc -w to count words)
ProcessBuilder pb = new ProcessBuilder("wc", "-w");
pb.redirectErrorStream(true);
Process process = pb.start();

// Write to the process's stdin in a separate thread
// (to avoid deadlock when output fills the buffer before we finish writing)
String input = "The quick brown fox jumps over the lazy dog";
Thread writer = Thread.ofVirtual().start(() -> {
    try (PrintWriter pw = new PrintWriter(
            new OutputStreamWriter(process.getOutputStream()))) {
        pw.println(input);
    } // closing the stream signals EOF to the process
});

String output;
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(process.getInputStream()))) {
    output = reader.lines().collect(java.util.stream.Collectors.joining());
}

writer.join();
process.waitFor();
System.out.println("Word count: " + output.trim()); // 9

ProcessHandle: Querying and Managing Processes (Java 9+)

ProcessHandle was introduced in Java 9 to address a gap in the original API: once a process was running, Java had almost no ability to inspect or control it. ProcessHandle provides a stable abstraction over OS PIDs. You can get a handle to the current JVM process, look up any OS process by PID, iterate all visible processes, and subscribe to process termination events through a CompletableFuture.

The ProcessHandle.Info snapshot provides the executable path, arguments, user, and start time, where the OS permits access. Note that some fields may return empty Optionals depending on the OS and the requesting user's permissions.

Java
import java.lang.ProcessHandle;
import java.time.ZoneId;

// Current JVM process
ProcessHandle current = ProcessHandle.current();
System.out.println("JVM PID: " + current.pid());

ProcessHandle.Info info = current.info();
info.command().ifPresent(cmd -> System.out.println("Command: " + cmd));
info.startInstant().ifPresent(t ->
    System.out.println("Started: " + t.atZone(ZoneId.systemDefault())));

// Look up a process by PID
ProcessHandle.of(1234).ifPresent(ph -> {
    System.out.println("Process 1234 alive: " + ph.isAlive());
    ph.destroy(); // SIGTERM
    // ph.destroyForcibly(); // SIGKILL
});

// React when a child process finishes
Process child = new ProcessBuilder("sleep", "2").start();
child.toHandle()
     .onExit()
     .thenAccept(ph -> System.out.println("Process " + ph.pid() + " finished"));

// List all children of the current process
ProcessHandle.current().children()
    .forEach(ph -> System.out.println("Child PID: " + ph.pid()));

Process Pipelines: Chaining Commands (Java 9+)

In a Unix shell, the pipe operator | chains processes so that one process's stdout feeds directly into the next process's stdin. ProcessBuilder.startPipeline(), added in Java 9, replicates this at the API level: it starts a list of ProcessBuilders as a connected pipeline and returns a list of Process handles. Only the last process in the pipeline has a readable stdout; the intermediate streams are connected internally by the OS.

Java
import java.util.List;

// Equivalent of: ls | grep .java | sort
List<Process> pipeline = ProcessBuilder.startPipeline(List.of(
    new ProcessBuilder("ls"),
    new ProcessBuilder("grep", ".java"),
    new ProcessBuilder("sort")
));

// Only the last process has output we need to read
Process last = pipeline.get(pipeline.size() - 1);
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(last.getInputStream()))) {
    reader.lines().forEach(System.out::println);
}

// Wait for all processes to finish
for (Process p : pipeline) {
    p.waitFor();
}
When a pipeline stage fails (non-zero exit), the JVM does not automatically kill the other stages. Iterate the Process list and call destroy() on each if you detect a failure, or check all exit codes after waitFor().