在前面介紹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,例如windows
、linux
、mac 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語言規則來進行編譯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
環境。
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
安裝完成後,如果還想安裝gdb
或make
等其他指令進行除錯或編譯,同樣可以使用強大的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和其他語言的耦合性,也提高了對專案程式碼的維護成本
如果文章對您有所幫助,歡迎關注公眾號 碼農參上