天天看點

JNI in Java

一、什麼是JNI 

(一)什麼是JNI (Java Native Interface) 

JNI全稱是Java Native Interface,顧名思義是Java和Native間的通信橋梁,如下圖所示,圖的上方是Java世界,下面是Native世界,中間是JNI通信,左邊箭頭從上往下是Java調用Native的方法,右邊是Native調用Java,彼此可以互通。 

JNI in Java

這種方式帶來的好處,Java調用Native,可以去調用非Java實作的庫,擴充Java的使用場景,比如調用Tensorflow;反之Native調用Java,可以在别的語言裡面調用Java,比如java launcher可以指令啟動Java程式。 

(二)為什麼要學習JNI 

掌握Java和Native之間的互相調用,大大豐富java的使用場景。了解原理,對于學習JVM/故障定位更加得心應手。 

經典例子,如下圖所示,在主函數裡面用Selector.open建立一個select,叫select方法,這是Java裡面通過NIO取允許網絡的方法。 

JNI in Java

public static void main(String[] args) throws Exception { 

        java.nio.channels.Selector.open().select(); 

    } 

這個方法會阻塞其目前線程,通過java.lang呈現狀态是RUNNABLE,看到RUNNABLE總覺得會消耗CPU、NIO的BUG, 其實是一個經典謬誤,實際上線程是禁止的。 

二、JNI實踐和思考 

實戰一: 從native調用Java 

首先要#include <jni.h> ,這個頭檔案定義了各種Java和Native互動的資料結構以及定義;在主函數裡面,首先聲明一個JVM的指針,然後一個JNIEnv *env的指針,JVM表示的Java虛拟執行個體,我通過執行個體消耗資源進行各種操作。 

env其實對應的是一個線程,然後建立JavaVMInitArgs結構體,結構體裡面要填充Java參數,用JavaVMOption表示。因為這裡不需要參數,場景比較簡單,是以用options[0],把 options傳入 vm_args.options結構體,最後調用JNI_CreateJavaVM建立 Java虛拟器,如果傳回的是“JNI_OK”,說明這次調用成功。 

有了JNI指針表示執行個體以後,就可以用标準方法使用JNI,在這裡調用一個Java方法,比如Java資料結構,先通過EMC的FindClass,找到SelectorProvider類,中間有個printf變量叫lock,先通過 GetStaticField擷取 field,再通過GetStaticObjectField從 cls對象上擷取fid,就是 lock對象,然後把它列印出來,最後jvm->DestroyJavaVM。詳情操作如下圖所示: 

JNI in Java

還有一個比較經典的例子Java Launcher, java –jar spring-application執行程式的時候,在背景默默的建立了一個jvm,把Java參數作為 arguments傳進去,調用Java入口方法,通過JNI實作。 

JNI in Java

平時所說,開發jvm其實就是開發jvm的動态庫, “libjvm.so”基本上本身是作為“os”提供出去,好處是非常靈活,可以作為獨立應用使用,也可以在别的像cer這樣的語言調用,使Java調用Native,Native調用Java更加靈活。 

JNI實戰二: Java調用C 

Java調用C是使用JNI最常見的方式,首先定一個類叫HelloJNI,裡面有System.loadLibrary("hello"); 系統會自動去找到library libhello.so,這個類裡面定義方法叫sayHello,加了C以後調用它,但這是調不通的,因為并沒有提供真正的Native實作。實作要通過一個頭檔案去告訴這個方法的簽名,這裡實作Java檔案,然後通過jni.h生成頭檔案,這個是自動生成的。 

簽名是 Java,然後是Java_HelloJNI_sayHello(JNIEnv *, jobject)規範,類名加上方法名,參數第一個是環境;第二個是jobject,無參數,但是 Java的方法預設是有一個this指針作為第一個參數,最後編寫它,實作HelloJNI.c,根據這個聲明定義實作,然後裡面隻是printf了一下,把 HelloJNI.c定義成libhelloHello.so這個程式就可以運作起來了。詳情如下圖所示: 

JNI in Java

在Java應用裡面,可以調通過JNI調用各種庫,調用到native以後,因為任何語言跟native都互互相動,大大豐富了Java使用場景。 

JNI in Java

