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

Java基础

浏览数:298

2018-12-9

原文: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 下对应的 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 ]:

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] )](https://ruby-doc.org/core-2.2… 类方法和 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 创建一个简单的脚手架:

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 各个模块的层次结构

使用 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)实现 chdirgetpid,示例代码:

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),测试结果如下:

# 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#L2122invokeIntinvokeLong 实现源码类似):

/*
 * 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.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/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;
}

参考资料

  1. Changing the current working directory in Java? https://stackoverflow.com/q/8…
  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/technet…
  6. JEP 191: Foreign Function Interface http://openjdk.java.net/jeps/191 作者是Charles Nutter
  7. 2014-03 Java 外部函数接口 http://www.infoq.com/cn/news/…
  8. 2005-08 Brian Goetz:用动态代理进行修饰 https://www.ibm.com/developer…

原文地址:https://segmentfault.com/a/1190000016793722