Java命令执行浅析

常用的两个类:RuntimeProcessBuilder

Runtime类

Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the getRuntime method.(每个Java应用程序都有一个Runtime类的Runtime ,允许应用程序与运行应用程序的环境进行接口。当前运行时可以从getRuntime方法获得。)

  1. java.lang.Runtime源码中看到,这个类自JDK1.0就存在了;该类的构造方法是私有的,目的为了不让”任何人”实例化这个类。如果需要得到Runtime类,可以使用静态方法getRuntime(),该方法返回值的是Runtime类型。

    01
  2. Runtime类中的方法

  3. 这里重点关注一下exec()方法

  • Runtime中关于exec()共有6种重载的方法,返回值均为Process类型,也就有6种参数列表
  • 其中主要有三个参数:command、envp、dir
    • command (String/String[]):需要执行的命令
    • envp (String[]):运行环境的数组,该值为空表示子进程继承当前进程环境
    • dir (File):子进程的工作目录,该值为空表示继承当前进程的工作目录
    • 这里主要区分在于传入的command的类型,分为字符串和字符串数组,envp和dir如果没有指定的话,默认为null

字符串类型的参数

  1. 以调用exec打开计算器为例:
1
Runtime.getRuntime().exec(new String[]{"open", "/System/Applications/Calculator.app"});

运行如上代码并调试,跟踪执行流程

1
2
3
4
5
exec(String command)
exec(String command, String[] envp, File dir)
StringTokenizer(command) //获取StringTokenizer对象,将command转换为字符串数组
exec(String[] command, String[] envp, File dir)
ProcessBuilder(cmdarry).environment().directory().start()
  1. 整个流程会将String类型的命令 –> 通过StringTokenizer处理为字符串数组 –> 调用ProcessBuilder类进行实例化 –> 调用ProcessBuilder的enviroment()、directory()、start()方法;ProcessBuilder的start()方法,返回的是Process对象。

  2. 实际上Runtime中执行系统命令的exec()方法最终仍然是对ProcessBuilder的调用。关于ProcessBuilder类的使用,先放到后面去分析。

StringTokenizer类

  1. 对于字符串类型的command,Runtime.exec()会通过StringTokenizer类中的方法,进一步分析command在这个过程中被怎样处理了。
1
2
3
4
5
6
7
8
9
10
11
12
//java.lang.Runtime.exec()	
public Process exec(String command, String[] envp, File dir)
throws IOException {
if (command.isEmpty())
throw new IllegalArgumentException("Empty command");

StringTokenizer st = new StringTokenizer(command);
String[] cmdarray = new String[st.countTokens()];
for (int i = 0; st.hasMoreTokens(); i++)
cmdarray[i] = st.nextToken();
return exec(cmdarray, envp, dir);
}
  1. 实例化StringTokenizer类 —> 新建String[]类型的变量,长度为command分割后的个数 —> 取分割后的每一段复制为cmdarray数组。
  • 实例化SkingTokenizer类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//java.util.StringTokenizer
public StringTokenizer(String str) {
this(str, " \t\n\r\f", false);
}
public StringTokenizer(String str, String delim, boolean returnDelims) {
currentPosition = 0;
newPosition = -1;
delimsChanged = false;
this.str = str;
maxPosition = str.length();
delimiters = delim;
retDelims = returnDelims;
setMaxDelimCodePoint();
}

如代码所示,分割为 \t\n\r\f 分别对应 空格、制表符、换行符、回车符、换页符。

实例化的过程中,基本是对一些内部变量的赋值。

  • StringTokenizer.countTokens()计算了command根据分割符会被分割的个数,hasMoreTokens()根据之前计算的分割总数和当前的分割次数判断是否继续循环取值,nextToken()则获取分割的内容,添加到cmdarray中。
  • 分割的过程比较常规,但是回到经常遇到的一个问题:在Runtime.getRuntime.exec()中不能使用某些特殊字符,如 | & ,这是为什么呢?

调试分析带有特殊字符的命令

