0%

Java 外部函数接口:JNI, JNA, JNR

遇到的问题

前段时间开发的时候,遇到一个问题,就是如何用 Java 实现 chdir?网上搜索一番,发现了 JNR-POSIX 项目 [ stackoverflow ]。俗话说,好记性不如烂笔头。现在将涉及到的相关知识点总结成笔记。

其实针对 Java 实现 chdir 问题,官方 20 多年前就存在对应的 bug,即 JDK-4045688 'Add chdir or equivalent notion of changing working directory'。这个 bug 在 1997.04 创建,目前的状态是 Won't Fix(不予解决),理由大致是,若实现与操作系统一样的进程级别的 chdir,将影响 JVM 上的全部线程,这样引入了可变(mutable)的全局状态,这与 Java 的安全性优先原则冲突,现在添加全局可变的进程状态,已经太迟了,对不变性(immutability)的支持才是 Java 要实现的特性。

chdir 是平台相关的操作系统接口,POSIX 下对应的 APIint chdir(const char *path);,而 Windows 下对应的 APIBOOL WINAPI SetCurrentDirectory(_In_ LPCTSTR lpPathName);,另外 Windows 下也可以使用 MSVCRT 中 APIint _chdir(const char *dirname);(MSVCRT 下内部实现其实就是调用 SetCurrentDirectory [ reactos ] )。

Java 设计理念是跨平台,"write once, run anywhere"。很平台相关的 API,虽然各个平台都有自己的类似的实现,但存在会差异。除了多数常见功能,Java 并没有对全部操作系统接口提供完整支持,比如很多 POSIX API。除了 chdir,另外一个典型的例子是,在 Java 9 以前 JDK 获取进程 id 一直没有简洁的方法 [ stackoverflow ],最新发布的 Java 9 中的 JEP 102(Process API Updates)才增强了进程 API。获取进程 id 可以使用以下方式 [ javadoc ]:

1
long pid = ProcessHandle.current().pid();

相比其他语言,Pyhon 和 Ruby,对操作系统相关的接口都有更多的原生支持。Pyhon 和 Ruby 实现的相关 API 基本上都带有 POSIX 风格。比如上文提到,chdirgetpid,在 Pyhon 和 Ruby 下对应的 API 为:Pyhon 的 os 模块 os.chdir(path)os.getpid();Ruby 的 Dir 类的 Dir.chdir( [ string] ) 类方法和 Process 类的 Process.pid 类属性。Python 解释器的 chdir 对应源码为 posixmodule.c#L2611,Ruby 解释器的 chdir 对应源码为 dir.c#L848win32.c#L6741

JNI 实现 getpid

Java 下要想实现本地方法调用,需要通过 JNI。关于 JNI 的介绍,可以参阅“Java核心技术,卷II:高级特性,第9版2013”的“第12章 本地方法”,或者读当年 Sun 公司 JNI 设计者 Sheng Liang(梁胜)写的“Java Native Interface: Programmer's Guide and Specification”。本文只给出实现 getpid 的一个简单示例。

首先使用 Maven 创建一个简单的脚手架:

1
2
3
4
5
mvn archetype:generate     \
-DgroupId=com.test \
-DartifactId=jni-jnr \
-DpackageName=com.test \
-DinteractiveMode=false

com.test 包下添加 GetPidJni 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.test;

public class GetPidJni {
public static native long getpid();

static {
System.loadLibrary("getpidjni");
}

public static void main(String[] args) {
System.out.println(getpid());
}
}

javac 编译代码 GetPidJNI.java,然后用 javah 生成 JNI 头文件:

1
2
3
$ mkdir -p target/classes
$ javac src/main/java/com/test/GetPidJni.java -d "target/classes"
$ javah -cp "target/classes" com.test.GetPidJni

生成的 JNI 头文件 com_test_GetPidJni.h,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_test_GetPidJni */

#ifndef _Included_com_test_GetPidJni
#define _Included_com_test_GetPidJni
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_test_GetPidJni
* Method: getpid
* Signature: ()J
*/
JNIEXPORT jlong JNICALL Java_com_test_GetPidJni_getpid
(JNIEnv *, jclass);

#ifdef __cplusplus
}
#endif
#endif

现在有了头文件声明,但还没有实现,手动敲入 com_test_GetPidJni.c

1
2
3
4
5
6
#include "com_test_GetPidJni.h"