思考一: Java和Native的資料是怎麼傳遞的 

在執行Java方法時,用的是java heap,假設暫時向下增長,需要調用 c函數的時候,它需要去壓站,把 object壓站、把JNIEnv壓站、cstack壓站,進入seat stack。然後 object本質上是指向handle的指針,handle指向戰上真正的OOP,使用二級指針結構,稍微有點複雜。詳情如下圖所示: 

JNI in Java

思考二: 回到問題,為什麼select()的線程狀态是RUNNABLE 

JNI隻是提供一種機制,讓Java程式可以進入Native狀态,Native狀态基本上沒有辦法管理。這段Native代碼在做一種非常複雜的數學運算,肯定是RUNNABLE狀态,也可以調用系統形象去阻塞,但這個阻塞基本上不知情,是以會一直顯示為RUNNABLE,除非通過JNI的特殊接口改變現實狀态,到其他狀态才會顯示為其他狀态,是以這裡顯示為RUNNABLE為正常,不用擔心RUNNABLE狀态消耗很多CPU等問題。 

JNI in Java

三、JNI與safepoint 

首先有這樣兩個問題: 

1、JNI是否會影響GC進行? 

2、GC時JNI修改Java Heap怎麼保證一緻? 

看到第二個問題的時候,已經回答第一個問題,假如GC是不能運作JNI,那也就沒有一緻性問題,是以在GC時可以執行JNI。 

(一)JNI與Safepoint的協作 

首先要知道Java的信任狀态,Java最主要信任狀态是“Thread in Java”狀态,這個狀态裡面在執行一個解釋器或者已經編譯的方法,純Java執行。這時候如果發生Safepoint,會通過Interpreter機制把這個線程直接挂起,暫停下來,然後去Safepoint裡面進行GC的各種操作。 

在Java裡面,調用JNI進入Native,會切換到Thread in native狀态,這裡執行Native函數,在執行的時候跟GC可以并行執行,因為理論上要麼執行,要麼通過JNI和JNI互動,所有的跟JNI相關的資料結構都可以被管理。然後Native還可以去切換到JVM狀态,這是非常關鍵的狀态,這個狀态不能發生GC,不用關心。 

JNI與Safepoint互動,假如JNI執行時發生Safepoint能并行。JNI執行的時候傳回Java,這時候會被阻塞,需要檢查狀态,卡在Safepoint狀态,直到Safepoint結束,繼續回到Java。 

JNI in Java

(二)JNI與GC 

透過幾個JNI管中窺豹,了解這個機制: 

void * GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);  

void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode); 

這個函數叫做GetPrimitiveArrayCritical。Critical作用是把一段記憶體傳回給使用者,使用者可以直接編輯裡面的資料,這時如果發生GC被移動,編輯肯定會導緻 heap亂掉,有Critical這段時間裡鎖住heap,沒法發生GC。假如 critical狀态發生期間,基本上不會影響GC,會等待,直ReleasePrimitiveArrayCritical發出,這是比較巧妙的互相協作。 

下圖所示的二級指針模型,還是前面Java調到Native,參數通過jobject到handle儲存使用,jobject指向handle,handle指向oop。 

JNI in Java

java heap時候,假如OOP對象被移動handle,同時會更新 handle裡面的位址。是以隻要C程式都是通過JNI通路對象,每次對象被移動它都可以被感覺,不會出現資料布局之後突然情況。 

“GC: handle_are->oops_do(f)” 

指有區域專門存放handle,裡面所有handle在GC裡,都會進行一次指針修正,保證資料一緻性。 

四、JNI與Intrinsic 

(一)進階主題: intrinsic 

如下圖所示,以非常常見JNA“currentThread”為例子,說明Intrinsic機制。Intrinsic在看到currentThread的時候,不會去JNI,而是通過形成更高效的版本。 

這裡inline_native_currentThread的時候,最終會調用generate_curent_thread工具。然後看裡面的實作核心部分,建立“ThreadLocalNode()”,代表目前JavaThread結構的指針,再通過JavaThread結構裡的threadObj_offset()拿到它,通常是一個偏移量,拿到Object以後作為傳回值傳回。這裡是一段AI,真正生成代碼時被翻譯成非常簡約的幾條指令,直接傳回。是以“currentThread”變得非常高效,這就是Intrinsic機制,主要為性能而生。 

