Java 外部函数接口:JNI, JNA, JNR
原文:http://nullwy.me/2018/01/java...
如果觉得我的文章对你有用,请随意赞赏
遇到的问题
前段时间开发的时候,遇到一个问题,就是如何用 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 下对应的 API 为 int chdir(const char *path);
,而 Windows 下对应的 API 为 BOOL WINAPI SetCurrentDirectory(_In_ LPCTSTR lpPathName);
,另外 Windows 下也可以使用 MSVCRT 中 API 的 int _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 ]:
long pid = ProcessHandle.current().pid();
相比其他语言,Pyhon 和 Ruby,对操作系统相关的接口都有更多的原生支持。Pyhon 和 Ruby 实现的相关 API 基本上都带有 POSIX 风格。比如上文提到,chdir
和 getpid
,在 Pyhon 和 Ruby 下对应的 API 为:Pyhon 的 os 模块 os.chdir(path) 和 os.getpid();Ruby 的 Dir 类的 [Dir.chdir( [ string] )](https://ruby-doc.org/core-2.2... 类方法和 Process 类的 Process.pid 类属性。Python 解释器的 chdir
对应源码为 posixmodule.c#L2611,Ruby 解释器的 chdir
对应源码为 dir.c#L848 和 win32.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 创建一个简单的脚手架:
mvn archetype:generate \ -DgroupId=com.test \ -DartifactId=jni-jnr \ -DpackageName=com.test \ -DinteractiveMode=false
在 com.test
包下添加 GetPidJni
类:
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 头文件:
$ 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
,内容如下:
/* 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
:
#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
:
$ 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:
$ 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 ]。
看下示例代码:
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 中阐述了原因:
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-FFI(github, mvn)实现 getpid
,示例代码:
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)实现 chdir
和 getpid
,示例代码:
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.java
(github),测试结果如下:
# 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 方法下。
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
然后根据返回参数的不同,分派到 Native 类的,invokeXxx 本地方法:
/** * 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#L2122(invokeInt
或 invokeLong
实现源码类似):
/* * 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:
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)
这个函数调用,编译器可能会生成如下等价汇编代码:
; 调用者清理堆栈(caller clean-up),参数从右到左压入栈 push 3 push 2 call _sum ; 将返回地址压入栈, 同时 sum 的地址装入 eip add esp, 8 ; 清理堆栈, 两个参数占用 8 字节
dispatch
函数是,需要调用的函数指针地址、输入参数和返回参数,全部是运行时确定。要想完成这个函数调用逻辑,就要运行时构造栈帧,生成参数压栈和清理堆栈的工作。JNA 3.0 之前,实现运行时构造栈帧的逻辑的对应代码 dispatch_i386.c、dispatch_ppc.c 和 dispatch_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/javas...
http://www.chiark.greenend.or...
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 ]
long pid = ProcessHandle.current().pid();
翻阅实现源码,可以看到对应的实现就是 JNI 调用:
jdk/src/java.base/share/classes/java/lang/ProcessHandleImpl [src ]
/** * 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 ]
/* * 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 ]
/* * 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; }
参考资料
- Changing the current working directory in Java? https://stackoverflow.com/q/8...
- How can a Java program get its own process ID? http://stackoverflow.com/q/35842
- Java核心技术,卷II:高级特性,第9版2013:第12章 本地方法,豆瓣
- Java Native Interface: Programmer's Guide and Specification, Sheng Liang (wiki,linkedin,msa), 1999,豆瓣:作者梁胜,中国科技大学少年班83级,并拥有耶鲁大学计算机博士学位(1990-1996),目前 Rancher Labs 创始人兼 CEO [ref ]
- 2013-07 Charles Nutter: Java Native Runtime http://www.oracle.com/technet...
- JEP 191: Foreign Function Interface http://openjdk.java.net/jeps/191 作者是Charles Nutter
- 2014-03 Java 外部函数接口 http://www.infoq.com/cn/news/...
- 2005-08 Brian Goetz:用动态代理进行修饰 https://www.ibm.com/developer...