Skip to content

How to Use ProcessBuilder to Run Commands in Java 21

How to use ProcessBuilder in Java 21: run a command, read stdout and stderr, feed stdin, set a timeout and kill a stuck child, with 14 checks and 5 traps.

how-to-tutorials java-io java-tutorials
Gopi Gorantala
Reading Progress

On This Page

A ticket export that runs four commands

A support team keeps its open tickets in a CSV file, and every morning someone runs grep, cut, sort and uniq by hand to see how many tickets each category has. This is what the finished Java program prints when it does that for them, then runs a command that hangs, and then runs one that doesn't exist:

-- 1. run and read stdout
exit=0 stdout=6 tickets.csv
-- 2. stderr kept separate, non-zero exit
exit=2 ok=false
stderr=ls: cannot access 'no-such-file.csv': No such file or directory
-- 5. pipeline: open tickets per category
      2 billing
      2 login
-- 6. timeout
timedOut=true tookAbout=1000ms
-- 7. command that does not exist
IOException: Cannot run program "no-such-tool" (in directory "/tmp/tickets6891492897552760550"): Exec failed, error: 2 (No such file or directory)

If you're searching for how to use ProcessBuilder in Java, the short version is new ProcessBuilder("wc", "-l", "tickets.csv").start(), then read the output, then waitFor(). That works on a sunny day. It also hangs forever on a rainy one, and most of this article is about the rainy days: a child that writes too much, a child that never finishes, a child that spawned a child of its own. I forced each of those on purpose so you can see them before they happen in production.

It's for you if you can write a Java class and run it from a terminal. There's one small library class (CommandRunner), a demo, a trap file and a checks file. No framework, no build tool, no container.

I built and ran everything on plain javac, because Maven Central wasn't reachable from my build machine. Most of the build time went on one bug: a timeout that fired on time but still took six seconds to return. That's Step 5, and it's the part I'd read twice.

Check your setup

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
javac 21.0.12.1 javac -version
OS Ubuntu 24.04 (Linux) uname -a
Commands used GNU coreutils 9.4 (wc, ls, sort, cut, uniq, head), grep, sh (dash) wc --version
Build tool none
Container runtime none needed

The commands are Linux/macOS commands. On Windows the Java is identical but the commands inside the lists aren't; there's a note in the going-further section. I did not run this on Windows.

CommandRunner uses Executors.newVirtualThreadPerTaskExecutor(), so it needs Java 21. On Java 11 to 20, swap in Executors.newCachedThreadPool() and nothing else changes.

If you don't have a JDK 21, SDKMAN installs one: sdk install java 21.0.12-tem (check sdk list java for the exact identifier on your machine; I didn't run SDKMAN myself).

Lay out the project

Create the folders and open them in your editor. The layout is flat on purpose:

mkdir -p ticket-tools/src/main/java/dev/ggorantala/tickets
mkdir -p ticket-tools/src/test/java/dev/ggorantala/tickets
cd ticket-tools
ticket-tools/
├── src/main/java/dev/ggorantala/tickets/
│   ├── CommandRunner.java
│   ├── Main.java
│   ├── Traps.java
│   └── Extras.java
└── src/test/java/dev/ggorantala/tickets/
    └── Checks.java

In IntelliJ IDEA: File | New | Project, choose Java, pick your JDK 21 and leave the build system on IntelliJ, then create the package dev.ggorantala.tickets under src. Put the test file in a separate folder marked as Test Sources if you like, or drop it in src. I did not run IntelliJ for this article; the wizard names are from the JetBrains help pages.

In Eclipse: File | New | Java Project, set the execution environment to JavaSE-21, and create the package with File | New | Package. Same caveat: docs only, not executed.

From the command line (what I ran): you compile everything into an out folder.

javac --release 21 -Xlint:all -d out $(find src -name '*.java')

On Windows PowerShell, find isn't the Unix one. Use javac --release 21 -d out (Get-ChildItem -Recurse -Filter *.java src).FullName. Docs only; I didn't run it.

How to use ProcessBuilder in Java, step by step

Five steps. Each one adds a capability and ends with something you can run.

Step 1: Run a command and read what it prints

Goal: the smallest useful wrapper. Start a process, read its output, report the exit code.

