C++庫封裝JNI介面——實現java呼叫c++

2023-04-05 18:00:37

1. JNI原理概述

通常為了更加靈活高效地實現計算邏輯,我們一般使用C/C++實現,編譯為動態庫,併為其設定C介面和C++介面。用C++實現的一個庫其實是一個或多個類的簡單編譯連結產物。然後暴露其實現類構造方法和純虛介面類。這樣就可以通過多型呼叫到庫內部的實現類及其成員方法。進一步地,為了讓不同庫之間呼叫相容,可以將C++介面進一步封裝為一組C介面函數,C介面編譯時不會新增複雜的函數簽名,也不支援函數過載,可以方便其他C或C++客戶程式呼叫。C介面的封裝需要有"extern C{}"標識,以告訴編譯器請使用C編譯方式編譯這些函數。
進一步地,為了方便上層應用呼叫C/C++庫, 如Android應用,可以為C++庫封裝Java介面。jdk中地jni元件可以方便地實現在java中呼叫c++庫函數。基本呼叫原理如下:

  • Java客戶程式碼實現和native方法宣告屬於java層,使用java編譯器編譯;
  • JNI介面實現程式碼和c++庫屬於c++層,使用G++編譯。

這裡假定C++類別庫已經預編譯好了,有現成的so庫和c介面使用。首先明確一點就是,我們要為C++庫封裝一個java介面,也即在java層使用C++庫暴露的所有函數,那麼:

  1. 第一步就是建立一個java類,並按照c++庫的介面函數宣告,建立所有的native本地介面函數宣告(可以是static的)。
  2. 第二步,將這些本地介面宣告對映為C++ JNI介面宣告,這一步是通過java提供的工具按照既定的對映機制自動生成。這也就保證了java層能正確找到c++實現。
  3. 第三步,實現第二步自動生成的c++ JNI介面函數,在這些介面實現中,按照需要呼叫c++類庫的介面函數,以使用特定的功能並拿到需要的結果。所以,這裡要注意的一點是,c++ JNI介面函數實現會編譯為一個單獨的動態庫,並且該動態庫動態連結C++類別動態庫。(這裡沒有嘗試過靜態庫,按道理應該也是可以的)。此外,在c++ JNI函數實現中按照型別簽名規則,我們可以獲取到從java層傳入的引數,也可以返回特定的資料到Java層。
  4. 第四步,在java應用層使用system.loadLibrary("libname.so");載入第三步編譯生成的jni so庫,即可間接呼叫到c++庫函數。

PS:

  1. jni層型別和java型別的對應關係,基本資料型別只是簡單地加了字首j,如int<=>jint, double<=>jdouble,下面是一些物件型別(包含陣列)的型別對映關係:

  2. 簽名規則對應表

  3. String 字串函數操作

    // 在jni實現函數中把jstring型別的字串轉換為C風格的字串,會額外申請記憶體
    const char *str = env->GetStringUTFChars(string,0);
    // 做檢查判斷
    if (str == NULL){
        return NULL;
    }
    // do something;
    // 使用完之後釋放申請的記憶體
    env->ReleaseStringUTFChars(string,str);
  • JNI 支援將 jstring 轉換成 UTF 編碼和 Unicode 編碼兩種。因為 Java 預設使用 Unicode 編碼,而 C/C++ 預設使用 UTF 編碼。所以使用GetStringUTFChars(jstring string, jboolean* isCopy)將 jstring 轉換成 UTF 編碼的字串。其中,jstring 型別引數就是我們需要轉換的字串,而 isCopy 引數的值在實際開發中,直接填 0或NULL就好了,表示深拷貝。

  • 當呼叫完GetStringUTFChars 方法時別忘了做完全檢查。因為 JVM 需要為產生的新字串分配記憶體空間,如果分配失敗就會返回 NULL,並且會丟擲 OutOfMemoryError 異常,所以要對 GetStringUTFChars 結果進行判斷。

  • 當使用完 UTF 編碼的字串時,還不能忘了釋放所申請的記憶體空間。呼叫 ReleaseStringUTFChars 方法進行釋放。

  • 除了將 jstring 轉換為 C 風格字串,JNI 還提供了將 C 風格字串轉換為 jstring 型別。

  • 通過 NewStringUTF函數可以將 UTF 編碼的 C 風格字串轉換為 jstring 型別,通過NewString 函數可以將 Unicode 編碼的 C 風格字串轉換為 jstring 型別。這個 jstring 型別會自動轉換成 Java 支援的 Unicode 編碼格式。

  • 除了 jstring 和 C 風格字串的相互轉換之外,JNI 還提供了其他的函數。

    參考:https://blog.csdn.net/TLuffy/article/details/123994246

2. JNI封裝範例

實踐出真知,分別建立一個c++工程和java工程,原始碼github地址
結構目錄如下:

├── cpp_project
│   ├── build.sh
│   ├── CMakeLists.txt
│   ├── include
│   │   ├── c_api.h
│   │   ├── com_Student.h
│   │   └── student.h
│   ├── jni_impl
│   │   └── jni_impl.cpp
│   ├── src
│   │   ├── c_api.cpp
│   │   └── student.cpp
│   └── test
│       └── main.cpp
└── java_project
    ├── com
    │   ├── Student.java
    │   └── Test.java
    ├── com_Student.h
    └── run.sh

整體構建流程如下:

  1. 在java工程下建立和C++類別庫同名(非必須)的java類原始檔,並宣告和c++工程介面統一的native成員函數;
    使用javac -encoding utf8 -h ./ com/Student.java命令生成naive本地介面.h標頭檔案。將其拷貝到c++工程下。
  2. 在c++工程下實現jni介面標頭檔案中的函數宣告,實現中呼叫c介面間接完成特定能力呼叫,編譯為libjnilib.so,並連結原始c++庫的動態庫。
  3. 回到java工程中,在native介面所在的那個類中,新增jni庫載入程式碼:
    // 載入jni庫
    static {
        try {
            System.loadLibrary("jnilib");
        }
        catch(UnsatisfiedLinkError e) {
			System.err.println(">>> Can not load library: " + e.toString());
		}
    }
  1. java 測試程式碼呼叫,使用如下指令碼:
# 編譯java檔案
javac -encoding utf8 com/Test.java -d bin

# 執行java檔案
java -Djava.library.path=/root/project/lzq/jni_demo/cpp_project/build/bin -cp bin com.Test

PS: 編譯指令碼分別在cpp工程和java工程目錄下

3. 思考

  1. 目前即使編譯debug版本,偵錯還是無法進入到jni實現層。有部落格說可以通過attach程序可以進入,我嘗試並沒有成功。
  2. JNI介面傳參和返回資料到java層要注意資料型別匹配,簽名要一致,否則會直接崩潰掉。
  3. 類似的為C++庫封裝Python介面,並生成一個安裝包可以直接使用pip安裝也是常見的封裝方式,有時間也可以嘗試一下。
  4. 為C++庫實現JNI介面可以用Android studio,IDEA,更加方便。也可以直接在Linux上進行,只要有jdk和gcc就可以,但正常人一般不會在linux上寫JAVA程式碼。