一文弄懂Java和C中動態連結機制

JavaDog發表於2019-01-31

概念

為了做實際的對比,先把概念搞清楚會有很大的幫助。那麼,為什麼要使用動態連結呢?動態連結是為了解決靜態連結的維護和資源利用問題而出現的。那麼,什麼是靜態連結呢?靜態連結是指將符號從庫拷貝到目標產物中的一種連結方式。那麼再進一步,連結又是什麼意思?

模組、符號和連結

大多數日常使用的都是高階語言。為了方便管理和關注點分離(Separation of Concern),一個具備一定規模的程式通常會拆分成多個部分(文字)來編寫,然後編譯成多個不同的模組。這些模組不一定存在於同一個檔案中,但是能夠通過符號去相互引用。通常來說,一個模組會包含三種符號:

  • 可被其他模組引用的公共符號(符號定義)
  • 本地或私有符號(符號定義)
  • 對其他模組中的公共符號的引用

如果一個程式由多個模組組成,在實際執行之前是需要連結器把多個模組組合起來的,因為只有把符號的定義給到符號的引用,程式才能完整串聯起來。根據符號引用去找尋符號定義,並將符號引用替換為符號定義的直接引用,就叫做連結(link,還是很形象的)。本文中還會出現另外一個詞,符號解析。我的理解是解析和連結在多數情況下意義相同,只不過解析更具體,連結則可能涵蓋了更多內容。比如,Java的類載入流程中連結這個步驟實際可以拆分成校驗、準備和解析三個小的步驟。另外,解析有時候用來表達符號定義查詢,但不進行實際的替換。

Jietu20170316-152839.jpg

靜態連結和動態連結

與靜態繫結和動態繫結的關係很類似,靜態連結發生在編譯期,而動態連結將連結推遲到了執行期,以期獲得更大的靈活性。

這裡需要注意的是,發生在編譯期的連結並非都是靜態連結。同時,連結看似是一個動作,其實可以拆分成多個小動作來執行。

語言層面的對比

上邊只是從概念層面對連結及相關的概念做了一下介紹,語言在實現符號連結時有很多選擇題可以做。另外,與我們的常識不同,Java世界也是存在編譯期靜態連結的,同時C的動態連結也是需要靜態編譯器參與的。所以雖然我一開始的想法是對比動態連結機制,但是編譯期也需要涉及一點。

接下來為了便於描述,本文把程式從原始碼到目標產物的整個過程都稱為編譯期,而從目標產物最終到達程式的可執行程式碼區的過程稱為載入期。

由於重點在於對比Java和C在連結機制上的異同,對一些相關的但與連結關係不那麼直接的資訊,比如實際的編譯流程、校驗邏輯以及類的載入機制,本文會有所省略,請見諒。

為了便於描述,下文預設以Linux作為執行環境。

編譯期的C/C++

Jietu20170316-152920.jpg

不管怎麼說,我們寫程式碼的目的都是為了讓程式碼在系統中執行起來。但如果沒有靜態連結器的參與,原始碼編譯後只能拿到Object檔案。通常來說,Object檔案只會包含針對當前特定架構的一系列機器指令,因此Object檔案是不能直接執行的。如果Object使用了外部符號,編譯器也不會去嘗試解析。

靜態連結器參與進來之後,能夠連結出兩種產物:可執行檔案(Executable)、共享庫(Shared Library)。一般來說,這兩種產物內部的格式是相同的,比如Linux中的ELF(Executable and Linking Format)檔案。其中主要包含的是符號資訊和程式碼。與共享庫相比,可執行檔案主要是多了一個既定入口的定義。不過另一方面,你也可以認為共享庫有多個自定義的入口。

Jietu20170316-152903.jpg

除了Object檔案和共享庫之外,連結器也可以從靜態連結庫中解析符號。靜態連結庫(.a, archive),由ar命令把Object檔案打包到一起而成,目的是方便靜態連結器使用。

靜態連結器在解析被程式碼引用的符號時,會按順序從Object檔案或庫中查詢。但是對於查詢到的符號,不同的來源處理是不同的。針對靜態連結庫和Object檔案中包含的符號,會直接打包到產物中,這就是前文提到的靜態連結。而對於共享庫中的符號,則不會打包到產物中。細節等到執行期部分再細說。