JNIEXPORT jlong JNICALL
Java_com_test_GetPidJni_getpid (JNIEnv * env, jclass c) {
return getpid();
}

编译 com_test_GetPidJni.c,生成 libgetpidjni.dylib

1
$ gcc -I $JAVA_HOME/include -I $JAVA_HOME/include/darwin -dynamiclib -o libgetpidjni.dylib com_test_GetPidJni.c

生成的 libgetpidjni.dylib,就是 GetPidJni.java 代码中的 System.loadLibrary("getpidjni");,需要加载的 lib。

现在运行 GetPidJni 类,就能正确获取 pid:

1
$ java -Djava.library.path=`pwd` -cp "target/classes" com.test.GetPidJni

JNI 的问题是,胶水代码(黏合 Java 和 C 库的代码)需要程序员手动书写,对不熟悉 C/C++ 的同学是很大的挑战。

JNA 实现 getpid

JNA(Java Native Access, wiki, github, javadoc, mvn),提供了相对 JNI 更加简洁的调用本地方法的方式。除了 Java 代码外,不再需要额外的胶水代码。这个项目最早可以追溯到 Sun 公司 JNI 设计者 Sheng Liang 在 1999 年 JavaOne 上的分享。2006 年 11月,Todd Fast (也来自 Sun 公司) 首次将 JNA 发布到 dev.java.net 上。Todd Fast 在发布时提到,自己在这个项目上已经断断续续开发并完善了 6-7 年时间,项目刚刚在 JDK 5 上重构和重设计过,还可能有很多缺陷或缺点,希望其他人能浏览代码并参与进来。Timothy Wall 在 2007 年 2 月重启了这项目,引入了很多重要功能,添加了 Linux 和 OSX 支持(原本只在 Win32 上测试过),加强了 lib 的可用性(而非仅仅基本功能可用)[ ref ]。

看下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.sun.jna.Library;
import com.sun.jna.Native;

public class GetPidJNA {

public interface LibC extends Library {
long getpid();
}

public static void main(String[] args) {
LibC libc = Native.loadLibrary("c", LibC.class);
System.out.println(libc.getpid());
}
}

JNR 实现 getpid

最初,JRuby 的核心开发者 Charles Nutter 在实现 Ruby 的 POSIX 集成时就使用了 JNA [ ref ]。但过了一段时候后,开始开发 JNR(Java Native Runtime, github, mvn) 替代 JNA。Charles Nutter 在介绍 JNR 的 slides 中阐述了原因:

1
2
3
4
5
Why Not JNA?
- Preprocessor constants?
- Standard API sets out of the box
- C callbacks?
- Performance?!?

即,(1) 预处理器的常量支持(通过 jnr-constants 解决);(2) 开箱即用的标准 API(作者实现了 jnr-posix, jnr-x86asm, jnr-enxio, jnr-unixsocket);(3) C 回调 callback 支持;(4) 性能(提升 8-10 倍)。

JNR 各个模块的层次结构

使用 JNR-FFI(github, mvn)实现 getpid,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import jnr.ffi.LibraryLoader;

public class GetPidJnr {

public interface LibC {
long getpid();
}

public static void main(String[] args) {
LibC libc = LibraryLoader.create(LibC.class).load("c");
System.out.println(libc.getpid());
}
}

使用 JNR-POSIX(github, mvn)实现 chdirgetpid,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import jnr.posix.POSIX;
import jnr.posix.POSIXFactory;

public class GetPidJnrPosix {

private static POSIX posix = POSIXFactory.getPOSIX();

public static void main(String[] args) {
System.out.println(posix.getcwd());
posix.chdir("..");
System.out.println(posix.getcwd());
System.out.println(posix.getpid());
}
}

JMH 性能比较

性能测试代码为 BenchmarkFFI.javagithub),测试结果如下:

1
2
3
4
5
6
7
8
# JMH version: 1.19
# VM version: JDK 1.8.0_144, VM 25.144-b01

Benchmark Mode Cnt Score Error Units
BenchmarkFFI.testGetPidJna thrpt 10 8225.209 ± 206.829 ops/ms
BenchmarkFFI.testGetPidJnaDirect thrpt 10 10257.505 ± 736.135 ops/ms
BenchmarkFFI.testGetPidJni thrpt 10 77852.899 ± 3167.101 ops/ms
BenchmarkFFI.testGetPidJnr thrpt 10 58261.657 ± 5187.550 ops/ms

