Java築基 - JNI到底是個啥

碼農參上發表於2021-05-11

在前面介紹Unsafe的文章中,簡單的提到了java中的本地方法(Native Method),它可以通過JNI(Java Native Interface)呼叫其他語言中的函式來實現一些相對底層的功能,本文我們就來順藤摸瓜,介紹一下jni以及它的使用。

首先回顧一下jni的主要功能,從jdk1.1開始jni標準就成為了java平臺的一部分,它提供的一系列的API允許java和其他語言進行互動,實現了在java程式碼中呼叫其他語言的函式。通過jni的呼叫,能夠實現這些功能:

通常情況下我們一般使用jni用來呼叫c或c++中的程式碼,在上一篇文章中我們用了下面的流程來描述了native方法的呼叫過程:

Java Code -> JNI -> C/C++ Code

但是準確的來說這一過程並不嚴謹,因為最終被執行的不是原始的c/c++程式碼,而是被編譯連線後的動態連結庫。因此我們將這個過程從單純的程式碼呼叫層面上進行升級,將jni的呼叫過程提高到了jvm和作業系統的層面,來加點細節進行一下完善:

看到這裡,可能有的小夥伴就要提出疑問了,不是說java語言是跨平臺的嗎,這種與作業系統本地編譯的動態連結庫進行的互動,會不會使java失去跨平臺的可移植性?

針對這一問題,大家可以回想一下以前安裝jdk的經歷,在官網的下載列表中提供了各個作業系統的不同版本jdk,例如windowslinuxmac os版本等等,在這些jdk中,針對不同系統有著不同的jvm實現。而java語言的跨平臺性恰好是和它底層的jvm密不可分的,正是依靠不同的作業系統下不同版本jvm的“翻譯”工作,才能使編譯後的位元組碼在不同的平臺下暢通無阻的執行。

在不同作業系統下,c/c++或其他程式碼生成的動態連結庫也會有差異,例如在window平臺下會編譯為dll檔案,在linux平臺下會編譯為so檔案,在mac os下會編譯為jnilib檔案。而不同平臺下的jvm,會“約定俗成”的去載入某個固定型別的動態連結庫檔案,使得依賴於作業系統的功能可以被正常的呼叫,這一過程可以參考下面的圖來進行理解:

在對jni的整體呼叫流程有了一定的瞭解後,對於它如何呼叫其他語言中的函式這一過程,你是否也會好奇它是怎樣實現的,下面我們就通過手寫一個java程式呼叫c++程式碼的例子,來理解它的呼叫過程。

1、準備java程式碼

首先定義一個包含了native方法的類如下,之後我們要使用這個類中的native方法通過jni呼叫c++編寫成的動態連結庫中的方法:

public class JniTest {
    static{
        System.loadLibrary("MyNativeDll");
    }

    public static native void callCppMethod();

    public static void main(String[] args) {
        System.out.println("DLL path:"+System.getProperty("java.library.path"));
        callCppMethod();
    }
}

在程式碼中主要完成了以下工作:

  • 在靜態程式碼塊中,呼叫loadLibrary方法載入本地的動態連結庫,引數為不包含副檔名的動態連結庫庫檔名。在window平臺下會載入dll檔案,在linux平臺下會載入so檔案,在mac os下會載入jnilib檔案
  • 宣告瞭一個native方法,native關鍵字負責通知jvm這裡呼叫方法的是本地方法,該方法在外部被定義
  • main方法中,列印載入dll檔案的路徑,並呼叫本地方法

2、生成標頭檔案

在使用c/c++來實現本地方法時,需要先建立.h標頭檔案。簡單的來說,c/c++程式通常由標頭檔案(.h)和定義檔案(.c.cpp)組成,標頭檔案包含了功能函式、資料介面的宣告,而定義檔案用於書寫程式的實現。

在jdk8中可以直接使用javac -h指令生成c/c++語言中的標頭檔案。如果你使用的是較早版本的jdk,需要在執行javac編譯完成class檔案後,再執行javah -jni生成c/c++風格的標頭檔案(在jdk10的新特性中已經刪除了javah這一指令)。我們使用的jdk8簡化了這一步驟,使其可以一步完成,在命令列視窗下執行命令:

javac -h ./jni JniTest.java