JNI in Java
JNI in Java

(二)Intrinsic性能分析 

對比一下Intrinsic與非Intrinsic性能,如下圖所示,是用jmh寫的Benchmark,可以規避掉一些具體的預熱不夠,導緻性能測試不準的問題,用它進行測試,也是官方推薦的版本。 

Intrinsic版本,下面測試叫“jni”,主要差別就是Intrinsic後面接了一個叫isAlive的調用。isAlive本身狀态調用看起來非常輕量,但因為他沒有做Intrinsic,是以最終會走JNI。 

JNI in Java

下圖所示,對比普通Intrinsic與加上JNI的Intrinsic性能,普通 Intrinsic的性能大概是3億次每秒;加上JNI的Intrinsic版本的性能是2000萬次每秒,差了十幾倍,差距很大。 

JNI in Java

進一步看性能問題,最重要的是performing,performing手段是perform,public第二段JNI版本,前面兩個熱點方法都是“ThreadStateTransition”現任狀态轉換。前面說到,假如JNI回到 Java時候做GC肯定要停下來,是以這有個記憶體同步比較好資源,要等的時間比較長,是以這兩個函數是最熱的。 

JNI in Java

下面是“JVM_IsThreadAlive”實作。後面是“HandleMark::pop_and_restore”在調JNI時需要把oop包裝成handle,JNI退出時,需要消費handle, restore指有開銷。再後面“java_lang_Thread::is_alive”占比4.77% 非常小。 

由此可以看出Intrinsic提供性能非常好的機制,直接調用JNI,性能可能差一點,但也可以接受。 

(三)案例分析: RocketMQ Intrinsic導緻應用卡頓 

RocketMQ 是阿裡巴巴開源的MQ産品,使用非常廣泛,裡面有個函數叫“warmMappedFile”,指的是RocketMQ是通過warmMapped機制記憶體映射磁盤去做IO,在申請完一塊磁盤映射的記憶體以後,會去做預熱。 

這裡有for循環“for (int i = 0, j = 0; i < this.fileSize”,每隔一個PACG_SIZE去“byteBuffer.put(i, (byte) 0)”;,這樣的話,作業系統就會發生缺頁,把記憶體真正配置設定出來,而不隻是EMV資料結構。配置設定出來以後,等到程式真正使用這塊記憶體的時候,就是純記憶體IO,不太會觸發這種缺頁了,可以變得更快,目的是減少程式卡頓。 

JNI in Java

但是後面加了if這一段,可以想到剛開始這個循環有問題,因為 byteBuffer.put是Intrinsic,最底層是Intrinsic,方法傳回的時候,沒有方法調用。JVM在方法傳回以及循環末尾檢查,是否有Safepoints,來看是否要進入GC,但是因為這是一個Intrinsic,是以沒有到檢查點,同樣這是一個CountedLoop,也沒法去進入檢查點。因為JVM有個機制,如果這是一個 int作為index去Counted次數的話,為了性能是不會去檢查,因為它認為這是有限次的循環,是以不用檢查次數。 

這種機制循環裡面非常簡單,中間有可能因為作業系統原因帶來卡頓,導緻循環,基本上沒法進入GC,因為線程沒有進入Safepoints,整個界面都沒法進入GC, 夯住很長時間,當時大家覺得很不可思議,但是通過一個很簡單方法修好了,就是每隔1000字循環的時候,去調一個“Thread.sleep(0)”。 

剛剛提到,“byteBuffer.put”沒法出發,Thread.sleep是個JNI,傳回的時候會檢查Safepoints,是以就可以讓這個程式能夠進入到Safepoints,這個代碼就不會影響JVM進入到GC了,代碼目前還可以從開源軟體上看到。 

“-XX:+UseCountedLoopSafepoints” 

解決這個問題,還有另一種方式,通過一個選項叫“-XX:+UseCountedLoopSafepoints”,可以JVM自動在CountedLoop結尾檢查這Safepoints,當然這帶來的副作用是,CountedLoop末尾都會檢查Safepoints,這樣就會影響整體性能。