Runtime.getRuntime().exec踩坑总结(/bin/sh -c、异常流重定向)

Runtime.getRuntime().exec踩坑总结

一、前言

想通过java执行shell命令,一定会用到Runtime.getRuntime().exec,可能最先使用的是它的只传一个字符串的方法,以为是一个命令语句,不曾想已经先入为主,踏入误区。

二、方法使用说明

exec底层原理是调用操作系统新建一个进程异步执行shell命令。踩坑之后本人最常用的是传一个字符串数组的方法,其他传环境变量、传一个File文件的没用过暂且不讨论。

java.lang.Runtime#exec(java.lang.String[])
java.lang.Runtime#exec(java.lang.String)

1、exec(java.lang.String)

只传递一个字符串,字符串代表一个没有参数的纯命令,如hostname、pwd、ll等。

// 可查看 本机hostname
Runtime.getRuntime().exec("hostname");

2、exec(java.lang.String[])

字符串数组,第一个元素是命令,和exec(java.lang.String)中的一个字符串代表的概念一样,后面的元素都是命令的参数

例如下面的字符串数组,第一个元素是执行bash文件的命令/bin/sh,也可以是bash,第二个元素是bash文件路径,第三个元素是bash文件内命令执行需要的参数。

String[] cmds = {"/bin/sh", "/root/tmp/xxx.sh", "xxx"};

在linux终端执行这样的一条命令/bin/sh /root/tmp/xxx.sh xxx,和exec传递一个数组最后执行组装的命令是一样的。

至于数组的第二个元素是命令文件的路径,其实也是第一个元素纯命令的参数,在自己开发的bash文件中获取的第一个参数$0就是当前执行的bash文件的路径,第二个参数$1开始才是bash文件中的命令需要的参数。

除了bash脚本,还有python、powershell、node、php等脚本执行命令都是类似的。比如想用java的exec执行一个python的脚本,如下:

String[] cmds = {"python", "/root/tmp/xxx.py", "xxx"};
Runtime.getRuntime().exec(cmds);

数组的第一个元素是执行该脚本的纯命令python,后面的元素都是参数,所以执行命令python /root/tmp/xxx.py xxx,在xxx.py脚本中获取的第一个参数是脚本路径,第二个才是用户传的参数。

3、不创建脚本文件执行复杂命令

如果执行的命令是一个完整的命令,不需要传参,可以直接使用exec(java.lang.String);如果执行的命令需要传递参数,则需要使用exec(java.lang.String[])

exec(java.lang.String[])数组参数的第二个元素是可执行脚本文件路径,对于bash命令,可以不用创建文件也可以执行复杂的命令,例如:

// ps -ef | grep xxx | grep -v grep | awk '{ print $2 }'
// 过滤xxx进程并输出其pid,-v是取反过滤,排除当前命令的影响
String[] cmds = {"/bin/sh", "-c", "ps -ef | grep xxx | grep -v grep | awk '{ print $2 }'"};
Runtime.getRuntime().exec(cmds);

/bin/sh -c可将一个多操作命令合并成一个完整命令执行。

三、Process用于控制进程的类

java.lang.Runtime#exec返回一个Process类,可用于控制命令执行的进程,比如获取标准输出流、标准输入流及异常流,获取该进程的执行状态,销毁该进程等。

如下示例获取xxx进程的pid:

String[] cmds = {"/bin/sh", "-c", "ps -ef | grep xxx | grep -v grep | awk '{ print $2 }'"};
try {
    Process process = Runtime.getRuntime().exec(cmds);
    // 获取标准输入流 process.getInputStream()
    BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
    String line = null;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
    // waitFor 阻塞等待 异步进程结束,并返回执行状态,0代表命令执行正常结束。
    System.out.println(process.waitFor());
} catch (IOException | InterruptedException e) {
    if (process != null) {
        // 销毁当前进程,阻断当前命令执行
        process.destroy();
    }
    e.printStackTrace();
}

1、获取I/O流管道

Process有三种I/O管道:

// java.lang.Process
// 标准输出流。
public abstract OutputStream getOutputStream();
// 标准输入流
public abstract InputStream getInputStream();
// 异常流
public abstract InputStream getErrorStream();
  • Process#getOutputStream:输出流,方向指从java层面传输数据到命令进程里,对于命令进程就是输入流,所以ProcessBuilder#redirectInput(Redirect)设置命令进程输入重定向到其他地方,Process#getOutputStream就会返回一个null output stream
  • Process#getInputStream:输入流,方向指从命令进程输入数据到java层面,对于命令进程是输出流,所以ProcessBuilder#redirectOutput(Redirect)设置命令进程输出重定向到其他地方,Process#getInputStream就会返回一个null input stream
  • Process#getErrorStream:异常流,方向指从命令进程输入异常到java层面,可通过ProcessBuilder#redirectError(Redirect)设置重定向到其他地方,Process#getInputStream就会返回一个null input stream

平时用的最多的是标准输入流和异常流,标准输出流暂且不讨论。

获取标准输入流,可读取命令执行过程中标准输出的日志;获取异常流,可读取命令本身执行出现的异常,而不是程序执行抛的异常。

标准输入流InputStream、异常流ErrorStream以及标准输出流OutputStream,三个流管道是相互独立的,管道空时不可读,满时不可写,遇到三个流管道至少其中一个处于不可读写状态时,命令进程会阻塞等待。

这里就存在一个坑点,虽然本人还没有遇到,可能是管道的大小比较大,标准输入流一时达不到满状态,但经参考其他博主写的文章,命令进程偶尔会出现莫名其妙的阻塞不执行不响应问题:

  • 当命令有大量标准输出日志时,一定要读取,否则可能会阻塞java层面的标准输入流,导致命令进程阻塞;
  • 同时一般情况下也要读取异常流,不然也会出现异常流管道满了导致命令进程阻塞的情况;
  • 而两条管道是相互独立的,则需要在java层面开启两个线程异步读取标准输入流和异常流。
  • 如果不想读取异常流,可以将其重定向至标准输入流。

2、获取命令进程的执行状态

// 阻塞等待结束并返回命令执行状态,0代表正常结束。
public abstract int waitFor()
// 阻塞等待timeout时长,true代表命令还在运行,false为已结束结束。    
public boolean waitFor(long timeout, TimeUnit unit)
// 获取执行状态,若命令未结束,会抛异常。    
public abstract int exitValue();
// 判断命令是否还在运行。
public boolean isAlive()

java.lang.Process是一个抽象类,具体方法的实现可以参考java.lang.ProcessImpl

截取部分java.lang.ProcessImpl的源码:

public int waitFor() throws InterruptedException {
    // 可中断等待命令执行结束,
    waitForInterruptibly(handle);
    if (Thread.interrupted())
        throw new InterruptedException();
    // 返回命令执行结束退出值
    return exitValue();
}

public int exitValue() {
    // 获取命令进程exitCode
    int exitCode = getExitCodeProcess(handle);
    // exitCode等于STILL_ACTIVE(命令依然存活),则抛异常
    if (exitCode == STILL_ACTIVE)
        throw new IllegalThreadStateException("process has not exited");
    // 否则返回 exitCode
    return exitCode;
}

3、销毁命令进程

// 销毁当前命令进程,是否是强制的,由实现类决定
public abstract void destroy();
// 强制销毁当前命令进程,语义上是强制销毁,
// 官方默认给的是直接调用destroy(),可根据isAlive()判断是否强制销毁命令进程
public Process destroyForcibly() {
    destroy();
    return this;
}

销毁命令进程也存在一个小坑,若执行命令时调用的是可执行脚本文件,直接销毁的是可执行脚本文件的进程,而其内的子进程将会变成孤儿进程挂载在pid=1的父进程上,继续运行。

如果想全部杀死,有两个思路:

  • 父进程退出时发退出信号给子进程。(但是不知道怎么做,可能有些复杂)
  • 获取父进程-可执行脚本文件的pid以及其子进程pid,但是子进程下面可能还有子进程,想彻底杀光,可递归获取所有子进程pid,然后一个个kill。(有可能误杀,如果业务进程的名称中包含某些进程的pid,就会误杀,但是不会那么巧吧,如有遇到需要改进获取子进程pid的命令)

示例代码,杀死传入参数param的进程及其所有子进程:

