Java 启动系统新进程调用解析

278 阅读6分钟

执行命令启动进程的两种方式

使用Runtime.exec 执行echo dial

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class RuntimeExec {
    public static void main(String[] args) throws IOException, InterruptedException {
        Process process = Runtime.getRuntime().exec(new String[]{"echo", "dial"});
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()))){
            System.out.println("current -----------");
            bufferedReader.lines().forEach(line -> {
                System.out.println(line);
            });
        }
        process.waitFor();
        System.out.println(String.format("process exit value is :%d", process.exitValue()));
    }
}

使用ProcessBuilder 执行echo dial

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class ProcessBuilderTest {
    public static void main(String[] args) throws IOException, InterruptedException {
        ProcessBuilder processBuilder = new ProcessBuilder();
        processBuilder.command(new String[]{"echo", "dial"});
        Process process = processBuilder.start();
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()))){
            System.out.println("current -----------");
            bufferedReader.lines().forEach(line -> {
                System.out.println(line);
            });
        }
        process.waitFor();
        System.out.println(String.format("process exit value is :%d", process.exitValue()));
    }
}

进程启动分析

  1. 我们去看下Runtime的exec函数的实现,其实就是调用ProcessBuilder的方法启动一个进程,具体如下:
public Process exec(String[] cmdarray, String[] envp, File dir)
    throws IOException {
    return new ProcessBuilder(cmdarray)
        .environment(envp)
        .directory(dir)
        .start();
}
  1. 我们来分析进程启动的具体的流程就只需要查看ProcessBuilder相关的功能即可。从名字上可以看出这个类主要就使用构造器的模式创建出一个Process,ProcessBuilder的参数,查看ProcessBuilder的实现中,可以发现主要就包含如下几个核心参数:

    1. command, 命令及其参数列表
      directory, 进程的工作目录
      environment,进程的环境变量
      redirectErrorStream,是否重定向异常流,该参数主要用于是否将异常流重定向到标准输出流中
      redirects, 进程的标准流信息。 一个进程至少有三个标准流,stdin,stdout,stderr。此处的redirects代表的就是三个标准流
      ProcessBuilder的函数
      command/directory两类函数就是设置相应的参数,没有过多的逻辑,就不做进一步的描述
      environment(String[] envp)
      
    2. 该函数的访问权限是包内访问的权限,不对外提供,默认情况下就是使用java启动获取到的信息,使用Runtime的方式可以调整默认的环境变量信息,在linux中可以使用env 打印出当前bash的环境变量,那么我们在当前bash启动java程序的时候会可以获取到本地的环境变量,例如我们想获取到启动java环境变量中的SHELL信息,System.getEnv(“SHEEL”)即可。环境变量是一个key value的信息,该函数的主要实现也就是将String[] 转化为一个Map<String, String>信息 ,如下:
    3. ProcessBuilder environment(String[] envp) {
          assert environment == null;
          if (envp != null) {
              environment = ProcessEnvironment.emptyEnvironment(envp.length);
              for (String envstring : envp) {
                  if (envstring.indexOf((int) '\u0000') != -1)
                      envstring = envstring.replaceFirst("\u0000.*", "");
                  int eqlsign =
                      envstring.indexOf('=', ProcessEnvironment.MIN_NAME_LENGTH);
                  if (eqlsign != -1)
                      environment.put(envstring.substring(0,eqlsign),
                                      envstring.substring(eqlsign+1));
              }
          }
          return this;
        }
      
  • 上面的实现很简单,如果envp不为空,则新建一个空map,将envp中的每一项按照=切割为key,value,不符合该结构的不使用。
  • redirectInput/redirectOutput/redirectError
  • 一个进程至少有三个标准流,这三个函数作用就是用于配置调整默认的三个流。 这三个流在java中默认使用的是PIPE形式,也就是通过PIPE实现java与其子进程之间的交互。通过这三个函数可以调整起默认行为。
  • java中的Redirect提供如下几种