指令中使用 -h引數指定放置生成的標頭檔案的位置,最後的引數是java原始檔的名稱。在這個過程中完成了兩件工作,首先生成class檔案,其次在引數指定的目錄下生成標頭檔案。生成的標頭檔案com_cn_jni_JniTest.h內容如下:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_cn_jni_JniTest */

#ifndef _Included_com_cn_jni_JniTest
#define _Included_com_cn_jni_JniTest
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_cn_jni_JniTest
 * Method:    callCppMethod
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_cn_jni_JniTest_callCppMethod
  (JNIEnv *, jclass);

#ifdef __cplusplus
}
#endif
#endif

生成的標頭檔案和大家熟悉的 java介面有些相似,只有函式的宣告而沒有具體實現。簡單的解釋一下標頭檔案中的程式碼:

  • extern "C"告訴編譯器,這部分程式碼使用C語言規則來進行編譯
  • JNIEXPORTJNICALLjni中定義的兩個巨集,使用JNIEXPORT支援在外部程式程式碼中呼叫該動態庫中的方法,使用JNICALL定義函式呼叫時引數的入棧出棧約定
  • 函式名稱由包名+類名+方法名組成,在該方法中有兩個引數,通過第一個引數JNIEnv *的物件可以呼叫jni.h中封裝好的大量函式 ,第二個引數代表著native方法的呼叫者,當java程式碼中定義的native方法是靜態方法時這裡的引數是jclass,非靜態方法的引數是jobject

接下來我們建立一個cpp檔案,引用標頭檔案並實現其中的函式,也就是native方法將要實際執行的邏輯:

#include "com_cn_jni_JniTest.h"
#include <stdio.h>
 
JNIEXPORT void JNICALL Java_com_cn_jni_JniTest_callCppMethod
  (JNIEnv *, jclass)
{
    printf("Print From Cpp: \n");
    printf("I am a cpp method ! \n");
}

在方法的實現中加入簡單的printf列印語句,在完成方法的實現後,我們需要將上面的cpp檔案編譯為動態連結庫,提供給java中的native方法呼叫,因此下面需要在window環境下安裝gcc環境。

3、gcc環境安裝

在window環境下,如果你不希望為了生成一個dll就去下載體積龐大的的Visual Studio的話,MinGW是一個不錯的選擇,簡單的說它就是一個windows版本下的gcc。那麼估計有的同學又要問了,gcc是什麼?簡單的來說就是linux系統下C/C++的編譯器,通過它可以將原始碼編譯成可執行程式。首先從下面的網址下載mingw-get-setup的安裝程式:

http://sourceforge.net/projects/mingw/  #32位
https://sourceforge.net/projects/mingw-w64/  #64位

需要注意,一定要按照系統位數安裝對應的版本,否則後面生成的dll在執行時就可能會因位數不匹配而報錯,我在實驗的過程中第一次就錯誤安裝了32位的MinGw,導致了在程式執行過程中報了下面錯誤:

Exception in thread "main" java.lang.UnsatisfiedLinkError: 
F:\Workspace20\unsafe-test\src\main\java\com\cn\jni\jni\MyNativeDll.dll: 
Can't load IA 32-bit .dll on a AMD 64-bit platform

安裝完成後,將MinGW\bin目錄加入系統環境變數PATH,輸入下面的指令測試gcc是否可以使用:

gcc -v

如果能夠正常輸出gcc的版本資訊,說明gcc安裝成功:

在測試的過程中發現,如果安裝的是64位的mingw,那麼在安裝完成後gcc就已經直接可以可用。但是如果安裝的是32位的mingw,需要使用下面的命令單獨安裝gcc

mingw-get install gcc

gcc安裝完成後,如果還想安裝gdbmake等其他指令進行除錯或編譯,同樣可以使用強大的mingw-get命令進行獨立安裝。

4、生成動態連結庫

gcc環境準備好的條件下,接下來使用下面的命令生成dll動態連結庫:

gcc -m64 -Wl,--add-stdcall-alias -I"D:\Program Files\Java\jdk1.8.0_261\include" 
-I"D:\Program Files\Java\jdk1.8.0_261\include\win32" 
-shared -o MyNativeDll.dll JniTestImpl.cpp