src/main/java/dev/ggorantala/tickets/CommandRunner.java

package dev.ggorantala.tickets;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/** Runs one external command and hands back everything that happened. */
public final class CommandRunner {

    public record Result(int exitCode, String stdout, String stderr, boolean timedOut) {
        public boolean ok() {
            return !timedOut && exitCode == 0;
        }
    }

    private static final ExecutorService DRAINERS = Executors.newVirtualThreadPerTaskExecutor();

    private final Path workDir;
    private final Map<String, String> extraEnv;

    public CommandRunner(Path workDir, Map<String, String> extraEnv) {
        this.workDir = workDir;
        this.extraEnv = new HashMap<>(extraEnv);
    }

    public static CommandRunner inDefaultDirectory() {
        return new CommandRunner(null, Map.of());
    }

    public Result run(List<String> command, Duration timeout, String stdin)
            throws IOException, InterruptedException {
        ProcessBuilder pb = new ProcessBuilder(command);
        if (workDir != null) {
            pb.directory(workDir.toFile());
        }
        pb.environment().putAll(extraEnv);

        Process process = pb.start();

        // Start reading BEFORE anything else, or a chatty child fills the pipe and blocks.
        CompletableFuture<String> out = drain(process.getInputStream());
        CompletableFuture<String> err = drain(process.getErrorStream());

        try (OutputStream toChild = process.getOutputStream()) {
            if (stdin != null) {
                toChild.write(stdin.getBytes(StandardCharsets.UTF_8));
            }
        } catch (IOException childClosedEarly) {
            // The child exited before reading everything ("head", for example). That's fine.
        }

        boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
        if (!finished) {
            killTree(process);
            return new Result(-1, collect(out), collect(err), true);
        }
        return new Result(process.exitValue(), collect(out), collect(err), false);
    }

    private static void killTree(Process process) throws InterruptedException {
        // Kill the children first: a shell that forked "sleep" leaves it holding our pipes open.
        process.descendants().forEach(ProcessHandle::destroyForcibly);
        process.destroy();
        if (!process.waitFor(2, TimeUnit.SECONDS)) {
            process.destroyForcibly();
            process.waitFor();
        }
    }