PIPE, java中默认的方式,如果不配置就是使用这种方式
INHERIT, 子进程的流使用java进程的流
READ, 以文件流的形式作为流
WRITE 以文件的形式作为流
APPEND 以追加的方式作为流
  1. Start 构造模式最后会生成对应的实例,通过调用前面几个函数调整默认的行为后,需要通过调用start来最终创建一个进程。 最终的实现是调用ProcessImpl的start来启动。简化逻辑如下:

    1. 检测命令行及参数是否存在为null
    2. 检查命令行及参数是否包含\u0000字符
    3. 将命令行及参数,环境变量,工作目录,标准流,以及重定向异常流作为参数调用
    4. ProcessImpl的实现
    5. public Process start() throws IOException {
          String[] cmdarray = command.toArray(new String[command.size()]);
          cmdarray = cmdarray.clone();
          for (String arg : cmdarray)
              if (arg == null)
                  throw new NullPointerException();
          String prog = cmdarray[0];
          SecurityManager security = System.getSecurityManager();
          if (security != null)
              security.checkExec(prog);
          String dir = directory == null ? null : directory.toString();
          for (int i = 1; i < cmdarray.length; i++) {
              if (cmdarray[i].indexOf('\u0000') >= 0) {
                  throw new IOException("invalid null character in command");
              }
          }
          return ProcessImpl.start(cmdarray, environment, dir, redirects,
               redirectErrorStream);
      }
      

redirects中的进程输入流分析

暂不做进一步展开的说明ProcessImpl的实现,有兴趣的可以自行阅读,先额外描述下ProcessImpl的start函数

redirects中的进程输入流说明一下,简化代码如下:

int[] std_fds;
FileInputStream  f0 = null;
std_fds = new int[3];
if (redirects[0] == Redirect.PIPE)
    std_fds[0] = -1;
else if (redirects[0] == Redirect.INHERIT)
    std_fds[0] = 0;
else {
    f0 = new FileInputStream(redirects[0].file());
    std_fds[0] = fdAccess.get(f0.getFD());
}

主要就分三种情况:

  1. redirects[0] 为PIPE 类型的时候,将std_fds[0] 设置为-1, 设置为-1的原因是jdk内部判断到是-1情况下会自行创建一个pipe
  2. redirects[0] 为INHERIT 类型的时候,将std_fds[0] 设置为0, 设置为0的原因是为0的fd代表的是当前java进程的标准输入流
  3. 其他情况,代表我们配置专门的文件作为输入流,就通过FileInputStream获取到对应的文件描述符

使用时我们需要注意什么

参数要求

  1. 传入的命令行及参数列表中不能包含null
  2. 传入的命令行及参数列表中不能包含\u0000 字符
  3. 传入的环境变量信息的格式是每一项item,以=分割为key/value两项

流的使用

  1. 默认情况下java与子进程的流交互使用的是PIPE进行交互,PIPE的大小不可能是无限大的,所以如果进程的输出流很大,java进程一直没有读取子进程的输出流的时候子进程就没办法继续写入数据进而会导致子进程阻塞。
  2. 如果我们这么去写代码,/Users/allen/a.out 这个是编译出的会输出65537个字符的进程,去执行的时候java进程没有办法执行(当前系统的pipe调用返回的是cache是65536的情况)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class ProcessBuilderTest {
    public static void main(String[] args) throws IOException, InterruptedException {
        Process process = Runtime.getRuntime().exec("/Users/allen/a.out", new String[]{"PWD=/"});
        process.waitFor();
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()))){
            System.out.println("current -----------");
            bufferedReader.lines().forEach(line -> {
                System.out.println(line);
            });
        }
        process.waitFor();
        System.out.println(String.format("process exit value is :%d", process.exitValue()));
    }
}

pipe的大小

  1. 类unix, 使用pipe的系统调用实现,取决于系统的参数,默认情况下是65536
  2. windows,是4096 + 24的大小

我们如何来避免这种情况

  1. 如果明确输出/异常信息不回达到pipe的上限我们就不用处理

  2. 如果我们不关注异常流,我们可以把异常流重定向到/dev/null, 在调用waitFor前去读去输出流

    1. processBuilder.redirectError(new File("/dev/null"))
      
  3. 如果我们关注异常流和输出流,但不区分两者,我们可以将异常流重定向到输出流, 在调用waitFor前去启动线程读去输出流

    1. processBuilder.redirectErrorStream(true)
      
  4. 如果同时关注异常流和输出流,且需要区分,在调用waitFor前去,启动新线程分别读去异常流和输出流或者使用重定向输出流/异常流到文件的方式。 waitFor结束后从文件中读取信息出来。

    1. processBuilder.redirectError(new File('xxxx'))
      processBuilder.redirectError(new File('xxxx2'))