還有一個點值得提一下:對於可執行檔案和共享庫,連結的要求是不同的。連結生成可執行檔案,要求所有符號都必須能夠從Object檔案或庫中能夠找到(resolved)。而共享庫則需要加上-Wl,--no-undefined,否則是允許有unresolved符號存在的。

靜態連結庫和共享庫

通常認為靜態連結庫有兩個缺點:
1. 當任意被連結的庫需要更新時,需要從頭重新連結產物。比如需要patch或升級被連結的某個庫時。
2. 當被引用的符號固化到產物中後,對檔案系統和記憶體都會是一種浪費。如果所有使用C標準庫實現的程式都將C library打包到內部,可以想象檔案系統和記憶體中會存在多少重複的程式碼。

通過將連結推遲到執行期,共享庫和動態連結解決了這兩個問題,也引入了一些其他的問題。但靜態連結並非一無是處,至少靜態連結的產物非常易於使用,目標機器上不需要安裝任何依賴,啟動時間相對來說也更短。

編譯期的Java

通常來說,編譯期我們只會接觸到Java Compiler(Javac)。它吃的是Java原始碼,吐出來的是Class檔案。Class檔案與ELF很類似,也是以一種固定的格式來儲存符號資訊、程式碼及相關資訊,同時因為Java是物件導向的,Class檔案還包含了型別的資訊。Jar包其實與靜態連結庫有點類似,是作為Class檔案的歸檔存在的(倒是也能把一些資原始檔打包到其中)。但不管是Class檔案還是Jar包,都無法直接被OS執行:必須通過JVM這個虛擬機器來執行(也需要定義好入口)。

為了拿到可執行檔案,就要祭出AOT(Ahead of Time)Compiler了。其輸入也是普通的Class檔案或Jar包,但輸出卻是OS可識別的可執行檔案,比如有入口的ELF檔案。雖然我對AOT瞭解不多,但是這個編譯器應該是是完成了一些Java層面的靜態連結的工作,再加上Bytecode到機器指令的翻譯,才使得直接執行成為了可能。最後可能還是會用到動態連結,只不過非Java平臺中的動態連結罷了。

我覺得,AOT Compiler和Hotspot JVM的區別主要在於目標Runtime不一樣,不同的平臺所支援的連結模型也會有所不同。這樣說起來,目標平臺是Dalvik VM的Android編譯器應該是類似的,只是不知道是否需要,瞭解的同學請不吝賜教。

編譯期對比

拋開Java使用bytecode來承載邏輯這一點,從連結的層面來看,兩種語言在這個階段最大的區別(使用方式和執行機制)是:在編譯連結可執行檔案或共享庫時,需要顯示宣告(如-lpthread)依賴的共享庫,而且這部分資訊需要靜態連結器寫入到ELF檔案中,以便動態連結器在執行時使用(有不用手動配置的辦法麼?)。反觀Java,我們只需要在編譯時將依賴的Jar包或Class檔案放到ClassPath下即可(Maven也幫我們做好了)。

我猜這與模組載入的機制和符號組織方式有關係。在C中,當連結器要連結某一個符號時,連結器是不知道符號存在於哪個庫中的。確實也可以去挨個兒掃描,但是效率不高,語言的實現者選擇讓語言的使用者來提供這個資訊。而Java中,符號必然是附帶了Class的資訊(欄位或方法屬於哪個類),由於名稱也是對應的,這樣類載入器就知道應該載入哪一個類對應的Class檔案,去檔案系統拿就好了(或者是網路或記憶體中)。

執行期的C/C++

當我們觸發可執行檔案執行時,程式就進入了執行期。

Jietu20170316-152931.jpg

在這個階段,作業系統會先載入並執行動態連結器,而並非直接執行我們的第一行程式碼。動態連結器會掃描由靜態連結器嵌入到可執行檔案中的共享庫依賴列表,把可執行檔案所依賴的所有共享庫都載入到記憶體中(這裡省略了對查詢的描述)。若庫已經載入到了記憶體中,則只需要記憶體對映一下。如果出現某個依賴的庫找不到,會出現錯誤而停止執行。如果共享庫依賴了其他共享庫,也會觸發其他共享庫的載入(載入流程是一個廣度優先的遍歷)。

