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:
     * <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版)》
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容