美文网首页
Java代码执行本地命令

Java代码执行本地命令

作者: 柴诗雨 | 来源:发表于2018-08-26 17:29 被阅读174次

    背景

    最近搞的一个项目中,需要用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:
         * <pre>
         *   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)
         * </pre>
         * @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 <em>not</em> -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<LaunchMechanism> validLaunchMechanisms;
    
            Platform(LaunchMechanism ... launchMechanisms) {
                this.defaultLaunchMechanism = launchMechanisms[0];
                this.validLaunchMechanisms =
                    EnumSet.copyOf(Arrays.asList(launchMechanisms));
            }
    
            LaunchMechanism launchMechanism() {
                return AccessController.doPrivileged(
                    (PrivilegedAction<LaunchMechanism>) () -> {
                        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<String>) () -> 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代码执行本地命令

          本文链接:https://www.haomeiwen.com/subject/jcboiftx.html