1
Runtime.getRuntime().exec("echo hello > /tmp/success.txt);

下断点进入调试

03

如图所示,命令被分割为new String[]{"echo", "hello", ">", "/tmp/success.txt"} ,相当于我们运行如下代码,

1
2
3
Runtime.getRuntime().exec(new String[]{"echo", "hello", ">" , "/tmp/success.txt"});
// -> 相当于
new ProcessBuilder(new String[]{"echo", "hello", ">" , "/tmp/success.txt"}).start();

看来不是分割字符的问题,实际上执行上面的代码也是不成功的,当然解决方法是使用如下代码执行,

1
Runtime.getRuntime().exec(new String[]{"bash","-c","echo hello > /tmp/success.txt"})

但是为什么重定符加入后,运行命令失败呢?看来问题在ProcessBuilder这个类的方法中。

ProcessBuilder

This class is used to create operating system processes.(这个类用于创建操作系统进程)

  1. 该类共有两个构造方法,很好理解,分别是String…和List类型的参数列表。传入的command最终均赋值给command变量。

    这里有个有意思的地方,实际上ProcessBuilder自JDK1.5中才有的。那是不是JDK1.5以前Runtime类中没有exec方法呢?或者当时1.5以前的exec()方法,还是有别的运行逻辑 :>

    04
  2. ProcessBuilder类中的方法

05

首先测试一个常规执行命令的代码

1
new ProcessBuilder("bash","-c","echo hello > /tmp/success.txt").start();

获取命令执行的返回结果

总结了四种获取命令执行的数据流

  • 执行命令
1
2
3
ProcessBuilder p = new ProcessBuilder("ifconfig");
System.out.println(p.command());
Process process = p.start();
  • 获取返回结果 - 1
1
2
3
4
5
6
BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = null;
StringBuffer sb = new StringBuffer();
while ((line = br.readLine()) != null){
sb.append(line + System.getProperty("line.separator"));
}
  • 读取返回结果 - 2
1
2
3
Scanner s = new Scanner(process.getInputStream());
String result = s.useDelimiter("\\A").hasNext() ? s.next() : "";
System.out.println(result);
  • 读取返回结果 - 3
1
2
String result = org.apache.commons.io.IOUtils.toString(process.getInputStream());
System.out.println(result);
  • 读取返回结果 - 4
1
2
3
4
5
//JDK8+
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringJoiner stringJoiner = new StringJoiner("\n");
reader.lines().iterator().forEachRemaining(stringJoiner::add);
System.out.println(stringJoiner.toString());

不能直接获取返回结果的命令

实际上,当我们执行python —version, java -version 这一类非系统命令,而通过运行环境的环境变量中添加的命令时,默认是无法通过 getInputStream()获取数据流的。

比如如下运行结果,是获取不到结果的。

06

这里可以调用ProcessBuilder.inheritIO()ProcessBuilder.redirectErrorStream()这两个方法让你看到执行的结果,不过这两个方法也是有区别的。

  • ProcessBuilder.inheritIO()

    Sets the source and destination for subprocess standard I/O to be the same as those of the current Java process.(将子进程标准I/O的源和目标设置为与当前Java进程的源和目标相同。)

    这个方法实际上将子进程运行的结果输出在当前Java进程的输出显示,比如

    07

​ 如上图所示,实际上并没有通过getInputsStream()取返回结果,但是控制台输出了打印结果。可以看到java -version的结果是红色的,这通常表示是一个异常信息的打印。

  • ProcessBuilder.redirectErrorStream(true)

    Sets this process builder’s redirectErrorStream property. If this property is true, then any error output generated by subprocesses subsequently started by this object’s start() method will be merged with the standard output, so that both can be read using the Process.getInputStream() method. This makes it easier to correlate error messages with the corresponding output. The initial value is false.

    大概的意思是,如果调用该方法且参数为truestart()方法启动的子进程生成的任何错误输出都将与标准输出合并,以便可以使用Process.getInputStream()方法获取结果。

    08

那么为什么java -version返回的结果被当做异常信息了呢?调试跟踪看一下是如何运行的。

跟踪一下运行流程,发现系统的异常打印从forkAndExec()这个方法后出现的。

1
2
3
4
ProcessBuilder.start()
ProcessImpl.start(...)
new UNIXProcess(...)
pid = forkAndExec(...)
09

这里forkAndExec() 的修复符是native ,相当于实际上运行forkAndExec时会调用C语言实现相应的功能。对应的源码可参照openjdk8/openjdk/jdk/src/solaris/native/java/lang/UNIXProcess_md.c 来阅读。

总结一下这部分的实现:

1
2
3
4
5
6
7
8
9
10
11
ProcessBuilder p = new ProcessBuilder("java", "-version");
p.redirectErrorStream(true);
Process process = p.start();
BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = null;
StringBuffer sb = new StringBuffer();
while ((line = br.readLine()) != null){
sb.append(line + System.getProperty("line.separator"));
}
process.waitFor();
System.out.println(sb.toString());

补充一下:Process.waitFor()会将当前线程阻塞,直到子进程执行退出

特殊符号的问题

回到Runtime.getRuntime().exec()中的问题,为什么带有重定符、管道符等特殊符号的命令不能直接执行呢?

先执行一下带有重定向符的命令:

10

返回结果初步判断是将"1" ">" "/tmp/success"作为echo命令的参数来执行了,相当于执行echo 1 ">" /tmp/success 这样的命令

11

继续调试一下

12 13

在实例化UNIXProcess对象后执行子进程时,字节数组第一位是待执行的命令,其他的都作为参数处理,而我理解的命令行中执行的管道符是由于bash命令提示符 会对管道符、重定向符等特殊符号赋予特殊的含义吧。方法的话可以使用new String[]{"bash","-c",""}来避免这些符号带来的困扰。

结束

  1. 根据StringTokenizer类中方法,在命令中加入一些不同的分割符能不能绕过一些安全设备的防护呢?估计效果不太好。

    1
    Runtime.getRuntime().exec("open\t\f\f\t\f\f\f/System/Applications/Calculator.app");
  2. JDK1.5以前没有ProcessBuilder类。

参考

https://download.java.net/openjdk/jdk8

https://www.oracle.com/java/technologies/java-archive-142docs-downloads.html