詳解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,例如windows
、linux
、mac os
版本等等,在這些jdk中,針對不同系統有著不同的jvm實現。而java語言的跨平臺性恰好是和它底層的jvm密不可分的,正是依靠不同的操作系統下不同版本jvm的“翻譯”工作,才能使編譯後的字節碼在不同的平臺下暢通無阻的運行。
在不同操作系統下,c/c++或其他代碼生成的動態鏈接庫也會有差異,例如在window平臺下會編譯為dll
文件,在linux平臺下會編譯為so
文件,在mac os下會編譯為jnilib
文件。而不同平臺下的jvm,會“約定俗成”的去加載某個固定類型的動態鏈接庫文件,使得依賴於操作系統的功能可以被正常的調用,這一過程可以參考下面的圖來進行理解:
在對jni
的整體調用流程有瞭一定的瞭解後,對於它如何調用其他語言中的函數這一過程,你是否也會好奇它是怎樣實現的,下面我們就通過手寫一個java程序調用c++代碼的例子,來理解它的調用過程。
二、準備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
文件的路徑,並調用本地方法
三、生成頭文件
在使用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語言規則來進行編譯JNIEXPORT
和JNICALL
是jni
中定義的兩個宏,使用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
環境。
四、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
安裝完成後,如果還想安裝gdb
或make
等其他指令進行調試或編譯,同樣可以使用強大的mingw-get
命令進行獨立安裝。
五、生成動態鏈接庫
在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++知識
當然瞭,使用jni
也會帶來一些缺點:
- 當在某個操作系統下使用瞭
jni
標準,將本地代碼編譯生成瞭動態鏈接庫後,如果要將這個程序移植到其他操作系統,需要在新的平臺重新編譯代碼生成動態鏈接庫 - 對其他語言的不正確使用可能會造成程序出現錯誤,例如之前提到的使用c語言進行內存操作時未及時回收內存可能引起的內存泄漏
- 對其他語言的依賴過高,會提高瞭java和其他語言的耦合性,也提高瞭對項目代碼的維護成本
以上就是詳解JNI到底是什麼的詳細內容,更多關於JNI的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- windows下vscode+vs2019開發JNI的示例
- Mac下用Java調用c/c++的思路詳解
- Java中的Native方法
- Java調用C++程序的實現方式
- android 調用JNI SO動態庫的方法