背景
最近搞的一个项目中,需要用Java调用一些Python的脚本。由于公司的技术栈主要是Java,并且没有Python开发工程师,所以不想搞的太复杂,比如使用Django搞一个Python的微服务,再用Java的微服务去调用这种形式。所以重点考察了两种方式:
- Jython
- 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);
...
}
其中这个launchMechanism
是LaunchMechanism
枚举的一个实例:
private static enum LaunchMechanism {
// order IS important!
FORK,
POSIX_SPAWN,
VFORK
}
可以看到,这里的三个枚举值正好对应了上面forkAndExec
方法重的三种执行策略:
- fork(2) and exec(2)
- posix_spawn(3P)
- 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枚举值的defaultLaunchMechanism
为LaunchMechanism.VFORK
。而platform
变量在调用launchMechanism()
方法获取执行方式的时候,是根据系统属性jdk.lang.Process.launchMechanism
来获取的。这个系统属性我试了一下,一般都是null
,所以最终结论就是,在Linux系统下,执行本地方法调用是使用vfork + exec
的方式来实现的。
vfork与exec
熟悉shell脚本和C语言编程的人应该都对fork
和exec
这两个命令(函数)不陌生。下面我们根据《Unix环境高级编程(第3版)》这本书中的内容,来看一下fork
函数和exec
函数的作用。fork
用来创建子进程,子进程获得父进程数据空间、堆和栈的副本(复制,并不是共享)。而exec
函数用于执行另一个程序,当一个进程调用exec
函数时,该进程执行的程序完全替换为新程序,而新程序则从其main
函数开始执行。因为调用exec
并不创建新进程,所以前后的进程ID并不会改变。exec
只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
看完了fork
和exec
函数,再来看看vfork
函数。由于fork
函数经常与exec
函数一起使用,所以很多系统实现了写时复制
(Copy-On-Write)技术。数据空间、堆栈等这些区域由父进程和子进程共享,并且内核会将它们的访问权限改变为只读。如果父进程和子进程中的任意一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本。通过这种方式,可以减少fork + exec
这个组合操作带来的性能开销。这里顺便提一下,由于fork + exec
这个组合操作较为常用,所以一些系统中将这两个操作变为一个组合操作——spawn
。通过上面的代码可以看到,在一些其他系统中,比如大家常用的Mac笔记本的操作系统OS X中,Java执行本地命令就是使用spawn
命令。接下来言归正传,上面讲到为了降低fork + exec
组合操作的开销,一些系统使用了Copy-On-Write技术,然而还有开销更低的方式,就是vfork + exec
。vfork
函数同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搞微服务),这种方式是完全可行的。
参考
- Java调用本地命令
- Forking the JVM
- 《Unix高级环境编程(第3版)》