    private static CompletableFuture<String> drain(InputStream in) {
        return CompletableFuture.supplyAsync(() -> {
            try (in) {
                return new String(in.readAllBytes(), StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }, DRAINERS);
    }

    private static String collect(CompletableFuture<String> future) {
        try {
            return future.get(5, TimeUnit.SECONDS);
        } catch (Exception e) {
            return "[output unavailable: " + e.getClass().getSimpleName() + "]";
        }
    }
}

That's the whole library class, and I'll walk through it in pieces below, because the order of the lines inside run matters more than any single line.

A command is a List<String>: the program first, then one list element per argument. There's no shell in between, so no quoting rules and no expansion. List.of("wc", "-l", "tickets.csv") is three strings, not a sentence.

start() returns immediately. The child is now running, and its stdout and stderr are two pipes that the operating system buffers for you (typically around 64 KB on Linux; I didn't measure the exact number on this machine). When a buffer's full, the child blocks until somebody reads. That somebody is you. That's why drain is called right after start() and before anything that waits.

Result is a record with the exit code, both streams as strings, and a timedOut flag. By convention exit code 0 means success; I wrapped that in ok() so callers don't re-learn it.

Checkpoint: compile the lot.

javac --release 21 -Xlint:all -d out $(find src -name '*.java')

No output means success. -Xlint:all stays quiet on this code.

Step 2: Add stdin, a working directory and an environment variable

Goal: run a command somewhere specific, with its own variables and some input.

CommandRunner already does this. The three relevant lines are pb.directory(...), pb.environment().putAll(extraEnv), and the try block that writes stdin and closes the child's input. Closing matters: sort doesn't print anything until its input ends, so forgetting to close it is another way to hang.

src/main/java/dev/ggorantala/tickets/Main.java

package dev.ggorantala.tickets;

import dev.ggorantala.tickets.CommandRunner.Result;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;

public class Main {

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("tickets");
        Path csv = dir.resolve("tickets.csv");
        Files.writeString(csv, """
                1001,OPEN,billing
                1002,CLOSED,login
                1003,OPEN,login
                1004,OPEN,billing
                1005,PENDING,shipping
                1006,OPEN,login
                """);

        CommandRunner runner = new CommandRunner(dir, Map.of("TICKET_QUEUE", "support"));
        Duration limit = Duration.ofSeconds(5);

        System.out.println("-- 1. run and read stdout");
        Result r = runner.run(List.of("wc", "-l", "tickets.csv"), limit, null);
        System.out.println("exit=" + r.exitCode() + " stdout=" + r.stdout().trim());

        System.out.println("-- 2. stderr kept separate, non-zero exit");
        r = runner.run(List.of("ls", "no-such-file.csv"), limit, null);
        System.out.println("exit=" + r.exitCode() + " ok=" + r.ok());
        System.out.println("stderr=" + r.stderr().trim());

        System.out.println("-- 3. feed stdin");
        r = runner.run(List.of("sort", "-t,", "-k3,3", "-k1,1"), limit, Files.readString(csv));
        System.out.print(r.stdout());

        System.out.println("-- 4. environment variable reaches the child");
        r = runner.run(List.of("sh", "-c", "echo queue=$TICKET_QUEUE"), limit, null);
        System.out.print(r.stdout());

        System.out.println("-- 5. pipeline: open tickets per category");
        List<Process> chain = ProcessBuilder.startPipeline(List.of(
                new ProcessBuilder("grep", ",OPEN,").directory(dir.toFile())
                        .redirectInput(csv.toFile()),
                new ProcessBuilder("cut", "-d,", "-f3"),
                new ProcessBuilder("sort"),
                new ProcessBuilder("uniq", "-c")));
        String piped = new String(chain.get(chain.size() - 1).getInputStream().readAllBytes());
        System.out.print(piped);
        for (Process p : chain) {
            p.waitFor();
        }

        System.out.println("-- 6. timeout");
        long t0 = System.nanoTime();
        r = runner.run(List.of("sh", "-c", "sleep 30; echo never"), Duration.ofSeconds(1), null);
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.println("timedOut=" + r.timedOut() + " tookAbout=" + (ms / 100) * 100 + "ms");

        System.out.println("-- 7. command that does not exist");
        try {
            runner.run(List.of("no-such-tool"), limit, null);
        } catch (IOException e) {
            System.out.println("IOException: " + e.getMessage());
        }
    }
}

Don't run it yet. Steps 3 to 5 explain the rest of it.

One note on pb.environment(): it starts as a copy of your own environment, so the child inherits PATH, HOME and everything else. Call pb.environment().clear() first if you want a clean slate, and then remember you've also removed PATH.

Step 3: Keep stderr separate and check the exit code

Goal: tell "it printed an error" apart from "it printed something".

In CommandRunner, stdout and stderr are drained by two separate virtual threads, so the two streams never mix. Section 2 of the demo asks ls for a file that isn't there, and the runner reports exit code 2 and the message on stderr, with stdout empty.

If you'd rather have a single interleaved stream (handy for a log file), ProcessBuilder can merge them for you. Extras.java shows that along with inheritIO() and the process metadata:

src/main/java/dev/ggorantala/tickets/Extras.java

package dev.ggorantala.tickets;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Extras {

    public static void main(String[] args) throws IOException, InterruptedException {
        Path log = Files.createTempFile("export", ".log");

        // Merge stderr into stdout and send both to a file.
        Process p = new ProcessBuilder("sh", "-c", "echo exporting; echo warning: slow disk >&2; echo finished")
                .redirectErrorStream(true)
                .redirectOutput(ProcessBuilder.Redirect.appendTo(log.toFile()))
                .start();
        System.out.println("exit=" + p.waitFor());
        System.out.print(Files.readString(log));

        // Let the child write straight to this JVM's console.
        System.out.println("-- inheritIO");
        int code = new ProcessBuilder("echo", "hello from the child").inheritIO().start().waitFor();
        System.out.println("exit=" + code);

        // Process metadata
        Process sleeper = new ProcessBuilder("sleep", "5").start();
        System.out.println("alive=" + sleeper.isAlive() + " command=" + sleeper.info().command().orElse("?"));
        sleeper.destroy();
        System.out.println("exit after destroy=" + sleeper.waitFor());
    }
}

Run it:

java -cp out dev.ggorantala.tickets.Extras
exit=0
exporting
warning: slow disk
finished
-- inheritIO
hello from the child
exit=0
alive=true command=/usr/bin/sleep
exit after destroy=143

Exit code 143 is 128 plus 15: the child died from signal 15 (SIGTERM) on Linux. Don't build logic on that specific value; it's platform behaviour.

Step 4: Chain commands with startPipeline

Goal: grep ... | cut ... | sort | uniq -c without a shell.

Section 5 of Main does it. ProcessBuilder.startPipeline takes a list of builders, wires each child's stdout to the next child's stdin at the operating system level, and returns the processes. The first builder needs an input source (I used redirectInput on the CSV), and you read from the last process only.

Two details I tripped over. First, the intermediate processes' output never passes through the JVM, which is why this is cheap for large files. Second, you still need to waitFor() on every process in the list; reading the last one's output doesn't reap the others.

Step 5: Add a timeout, and kill the whole process tree

Goal: a command that never finishes can't hang the application.

This is the waitFor(timeout) call and the killTree method in CommandRunner. The first version I wrote was shorter:

// What I wrote first. Don't copy this.
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
    process.destroy();
}

It looked right. The timeout fired after half a second and destroy() returned at once. And the reader thread was still blocked six seconds later. I'll show you why in the traps section; the fix is the first line of killTree, which kills the descendants before the process itself.

Checkpoint: run the demo.

java -cp out dev.ggorantala.tickets.Main

The full output is in the next section.

See it working

Run the demo:

java -cp out dev.ggorantala.tickets.Main
-- 1. run and read stdout
exit=0 stdout=6 tickets.csv
-- 2. stderr kept separate, non-zero exit
exit=2 ok=false
stderr=ls: cannot access 'no-such-file.csv': No such file or directory
-- 3. feed stdin
1001,OPEN,billing
1004,OPEN,billing
1002,CLOSED,login
1003,OPEN,login
1006,OPEN,login
1005,PENDING,shipping
-- 4. environment variable reaches the child
queue=support
-- 5. pipeline: open tickets per category
      2 billing
      2 login
-- 6. timeout
timedOut=true tookAbout=1000ms
-- 7. command that does not exist
IOException: Cannot run program "no-such-tool" (in directory "/tmp/tickets6891492897552760550"): Exec failed, error: 2 (No such file or directory)

The temp directory name changes on every run. Everything else should match yours.

Now the traps. Each one below is something I've seen in real code reviews, forced here so you can watch it fail.

src/main/java/dev/ggorantala/tickets/Traps.java

package dev.ggorantala.tickets;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;

/** Each trap is forced on purpose. Don't copy these into real code. */
public class Traps {