載入完畢後,動態連結器也不會立刻開始解析符號。鑑於大多數時候並非所有程式碼都會使用到,也為了使startup時間儘量短(因為每次執行都需要連結),連結器採用了延遲繫結符號(lazy symbol binding)的策略。

為了實現延遲符號繫結,靜態連結器會幫忙對共享庫中的符號進行特殊處理。靜態連結器在連結可執行檔案時,會構造一個名為procedure-linking table(PLT)的跳錶,幷包含到可執行檔案中。然後,靜態連結器讓程式碼中所有解析到的對共享庫中符號(函式)的引用都指向PLT中特定的entry。

回到執行時,動態連結器會把PLT中所有entries都指向自己內部一個特殊的符號繫結函式。當任意庫函式被第一次觸發時,動態連結器會恢復控制,然後執行實際的符號繫結:定位到一個符號然後將對應的PLT entry替換為對符號的直接引用。這樣之後的請求都會直接呼叫到對應的符號。這就是C中的動態連結的基本工作機制。

共享庫之所以能成為共享庫,首先是因為有動態連結這種機制,可以使得符號解析推遲到執行期,這樣共享庫中的符號就不需要打包到可執行檔案中。同時,鑑於庫中主要包含系統指令,類似於只讀資料,基於記憶體對映實現庫中符號在可執行檔案之間共享就順理成章了。為此,共享庫通常也稱動態連結共享庫(Dynamically linked shared library),或者就稱之為動態連結庫。

現在大多數情況都會選擇使用共享庫,因為相對靜態連結庫,使用動態連結共享庫還有一個好處:可以直接升級共享庫而達到動態patch的目的,而不用重新連結整個可執行檔案。

執行期的Java

Java中符號是按照類來組織的,所以Java的連結模型的核心,就是JVM對類的操作。

java -cp . Main複製程式碼