簡單的解釋一下各個引數的含義:

  • -m64 :將cpp程式碼編譯為64位的應用程式
  • -Wl,--add-stdcall-alias-Wl表示將後面的引數傳遞給連線程式,引數--add-stdcall-alias表示帶有標準呼叫字尾@NN的符號會被剝掉字尾後匯出
  • -I:指定標頭檔案的路徑,在生成的標頭檔案程式碼中引入的jni.h就在這個目錄下
  • -shared:指定生成動態連結庫,如果不使用這個標誌那麼外部程式將無法連線
  • -o:指定目標的名稱,這裡將生成的動態連結庫命名為MyNativeDll.dll
  • JniTestImpl.cpp:被編譯的源程式檔名

在指令的執行過程中,都做了什麼事呢,可以參考下面這張圖:

在執行過程中,以.cpp原始碼和.h標頭檔案作為原始檔,先進行了預處理、編譯、彙編的操作,圖中省略了這一階段產生的一些中間檔案,編譯完成後生成的.o二進位制檔案相對重要,依賴這個檔案,最終生成動態連結庫。

在執行了上面的指令後,就會在當前目錄下生成一個MyNativeDll.dll檔案,再執行之前準備好的java程式碼:

程式報錯,這是因為在預設的載入庫檔案的目錄下沒有找到我們的dll檔案。有兩種方式可以解決:

  • 直接將dll檔案拷貝到預設的載入目錄下,具體的路徑可以通過System.getProperty("java.library.path")獲取,該方法可能會獲得多個目錄,放在任意一個目錄下即可
  • 是在VM Option中修改啟動引數,指定dll的存放目錄:
-Djava.library.path=F:\Workspace20\unsafe-test\src\main\java\com\cn\jni\jni

再次執行,輸出結果:

DLL path:F:\Workspace20\unsafe-test\src\main\java\com\cn\jni\jni
Print From Cpp: 
I am a cpp method ! 

可以看到程式載入dll的路徑已經切換成了它的存放路徑,並且通過jni呼叫成功,輸出了在c++中的程式碼邏輯。可以用下面的圖來總結上面實現jni呼叫的過程:

在對jni的呼叫有了一個整體的瞭解後,如果大家對代理模式比較熟悉的話,也可以從代理模式的角度來理解jni,將jni呼叫過程中的各個角色帶入到代理模式中:

  • 代理角色:包含native方法的jni
  • 實現角色:c/c++或其他語言實現的動態連結庫
  • 客戶端:呼叫native方法的java類程式
  • 介面(抽象角色):在jni中介面這一角色的存在感相對薄弱,因為jni是跨語言的,所以說無法嚴格的定義一個介面並讓它同時應用於java和其他語言。但是通過生成的.h標頭檔案,在一定程度上實現了從介面規範上統一了java中native方法和其他語言中的函式

以代理模式的概述圖來進行描述:

上圖在標準代理模式的基礎上做了一些修改以便於理解,因為這裡的介面只做規範約束作用,所以讓客戶端的呼叫過程跳過了介面,直接指向了代理角色,再由代理角色呼叫實現角色完成功能的呼叫。總的來說,jni起到了一個代理或中介的作用,與常見代理不同的是這裡只做方法的呼叫,而不實現邏輯上的增強。通過這一模式,向java程式設計師隱藏了底層c/c++程式碼的實現細節,讓我們專注於業務程式碼的編寫即可。

總結

在前面對native方法有了一定了解的基礎上,本文介紹了jni的相關知識。通過本文的學習,有助於我們:

  • 理解java的為何能夠做到跨平臺,以及依賴作業系統的底層操作是如何實現的
  • 瞭解native方法的呼叫過程,在必要時可以自己實現jni類介面呼叫
  • 學到一點C/C++知識

當然了,使用jni也會帶來一些缺點:

  • 當在某個作業系統下使用了jni標準,將原生程式碼編譯生成了動態連結庫後,如果要將這個程式移植到其他作業系統,需要在新的平臺重新編譯程式碼生成動態連結庫
  • 對其他語言的不正確使用可能會造成程式出現錯誤,例如之前提到的使用c語言進行記憶體操作時未及時回收記憶體可能引起的記憶體洩漏
  • 對其他語言的依賴過高,會提高了java和其他語言的耦合性,也提高了對專案程式碼的維護成本

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

相關文章