[Java] JEP 412 Foreign Function & Memory (FFM) API 外部函數(shù)與內(nèi)存API
這篇專(zhuān)欄翻譯自https://openjdk.java.net/jeps/412,"JEP 412: Foreign Function & Memory API (Incubator)",講述了有關(guān)于Java 17中加入的FFM API。

前言
引入一個(gè)可以讓Java程序與Java運(yùn)行時(shí)以外的代碼和數(shù)據(jù)進(jìn)行交換的API。通過(guò)高效的調(diào)用外部函數(shù)(即JVM外部的代碼),并且通過(guò)安全地訪問(wèn)外部?jī)?nèi)存(即不是由JVM管理的內(nèi)存),這套API能讓Java程序調(diào)用本地庫(kù)和操作本地?cái)?shù)據(jù)的同時(shí)避免JNI的脆弱性和不安全性。
歷史(略,請(qǐng)參照英文文檔)
目標(biāo)
易用性 - 將Java本地接口(JNI)替換為優(yōu)越的,純Java開(kāi)發(fā)的模型
性能 -?提供與現(xiàn)有 API(如 JNI 和 sun.misc.Unsafe)相媲美(如果不是更好的話)的性能
通用性 - 提供操作不同類(lèi)型的外部?jī)?nèi)存(如:本地內(nèi)存,永久內(nèi)存和堆內(nèi)存)的方法,并且隨著時(shí)間的推移,去適應(yīng)其他的平臺(tái)(如:32bit x86)和除C以外的語(yǔ)言(如:C++,Fortran)編寫(xiě)的外部函數(shù)
安全性 - 在默認(rèn)情況下禁用不安全的操作,僅在從應(yīng)用程序開(kāi)發(fā)人員或最終用戶明確選擇后才能禁用它們
非目標(biāo)
在此 API 之上重新實(shí)現(xiàn)JNI,或以任何方式修改JNI
在此 API 之上重新實(shí)現(xiàn)傳統(tǒng) Java API,例如:sun.misc.Unsafe類(lèi)
提供從本地代碼頭文件中自動(dòng)生成 Java 代碼的工具,或者
更改與本地庫(kù)交互的 Java 應(yīng)用程序的包裝和部署方式(例如,通過(guò)多平臺(tái) JAR 文件)
動(dòng)機(jī)(略,請(qǐng)參照英文文檔)
外部?jī)?nèi)存
存儲(chǔ)在Java運(yùn)行時(shí)之外的內(nèi)存的數(shù)據(jù)被稱為堆外數(shù)據(jù)(off-heap data)。"heap"(堆)是Java對(duì)象生存(對(duì)象生命周期)的地方,也是垃圾回收(Garbage Collector,GC)處理的地方。訪問(wèn)堆外數(shù)據(jù)對(duì)于Tensorflow、Ignite、Lucene和Netty等Java庫(kù)的性能至關(guān)重要,這主要是因?yàn)檫@避免了由垃圾回收引起的成本和不可預(yù)測(cè)性,并且這也允許程序通過(guò)mmap等將文件映射入內(nèi)存中進(jìn)行數(shù)據(jù)結(jié)構(gòu)的序列化和反序列化。但是,Java平臺(tái)到今天沒(méi)有為訪問(wèn)堆外數(shù)據(jù)提供令人滿意的解決方案。
ByteBuffer API(java.nio)允許創(chuàng)建直接緩沖區(qū)(direct buffer),這些緩沖區(qū)在堆外分配,但是它們的最大大小限制為2GB并且不能及時(shí)釋放。這些和其他的限制都來(lái)源于一個(gè)事實(shí):ByteBuffer API不僅被用于訪問(wèn)堆外內(nèi)存,還被用于生產(chǎn)者/消費(fèi)者之間批量數(shù)據(jù)的交換,如字符集的編碼/解碼和部分I/O操作。在這方面,它無(wú)法滿足多年來(lái)提出的許多堆外內(nèi)存增強(qiáng)請(qǐng)求。
sun.misc.Unsafe API暴露了堆內(nèi)內(nèi)存的訪問(wèn)操作,這對(duì)堆外內(nèi)存也適用。使用它很高效,因?yàn)樗膬?nèi)存操作被定義在HotSpot JVM內(nèi)部并且會(huì)被JIT編譯器優(yōu)化。但是,因?yàn)樗梢栽L問(wèn)任何內(nèi)存位置,使用它是危險(xiǎn)的。這意味著一個(gè)Java程序可以通過(guò)訪問(wèn)一個(gè)已經(jīng)釋放的內(nèi)存位置使JVM崩潰。因?yàn)檫@個(gè)和其他的原因,使用Unsafe是強(qiáng)烈不被推薦的。
使用JNI調(diào)用本地庫(kù)來(lái)訪問(wèn)堆外內(nèi)存是可能的,但是因?yàn)樗男书_(kāi)銷(xiāo)(較高)而很少找到適用的地方:從Java到本地代碼的速度要比直接訪問(wèn)內(nèi)存的速度慢幾個(gè)數(shù)量級(jí),因?yàn)镴NI方法調(diào)用并不能從常見(jiàn)的JIT優(yōu)化(如內(nèi)聯(lián))中獲益。
總之,當(dāng)訪問(wèn)堆外數(shù)據(jù)時(shí),Java開(kāi)發(fā)者就面臨著兩難的境地:他們是選擇安全但效率不高的方式(ByteBuffer API)還是放棄安全轉(zhuǎn)而選擇性能(Unsafe API)?開(kāi)發(fā)者需要的是一個(gè)Java支持的API,用于在JIT優(yōu)化下從頭到腳安全地訪問(wèn)堆外數(shù)據(jù)(即外部?jī)?nèi)存)。
外部函數(shù)
從Java 1.1開(kāi)始,JNI就已經(jīng)支持本地代碼的調(diào)用(即外部函數(shù)),但是因?yàn)楹芏嘣蛩⒉贿m合。
JNI涉及幾個(gè)乏味的構(gòu)件:Java API(本地方法)、源自Java API的C頭文件(譯注:即javah.exe的工作,當(dāng)Java 10移除javah.exe后,這項(xiàng)工作由javac -h完成),以及調(diào)用感興趣的本地庫(kù)的C實(shí)現(xiàn)。Java開(kāi)發(fā)人員必須跨多個(gè)工具鏈工作,以保持與平臺(tái)相關(guān)的構(gòu)件同步,當(dāng)本地庫(kù)快速發(fā)展時(shí),這尤其繁重。
JNI只能與以一些語(yǔ)言(通常為C/C++)進(jìn)行交互,這些庫(kù)使用了JVM在構(gòu)建中使用的操作系統(tǒng)和CPU的約定。本地方法不能被用于去調(diào)用一個(gè)由不同約定的語(yǔ)言編寫(xiě)的函數(shù)。
JNI沒(méi)有協(xié)調(diào)Java類(lèi)型系統(tǒng)和C類(lèi)型系統(tǒng)。Java中的聚合數(shù)據(jù)是用對(duì)象表示的,但C中的聚合數(shù)據(jù)是用結(jié)構(gòu)體表示的,因此傳遞給本地方法的任何Java對(duì)象都必須費(fèi)力地由本地代碼解包。例如,考慮一個(gè)Java中的記錄(record,Java 16加入)類(lèi)Person:將Person對(duì)象傳遞給本地方法將要求本地代碼使用JNI的C API從對(duì)象中提取字段(例如,firstName和lastName)(譯注:提取字段就是使用JNIEnv*的函數(shù))。結(jié)果是,Java開(kāi)發(fā)者們有些時(shí)候會(huì)把他們的數(shù)據(jù)轉(zhuǎn)變成一個(gè)單獨(dú)的對(duì)象(如:一個(gè)字節(jié)數(shù)組或一個(gè)direct ByteBuffer),但更常見(jiàn)的是,因?yàn)橥ㄟ^(guò)JNI傳遞Java對(duì)象很慢,他們就使用Unsafe API去分配堆外內(nèi)存并且以long的形式將內(nèi)存地址傳遞給本地方法(譯注:比如LWJGL)——可悲的是這使得Java代碼變得不安全!
多年來(lái),有許多框架填補(bǔ)JNI留下的空白,這其中包括JNA、JNR和JavaCPP。雖然這些框架通常被視為JNI的改進(jìn),但是情況依舊不理想,尤其是當(dāng)與提供一流的本地代碼交互的語(yǔ)言相比。比如,Python的ctypes包可以動(dòng)態(tài)地將函數(shù)包裝在本地庫(kù)中而不用生成任何的粘合代碼。其他語(yǔ)言,例如Rust,提供了可以從C/C++頭文件中自動(dòng)派生本地代碼包裝的工具。
總之,Java開(kāi)發(fā)者應(yīng)該有一個(gè)讓他們能直接使用任何被認(rèn)為對(duì)特定任務(wù)有用的本機(jī)庫(kù)并且避免使用JNI帶來(lái)的繁瑣與沉悶的API。對(duì)于此的一個(gè)絕佳的抽象是方法句柄(Method Handle),它在Java 7被引入,用于支持在JVM上的快速動(dòng)態(tài)語(yǔ)言(invokedynamic,inDy)。通過(guò)方法句柄公開(kāi)本機(jī)代碼將從根本上簡(jiǎn)化編寫(xiě)、構(gòu)建和分發(fā)依賴于本機(jī)庫(kù)的Java庫(kù)的任務(wù)。此外,能夠建模外部函數(shù)(即本機(jī)代碼)和外部?jī)?nèi)存(即堆外數(shù)據(jù))的API將為第三方本機(jī)交互框架提供堅(jiān)實(shí)的基礎(chǔ)。
描述
外部函數(shù)與內(nèi)存API(Foreign Function & Memory API,下文簡(jiǎn)稱為"FFM API")定義了一系列類(lèi)與接口以便于在庫(kù)與應(yīng)用程序中的客戶端代碼:
分配外部?jī)?nèi)存
(MemorySegment,MemoryAddress和SegmentAllocator),
操作和訪問(wèn)結(jié)構(gòu)化外部?jī)?nèi)存
(MemoryLayout,MemoryHandles和MemoryAccess),
管理外部資源的生命周期(ResourceScope)和
調(diào)用外部函數(shù)(SymbolLookup和CLinker)
FFM API定義在jdk.incubator.foreign模塊下的jdk.incubator.foreign包內(nèi)。
例子
下面是一個(gè)簡(jiǎn)單的使用FFM API的例子,Java代碼獲得了一個(gè)C庫(kù)函數(shù)radixsort的方法句柄,然后用它來(lái)對(duì)Java數(shù)組中的四個(gè)字符串進(jìn)行排序(一些細(xì)節(jié)被省略了):
這段代碼比任何使用JNI的解決方案都清晰得多,因?yàn)樵倦[藏在本機(jī)方法調(diào)用后面的隱式轉(zhuǎn)換和內(nèi)存解引用現(xiàn)在直接用Java表示了。也可以使用現(xiàn)代Java語(yǔ)言特性;例如,流可以允許多個(gè)線程并行地在堆內(nèi)和堆外內(nèi)存之間復(fù)制數(shù)據(jù)。
內(nèi)存段(Memory Segments)
內(nèi)存段是對(duì)位于堆外或堆內(nèi)的連續(xù)內(nèi)存區(qū)域進(jìn)行建模的抽象。內(nèi)存段可以為
本地段,在本地內(nèi)存內(nèi)從頭開(kāi)始分配(例如通過(guò)malloc),
映射段,將映射包裝在本地內(nèi)存區(qū)域中(例如通過(guò)mmap),或者
數(shù)組或緩沖區(qū)段,將現(xiàn)有的Java數(shù)組或字節(jié)緩沖區(qū)相關(guān)的內(nèi)存分別包裝
所有的內(nèi)存段都提供了空間、時(shí)間和線程限制的保證,為了使內(nèi)存解引用操作安全,這些保證都是強(qiáng)制的。例如,下面的代碼在堆外分配了100個(gè)字節(jié):
段的空間邊界決定了與段相關(guān)聯(lián)的內(nèi)存地址的范圍。上面代碼中段的邊界由表示為MemoryAddress實(shí)例的基礎(chǔ)地址b和以字節(jié)為單位的大?。?00)定義,結(jié)果是地址范圍從b到b + 99(包括b + 99)。
段的時(shí)間邊界決定了段的生存期,也就是這個(gè)段什么時(shí)候會(huì)被釋放。段的生存期和線程限制狀態(tài)是通過(guò)ResourceScope抽象建模的,下面將對(duì)此進(jìn)行討論。上面代碼中的資源作用域是一個(gè)新的隱式作用域,它確保當(dāng)垃圾回收器認(rèn)為MemorySegment對(duì)象不可達(dá)時(shí)才釋放與此段相關(guān)的內(nèi)存。隱式作用域還確??梢詮亩鄠€(gè)線程訪問(wèn)內(nèi)存段。
換句話說(shuō),上面的代碼創(chuàng)建了一個(gè)行為與allocateDirect工廠分配的ByteBuffer的行為緊密匹配的段。FFM API還支持還支持確定性內(nèi)存釋放和其他線程限制選項(xiàng),將在下面討論。
解引用內(nèi)存段
與段關(guān)聯(lián)的內(nèi)存解引用是通過(guò)獲取變量句柄來(lái)實(shí)現(xiàn)的,它是Java 9中引入的數(shù)據(jù)訪問(wèn)抽象模型。特別地,段是用內(nèi)存訪問(wèn)變量句柄來(lái)解引用的。這種類(lèi)型的變量句柄使用一對(duì)訪問(wèn)坐標(biāo):
以MemorySegment對(duì)象表示的坐標(biāo)——也就是控制的內(nèi)存要被解引用的段,和
以long表示的坐標(biāo)——也就是偏移量(offset),從段的基礎(chǔ)地址到解引用開(kāi)始的偏移量
內(nèi)存訪問(wèn)變量句柄可以通過(guò)在MemoryHandles類(lèi)中的工廠方法獲取。例如,這段代碼獲取了可以將int寫(xiě)入本地內(nèi)存段的內(nèi)存訪問(wèn)變量句柄,并且使用它在連續(xù)的偏移下寫(xiě)入25個(gè)4字節(jié)的值(譯注:指int為4字節(jié)):
更高級(jí)的訪問(wèn)用法可以通過(guò)使用MemoryHandles類(lèi)提供的一個(gè)或多個(gè)組合子方法來(lái)組合內(nèi)存訪問(wèn)變量句柄來(lái)表達(dá)。使用這些客戶端可以,例如,對(duì)給定的內(nèi)存訪問(wèn)變量句柄進(jìn)行重排序,刪除一個(gè)或多個(gè)坐標(biāo),或插入新的坐標(biāo)。這允許創(chuàng)建接受一個(gè)或多個(gè)邏輯索引到一個(gè)在堆外內(nèi)存區(qū)域的多維數(shù)組中的內(nèi)存訪問(wèn)變量句柄。
為了使FFM API更容易訪問(wèn),MemoryAccess類(lèi)提供了靜態(tài)訪問(wèn)器來(lái)解引用內(nèi)存段,而不需要構(gòu)造內(nèi)存訪問(wèn)變量句柄。例如,有一個(gè)訪問(wèn)器可以在給定偏移量的段中設(shè)置一個(gè)int值,允許上面的代碼簡(jiǎn)化為:
內(nèi)存布局(Memory Layouts)
為了減少對(duì)內(nèi)存布局的繁瑣計(jì)算(例如,上面例子中的i * 4), MemoryLayout可以用更聲明式的方式來(lái)描述內(nèi)存段的內(nèi)容。例如,上面例子中需要的本地內(nèi)存段的布局可以用以下方式描述:
這將創(chuàng)建一個(gè)序列內(nèi)存布局(sequence memory layout),內(nèi)部由重復(fù)了25次的32比特值布局(一個(gè)描述了單一32字節(jié)值的布局)構(gòu)成。給定一個(gè)內(nèi)存布局,我們可以避免在代碼中計(jì)算偏移量,并簡(jiǎn)化內(nèi)存分配和創(chuàng)建內(nèi)存訪問(wèn)變量句柄:
intArrayLayout對(duì)象通過(guò)創(chuàng)建布局路徑來(lái)驅(qū)動(dòng)內(nèi)存訪問(wèn)變量句柄的創(chuàng)建,該路徑用于從復(fù)雜布局表達(dá)式中選擇嵌套布局。intArrayLayout對(duì)象也驅(qū)動(dòng)了本地內(nèi)存段的分配,這個(gè)內(nèi)存段基于來(lái)自于布局的大小和對(duì)齊信息。在之前的例子中的循環(huán)常數(shù),也就是25,已經(jīng)被序列布局的元素?cái)?shù)量所替代。
資源作用域(Resource Scopes)
在前面的例子中看到的所有內(nèi)存段都使用了非確定性的釋放:一旦內(nèi)存段實(shí)例變得不可達(dá),垃圾收集器就會(huì)釋放與這些段相關(guān)的內(nèi)存。我們說(shuō)這樣的段是隱式釋放的。
在某些情況下,客戶端可能希望控制何時(shí)發(fā)生內(nèi)存釋放。試想,例如,使用MemorySegment::map從一個(gè)文件中映射出一個(gè)很大的內(nèi)存段。客戶端可能更喜歡在段不再需要時(shí)釋放(即取消映射)與段相關(guān)的內(nèi)存,而不是等待垃圾收集器這樣做,因?yàn)榈却赡軙?huì)對(duì)應(yīng)用程序的性能產(chǎn)生不利影響。
內(nèi)存段支持通過(guò)資源作用域的確定性釋放。資源作用域?qū)εc一個(gè)或多個(gè)資源(如內(nèi)存段)相關(guān)聯(lián)的生命周期進(jìn)行建模。新創(chuàng)建的資源作用域處于活動(dòng)狀態(tài),這意味著可以安全地訪問(wèn)它管理的所有資源。在客戶端請(qǐng)求時(shí),可以關(guān)閉資源作用域,這意味著不再允許訪問(wèn)由該作用域管理的資源。因?yàn)镽esourceScope類(lèi)實(shí)現(xiàn)了AutoClosable接口,所以它可以使用try-with-resource語(yǔ)句:
這段代碼創(chuàng)建了一個(gè)受限(confined)的資源作用域,并將其用于創(chuàng)建兩個(gè)段:映射段(s1)和本地段(s2)。這兩個(gè)段的生命周期與資源作用域的生命周期相關(guān)聯(lián),因此在try-with-resources語(yǔ)句完成后訪問(wèn)段(例如,使用內(nèi)存訪問(wèn)變量句柄對(duì)它們進(jìn)行解引用)將導(dǎo)致拋出一個(gè)運(yùn)行時(shí)異常。
除了管理內(nèi)存段的生命周期外,資源作用域還可以作為一種方法來(lái)控制哪些線程可以訪問(wèn)內(nèi)存段。受限資源作用域只允許創(chuàng)建作用域的線程的訪問(wèn),而共享資源作用域允許從任何線程訪問(wèn)。
資源作用域,無(wú)論是受限的還是共享的,都可能與java.lang.ref.Cleaner對(duì)象相關(guān)聯(lián),該對(duì)象負(fù)責(zé)執(zhí)行隱式釋放,以防在客戶端調(diào)用close方法之前,資源作用域?qū)ο笞兊貌豢蛇_(dá)。
一些稱為隱式資源作用域的資源作用域不支持顯式釋放——調(diào)用close將失敗。隱式資源作用域總是使用Cleaner來(lái)管理它們的資源。隱式作用域可以使用ResourceScope::newImplicitScope工廠創(chuàng)建,如前面的示例所示。
段分配器(Segment Allocators)
當(dāng)客戶端使用堆外內(nèi)存時(shí),內(nèi)存分配通常是一個(gè)瓶頸。FFM API包括一個(gè)SegmentAllocator抽象模型,它定義了分配和初始化內(nèi)存段的操作。段分配器是通過(guò)SegmentAllocator接口中的工廠獲得的。例如,下面的代碼創(chuàng)建了一個(gè)基于區(qū)域(arena-based)的分配器,并使用它來(lái)分配一個(gè)內(nèi)容是從Java int數(shù)組初始化的段:
這段代碼創(chuàng)建一個(gè)受限的資源范圍,然后創(chuàng)建與該范圍相關(guān)聯(lián)的無(wú)邊界區(qū)域分配器(unbounded arena allocator)。這個(gè)分配器將分配特定大小的內(nèi)存塊,并通過(guò)返回預(yù)先分配的內(nèi)存塊的不同片(譯注:也就是分配器先分配一定大小的塊后,用戶要求內(nèi)存時(shí)按用戶需求在內(nèi)存塊中取出相當(dāng)長(zhǎng)度的內(nèi)存切片)來(lái)響應(yīng)分配請(qǐng)求。如果一個(gè)內(nèi)存塊沒(méi)有足夠的空間來(lái)容納一個(gè)新的分配請(qǐng)求,那么就分配一個(gè)新的內(nèi)存塊。如果與區(qū)域分配器相關(guān)聯(lián)的資源作用域被關(guān)閉,所有與分配器創(chuàng)建的段相關(guān)聯(lián)的內(nèi)存(例如,在for循環(huán)體中)都會(huì)被以原子方式釋放。這種用法結(jié)合了ResourceScope抽象提供的確定性釋放的優(yōu)點(diǎn),以及更靈活和可伸縮的分配方案。在編寫(xiě)管理大量堆外內(nèi)存段的代碼時(shí),它非常有用。
不安全的內(nèi)存段
到目前為止,我們已經(jīng)看到了內(nèi)存段、內(nèi)存地址和內(nèi)存布局。解引用操作只能在內(nèi)存段上進(jìn)行。由于內(nèi)存段具有空間和時(shí)間邊界,Java運(yùn)行時(shí)總是可以確保與給定段相關(guān)聯(lián)的內(nèi)存被安全解引用。然而,在某些情況下,客戶端可能只有MemoryAddress實(shí)例,這在與本機(jī)代碼交互時(shí)經(jīng)常發(fā)生。由于Java運(yùn)行時(shí)無(wú)法知道與內(nèi)存地址相關(guān)的空間和時(shí)間邊界,因此FFM API禁止直接解引用內(nèi)存地址。
為了解引用內(nèi)存地址,客戶端有兩種選擇:
如果已知地址位于一個(gè)內(nèi)存段,客戶端可以通過(guò)MemoryAddress::segmentOffset進(jìn)行重新基準(zhǔn)(rebase)操作。重新基準(zhǔn)操作會(huì)重新定義地址相對(duì)于段的基本地址的偏移量,以產(chǎn)生一個(gè)新的可以應(yīng)用于現(xiàn)有段上的偏移量——然后可以安全地對(duì)該段解引用。
或者,如果沒(méi)有這樣的段存在,那么客戶端可以使用MemoryAddress::asSegment工廠不安全地創(chuàng)建一個(gè)。這個(gè)工廠有效地將新的空間和時(shí)間邊界附加到一個(gè)原始的內(nèi)存地址,以便允許解引用操作。該工廠返回的內(nèi)存段是不安全的:一個(gè)原始內(nèi)存地址可能與一個(gè)10字節(jié)長(zhǎng)的內(nèi)存區(qū)域相關(guān)聯(lián),但客戶端可能意外地高估了該區(qū)域的大小,并創(chuàng)建了一個(gè)100字節(jié)長(zhǎng)的不安全內(nèi)存段。這可能會(huì)導(dǎo)致稍后試圖對(duì)與不安全段關(guān)聯(lián)的內(nèi)存區(qū)域邊界之外的內(nèi)存的解引用,這可能會(huì)導(dǎo)致JVM崩潰,或者更糟的是,導(dǎo)致在無(wú)形中的內(nèi)存損壞。因此,創(chuàng)建不安全的段被視為受限操作,默認(rèn)情況下是禁用的(參見(jiàn)下面的詳細(xì)內(nèi)容)。
尋找外部函數(shù)
任何對(duì)外部函數(shù)的支持的第一個(gè)組成部分都是加載本地庫(kù)的機(jī)制。在JNI中,這是通過(guò)System::loadLibrary和System::load方法完成的,它們?cè)趦?nèi)部映射到對(duì)dlopen或其等效函數(shù)的調(diào)用。使用這些方法加載的庫(kù)總是與類(lèi)加載器(即調(diào)用System方法的類(lèi)加載器)相關(guān)聯(lián)。庫(kù)和類(lèi)加載器之間的關(guān)聯(lián)是至關(guān)重要的,因?yàn)樗芾硌b入的庫(kù)的生命周期:只有當(dāng)類(lèi)加載器不再可訪問(wèn)時(shí),它的所有庫(kù)才能被安全卸載。
FFM API沒(méi)有提供加載本地庫(kù)的新方法。開(kāi)發(fā)者使用System::loadLibrary和System::load方法來(lái)加載將通過(guò)FFM API調(diào)用的本地庫(kù)。庫(kù)和類(lèi)加載器之間的關(guān)聯(lián)被保留,因此庫(kù)將以與JNI相同的可預(yù)測(cè)方式卸載。
與JNI不同,F(xiàn)FM API提供了在加載的庫(kù)中查找給定標(biāo)識(shí)地址的功能。這種由SymbolLookup對(duì)象表示的功能對(duì)于將Java代碼鏈接到外部函數(shù)至關(guān)重要(參見(jiàn)下面)。有兩種方法可以獲得SymbolLookup對(duì)象:
SymbolLookup::loaderLookup返回一個(gè)包括本加載器內(nèi)加載的所有庫(kù)內(nèi)部的標(biāo)識(shí)的查找器
CLinker::systemLookup返回一個(gè)特定于平臺(tái)的標(biāo)識(shí)查找器,它能查找標(biāo)準(zhǔn)C庫(kù)內(nèi)的標(biāo)識(shí)
給定一個(gè)標(biāo)識(shí)查找器,客戶端可以使用SymbolLookup::lookup(String)方法找到一個(gè)外部函數(shù)。如果指定的函數(shù)出現(xiàn)在標(biāo)識(shí)查找器所包括的標(biāo)識(shí)中,則該方法返回指向函數(shù)入口點(diǎn)的MemoryAddress。例如,下面的代碼加載OpenGL庫(kù)(使它與當(dāng)前類(lèi)加載器相關(guān)聯(lián)),并找到它的glGetString函數(shù)的地址:
將Java代碼鏈接到外部函數(shù)
CLinker接口是Java代碼與本地代碼交互的核心。雖然CLinker專(zhuān)注于提供Java和C庫(kù)之間的互操作,但接口中的概念已經(jīng)足夠通用,可以在未來(lái)支持其他非Java語(yǔ)言。該接口支持向下調(diào)用(downcall,從Java代碼調(diào)用本地代碼)和向上調(diào)用(upcall,從本地代碼調(diào)用回Java代碼)。
對(duì)于向下調(diào)用,downcallHandle方法接受外部函數(shù)的地址——通常是從庫(kù)查找中獲得的MemoryAddress——并將外部函數(shù)作為向下調(diào)用方法句柄公開(kāi)。稍后,Java代碼通過(guò)調(diào)用invokeExact方法調(diào)用downcall方法句柄,然后運(yùn)行外部函數(shù)。傳遞給方法句柄的invokeExact方法的任何參數(shù)都會(huì)傳遞給外部函數(shù)。
對(duì)于上行調(diào)用,upcallStub方法接受一個(gè)方法句柄——通常是指一個(gè)Java方法句柄,而不是下行調(diào)用方法句柄——并將其轉(zhuǎn)換為內(nèi)存地址。稍后,當(dāng)Java代碼調(diào)用downcall方法句柄時(shí),將內(nèi)存地址作為參數(shù)傳遞。實(shí)際上,內(nèi)存地址充當(dāng)函數(shù)指針。(欲了解更多關(guān)于upcall的信息,請(qǐng)參閱下面)
假設(shè)我們想從Java向下調(diào)用定義在C標(biāo)準(zhǔn)庫(kù)中的strlen函數(shù):
一個(gè)暴露strlen的向下調(diào)用方法句柄可以像下面這樣獲取(關(guān)于MethodType和FunctionDescriptor的細(xì)節(jié)將會(huì)簡(jiǎn)短介紹):
調(diào)用向下調(diào)用方法句柄會(huì)執(zhí)行strlen并且讓結(jié)果在Java端可見(jiàn)。對(duì)于strlen的參數(shù),我們使用一個(gè)helper方法將Java的字符串轉(zhuǎn)變?yōu)槎淹鈨?nèi)存段并且傳遞這個(gè)段的地址:
方法句柄在公開(kāi)外部函數(shù)時(shí)工作得很好,因?yàn)镴VM已經(jīng)優(yōu)化了方法句柄的調(diào)用,一直優(yōu)化到本地代碼。當(dāng)方法句柄引用類(lèi)文件中的方法時(shí),調(diào)用方法句柄通常會(huì)導(dǎo)致目標(biāo)方法被JIT編譯;隨后,JVM通過(guò)將控制轉(zhuǎn)移到為目標(biāo)方法生成的匯編代碼來(lái)解釋調(diào)用MethodHandle::invokeExact的Java字節(jié)碼。因此,調(diào)用傳統(tǒng)方法句柄已經(jīng)幾乎是外部調(diào)用;以C庫(kù)中的函數(shù)為目標(biāo)的downcall方法句柄只是一種更外部的方法句柄形式。方法句柄還具有一個(gè)名為簽名多態(tài)性的屬性,該屬性允許基本類(lèi)型參數(shù)的非裝箱傳入(譯注:就是直接傳int而不是Integer避免裝箱/拆箱操作)??傊椒ň浔孋Linker以一種自然、有效和可擴(kuò)展的方式公開(kāi)外部函數(shù)。
在Java中描述C類(lèi)型
為了創(chuàng)建向下調(diào)用方法句柄,F(xiàn)FM API需要客戶端提供對(duì)于目標(biāo)C函數(shù)的兩種簽名:使用非透明的Java對(duì)象(MemoryAccess和MemorySegment)的高級(jí)別簽名和使用透明的Java對(duì)象(MemoryLayout)的低級(jí)別簽名。依次取每個(gè)簽名:
高級(jí)別簽名,即MethodType,用作向下調(diào)用方法句柄的類(lèi)型。每個(gè)方法句柄都是強(qiáng)類(lèi)型的,這意味著可以傳遞給它的invokeExact方法的參數(shù)的數(shù)量和類(lèi)型是嚴(yán)格的。例如,為接受一個(gè)MemoryAddress參數(shù)而創(chuàng)建的方法句柄不能通過(guò)invokeExact(MemoryAddress, MemoryAddress)或通過(guò)invokeExact("Hello")調(diào)用。因此,MethodType描述了客戶端在調(diào)用向下調(diào)用方法句柄時(shí)必須使用的Java簽名。實(shí)際上,它是C函數(shù)的Java視圖。
低級(jí)別簽名,即FunctionDescriptor,包含MemoryLayout對(duì)象。這使CLinker能夠精確地理解C函數(shù)的參數(shù),以便它能夠正確地安排它們,如下所述。客戶端通常有MemoryLayout對(duì)象,以便解引用外部?jī)?nèi)存中的數(shù)據(jù),這樣的對(duì)象可以在這里作為外部函數(shù)簽名重用。
例如,為接受int值并返回long值的C函數(shù)獲取向下調(diào)用方法句柄時(shí),downcallHandle方法需要以下MethodType和FunctionDescriptor參數(shù):
(這個(gè)例子的目標(biāo)系統(tǒng)是Linux/x64和macOS/x64,其中Java類(lèi)型long和int分別與預(yù)定義的CLinker布局C_LONG和C_INT關(guān)聯(lián)。Java類(lèi)型與內(nèi)存布局的關(guān)聯(lián)因平臺(tái)而異:例如,在Windows/x64上,Java long與C_LONG_LONG布局相關(guān)聯(lián))
(譯注:這里的原因是C中l(wèi)ong的位數(shù)取決于系統(tǒng),而long long為確定64位;在Java中,int確定32位而long為64位,為了確保數(shù)據(jù)的對(duì)齊需要調(diào)整布局)
另一個(gè)例子,獲取一個(gè)帶有指針的void C函數(shù)的向下調(diào)用方法句柄需要以下MethodType和FunctionDescriptor:
(C語(yǔ)言中的所有指針類(lèi)型在Java中都表示為MemoryAddress對(duì)象,對(duì)應(yīng)的布局是C_POINTER,其大小取決于當(dāng)前平臺(tái)??蛻舳瞬粫?huì)區(qū)分int*和char**,因?yàn)閭鬟f給CLinker的Java類(lèi)型和內(nèi)存布局包含足夠的信息來(lái)正確地將Java參數(shù)傳遞給C函數(shù))
最后,與JNI不同的是,CLinker支持將結(jié)構(gòu)化數(shù)據(jù)傳遞給外部函數(shù)。獲取一個(gè)接受struct的無(wú)返回值C函數(shù)的向下調(diào)用方法句柄需要以下MethodType和FunctionDescriptor:
(對(duì)于高級(jí)別的MethodType簽名,Java客戶端總是使用不透明的類(lèi)型MemorySegment,其中C函數(shù)需要一個(gè)按值傳遞的struct。對(duì)于低級(jí)別的FunctionDescriptor簽名,與C結(jié)構(gòu)類(lèi)型相關(guān)聯(lián)的內(nèi)存布局必須是一個(gè)復(fù)合布局,它定義了C的struct中所有字段的子布局,包括可能由本地編譯器插入的填充)
如果C函數(shù)返回由低級(jí)別簽名表示的按值struct,則必須在堆外分配一個(gè)新的內(nèi)存段并返回給Java客戶端。為了實(shí)現(xiàn)這一點(diǎn),downcallHandle返回的方法句柄需要一個(gè)額外的SegmentAllocator參數(shù),F(xiàn)FM API使用該參數(shù)分配內(nèi)存段來(lái)保存C函數(shù)返回的struct。
為C函數(shù)打包Java參數(shù)
不同語(yǔ)言之間的交互操作需要一個(gè)調(diào)用約定來(lái)指定一種語(yǔ)言中的代碼如何調(diào)用另一種語(yǔ)言中的函數(shù)、如何傳遞參數(shù)以及如何接收任何結(jié)果。CLinker實(shí)現(xiàn)具有一些"開(kāi)箱即用"的調(diào)用約定的知識(shí):Linux/x64、Linux/AArch64、macOS/x64和Windows/x64。CLinker是用Java編寫(xiě)的,維護(hù)和擴(kuò)展起來(lái)要比JNI容易得多,JNI的調(diào)用約定是硬連接到HotSpot的C++代碼中的(譯注:JNI的調(diào)用約定即JNIEnv*)。
考慮上面顯示的SYSTEMTIME結(jié)構(gòu)和布局的函數(shù)描述符(FunctionDescriptor)。根據(jù)運(yùn)行JVM的操作系統(tǒng)和CPU的調(diào)用約定,當(dāng)使用MemorySegment參數(shù)調(diào)用向下調(diào)用方法句柄時(shí),CLinker使用函數(shù)描述符來(lái)推斷結(jié)構(gòu)體的字段應(yīng)該如何傳遞給C函數(shù)。對(duì)于一個(gè)調(diào)用約定,CLinker可以安排分解傳入的內(nèi)存段,使用通用CPU寄存器傳遞前四個(gè)字段,并在C堆棧上傳遞其余字段。對(duì)于不同的調(diào)用約定,CLinker可以安排FFM API通過(guò)分配一個(gè)內(nèi)存區(qū)域來(lái)間接傳遞結(jié)構(gòu)體,將傳入內(nèi)存段的內(nèi)容批量復(fù)制到該區(qū)域,并將指向該內(nèi)存區(qū)域的指針傳遞給C函數(shù)。這種最低層次的參數(shù)打包是在幕后進(jìn)行的,不需要任何客戶端代碼的監(jiān)督。
向上調(diào)用
有時(shí),將Java代碼作為函數(shù)指針傳遞給某個(gè)外部函數(shù)是很有用的。我們可以通過(guò)使用對(duì)上行調(diào)用的CLinker支持來(lái)實(shí)現(xiàn)這一點(diǎn)。在本節(jié)中,我們將逐塊構(gòu)建一個(gè)更復(fù)雜的示例,該示例演示了CLinker的全部功能,以及代碼和數(shù)據(jù)跨Java/本地邊界的完全雙向互操作。
考慮標(biāo)準(zhǔn)C庫(kù)中定義的以下函數(shù):
為了從Java端調(diào)用qsort,我們首先需要?jiǎng)?chuàng)建向下調(diào)用方法句柄:
和前面一樣,我們使用C_LONG和long.class來(lái)映射C size_t類(lèi)型,并且在第一個(gè)指針形式參數(shù)(數(shù)組指針)和最后一個(gè)形式參數(shù)(函數(shù)指針)上使用MemoryAddress.class。
qsort使用作為函數(shù)指針傳遞的自定義比較器函數(shù)compar對(duì)數(shù)組的內(nèi)容進(jìn)行排序。因此,要調(diào)用向下調(diào)用方法句柄,我們需要一個(gè)函數(shù)指針作為最后一個(gè)參數(shù)傳遞給方法句柄的invokeExact方法。CLinker::upcallStub通過(guò)使用現(xiàn)有的方法句柄幫助我們創(chuàng)建函數(shù)指針,如下所示。
首先,我們?cè)贘ava中編寫(xiě)一個(gè)靜態(tài)方法來(lái)比較兩個(gè)long值,間接表示為MemoryAddress對(duì)象:
接著,我們創(chuàng)建一個(gè)指向Java比較方法的MethodHandle:
之后,現(xiàn)在我們有了Java比較器的方法句柄,我們可以使用CLinker::upcallStub創(chuàng)建函數(shù)指針。就像向下調(diào)用一樣,我們使用CLinker類(lèi)中的布局來(lái)描述函數(shù)指針的簽名:
我們終于有了一個(gè)內(nèi)存地址,comparFunc,它指向一個(gè)方法存根,可以用來(lái)調(diào)用我們的Java比較方法,所以現(xiàn)在我們有了調(diào)用qsort向下調(diào)用句柄所需的所有東西:
這段代碼創(chuàng)建了一個(gè)堆外數(shù)組,將Java數(shù)組的內(nèi)容復(fù)制到其中,然后將數(shù)組連同我們從CLinker獲得的比較器函數(shù)(指針)傳遞給qsort句柄。調(diào)用之后,堆外數(shù)組的內(nèi)容將根據(jù)我們用Java編寫(xiě)的比較器函數(shù)進(jìn)行排序。然后從段中提取一個(gè)新的Java數(shù)組,其中包含已排序的元素。
安全
基本上,Java代碼和本機(jī)代碼之間的任何交互都可能危及Java平臺(tái)的完整性。鏈接到預(yù)編譯庫(kù)中的C函數(shù)本質(zhì)上是不可靠的,因?yàn)镴ava運(yùn)行時(shí)不能保證函數(shù)的簽名符合Java代碼的期望,甚至不能保證C庫(kù)中的標(biāo)識(shí)是真正的函數(shù)。此外,如果鏈接了一個(gè)合適的函數(shù),實(shí)際上調(diào)用該函數(shù)可能會(huì)導(dǎo)致如分段錯(cuò)誤的底層故障,最終導(dǎo)致VM崩潰。Java運(yùn)行時(shí)無(wú)法阻止此類(lèi)故障,Java代碼也無(wú)法捕獲此類(lèi)故障。
使用JNI函數(shù)的本地代碼尤其危險(xiǎn)。這樣的代碼可以在沒(méi)有命令行標(biāo)志(例如--add-open)的情況下,通過(guò)使用getStaticField和callVirtualMethod等函數(shù)訪問(wèn)JDK內(nèi)部。它還可以在final字段初始化很久之后更改它們的值。它允許本地代碼繞過(guò)應(yīng)用于Java代碼的檢查,這會(huì)破壞JDK中的每個(gè)邊界和假設(shè)。換句話說(shuō),JNI本質(zhì)上就是不安全的。
JNI不能被禁用,因此無(wú)法確保Java代碼不會(huì)調(diào)用使用危險(xiǎn)的JNI函數(shù)的本地代碼。這是對(duì)平臺(tái)完整性的一種風(fēng)險(xiǎn),應(yīng)用程序開(kāi)發(fā)人員和最終用戶幾乎看不到這種風(fēng)險(xiǎn),因?yàn)檫@些函數(shù)99%的使用通常來(lái)自?shī)A在應(yīng)用程序和JDK之間的第三、第四和第五方庫(kù)。
大多數(shù)FFM API的設(shè)計(jì)是安全的。過(guò)去需要使用JNI和本地代碼的許多場(chǎng)景都可以通過(guò)調(diào)用不會(huì)危及Java平臺(tái)的FFM API中的方法來(lái)實(shí)現(xiàn)。例如,JNI的一個(gè)主要用例——靈活的內(nèi)存分配——是由一個(gè)簡(jiǎn)單的方法MemorySegment::allocateNative支持的,該方法不涉及本機(jī)代碼,并且總是返回由Java運(yùn)行時(shí)管理的內(nèi)存。一般來(lái)說(shuō),使用FFM API的Java代碼不會(huì)使JVM崩潰。
然而,F(xiàn)FM API的一部分本身就是不安全的。當(dāng)與CLinker交互時(shí),Java代碼可以通過(guò)指定與底層C函數(shù)不兼容的參數(shù)類(lèi)型來(lái)請(qǐng)求向下調(diào)用方法句柄。在Java中調(diào)用向下調(diào)用方法句柄會(huì)導(dǎo)致與在JNI中調(diào)用本機(jī)方法時(shí)相同的結(jié)果——VM崩潰或未定義的行為。FFM API也可以產(chǎn)生不安全的段,即內(nèi)存段的空間和時(shí)間邊界是用戶提供的,這種段不能由Java運(yùn)行時(shí)驗(yàn)證(參見(jiàn)上文的MemoryAddress::asSegment)。
FFM API中的不安全方法不會(huì)帶來(lái)與JNI函數(shù)相同的風(fēng)險(xiǎn):例如,它們不能更改Java對(duì)象中的final字段的值。另一方面,F(xiàn)FM API中的不安全方法很容易從Java代碼中調(diào)用。由于這個(gè)原因,F(xiàn)FM API中不安全方法的使用受到限制:默認(rèn)情況下,不安全方法的訪問(wèn)是禁用的,調(diào)用這些方法會(huì)拋出一個(gè)IllegalAccessException異常。要使某些模塊M中的代碼能夠訪問(wèn)不安全的方法,請(qǐng)?jiān)诿钚兄兄付╦ava --enable-native-access=M。(在以逗號(hào)分隔的列表中指定多個(gè)模塊;指定ALL-UNNAMED以允許類(lèi)路徑上的所有代碼訪問(wèn)不安全方法)FFM API的大多數(shù)方法都是安全的,Java代碼可以使用這些方法,不管是否給出了--enable-native-access。
我們?cè)谶@里不建議限制JNI的任何方面。在Java中仍然可以調(diào)用本地方法,本地代碼也可以調(diào)用不安全的JNI函數(shù)。然而,在未來(lái)的版本中,我們可能會(huì)以某種方式限制JNI。例如,不安全的JNI函數(shù)(如newDirectByteBuffer)可能會(huì)在默認(rèn)情況下被禁用,就像FFM API中的不安全方法一樣。更廣泛地說(shuō),JNI機(jī)制是如此的危險(xiǎn),以至于我們希望庫(kù)在安全和不安全的操作中偏向于純Java的FFM API,這樣我們就可以在默認(rèn)情況下禁用所有JNI。這與使平臺(tái)成為“開(kāi)箱即用”的安全平臺(tái)的更廣泛的Java路線圖一致,要求終端用戶選擇不安全的行為,如破壞強(qiáng)封裝或鏈接到未知代碼。
我們不建議以任何方式去修改sun.misc.Unsafe。FFM API對(duì)堆外內(nèi)存的支持是對(duì)sun.misc.Unsafe中的malloc和free,即allocateMemory, setMemory, copyMemory,和freeMemory的一個(gè)很好的替代方案。我們希望需要非堆存儲(chǔ)的庫(kù)和應(yīng)用程序采用FFM API,以便及時(shí)地棄用并最終刪除這些sun.misc.Unsafe方法。
選擇
繼續(xù)使用java.nio.ByteBuffer,sun.misc.Unsafe,JNI和其他第三方框架。
風(fēng)險(xiǎn)和假設(shè)
創(chuàng)建一個(gè)API以既安全又高效的方式訪問(wèn)外部?jī)?nèi)存是一項(xiàng)艱巨的任務(wù)。由于前幾節(jié)中描述的空間和時(shí)間檢查需要在每次訪問(wèn)時(shí)執(zhí)行,因此JIT編譯器能夠優(yōu)化這些檢查是至關(guān)重要的,例如,將它們提升到熱循環(huán)之外。JIT實(shí)現(xiàn)可能需要做一些工作,以確保API的使用與ByteBuffer和Unsafe等現(xiàn)有API的使用一樣有效和可優(yōu)化。JIT實(shí)現(xiàn)還需要確保從API中檢索到的本地方法句柄的使用至少與使用現(xiàn)有JNI本地方法一樣有效和可優(yōu)化。
依賴
外部函數(shù)和內(nèi)存API可以用來(lái)訪問(wèn)非易失性內(nèi)存,已經(jīng)可以通過(guò)JEP 352(非易失性映射字節(jié)緩沖區(qū),Non-Volatile Mapped Byte Buffers,Java 14引入)用一種更通用和更有效的方式訪問(wèn)
這里描述的工作可能會(huì)使后續(xù)工作能夠提供一個(gè)工具,jextract,它從給定本地庫(kù)的頭文件開(kāi)始,機(jī)械地生成與該庫(kù)交互操作所需的本機(jī)方法句柄。這將進(jìn)一步減少使用Java本地庫(kù)的開(kāi)銷(xiāo)