    public static void main(String[] args) throws Exception {
        // Trap 1: child writes 200 KB to stderr, we only read stdout (after waitFor).
        Process p = new ProcessBuilder("sh", "-c", "head -c 200000 /dev/zero | tr '\\0' x >&2; echo done")
                .start();
        boolean finished = p.waitFor(3, TimeUnit.SECONDS);
        System.out.println("1. undrained stderr finished within 3s? " + finished);
        p.destroyForcibly();

        // Trap 2: no shell, so no expansion.
        p = new ProcessBuilder("echo", "$HOME", "*.csv").start();
        System.out.print("2. echo via ProcessBuilder: " + new String(p.getInputStream().readAllBytes()));
        p = new ProcessBuilder("sh", "-c", "echo $HOME").start();
        System.out.print("   echo via sh -c:          " + new String(p.getInputStream().readAllBytes()));

        // Trap 3: destroy() only the shell while another thread is reading its stdout.
        p = new ProcessBuilder("sh", "-c", "sleep 6; echo late").start();
        InputStream stdout = p.getInputStream();
        long t0 = System.nanoTime();
        Thread reader = Thread.ofVirtual().start(() -> {
            try {
                stdout.readAllBytes();
            } catch (IOException ignored) {
            }
        });
        Thread.sleep(300);
        p.destroy();
        p.waitFor();
        System.out.println("3. shell dead after " + (System.nanoTime() - t0) / 1_000_000 + " ms, alive=" + p.isAlive());
        reader.join();
        System.out.println("   reader finished after " + (System.nanoTime() - t0) / 1_000_000 + " ms");

        // Trap 4: one argument string with spaces.
        try {
            new ProcessBuilder("wc -l tickets.csv").start();
        } catch (IOException e) {
            System.out.println("4. " + e.getMessage());
        }

        // Trap 5: shell injection
        String userInput = "x; echo INJECTED";
        p = new ProcessBuilder("sh", "-c", "echo searching " + userInput).start();
        System.out.print("5. sh -c with concatenated input prints: " + new String(p.getInputStream().readAllBytes()).replace("\n", " | ") + "\n");
        p = new ProcessBuilder("echo", "searching", userInput).start();
        System.out.print("   argument list prints:               " + new String(p.getInputStream().readAllBytes()));
        System.exit(0);
    }
}
java -cp out dev.ggorantala.tickets.Traps
1. undrained stderr finished within 3s? false
2. echo via ProcessBuilder: $HOME *.csv
   echo via sh -c:          /root
3. shell dead after 310 ms, alive=false
   reader finished after 6002 ms
4. Cannot run program "wc -l tickets.csv": Exec failed, error: 2 (No such file or directory) 
5. sh -c with concatenated input prints: searching x | INJECTED | 
   argument list prints:               searching x; echo INJECTED

Five lines, five bugs:

  1. The child wrote 200 KB to stderr while the parent only waited. The pipe filled, the child blocked, and waitFor never saw it exit. This is the classic deadlock. The fix is to read both streams from the start, which CommandRunner does.
  2. echo $HOME *.csv printed the dollar sign and the star, literally. No shell, no expansion. When you do need a shell, you have to ask for one with sh -c, and then you've also taken on shell quoting.
  3. destroy() killed the shell in 310 ms (300 of those are my deliberate sleep), but the sleep 6 it had started carried on, still holding the output pipe open. The reader didn't finish until 6002 ms. That's the bug from Step 5.
  4. One string containing spaces is a program name, not a command line. Split it into list elements.
  5. When user input is glued into an sh -c string, ; echo INJECTED runs as a second command. The same input as a list element is one inert argument. If any part of a command comes from a user, a file or a network call, build a list and don't use a shell.

Write the checks

I'd normally reach for JUnit 5 here, and I'll say how in a moment. But Maven Central wasn't reachable from my build machine, so these are plain checks with a main method, one line per behaviour. Run them the same way.

src/test/java/dev/ggorantala/tickets/Checks.java

package dev.ggorantala.tickets;

import dev.ggorantala.tickets.CommandRunner.Result;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;

/** Plain checks, no test framework. Exit code 1 if any check fails. */
public class Checks {

    private static int passed;
    private static int failed;

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("checks");
        Files.writeString(dir.resolve("a.txt"), "one\ntwo\nthree\n");
        CommandRunner runner = new CommandRunner(dir, Map.of("TICKET_QUEUE", "support"));
        Duration limit = Duration.ofSeconds(5);

        Result r = runner.run(List.of("wc", "-l", "a.txt"), limit, null);
        check("exit code 0 on success", r.exitCode() == 0 && r.ok());
        check("stdout captured", r.stdout().trim().equals("3 a.txt"));
        check("working directory applied", r.stdout().contains("a.txt"));

        r = runner.run(List.of("ls", "missing.txt"), limit, null);
        check("non-zero exit code reported", r.exitCode() != 0 && !r.ok());
        check("stderr is separate from stdout", r.stdout().isEmpty() && r.stderr().contains("missing.txt"));

        r = runner.run(List.of("sort"), limit, "b\na\nc\n");
        check("stdin reaches the child", r.stdout().equals("a\nb\nc\n"));

        r = runner.run(List.of("sh", "-c", "echo $TICKET_QUEUE"), limit, null);
        check("extra environment variable visible", r.stdout().trim().equals("support"));

        r = runner.run(List.of("echo", "$TICKET_QUEUE", "*.txt"), limit, null);
        check("no shell means no expansion", r.stdout().trim().equals("$TICKET_QUEUE *.txt"));