即:JNI > JNR > JNA (Direct Mapping) > JNA (Interface Mapping)。相对 JNI 的实现性能,其他三种方式,从大到小的性能百分比依次为:74.8% (JNR), 13.2% (JnaDirect), 10.6% (JNA)。在博主电脑上测试,JNR 相比 JNA 将近快了 6-7 倍(JNR 作者 Charles Nutter 针对 getpid 的测试结果是 JNR 比 JNA 快 8-10 倍 [ twitter slides ])。

实现原理

JNA 源码简析

先来看下 JNA,JNA 官方文档 FunctionalDescription.md,对其实现原理有很好的阐述。这里将从源码角度分析实现的核心逻辑。

回顾下代码,我们现实定义了接口 LibC,然后通过 Native.loadLibrary("c", LibC.class) 获取了接口实现。这一步是怎么做到的呢?翻下源码 Native.java#L547 就知道,其实是通过**动态代理(dynamic proxy)**实现的。使用动态代理需要实现 InvocationHandler 接口,这个接口的实现在 JNA 源码中是类 com.sun.jna.Library.Handler。示例中的 LibC 接口定义的全部方法,将全部分派到 Handler 的 invoke 方法下。

1
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;

然后根据返回参数的不同,分派到 Native 类的,invokeXxx 本地方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Call the native function.
*
* @param function Present to prevent the GC to collect the Function object
* prematurely
* @param fp function pointer
* @param callFlags calling convention to be used
* @param args Arguments to pass to the native function
*
* @return The value returned by the target native function
*/
static native int invokeInt(Function function, long fp, int callFlags, Object[] args);

static native long invokeLong(Function function, long fp, int callFlags, Object[] args);

static native Object invokeObject(Function function, long fp, int callFlags, Object[] args);
...

比如,long getpid() 会被分派到 invokeLong,而 int chmod(String filename, int mode) 会被分派到 invokeInt。invokeXxx 本地方法参数:

  • 参数 Function function,记录了 lib 信息、函数名称、函数指针地址、调用惯例等元信息;
  • 参数 long fp,即函数指针地址,函数指针地址通过 Native#findSymbol()获得(底层是 Linux API dlsym 或 Windows API GetProcAddress )。
  • 参数 int callFlags,即调用约定,对应 cdecl 或 stdcall。
  • 参数 int callFlags,即函数入参,若无参数,args 大小为 0,若有多个参数,原本的入参被从左到右依次保存到 args 数组中。

再来看下 invokeXxx 本地方法的实现 dispatch.c#L2122invokeIntinvokeLong 实现源码类似):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* Class: com_sun_jna_Native
* Method: invokeInt
* Signature: (Lcom/sun/jna/Function;JI[Ljava/lang/Object;)I
*/
JNIEXPORT jint JNICALL
Java_com_sun_jna_Native_invokeInt(JNIEnv *env, jclass UNUSED(cls),
jobject UNUSED(function), jlong fp, jint callconv,
jobjectArray arr)
{
ffi_arg result;
dispatch(env, L2A(fp), callconv, arr, &ffi_type_sint32, &result);
return (jint)result;
}

即,全部 invokeXxx 本地方法统一被分派到 dispatch 函数 dispatch.c#L439

1
2
3
static void
dispatch(JNIEnv *env, void* func, jint flags, jobjectArray args,
ffi_type *return_type, void *presult)

这个 dispatch 函数是全部逻辑的核心,实现最终的本地函数调用。

我们知道,发起函数调用,需要构造一个栈帧stack frame)。构造栈帧,涉及到参数压栈次序(参数从左到右压入还是从右到左压入)和清理栈帧(调用者清理还是被调用者清理)等实现细节问题。不同的编译器在不同的 CPU 架构下有不同的选择。构造栈帧的具体实现细节的选择,被称为调用惯例calling convention)。按照调用惯例构造整个栈帧,这个过程由编译器在编译阶段完成的。比如要想发起 sum(2, 3) 这个函数调用,编译器可能会生成如下等价汇编代码:

1
2
3
4
5
; 调用者清理堆栈(caller clean-up),参数从右到左压入栈
push 3
push 2
call _sum ; 将返回地址压入栈, 同时 sum 的地址装入 eip
add esp, 8 ; 清理堆栈, 两个参数占用 8 字节

