Java代码执行本地命令

背景

最近搞的一个项目中,需要用Java调用一些Python的脚本。由于公司的技术栈主要是Java,并且没有Python开发工程师,所以不想搞的太复杂,比如使用Django搞一个Python的微服务,再用Java的微服务去调用这种形式。所以重点考察了两种方式:

  1. Jython
  2. Java调用本地命令,调用Python脚本

由于Jython不支持Python3,所以暂时把注意力集中在调用本地命令上。隐约记得之前在网上看过,在Java代码中执行本地调用会占用当前JVM的双倍内存,感觉这性能也太挫了点,有些接受不了,所以就详细的考察了一下Java执行本地调用的过程,于是就有了这篇文章。
如果不熟悉Java调用本地命令的同学,可以看这篇文章: Java调用本地命令。大概就是创建子进程,然后父子进程通过标准输入输出和管道那一套东西进行数据交互,这里就不详细展开说了。

JDK代码

在Java代码中,使用Runtime.getRuntime().exec()方式来执行外部命令,其内部的API调用链为:

Runtime.exec -> ProcessBuilder.start -> ProcessImpl.start -> new UNIXProcess() -> UNIXProcess.forkAndExec

从这个函数的名字,大概就可以看出是使用了fork + exec这一组合操作进行调用本地命令的。查看UNIXProcess.forkAndExec代码如下:

    /**
     * Creates a process. Depending on the {@code mode} flag, this is done by
     * one of the following mechanisms:
     * 
     *   1 - fork(2) and exec(2)
     *   2 - posix_spawn(3P)
     *   3 - vfork(2) and exec(2)
     *
     *  (4 - clone(2) and exec(2) - obsolete and currently disabled in native code)
     * 
* @param fds an array of three file descriptors. * Indexes 0, 1, and 2 correspond to standard input, * standard output and standard error, respectively. On * input, a value of -1 means to create a pipe to connect * child and parent processes. On output, a value which * is not -1 is the parent pipe fd corresponding to the * pipe which has been created. An element of this array * is -1 on input if and only if it is not -1 on * output. * @return the pid of the subprocess */ private native int forkAndExec(int mode, byte[] helperpath, byte[] prog, byte[] argBlock, int argc, byte[] envBlock, int envc, byte[] dir, int[] fds, boolean redirectErrorStream) throws IOException;

可以看到,进行了native调用,由于不太熟悉JVM源码,JVM的源码层就不往下追了。通过这个方法的注释可以看到,根据参数mode的不同,以不同的方式执行。那么这个mode是哪里来的呢?其实就是在UNIXProcess的构造函数中传入的(省略了部分代码):

    UNIXProcess(final byte[] prog,
                final byte[] argBlock, final int argc,
                final byte[] envBlock, final int envc,
                final byte[] dir,
                final int[] fds,
                final boolean redirectErrorStream)
            throws IOException {

        pid = forkAndExec(launchMechanism.ordinal() + 1,
                          helperpath,
                          prog,
                          argBlock, argc,
                          envBlock, envc,
                          dir,
                          fds,
                          redirectErrorStream);

        ...
    }

其中这个launchMechanismLaunchMechanism枚举的一个实例:

    private static enum LaunchMechanism {
        // order IS important!
        FORK,
        POSIX_SPAWN,
        VFORK
    }

可以看到,这里的三个枚举值正好对应了上面forkAndExec方法重的三种执行策略:

  1. fork(2) and exec(2)
  2. posix_spawn(3P)
  3. vfork(2) and exec(2)

由于我们的代码一般都是运行在Linux上的,我们还是比较关注Linux上代码的运行策略。而其实上面的launchMechanism变量的赋值就是和平台相关的:

    private static final Platform platform = Platform.get();
    private static final LaunchMechanism launchMechanism = platform.launchMechanism();

这个platform又是个啥呢?我们再来看看这个Platform的代码(省略部分代码):

   private static enum Platform {

        LINUX(LaunchMechanism.VFORK, LaunchMechanism.FORK),

        BSD(LaunchMechanism.POSIX_SPAWN, LaunchMechanism.FORK),

        SOLARIS(LaunchMechanism.POSIX_SPAWN, LaunchMechanism.FORK),

        AIX(LaunchMechanism.POSIX_SPAWN, LaunchMechanism.FORK);

        final LaunchMechanism defaultLaunchMechanism;
        final Set validLaunchMechanisms;

        Platform(LaunchMechanism ... launchMechanisms) {
            this.defaultLaunchMechanism = launchMechanisms[0];
            this.validLaunchMechanisms =
                EnumSet.copyOf(Arrays.asList(launchMechanisms));
        }

        LaunchMechanism launchMechanism() {
            return AccessController.doPrivileged(
                (PrivilegedAction) () -> {
                    String s = System.getProperty(
                        "jdk.lang.Process.launchMechanism");
                    LaunchMechanism lm;
                    if (s == null) {
                        lm = defaultLaunchMechanism;
                        s = lm.name().toLowerCase(Locale.ENGLISH);
                    } else {
                        try {
                            lm = LaunchMechanism.valueOf(
                                s.toUpperCase(Locale.ENGLISH));
                        } catch (IllegalArgumentException e) {
                            lm = null;
                        }
                    }
                    if (lm == null || !validLaunchMechanisms.contains(lm)) {
                        throw new Error(
                            s + " is not a supported " +
                            "process launch mechanism on this platform."
                        );
                    }
                    return lm;
                }
            );
        }

        static Platform get() {
            String osName = AccessController.doPrivileged(
                (PrivilegedAction) () -> System.getProperty("os.name")
            );

            if (osName.equals("Linux")) { return LINUX; }
            if (osName.contains("OS X")) { return BSD; }
            if (osName.equals("SunOS")) { return SOLARIS; }
            if (osName.equals("AIX")) { return AIX; }

            throw new Error(osName + " is not a supported OS platform.");
        }
    }

