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

原文: http://nullwy.me/2018/01/java...
若是以爲個人文章對你有用,請隨意讚揚

遇到的問題

前段時間開發的時候,遇到一個問題,就是如何用 Java 實現 chdir?網上搜索一番,發現了 JNR-POSIX 項目 [stackoverflow ]。俗話說,好記性不如爛筆頭。如今將涉及到的相關知識點總結成筆記。html

其實針對 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 要實現的特性。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 ] )。python

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 ]:react

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#L6741git

JNI 實現 getpid

Java 下要想實現本地方法調用,須要經過 JNI。關於 JNI 的介紹,能夠參閱「Java核心技術,卷II:高級特性,第9版2013」的「第12章 本地方法」,或者讀當年 Sun 公司 JNI 設計者 Sheng Liang(梁勝)寫的「Java Native Interface: Programmer's Guide and Specification」。本文只給出實現 getpid 的一個簡單示例。程序員

首先使用 Maven 建立一個簡單的腳手架:github

mvn archetype:generate     \
  -DgroupId=com.test       \
  -DartifactId=jni-jnr     \
  -DpackageName=com.test   \
  -DinteractiveMode=false

com.test 包下添加 GetPidJni 類:web

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 頭文件:c#

$ 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,內容以下:windows

/* 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 x8六、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...
相關文章
相關標籤/搜索