執行命令之後,JVM首先會去AppClassLoader載入啟動類Main,目的是為了執行其中的main方法(見sun.launcher.LauncherHelper#checkAndLoadMain)。與其他類一樣,這個啟動類會經過載入、連結、初始化、使用和解除安裝幾個階段,其中連結又可分為驗證、準備和解析三個子階段。

Java中載入一個類,意味著通過類載入器定位到這個類的Class檔案(或對應格式的內容),將其中的資訊儲存起來,還會構建一個Class類的物件來提供對這些資訊的程式設計式訪問。接下來還會有對這些資訊的驗證和準備,然後才會開始具體的解析動作。

Java中的符號,一開始儲存在Class檔案中的常量池,在載入完成後則進入JVM為每個類單獨維護的執行時常量池。符號的解析,則是指將執行時常量池中的符號引用替換為直接引用。

C中符號解析是延遲化的,按需的。那Java中符號的解析呢?

就虛擬機器規範來說,並沒有規定解析實際發生的時機。虛擬機器實現可以選擇在載入完成後就對類中所有的符號進行解析,也可以當類中某個符號實際被使用到時,才觸發真正的連結過程。實際上,JVM規範只要求在用於操作符號引用的位元組碼執行之前,對這些指令所使用的符號引用完成解析就行了,LinkageError這樣的連結錯誤也只能在符號被使用時才能丟擲。換句話說,規範要求JVM實現對外表現出來是按需解析符號的。那實際呢?

我看過的多數講解類生命週期或載入機制的文章都是從類載入講起的,”當我們主動使用某個類的時候,會觸發類載入器這個類的載入”,並且還歸納出六種主動使用類的方式:

  1. 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
  2. 呼叫類的靜態方法
  3. new建立類的例項
  4. 初始化某個類的子類,則其父類也會被初始化
  5. 反射(如Class.forName(“com.xxx.xxx”))
  6. Java虛擬機器啟動時被標明為啟動類的類,就如我們的Main

前邊兩種方式是我們平時寫Java程式碼使用得很頻繁的,由於相應位元組碼需要使用符號(getstatic,setstatic,invokestatic),到了符號必須連結的時候了。如果需要的符號不存在在記憶體中(因為對應的類尚未載入),會觸發類載入流程。所以,前兩種方式實際上是由連結觸發類的載入,載入完成後接著就要進行連結,完成所需符號的解析。

第三和第四種是普通的類載入流程及其副作用。第五種方式是另外一種特殊情況,後邊再講。第六種情況是實際上就是用第五種方式實現的。

前文提到過,C是在一開始就確保了所有依賴的模組(共享庫)載入到了記憶體中,但連結卻是按需的。Java中對類(符號)的載入是按需的,而連結則分兩種情況:

  1. 當前執行的位元組碼需要解析符號,如果所需要的類尚未載入,則先進入類載入流程,然後完成連結。這種情況下,解析是按需的
  2. 先觸發了類載入,JVM可以選擇直接完成類中所引用符號的連結,也可以選擇按需連結

動態載入

前文提到,Java中模組的載入可能是由符號連結觸發的,也可能是因為需要建立類例項導致的。另外還有一種情況,那就是由動態載入機制(其實我覺得用主動載入更能表達目的)觸發的,即在執行時由程式決定載入和連結什麼符號(以動態連結為基礎)。這種能力讓語言具備了獲取在編譯時尚未存在的模組和符號,以支援程式的動態擴充套件和實現外掛機制。

在C中也支援動態載入,我們可以通過動態載入系統函式來載入指定的共享庫並使用其中的符號。JNI中對共享庫的載入就是基於dlopen函式實現的。但針對這些動態載入的符號的具體連結過程還有有一些小的差別,詳見The inside story on shared libraries and dynamic loading ,這裡就不展開說了。

實際的使用過程中,Java提供了ClassLoader.loadClassClass.forname(前文提到過的反射)兩種方式來完成Class檔案的動態載入。

因為是先觸發類載入,類中符號解析的時機就不確定了。JVM可以選擇提前,也可以選擇在符號即將被使用時再完成連結。

動態連結的對比

最後,回到本文的主題。符號解析的前置條件是符號(模組)已經完成了載入,所以其實載入和連結是一個整體(先後順序不定),都是語言連結模型的一部分。從載入和連結的角度,Java和C中動態連結的不同點有以下這些:

  1. 對於模組載入,C以共享庫為單位,而Java則是以Class檔案為單位
  2. C的動態連結依賴於靜態連結器在編譯期寫入的共享庫依賴列表,而Java不需要
  3. C中可執行檔案依賴的所有共享庫會在啟動時完成載入,而Java的Class是按需載入的
  4. C只支援從本地檔案系統中載入共享庫(有一套既定的查詢規則),而Java的類載入體系除了支援從本地檔案系統查詢類,還支援自定義類載入器,從而支援程式從任意自定義位置載入類,比如網路、資料庫甚至是動態生成類
  5. Java中的Class檔案最多隻能被稱為動態連結庫,因為它載入到記憶體中之後無法在多個JVM間共享

總結

從後往前看,我覺得我對連結的困惑,源自Java的程式設計模型沒有(顯示)包含連結這一環,以至於我對連結的概念太陌生了(也是理解得還不夠好)。而且,我感覺自己在很長一段時間內都是假裝知道連結是什麼意思,同時也模模糊糊地把Library等同於了Jar包。

不懂裝懂真可怕,特別是自己。

Ref

  1. The Linking Model, Chapter 8 of Inside the Java Virtual Machine,本文的基礎,非常詳細,但是不好get到big picture

  2. Library (computing) - Wikipedia

  3. c++ - Static linking vs dynamic linking

  4. Linker (computing) - Wikipedia 概念上很全面

  5. Static, Shared Dynamic and Loadable Linux Libraries 具體細節足夠詳細

  6. The 101 of ELF Binaries on Linux: Understanding and Analysis

  7. c - Shared libraries vs executable

  8. c++ - Force GCC to notify about undefined references in shared libraries

  9. The inside story on shared libraries and dynamic loading

  10. What is the difference in byte code like Java bytecode and files and machine code executables like ELF?

  11. How do Java AOT compilers work?

  12. PLT and GOT - the key to code sharing and dynamic libraries

  13. Java is Dynamic(ly linked)

一文弄懂Java和C中動態連結機制


相關文章