public void destroy(String param) {
    // 存获取的pid
    List<String> pidList = new ArrayList<String>();
    // 获取所有pid
    getPid(param, pidList);
    // 遍历杀死所有pid
    for (String pid : pidList) {
        try {
            String[] cmdArr = new String[]{"kill", pid};
            Process process = Runtime.getRuntime().exec(cmdArr);
            int exitValue = process.waitFor();      // exit code: 0=success
            System.out.println("exitValue=" + exitValue + ", pid=" + pid + ", kill ok!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

private void getPid(String param, List<String> pids) {
    Process process = null;
    BufferedReader stdInput = null;
    try {
        String[] cmdArr = {"/bin/sh", "-c" , "ps -ef | grep " + param + " | grep -v grep | awk '{ print $2 }'"};
        process = Runtime.getRuntime().exec(cmdArr);
        stdInput = new BufferedReader(new
                InputStreamReader(process.getInputStream()));
        String pid = null;
        while ((pid = stdInput.readLine()) != null) {
            // 剔除 自己进程的pid,没有这一步会造成死递归
            if (pid.equals(param)) {
                continue;
            }
            // 将子进程pid 加入list
            pids.add(pid);
            // 递归获取子进程pid
            getPid(path, pid, pids);
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (stdInput != null) {
            try {
                stdInput.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

四、使用ProcessBuilder创建进程Process

官方提供的java.lang.Runtime#exec,其实是实例化ProcessBuilder,调用java.lang.ProcessBuilder#start,然后调用java.lang.ProcessImpl#start,最后调用底层操作系统开辟一个进程执行命令的。直接使用java.lang.Runtime#exec的弊端是无法对Process做一些设置,比如重定向流等

// java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)
public Process exec(String[] cmdarray, String[] envp, File dir)
    throws IOException {
    return new ProcessBuilder(cmdarray)
        .environment(envp)
        .directory(dir)
        .start();
}

1、将异常流重定向至java进程标准输入流

Process#getInputStream获取标准输入流读取命令运行时标准输出的日志时,需要同时读取异常流,所以要在java层面新建两个线程异步去处理标准输入流和异常流。

倘若将异常流重定向至标准输入流,使用一个流就可以读取标准输入和异常流,这样操作就简单很多了。

代码示例,通过实例化ProcessBuilder执行命令,并将异常流重定向至标准输入流。

private void testExec() {
    String[] cmds = {"/bin/sh", "-c", "ps -ef | grep AlarmModuleSvr | grep -v grep | awk '{ print $2 }'"};

    try {
        // redirectErrorStream = true 异常流重定向 2>&1
        Process process = (new ProcessBuilder(cmds)).redirectErrorStream(true).start();
        BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
        String line = null;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
        }
        System.out.println(process.waitFor());
    } catch (IOException | InterruptedException e) {
        e.printStackTrace();
    }
}

2、流重定向在ProcessBuilder源码中的体现

(1)五种重定向类型

命令进程有三种流,标准输出流,异常输出流,标准输入流,有五种重定向类型PIPEINHERITREADWRITEAPPEND

  • PIPE: 表示子进程I/O将被连接到当前java进程上的一个管道,这是子进程标准I/O的默认处理。
  • INHERIT:继承父进程的I/O流源或者目的路径。
  • READ:读类型。
  • WRITE:写类型。
  • APPEND:追加类型,也是写类型的一种。

注意:输出流和输入流不可互相重定向,即重定向类型是READ不能修改为WRITE和APPEND,反之亦然。

默认情况,三种流都是PIPE类型,即都是会和java进程上的管道进行连接:

  • 命令进程-标准输出流—对应—java进程-标准输入流。
  • 命令进程-异常输出流—对应—java进程-异常输入流。
  • 命令进程-标准输入流—对应—java进程-标准输出流。

源码中的体现:

/**
 * 0-标准输入,1-标准输出,2-异常输出
 * @return
 */
private Redirect[] redirects() {
    if (redirects == null)
        redirects = new Redirect[] {
            Redirect.PIPE, Redirect.PIPE, Redirect.PIPE
        };
    return redirects;
}
(2)重定向异常输出流至标准输出流(2>&1)
/**
 * 默认false,不重定向异常流。
 * 设置为true后,异常流重定向至命令进程的标准输出流,即java层面的标准输入流
 * 可用Process#getInputStream() 读取标准输入同时读取异常流。
 */
public ProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
    this.redirectErrorStream = redirectErrorStream;
    return this;
}
(3)重定向流至文件

重定向读取文件:

public static Redirect from(final File file) {
    if (file == null)
        throw new NullPointerException();
    return new Redirect() {
            public Type type() { return Type.READ; }
            public File file() { return file; }
            public String toString() {
                return "redirect to read from file \"" + file + "\"";
            }
        };
}

重定向写入文件:

public static Redirect to(final File file) {
    if (file == null)
        throw new NullPointerException();
    return new Redirect() {
            public Type type() { return Type.WRITE; }
            public File file() { return file; }
            public String toString() {
                return "redirect to write to file \"" + file + "\"";
            }
            boolean append() { return false; }
        };
}

重定向追加写入文件:

public static Redirect appendTo(final File file) {
    if (file == null)
        throw new NullPointerException();
    return new Redirect() {
            public Type type() { return Type.APPEND; }
            public File file() { return file; }
            public String toString() {
                return "redirect to append to file \"" + file + "\"";
            }
            boolean append() { return true; }
        };
}

标准输入重定向读取文件:

public ProcessBuilder redirectInput(File file) {
    return redirectInput(Redirect.from(file));
}

public ProcessBuilder redirectInput(Redirect source) {
    if (source.type() == Redirect.Type.WRITE ||
        source.type() == Redirect.Type.APPEND)
        throw new IllegalArgumentException(
            "Redirect invalid for reading: " + source);
    redirects()[0] = source;
    return this;
}

标准输出流重定向写入文件(1 > file)

public ProcessBuilder redirectOutput(File file) {
    return redirectOutput(Redirect.to(file));
}

public ProcessBuilder redirectOutput(Redirect destination) {
    if (destination.type() == Redirect.Type.READ)
        throw new IllegalArgumentException(
            "Redirect invalid for writing: " + destination);
    redirects()[1] = destination;
    return this;
}

异常输出流重定向写入文件(2 > file):

public ProcessBuilder redirectError(File file) {
    return redirectError(Redirect.to(file));
}

public ProcessBuilder redirectError(Redirect destination) {
    if (destination.type() == Redirect.Type.READ)
        throw new IllegalArgumentException(
            "Redirect invalid for writing: " + destination);
    redirects()[2] = destination;
    return this;
}

3、将java进程输入流重定向至文件

java进程输入流重定向即是对命令进程输出流重定向。若java层面不会对命令输出的日志做处理,则可以直接重定向至文件中,该文件必须存在,否则会报错找不到文件。

private void testExec() {
        String[] cmds = {"/bin/sh", "-c", "ps -ef | grep AlarmModuleSvr | grep -v grep | awk '{ print $2 }'"};

        try {
            File toFile = new File("/home/faier/tmp/stefan/testExec.log");
            if (!toFile.exists()) {
                toFile.createNewFile();
            }
            // > file 2>&1
            Process process = (new ProcessBuilder(cmds)).redirectErrorStream(true).redirectOutput(toFile).start();
            // 以追加的形式重定向输出至文件 >> file 2>&1
//            Process process = (new ProcessBuilder(cmds)).redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.appendTo(toFile)).start();
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            String line = null;
            while ((line = reader.readLine()) != null) {
                // 因为重定向至文件,所以这里读取不到了
                System.out.println(line);
            }
            System.out.println(process.waitFor());
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }

五、总结

  • exec(java.lang.String)并不是想当然的是一个由命令+参数拼接的字符串。
  • exec(java.lang.String[])数组第一个元素是命令,从第二个元素起都是第一个元素命令的参数。
  • 可通过/bin/sh -c将一个多操作命令合并成一个完整命令执行,避免创建可执行脚本文件。
  • 用于控制进程的类Process,有三种I/O流管道,互相独立,管道空时不可读,满时不可写,遇到三个流管道中至少一个处于不可读写状态时,命令进程会阻塞等待。
  • java.lang.Process#waitFor()阻塞等待命令进程结束,正常结束返回0。
  • java.lang.Process#exitValue()获取命令进程运行状态,没有结束前,会抛异常。
  • 若确保exec调用执行的命令没有子进程可通过java.lang.Process#destroy销毁,否则需要获取当前命令进程pid以及所有子进程pid,遍历kill。
  • 如果不及时读取流中的数据,可能会导致java进程输入流满而阻塞,java进程标准输入流和异常流需要两个线程异步处理,也可重定向异常流至标准输入流,简化操作。

PS: 如若文章中有错误理解,欢迎批评指正,同时非常期待你的评论、点赞和收藏。我是徐同学,愿与你共同进步!

  • 13
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

徐同学呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值