        r = runner.run(List.of("sh", "-c", "head -c 300000 /dev/zero >&2; head -c 300000 /dev/zero"), limit, null);
        check("300 KB on both streams does not deadlock", !r.timedOut() && r.stdout().length() == 300_000 && r.stderr().length() == 300_000);

        r = runner.run(List.of("head", "-c", "1"), limit, "x".repeat(2_000_000));
        check("child that stops reading stdin early is fine", !r.timedOut() && r.stdout().equals("x"));

        long t0 = System.nanoTime();
        r = runner.run(List.of("sh", "-c", "sleep 30; echo never"), Duration.ofMillis(500), null);
        long ms = (System.nanoTime() - t0) / 1_000_000;
        check("timeout flagged", r.timedOut() && !r.ok());
        check("timeout returns in under 3 s even though sleep was a grandchild (" + ms + " ms)", ms < 3000);
        check("no output captured from a killed command", r.stdout().isEmpty());

        try {
            runner.run(List.of("definitely-not-a-command"), limit, null);
            check("missing executable throws IOException", false);
        } catch (IOException expected) {
            check("missing executable throws IOException", expected.getMessage().contains("definitely-not-a-command"));
        }

        System.out.println(passed + " passed, " + failed + " failed");
        System.exit(failed == 0 ? 0 : 1);
    }

    private static void check(String name, boolean condition) {
        if (condition) {
            passed++;
            System.out.println("PASS " + name);
        } else {
            failed++;
            System.out.println("FAIL " + name);
        }
    }
}
javac --release 21 -Xlint:all -d out $(find src -name '*.java')
java -cp out dev.ggorantala.tickets.Checks
PASS exit code 0 on success
PASS stdout captured
PASS working directory applied
PASS non-zero exit code reported
PASS stderr is separate from stdout
PASS stdin reaches the child
PASS extra environment variable visible
PASS no shell means no expansion
PASS 300 KB on both streams does not deadlock
PASS child that stops reading stdin early is fine
PASS timeout flagged
PASS timeout returns in under 3 s even though sleep was a grandchild (507 ms)
PASS no output captured from a killed command
PASS missing executable throws IOException
14 passed, 0 failed

The 507 ms line is the one worth keeping. I checked that it earns its place: with the descendants() line deleted, the same check fails at 10511 ms (two 5-second output guards back to back) and the "no output" check fails with it, so 12 pass and 2 fail.

To move these into JUnit 5, each check(...) becomes an @Test with an AssertJ or JUnit assertion. I did not run that version, so I won't show output for it. These tests also use Linux commands, so annotate them with @EnabledOnOs({OS.LINUX, OS.MAC}) if your CI includes Windows.

What the pipe buffer is doing to you

Every child process gets three pipes from the operating system: one in, two out. Each has a small buffer, and a write to a full buffer blocks the writer. Java doesn't read the child's output on your behalf; getInputStream() is you reading the child's stdout, and getErrorStream() is you reading its stderr. If you read one and the child fills the other, both of you wait on each other forever.

That one fact explains three of the traps. Undrained stderr: the child blocks on write. Unclosed stdin: the child blocks on read. Grandchild holding a pipe: your read blocks because the pipe's write end is still open somewhere, even though the process you started is gone. A pipe only reports end-of-file when every holder of the write end has closed it.

destroy() sends SIGTERM to that one process on Linux and macOS. destroyForcibly() sends SIGKILL. Neither touches the children. Process.descendants() returns a stream of ProcessHandles for them, which is how killTree reaches them.

For deeper reading on the threading side, the virtual-thread drainers in CommandRunner are the same pattern as the worker pools in how to configure a thread pool with ExecutorService, and the stream handling follows the same rules as how to use try-with-resources correctly. If you want a producer and a consumer on separate threads instead of one-shot draining, see how to use BlockingQueue for producer-consumer pipelines.

If something breaks