dispatch 函数是,需要调用的函数指针地址、输入参数和返回参数,全部是运行时确定。要想完成这个函数调用逻辑,就要运行时构造栈帧,生成参数压栈和清理堆栈的工作。JNA 3.0 之前,实现运行时构造栈帧的逻辑的对应代码 dispatch_i386.cdispatch_ppc.cdispatch_sparc.s,分别实现 Intel x86、PowerPC 和 Sparc 三种 CPU 架构。

运行时函数调用,这个问题其实是一个一般性的通用问题。早在 1996 年 10 月,Cygnus Solutions 的工程师 Anthony Green 等人就开发了 libffi(home, wiki, github, doc),解决的正是这个问题。目前,libffi 几乎支持全部常见的 CPU 架构。于是,从 JNA 3.0 开始,摒弃了原先手动构造栈帧的做法,把 libffi 集成进了 JNA。

直接映射(Direct Mapping)
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#RegisterNatives
http://www.chiark.greenend.org.uk/doc/libffi-dev/html/The-Closure-API.html

JNR 源码简析

JNR 底层同样也是依赖 libffi,参见 jffi。但 JNR 相比 JNA 性能更好,做了很有优化。比较重要的点是,JNA 使用动态代理生成实现类,而 JNR 使用 ASM 字节码操作库生成直接实现类,去除了每次调用本地方法时额外的动态代理的逻辑。使用 ASM 生成实现类,对应的代码为 AsmLibraryLoader.java。其他细节,限于文档不全,本人精力有限,不再展开。

Java 9 的 getpid 实现

Java 9 以前 JDK 获取进程 id 没有简洁的方法,最新发布的 Java 9 中的 JEP 102(Process API Updates)增强了进程 API。进程 id 可以使用以下方式 [ javadoc ]

1
long pid = ProcessHandle.current().pid();

翻阅实现源码,可以看到对应的实现就是 JNI 调用:

jdk/src/java.base/share/classes/java/lang/ProcessHandleImpl [ src ]

1
2
3
4
5
6
/**
* Return the pid of the current process.
*
* @return the pid of the current process
*/
private static native long getCurrentPid0();

*nix 平台下实现为:

jdk/src/java.base/unix/native/libjava/ProcessHandleImpl_unix.c [ src ]

1
2
3
4
5
6
7
8
9
10
/*
* Class: java_lang_ProcessHandleImpl
* Method: getCurrentPid0
* Signature: ()J
*/
JNIEXPORT jlong JNICALL
Java_java_lang_ProcessHandleImpl_getCurrentPid0(JNIEnv *env, jclass clazz) {
pid_t pid = getpid();
return (jlong) pid;
}

Windows 平台下实现为:

jdk/src/java.base/windows/native/libjava/ProcessHandleImpl_win.c [ src ]

1
2
3
4
5
6
7
8
9
10
11
12
/*
* Returns the pid of the caller.
*
* Class: java_lang_ProcessHandleImpl
* Method: getCurrentPid0
* Signature: ()J
*/
JNIEXPORT jlong JNICALL
Java_java_lang_ProcessHandleImpl_getCurrentPid0(JNIEnv *env, jclass clazz) {
DWORD pid = GetCurrentProcessId();
return (jlong)pid;
}

参考资料

  1. Changing the current working directory in Java? https://stackoverflow.com/q/840190
  2. How can a Java program get its own process ID? http://stackoverflow.com/q/35842
  3. Java核心技术,卷II:高级特性,第9版2013:第12章 本地方法,豆瓣
  4. Java Native Interface: Programmer's Guide and Specification, Sheng Liang (wikilinkedinmsa), 1999,豆瓣:作者梁胜,中国科技大学少年班83级,并拥有耶鲁大学计算机博士学位(1990-1996),目前 Rancher Labs 创始人兼 CEO [ ref ]
  5. 2013-07 Charles Nutter: Java Native Runtime http://www.oracle.com/technetwork/java/jvmls2013nutter-2013526.pdf
  6. JEP 191: Foreign Function Interface http://openjdk.java.net/jeps/191 作者是Charles Nutter
  7. 2014-03 Java 外部函数接口 http://www.infoq.com/cn/news/2014/03/java-foreign-function-interface
  8. 2005-08 Brian Goetz:用动态代理进行修饰 https://www.ibm.com/developerworks/cn/java/j-jtp08305.html