可以看到,这个Platform也是个枚举,它的枚举值分别为LINUX、BSD、SOLARIS和AIX,get()方法根据系统属性os.name来返回不同的值。这个os.name想必大家应该比较熟悉了,在Linux环境下,当然会返回LINUX这个枚举值。根据上面的代码,LINUX枚举值的defaultLaunchMechanismLaunchMechanism.VFORK。而platform变量在调用launchMechanism()方法获取执行方式的时候,是根据系统属性jdk.lang.Process.launchMechanism来获取的。这个系统属性我试了一下,一般都是null,所以最终结论就是,在Linux系统下,执行本地方法调用是使用vfork + exec的方式来实现的。

vfork与exec

熟悉shell脚本和C语言编程的人应该都对forkexec这两个命令(函数)不陌生。下面我们根据《Unix环境高级编程(第3版)》这本书中的内容,来看一下fork函数和exec函数的作用。fork用来创建子进程,子进程获得父进程数据空间、堆和栈的副本(复制,并不是共享)。而exec函数用于执行另一个程序,当一个进程调用exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并不会改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
看完了forkexec函数,再来看看vfork函数。由于fork函数经常与exec函数一起使用,所以很多系统实现了写时复制(Copy-On-Write)技术。数据空间、堆栈等这些区域由父进程和子进程共享,并且内核会将它们的访问权限改变为只读。如果父进程和子进程中的任意一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本。通过这种方式,可以减少fork + exec这个组合操作带来的性能开销。这里顺便提一下,由于fork + exec这个组合操作较为常用,所以一些系统中将这两个操作变为一个组合操作——spawn。通过上面的代码可以看到,在一些其他系统中,比如大家常用的Mac笔记本的操作系统OS X中,Java执行本地命令就是使用spawn命令。接下来言归正传,上面讲到为了降低fork + exec组合操作的开销,一些系统使用了Copy-On-Write技术,然而还有开销更低的方式,就是vfork + execvfork函数同fork函数一样,会创建一个新的进程,但该新进程的唯一目的就是使用exec运行一个新程序。vfork并不将父进程的地址空间复制到子进程中,因为子进程会立刻调用exec函数,所以父进程的地址空间对子进程没有意义,因此子进程在调用exec函数之前,会在父进程的空间中运行。并且调用了vfork函数以后,系统内核保证父进程阻塞,直到子进程执行了exec函数后再继续运行。
不过vfork函数有一个限制条件,就是在子进程修改数据、进行函数调用、或没有调用exec(or exit),可能会带来未知的后果。但我们此处不必关心这个问题,因为这两个函数是JVM执行的,并不用我们去手动操作。

双倍内存?

网上有些资料说,在Java内部执行本地命令时,会fork出一个新的进程,其内存占用与原JVM进程相同,因此在执行exec命令前会短暂的占用系统的双倍内存。为了避免这种情况,提出了一些比较trick的方案,比如agent代理,或者修改系统的vm.overcommit_memory参数从而避免内存分配检查。那么真实情况是这样么?使用vfork时,子进程在执行exec前并不会真正创建一个进程,而是与父进程共享内存,所以起码在Linux,不会存在内存问题。至于其他系统,比如OS X,由于默认使用spawn函数进行进程创建,则取决于系统对spawn函数的实现,大部分系统使用vfork + exec来实现spawn函数,不过也有极端情况下,一些系统可能会使用fork + spawn来实现spawn函数。所以在非Linux的其他Unix环境下,大家在使用Java本地命令时如果不太放心,就需要自己查阅资料,看看服务器的操作系统的spawn函数是如何实现的了。

结论

使用Java调用本地命令的方式,不会造成很大的性能开销,在一些存在跨语言调用的应用程序,对性能没有特别高的要求,又不想搞得过于复杂的情况下(比如RPC或RESTful搞微服务),这种方式是完全可行的。

参考

  1. Java调用本地命令
  2. Forking the JVM
  3. 《Unix高级环境编程(第3版)》

你可能感兴趣的:(Java代码执行本地命令)