What you see What it means Fix
java.io.IOException: Cannot run program "no-such-tool" ... error=2, No such file or directory The executable isn't on PATH (or the working directory doesn't exist). Note: the message names the directory too, which misleads people into blaming it. Check the program name with which <name>. On Windows, built-ins like dir aren't programs; run cmd /c dir.
The program hangs, no output, CPU idle A pipe filled up. Usually stderr you didn't read. Read stdout and stderr concurrently from the start, or use redirectErrorStream(true).
The timeout fires, but the call takes much longer to return You killed the parent but a grandchild kept the pipe open. Destroy process.descendants() first, then the process.
cannot find symbol ... newVirtualThreadPerTaskExecutor() when compiling with --release 17 Virtual threads are Java 21. Compile with --release 21, or replace the executor with newCachedThreadPool().
echo $HOME prints $HOME No shell, no expansion. Resolve the value in Java (System.getenv("HOME")), or deliberately use sh -c.
A command with a space in its name fails with error=2 One string is one program name. Split into list elements.
Exit code 143 or 137 143 is SIGTERM (128+15), 137 is SIGKILL (128+9). You, or the operating system, killed it. Not a bug in the command.

Taking it to production

Treat the command line as an API boundary. Fixed program name, fixed flags, user data only ever as a single list element. Never build a string and hand it to sh -c. If you must use a shell, validate the input against an allow-list first.

Set a timeout on every call. Pick it from what the command should take, then double it. There's no "no timeout" setting that's safe.

Log the command, the exit code and the first few hundred characters of stderr on failure. Don't log the full stdout; it can be enormous, and it can contain data you shouldn't be writing to logs.

Large output: CommandRunner keeps all of stdout in memory. For a command that emits gigabytes, use redirectOutput(Redirect.to(file)) instead and read the file with NIO.2.

Retries: a command that failed with a transient error is a candidate for retry with exponential backoff; one that timed out usually isn't, since it may have done half its work.

Windows: the commands in this article don't exist there. Use cmd /c <builtin> for built-ins and powershell -NoProfile -Command ... for PowerShell, and expect .cmd and .bat files to need cmd /c as well. I didn't test any of that, so check the current JDK documentation for ProcessBuilder and the Windows quoting notes.

Versions: Process.inputReader() and errorReader() arrived in Java 17 and are a shorter way to get a text reader. ProcessHandle and Process.descendants() came with Java 9. startPipeline is also Java 9. Java 8 code looks different, and you'll see Runtime.exec(String) in older tutorials; it splits the string on whitespace, which is trap 4 in disguise.

Next in this series: reading configuration safely is covered in the environment variables article (coming at how to read environment variables and system properties in Java), and when you're ready for proper tests, how to write unit tests with JUnit 5.

Checklist

  • Build the command as a List<String>, one argument per element.
  • Start reading stdout and stderr right after start().
  • Close the child's stdin when you're done writing.
  • Wait with a timeout, and on timeout kill the descendants first, then the process.
  • Check the exit code; don't assume 0.
  • Never glue user input into an sh -c string.
  • Expect an IOException when the program isn't found.

Questions people ask about running commands from Java

How do I get the output of a command in Java?
Start it with ProcessBuilder.start() and read getInputStream() (stdout) and getErrorStream() (stderr). Read both from the start on separate threads, or merge them with redirectErrorStream(true). In Java 17 and later, process.inputReader() gives you a BufferedReader directly.

What's the difference between ProcessBuilder and Runtime.exec()?
Runtime.exec is the older API, and its single-string form splits on whitespace. ProcessBuilder takes a list, lets you set the working directory, environment and redirects, and is what Runtime.exec uses internally. Use ProcessBuilder.

How do I run a command with pipes or wildcards in Java?
Pipes: use ProcessBuilder.startPipeline, as in Step 4. Wildcards: expand them yourself with Files.newDirectoryStream and pass the matches as arguments, or deliberately run sh -c with a fixed string that contains no user input.

How do I stop a command that runs too long?
Use process.waitFor(timeout, unit). If it returns false, destroy process.descendants() and then the process, as in CommandRunner.killTree.

Why does my Java program hang after starting a process?
Almost always a full pipe: the child is blocked writing output nobody is reading, usually stderr. Drain both streams as soon as you start the process.

References

  • Java SE 21 API: java.lang.ProcessBuilder, java.lang.Process, java.lang.ProcessHandle (docs.oracle.com/en/java/javase/21/docs/api/)
  • JEP 444, Virtual Threads (openjdk.org/jeps/444).
how-to-tutorialsjava-iojava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments