摘要:拆解虛擬機的基本步聚如下首先,要等待到自身成為唯一一個正在運行的非守護線程時,在整個等待過程中,虛擬機仍舊是可工作的。將相應的事件發送給,禁用,并終止信號線程。
本文簡單介紹HotSpot虛擬機運行時子系統,內容來自不同的版本,因此可能會與最新版本之間(當前為JDK12)存在一些誤差。
1.命令行參數處理
HotSpot虛擬機中有大量的可影響性能的命令行屬性,可根據他們的消費者進行簡單分類:執行器消費(如-server -client選項),執行器處理并傳遞給JVM,直接由JVM消費(大多)。
這些選項可分為三個主要的類別:標準選項,非標準選項,開發者選項。標準選項是指所有的JVM不同實現均可以處理且在不同版本之間穩定可用的選項(但是也可以deprecated)。
以-X開頭的選項是非標準選項(不保證所有JVM虛擬機的實現均支持),后續的JAVA SDK更新也不保證會對它進行通知。使用-XX開頭的為開發者選項,它一般需要特定的系統環境(以保證實現正確的操作)和足量的權限(以訪問系統配置參數),這些實現應當慎重使用,相應的選項在更新后也并不保證通知到用戶。
命令行參數控制了JVM內部變量的屬性值,這些參數同時具備"類型"與"值"。對于布爾型的屬性值,以+或-置于參數之前,可分別表示該屬性的值為true或false。對于需要其他數據的變量,同樣有許多機制可以設置其值數據(很遺憾并不統一),一部分參數在格式上要求在屬性名稱后直接跟隨屬性值,有些不需要分隔符,有些又不得不加上分隔符,分隔符又可能是":","="等,如-XX:+OptionName, -XX:-OptionName, and -XX:OptionName=。
大多整型的參數(如內存大小)可接受"k","m","g"分別代表kb,mb,gb這種簡寫形式。
2.虛擬機運行周期
執行器
HotSpot虛擬機有幾種Java標準版的執行器,在unix系統上即java命令,在windows系統下即java和javaw(javaw其實是指基于網絡的執行器)。從屬于虛擬機啟動過程的執行器操作有:
a.解析命令行選項,部分選項直接由執行器自身消費,如-client和-sever屬性被用來決斷加載合適的vm庫,其他的屬性則作為虛擬機初始化參數(JavaVMInitArgs)傳遞給vm。
b.如果未明確指定選項,執行器來確定堆的大小和編譯器類型(是client還是server)。
c.確立如LD_LIBRARY_PATH 和 CLASSPATH等環境變量。
d.如果未在命令行中明確指定主類,執行器會從jar文件清單中找出主類名稱。
e.執行器會在一個新創建的線程(非原生線程)中使用JNI_CreateJavaVM來創建虛擬機實例。 注意,在原生線程中創建vm會極大的減少定制vm的可能性,如windows中的棧大小等。
f.一旦vm創建并初始化成功,加載主類成功,執行器可從主類中得到main方法的屬性,然后使用CallStaticVoidMethod執行主方法并以命令行參數為它的方法入參。
g.當java主方法執行完成時,檢查和清理任何可能已發生的掛起的異常,返回退出狀態。它會使用ExceptionOccurred來清理異常,方法如果執行成功,它會給調用進程返回一個0值,否則為其他值。
h.使用DetachCurrentThread解除主線程的關聯,這樣減少了線程的數量,保證可安全調用DestroyJavaVM,它也能保證線程不在vm中執行操作,棧中不再有存活的棧楨。
最重要的兩個階段是JNI_CreateJavaVM以及DestroyJavaVM,下面詳述。
JNI_CreateJavaVM執行步驟:
首先保證沒有兩個線程同時調用此方法,從而保證不在同一進程中出現兩個vm實例。當在一個進程空間中達到一個初始化點時,該進程空間中不能再創建vm,該點也被稱為“不返回的點”(point of no return)。原因在于此時vm已創建的靜態數據結構不能夠重新初始化。
接下來,要對JNI的版本支持進行檢查,檢測gc日志的ostream是否初始化。此時會初始化一些操作系統模塊,如隨機數生成器,當前進程號,高分辨率的時間,內存頁大小和保護頁等。
解析傳入的參數和屬性值,并存放留用。初始化java標準系統屬性。
基于上一步解析的參數和屬性進一步創建和初始化系統模塊,這一次的初始化是為同步,棧內存,安全點頁做準備。在此時如libzip,libhpi,libjava,libthread等庫也完成了加載,同時完成信號句柄的初始化和設定,并初始化線程庫。
接下來初始化輸出流日志,任何必需的代理庫(hprof jdi等)均于此時完成初始化和開啟。
完成線程狀態的初始化以及持有了線程操作所需的指定的數據的線程本地存儲(TLS Thread Local Storage)的初始化。
全局數據的初始化,如事件日志,操作系統同步,性能內存(perfMemory),內存分配器(chunkPool)等。
到此時開始創建線程,會創建java版本的主線程并綁定到一個當前操作系統線程上。然而這個線程還不能被Threads線程列表感知,完成java級別的線程初始化和啟用。
緊接著進行余下部分的全局模塊的初始化,它們包括啟動類加載器(BootClassLoader),代碼緩存(CodeCache),解釋器(Interpreter),編譯器(Compiler),JNI,系統字典(SystemDictionary),Universe。此時便已到達前述的“不返回的點”,也就是說,我們此時已不能在進程的地址空間中再創建一個vm實例了。
主線程會在此時被加入到線程列表中,這一步首先要對Thread_Lock進行加鎖操作。此處會對Universe(戲稱為小宇宙,即所需的全局數據結構)進行健全檢查。此時創建執行所有重要vm函數的VMThread,創建完成后,即達到一個合適的點,在這個點,可發出適當的JVMTI事件通知當前jvm的狀態。
加載并初始化一些類,包含java.lang.String,java.lang.System,java.lang.Thread,java.lang.ThreadGroup,java.lang.reflect.Method,java.lang.ref.Finalizer,java.lang.Class,以及系統類中的其他成員。在這一刻,vm已經完成初始化并且可操作,但是并未具備完整的功能。
到這一步,信號處理器線程也被開啟,同時也完成了啟動編譯器線程和CompileBroker線程,以及StatSampler和WatcherThreads等輔助線程,此時vm具備了完整的功能,生成JNIEnv信息并返回給調用者,此時的vm已經準備就緒,可服務新的JNI請求。
DestroyJavaVM執行步驟
DestroyJavaVM的調用有兩種情況:執行器調用它拆解vm,或vm自身在出現嚴重錯誤時調用。拆解虛擬機的基本步聚如下:
首先,要等待到自身成為唯一一個正在運行的非守護線程時,在整個等待過程中,虛擬機仍舊是可工作的。
調用java.lang.Shutdown.shutdown()方法,它會執行java級別的關閉勾子方法,如果有退出終結器可用,運行相應的終結器(finalizer)。
調用before_exit()為vm退出做出準備,運行vm級別的關閉勾子(它們是用JVM_OnExit()注冊的),停止剖析器(Profiler),采樣器(StatSampler),Watcher和GC線程。將相應的事件發送給JVMTI/PI,禁用JVMPI,并終止信號線程。
調用JavaThread的exit方法,釋放JNI句柄塊,移除棧保護頁,把此線程從線程列表中移除,從這個點起,任何java代碼不可被執行。
終止vm線程,它會把當前的vm帶到安全點并終止編譯器線程。在安全點,應注意任何可能會在安全點阻塞的功能都不可使用。
禁用JNI/JVM/JVMPI屏障的追蹤。
給native代碼中依舊在運行的線程設置_vm_existed標記。
刪除這個線程。
調用exit_globals刪除IO和PerfMemory資源。
返回調用者。
3.虛擬機類加載(class loading)
虛擬機要負責常量池符號的解析,它需要對有關的類和接口先后進行裝載(loading),鏈接(linking)然后初始化。一般用“類加載機制”來描述把一個類或接口的名稱映射到一個class對象的過程,相應的,JVMS定義了詳細的裝載,鏈接和初始化階段的協議。
類的加載是在字節碼解析過程中完成的,典型是當一個類文件中的常量池符號需要被解析時。有一些JAVA的api會觸發這個過程,如Class.forName(),classLoader.loadClass(),反射api,以及JNI_FindClass均能初始化類的加載。虛擬機自身也能初始化類加載。虛擬機會在啟動時加載如Object,Thread等核心類。裝載一個類需要裝載所有的超類和超接口。且對于鏈接階段的類文件驗證過程,可能需要裝載額外的類。
虛擬機和JAVA SE類加載庫共同承擔了類的加載,虛擬機執行了常量池的解析,類和接口的鏈接和初始化。加載階段是vm和特定的類加載器(java.lang.ClassLoader)之間的一個協作過程。
類加載階段
裝載階段,根據類或接口的名稱,在類文件中找出二進制語義,定義類并創建java.lang.Class對象。如果在類文件中找不到二進制表示,則拋出NoClassDefFound錯誤。此外裝載階段也做了一些類文件的在語法上的格式檢查,檢查不通過會拋出ClassFormatError或UnsupportedClassVersionError。在完成裝載之前,vm必須載入所有的超類和超接口。如果類繼承樹存在問題,如類直接或間接地自己繼承或實現自己,則vm會拋出ClassCircularityError。若vm發現類的直接接口不是接口,或者直接父類是一個接口,則會拋出IncompatibleClassChangeError。
類加載的鏈接階段首先做一些校驗,它會檢測類文件的語義,常量池符號以及類型檢測,這個過程可能會拋出VerifyError。鏈接階段接下來進行一些準備工作,它會為靜態字段進行創建和初始化標準默認值,并分配方法表。注意,到此步為止不會進行任何java代碼的執行。之后鏈接階段還有一個可選的步驟,即符號引用的解析。
接下來是類的初始化階段,它會運行類的靜態初始化器,初始化類的靜態字段。這是類的java代碼的第一次執行。注意類的初始化需要超類的初始化,但不包含超接口的初始化。
JAVA虛擬機規范(JVMS)規定了類的初始化發生在類的第一次“活化使用”,java語言規范(JLS)允許鏈接階段的符號解析過程在不破壞java語義前提下的靈活性,裝載,鏈接和初始化的每一個步驟都要在前一步驟完成后進行。為了性能考慮,HotSpot虛擬機一般會等到要去初始化一個類時才會去進行類的裝載和鏈接。所以舉個簡單的例子,如果類A引用了類B,那么加載類A將不會必然導致B的加載,除非在驗證階段必需。當執行了第一個引用B的指令時,將會導致B的初始化,而這又需要先對類B進行裝載和鏈接。
類加載的委托機制
當一個加載器被要求查找和加載一個class時,它可以請求另一個類加載器去做實際的加載工作。這個機制被稱為加載委托。第一個類加載器是一個“初始化加載器”,而最終定義了該類的類加載器被稱為“定義加載器”,在字節碼解析的例子中,初始化加載器負責該類的常量池符號的解析。
類加載器是分層定義的,每個類加載器可有委托的雙親。委托機制定義了二進制類表示的檢索順序。JAVASE類加載器按層序檢索啟動類加載器,擴展類加載器和系統類加載器。系統類加載器同時也是默認的應用類加載器,它會運行main方法并從類路徑下加載類。應用類加載器可以是JAVASE 類加載器庫中的實現,也可以由應用開發人員實現。JAVASE類庫實現了擴展類加載器,它負責加載jre下lib/ext目錄中的類。
作者在“54個JAVA官方文檔術語”一文中曾說過,這一機制已經不適用于JAVA9以上版本的描述,如果去查詢有關文章,可以發現這個經典的類加載委托機制其實已經歷過三次破壞(委托機制出廠時晚于加載器本身,破壞一;線程上下文類加載器,破壞二;熱部署的后門,破壞三),而作者個人認為類加載器支持JAVA9之后的模塊路徑的加載也是一種破壞,它們之間不再是簡單的委托加載,也不僅從類路徑下加載,不同路徑加載到的模塊也有不同的處理機制,詳細描述見該文。
啟動類加載器是由vm實現的,它從BOOTPATH下加載類,包含rt.jar中的類定義。為了快速啟動,vm也會通過類數據共享(cds)來處來類的預加載。關于cds,在最新的幾版jdk中有所更新,我們在稍后的章節中簡述。
類型安全
類或者接口名是由包含包名稱的全限定名定義的。一個類的類型由該全限定名和類加載器所唯一定義,所以類加載器其實可以理解為一個名稱空間,兩個不同類加載器定義的同一個類實際上會是兩個class類型。
vm會對自定義類加載器進行限定,保證不能與類型安全發生沖突。當類A中調用類B的方法時,vm通過追蹤和檢查加載器約束保證兩個類的加載器在方法參數和返回值上協商一致。
HotSpot中的類元數據
類加載的結果是在永久代(舊版)創建一個instanceKlass或者arrayKlass。instanceKlass指向一個java.lang.Class的實例,虛擬機c++代碼通過klassOop訪問instanceClass。
HotSpot內部類加載數據
HotSpot虛擬機為了追蹤類加載而維護了三張主哈希表。分別是SystemDictionary表,它包含被加載的類,它們映射鍵為一個類名/類加載器對,值為一個klassOop,它同時包含了類名/初始化加載器對和類名/定義加載器對,目前只有在安全點才可以移除它們;PlaceholderTable表,它包含當前正在被載器的類,它被用于前述ClassCircularityError檢查和支持多線程類加載的加載器進行并行加載;LoaderConstraintTable,它追蹤類型安全檢查約束。這些哈希表都由一個鎖SystemDictionary_lock來保護,一般情況下vm中的類加載階段是使用類加載器對象鎖串行執行的。
4.字節碼驗證和格式檢查
JAVA語言是類型安全的,標準的java編譯器會生產可用的類文件和類型安全的代碼,但是jvm不能保證代碼是由可信任的編譯器生成的,因此它必須在鏈接時進行字節碼校驗(bytecode verification)重建類型安全。
字節碼校驗的規范詳見java虛擬機規范的4.8節。規范中規定了JVM校驗的代碼動態和靜態約束。如果發現了任何與約束沖突的地方,虛擬機將會拋出VerifyError并阻斷類的鏈接。
可靜態檢查的字節碼約束有很多,"ldc"碼(Low Disparity Code 低差別編碼)的操作數必須為一個可用的常量池索引,它的類型是CONSTANT_Integer, CONSTANT_String 或 CONSTANT_Float。其他指令需要的檢查參數類型和個數的約束需要對代碼動態分析,這樣來決定執行時哪個操作數可出現在表達式棧。
目前,有兩種辦法(截止1.6)分析字節碼并決定在每一條指定中出現的操作數類型和個數。傳統的辦法被稱作“類型推斷”,它通過對每個字節碼進行抽象解釋,在代碼的分支處或異常句柄處進行類型狀態的合并。整個分析過程會迭代全部的字節碼,直到發現這些類型的“穩態”。如果不能達到穩態,或者結果類型與一些字節碼的約束沖突,那么拋出VerifyError。這一步的驗證代碼位于外部庫libverify.so中,它使用JNI去收集所需的類和類型的信息。
在JDK6中出現了第二種被稱為“類型驗證“的方法,在這種方法中,java編譯器通過代碼屬性,StackMapTable來提供每一個分支和異常目標的穩態類型信息。StackMapTable包含大量的棧圖楨,每一個楨表示方法的某一個偏移量的表達式棧和局部變量表中的條目類型。jvm接下來只需要遍歷字節碼并驗證字其中的類型正確性。這是一個已經在JAVAME CLDC中使用的技術。因為它小而快,此驗證方法vm自身即可構建。
對于所有版本號低于50,創建早于JDK6的類文件,jvm會使用傳統的類型推薦方式驗證類文件,否則會使用新辦法。
5.類數據共享(cds)
類數據共享是一個JDK5引入的功能,旨在提高java程序語言應用的啟動時間,尤其是小型應用,同時,它也能減少內存占用。當jre安裝在32位系統時并且使用sun提供的安裝器時,安裝器會從系統jar中載入一組類并生成一種內部的格式,然后轉儲為一個文件,這個文件被稱作”共享存檔“。如果沒有使用sun提供的jre安裝器,也可以手動執行。在后續的jvm執行時,這個共享存檔文件被映射進內存,節省了其他jvm裝載類和元數據的時間。
目前官方對于cds的文檔未整理完善,在截止到JAVA8的有關文檔中,仍可以見到這樣一句描述:Class data sharing is supported only with the Java HotSpot Client VM, and only with the serial garbage collector,即類共享目前只在HotSpot client虛擬機中支持,且只能使用serial垃圾收集器。而在JAVA9-12的若干新特性中,也對cds有過一些更新描述,如JAVA10中對類數據的共享包含了應用程序的類,在JAVA11中模塊路徑也支持了cds。但作者并未在專門的垃圾收集器中找到大篇幅的詳述,不過根據jdk12的jvm文檔中介紹,cds已經在 G1, serial, parallel, 和 parallelOldGC 幾種垃圾收集器中支持,且默認使用G1的128M堆內存。且G1在JDK7中已出現,在JDK9中已經成為默認的垃圾收集器,parallel 出現的相對更早,因此作者嚴重懷疑JAVA8中相應文檔描述的準確性,好在我們可以直接去看最新版。
cds可以減少啟動時間,因為它減少了裝載固定的類庫的開銷,應用程序相對于使用的核心類越小,cds就相當節省了越多的啟動時間。cds同時也有兩種方式減少了jvm實例的內存占用。首先,一部分共享存檔文件被映射進內存并作為只讀的庫,多個jvm進程不需要重復占用進程的內存空間;其次,因為共享存檔文件中包含的類數據已經是jvm使用的格式,處理rt.jar(低于9的版本)所需的額外內存開銷也可以省去了,這使得多個應用在同一機器上能夠更優的并發執行。
在HotSpot虛擬機中,類共享的實現實際是在永久代(元空間)中開辟了新的內存區域存放共享數據。存檔文件名為”classes.jsa“,它會在vm啟動時映射進這個空間。后續的管理由vm內存管理子系統負責。
共享數據是只讀的,它包含常量方法對象(constMethodOops),符號對象(symbolOops),基本類型數組,多數字符數組。可讀寫的共享數據包含可變的方法對象(methodOops),常量池對象(constantPoolOops),vm內部的java類和數組實現(instanceKlasses和arrayKlasses), 以及大量的String,Class和Exception對象。
作者看來,近幾版的jdk關于cds的幾處更新明顯借鑒了一些如tomcat等服務器的機制,適配越來越多的云生產環境,減少內存開銷和啟動開銷都是為云用戶省錢的方式。
6.解釋器
當前HotSpot解釋器是一個基于模板的解釋器,它被用來執行字節碼。HotSpot在啟動時運行時用InterpreterGenerator在內存中利用TemplateTable(每個字節碼有關的匯編代碼)中的信息生成一個解釋器實例。模板是每個字節碼的描述,模板表定義了所有模板并提供了獲取指定字節碼的訪問方法。在jvm啟動時,可使用-XX:+PrintInterpreter打印有關的模板表信息。
執行效果上看,模板好于經典的switch語句循環的方式,原因也很簡單,首先switch語句執行重復的比較操作來得到目標字節碼,最極端情況它可能需要對一個給定的指定比較所有的字節碼;第二,模板使用共享的棧來傳遞java參數,同時本地c方法棧被vm自身來使用,大量的jvm內部變量是用c變量存放的(如線程的程序計數器或棧指針),它們不保證永久存放在硬件寄存器中,管理這些軟件的解釋結構會消耗總執行時間中的相當可觀的一部分。
從全局來看,HotSpot解釋器大幅彌合了虛擬機和實體機器之間的裂縫,它大大加快了解釋的時間,但是犧牲了很多代碼的機器塊,同時也增大了代碼大小和復雜度,也需要一些代碼的動態生成。很明顯,debug機器動態生成的代碼要比靜態代碼更加困難。
對于一些對匯編語言來說過于復雜的操作,如常量池的查找,解釋器會運行時調用vm來完成。
HotSpot解釋器也是整個HotSpot自適應優化歷史中重要的一部分,自適應優化解決了JIT編譯的問題,大部分情況下,幾乎所有的程序都是用大量時間執行極少量的代碼,因此運行時不需要逐方法編譯,vm僅使用解釋器來立即運行程序,分析代碼在程序中運行的次數,避免編譯不頻繁運行的程序代碼(大多數),這樣HotSpot編譯器可以專注于程序中最需要性能優化的部分,并不增加全局的編譯時間,在程序持續運行期間進行動態的監控,達到最適應用戶需要的目的。
7.JAVA異常處理
jvm使用異常作為一個信號,它說明程序中出現了與java語言語義相沖突的事件,數組越界是一個極簡的案例。異常會導致控制流從異常發生或拋出的點轉到程序指定的處理點或捕獲點的一次非本地轉換。HotSpot解釋器和動態編譯器在運行時協作實現了異常的處理。異常處理有兩種簡單案例,異常拋出并由同一方法捕獲,異常拋出并由調用者捕獲。后一種情況稍微復雜一些,因為需要展開棧來找出恰當的處理者。
要初始化一個異常有多個方式,如throw字節碼,從vm內部調用中返回,JNI調用中返回,或java調用中返回,最后一個情況其實是前三者的后一階段。當vm意識到有異常拋出時,執行運行時系統去找出該異常最近的處理器,這一過程會用到三片信息:當前方法,當前字節碼,異常對象。如果當前方法沒有找到處理器,如上面提到的,將當前活化的棧楨出棧,進程將在此前的棧楨中迭代重復上述步驟。一旦找到了合適的處理器,vm更新執行狀態,跳轉到相應的處理器,java代碼在相應位置繼續執行。
8.同步
廣泛來講,可以把“同步”定義為一個阻止或恢復不恰當的并發交互(一般稱為競態)的一個機制。在java中,并發通過線程來表示,鎖排他是java中常見的一個同步案例,這一過程中,只有一個線程同時被允許訪問一段保護的代碼或數據。
HotSpot提供了java監視器的概念,線程可通過監視器來排它的運行應用代碼。監視器只有兩個狀態:鎖或者未鎖,一個線程可以在任何時間持有(鎖住)監視器。只有在獲取了監視器后,線程才能進入被監視器保護的代碼塊。在java中這類被監視器保護的代碼塊稱為同步代碼塊。
無競態的同步包含了大多的同步情況,它由常量時技術實現。java對于同步機制做了大量的優化,偏向鎖技術是其中之一,因為大多數的對象一生只被最多一個線程持有鎖,因此允許該線程將監視器偏向給自己,一旦偏向,該線程后續鎖和解鎖不再需要額外又昂貴的原子指令開銷。
對于有競態的同步操作場景,使用高級自適應自旋技術提高吞吐量。即使此時應用中有大量的競態,在經歷這些優化后,同步操作性能已經大幅提升,從jdk6開始,它不再是現在的real-world程序中的重大問題。
在HotSpot中,大多的同步操作是由一種被稱作“fast-path”(快路)代碼的調用完成的。有兩種即時編譯器(JIT)和一個解釋器,它們都可以產生快路代碼。兩種編譯器分別是C1,即-client編譯器,以及C2,即-server編譯器。C1和C2均直接在同步點生成快路代碼。在一般沒有競態的情況下,同步操作將會完全在快路中執行,然而當發現需要去阻塞或者喚醒一個線程時(如monitorenter monitorexit),將會進入slow-path執行,它由本地C++代碼實現。
單個對象的同步狀態是在對象中的第一個word中編碼存放的(mark word,詳見前面的文章“54個JAVA官方文檔術語”)。mark word對同步狀態元數據來說是多用的(其實mark word本身也是多用的,它還包含gc分代數據,對象的hash碼值)。這些狀態包含:
Neutral(中立): 未鎖
Biased(偏向): 鎖/未鎖+非共享
Stack-Locked(棧鎖): 鎖+共享 無競態
Inflated(膨脹鎖): 鎖/未鎖+共享和競態
9.線程管理
線程管理覆蓋線程從創建到銷毀的整個生命周期,并負責在vm內協調各個線程。這個過程包含java代碼創建的線程(應用代碼或庫代碼),綁定到vm的本地線程,出于各種目的創建的vm內部線程。線程管理在絕大多數情況下是獨立于運行平臺的,但仍有一些細節與所運行的操作系統有所關聯。
線程模型
在hotspot虛擬機中,java線程和操作系統線程是一對一映射的關系,java線程即一個java.lang.Thread實例,當它被開啟(start)后,本地線程也隨之創建,當它終止(terminated)時,本地線程回收。操作系統負責調度所有的線程以及派發可用的cpu資源。java線程的優先級以及操作系統線程的優先級機制非常復雜,在不同的操作系統中表現也差異極大,此處略。
線程創建和銷毀
有兩種辦地可以向虛擬機中引入一個線程:執行java.lang.Thread對象的start方法;或使用JNI將一個已存在的本地線程綁定到vm。出于一些目的,vm內部也有一些辦法創建線程,本處不予討論。
在vm的一個線程上實際上關聯了若干個對象(HotSpot虛擬機是由面向對象的c++實現),具體有:
a.java.lang.Thread實例表現java代碼中的一個線程。
b.JavaThread實例表示vm中的一個java.lang.Thread,JavaThread是Thread的子類,它包含額外的用以追蹤線程狀態的信息。一個JavaThread實例持有關聯的java.lang.Thread對象的引用(指針),同時持有OSThread實例的引用。java.lang.Thread也持有JavaThread的引用(以一個整數表示)。
c.OSThread(直譯為操作系統線程)實例表示了一個操作系統的線程,它包含額外的可用于追蹤線程狀態的操作系統級別的信息。OSThread包含了一個平臺指定的可用于定位真實操作系統線程的句柄。
當java.lang.Thread實例啟動,vm創建關聯的JavaThread和OSThread對象,并最終創建了一個本地線程。在準備好所有vm狀態后(如線程本地存儲,分配緩存,同步對象等)之后,本地線程得以啟動。本地線程完成初始化并執行一個start-up方法,它會導向java.lang.Thread對象的run方法。隨后,在該方法返回或拋出未捕獲的異常時終止線程,并且在終止時與vm交互,這個過程是用以判斷是否它此時也需要終止vm。線程終止會釋放掉所有關聯的資源,從已知線程集中移除掉JavaThread實例,執行OSThread實例和JavaThread實例的銷毀過程,并最終停止startup 方法的執行。
可使用JNI調用AttachCurrentThread把本地線程綁定到虛擬機。作為此方法的響應,OSThread和JavaThread實例會被創建并進行基本的初始化。接下來會使用綁定線程命令提供的參數及Thread類的構造器反射初始化一個java線程。綁定完成后,線程可以通過可用的JNI方法調用所需的java代碼。當本地線程不希望繼續在vm中進行執行時,可使用JNI調用DetachCurrentThread來解除與vm的關聯(會釋放資源,丟棄指向java.lang.Thread實例的引用,銷毀JavaThread和OSThread對象等)。
使用JNI調用CreateJavaVM創建vm是一個特殊的綁定本地線程的例子,它會由執行器(java.c)完成或通過一個本地應用來完成。這件事會造成一系列的初始化操作,也會在接下來出現類似執行AttachCurrentThread的行為。隨后線程繼續執行所需的java代碼(此例中即反射執行main方法)
線程狀態
vm維護了一組內部的線程狀態來標識各線程的工作。協調各線程的交互,或當線程執行錯誤進行debug時均需要用到這些狀態標識。當執行了不同的動作時,線程的狀態可以發生改變,可即時使用這些轉換點檢測相應的線程是否具備執行將要執行的動作的客觀條件,安全點即是一個典型的例子。
以虛擬機的視圖來看,線程有以下幾個狀態:
_thread_new:表示一個新線程處于初始化的過程中。
_thread_in_Java:表示一個線程正在執行java代碼。
_thread_in_vm:表示一個線程正在vm內部執行。
_thread_blocked:表示線程因某些原因阻塞(原因可能是正在獲取一個鎖,等待一個條件,sleep,執行阻塞io等)。
出于debug的目的,可能需要一些額外的信息。如對于一些工具,或者用戶需要進行線程棧轉儲或棧跡追蹤等操作時,均需要額外的信息,OSThread維護了相應的一些信息,但部分信息現在已經不再使用了,在線程轉儲時,可報告的狀態額外包含:
MONITOR_WAIT:表示線程正在等待獲取競態鎖。
CONDVAR_WAIT:表示線程正在等待vm使用的內部條件變量(與java級別的對象無關聯)。
OBJECT_WAIT:線程執行了Object.wait方法。
虛擬機的其他子系統和庫可能維護了自己的狀態信息,如JVMTI工具,Thread類本身維護的ThreadState等。這些狀態一般不被其他組件使用。
虛擬機內部線程
JAVA的執行有著嚴格的步驟,不同于某些腳本語言,java即使運行簡單的Hello World也需要相應的資源準備,因此,對于最簡單的Hello World,也可以發現系統中其實創建了若干個線程,它們主要是由vm中的線程和有關代碼庫中使用的線程(包含引用處理器,終結者線程等)組成。主要的虛擬機線程有以下幾種:
a.vm線程:它是VMThread的單例,負責執行虛擬機操作。
b.周期任務線程:它是在vm內部執行周期操作的線程,是WatcherThread的實例。
c.GC線程:顧名思義。
d.編譯器線程:負責運行時執行字節碼到本地代碼的編譯。
e.信號派發線程:負責等待進程信號并派發給java級別的信號處理方法。
以上所有線程是Thread類的實例,且所有執行java代碼的線程均為JavaThread實例。vm內部維護了一個Threads_list的數據結構,它是一個追蹤所有線程的鏈表,在vm內部有一個核心的同步鎖Threads_lock,該鎖就用于保護Threads_list。
10.虛擬機操作和安全點
VMThread會監測一個VMOperationQueue隊列,該隊列中存放的成員全部為“操作”,等待相應的操作入隊后,它會執行相應的操作。這些操作被交給VMThread來執行,因為它們需要vm到達安全點才可執行。簡單來說,當vm在到達安全點時,所有vm內運行的線程均會阻塞,所有在本地代碼中執行的線程在安全點期間被禁止返回vm執行。這意味著虛擬機操作可以在已知無線程處于正在更改java堆的前提下進行運行,且此時所有的線程處在一個特殊的,不改變java棧的可檢視狀態。
最著名的虛擬機操作之一即gc,或者更精確一點是很多gc算法中的“stop the world”階段,但也存在很多基于安全點的其他操作,作者在“54個java官方文檔術語”一文中簡單列舉了這些操作。
很多虛擬機操作是同步阻塞的,請求者會阻塞到操作完成,但也有一些異步并發的操作,請求者可以和VMThread并行執行。
安全點是使用協作輪詢的機制初始化的。簡單來說,線程會去詢問“我是否要為一個安全點阻塞”。這個詢問機制的實現并不簡單。當發生線程的狀態轉換時會常見詢問這個問題,但并是所有的狀態轉換都會詢問,如當一個線程離開vm并進入native代碼塊時。當從編譯的代碼返回時,或在循環迭代的階段,線程也會詢問這個問題。對于執行解釋代碼的線程來說是不常詢問的,但在安全點,它也有相應的方案,當請求安全點時,解釋器會切換到一個包含了該詢問的代碼的轉發表,當安全點結束后,從派發表切回。一旦請求了安全點,VMThread必須等到所有已知線程均處于安全點-安全狀態,然后才可執行虛擬機操作。在安全點期間,使用Threads_lock來block住那些正在運行的線程,虛擬機操作完成后,VMThread釋放該鎖。
11.C++堆管理
除了由JAVA堆管理者和gc維護的JAVA堆以外,HotSpot虛擬機也使用一個c/c++堆(即所謂的分配堆)來存放虛擬機的內部對象和數據。這些用來管理C++堆操作的類都由一個基類Arena(競技場)派生而來。
Arena和它的子類提供了位于分配/釋放機制頂層的一個快速分配層。每一個Arena在3個全局的塊池(ChunkPools)中進行內存塊(Chunk)的分配。不同的塊池滿足不同大小區間的分配,舉例說明,如果請求分配1k的內存,那么會用“small”塊池分配,如果請求分配10k內存,則使用“medium”塊池,這樣可以避免內存碎片浪費。
Arena系統也提供了比純粹的分配/釋放機制更佳的性能。因為后者可能需要獲取一個操作系統的全局鎖,它會嚴重影響擴展性并傷害系統性能。Arena是一些緩存了指定內存數量的線程本地對象,這樣的設計使得它可以在分配時使用“快路”分配而不用獲取該全局鎖,對于釋放內存的操作,通常情況下Arena不需要獲得鎖。
Arena的兩個子類,ResourceArena應用于線程本地資源管理,HandleArena用于句柄管理,在client和server編譯器中均用到了這兩種arena。
12.JAVA本地接口(JNI)
JNI代表本地程序接口。它允許運行在jvm中的java代碼與使用其他語言(如c/c++)實現的應用或庫進行交互。JNI本地方法可以用來做很多事情,如創建對象,檢視對象,更新對象,調用java方法,捕獲拋出的異常,加載類和獲取類信息,執行運行時類型檢測等。JNI也可以使用Invocation api來啟用jvm中嵌入的任意native應用,通過它,我們可以輕易地讓已有應用可以用java運行而不用去鏈接vm源碼。
但有重要的一點,一旦使用了JNI,便失去了使用java平臺的兩個重要的好處。
第一,依賴jni的java應用不保證能在多平臺上可用,盡管基于java實現的部分是可以跨宿主機環境的,使用本地程序語言實現的部分仍舊需要重新編譯。
第二,使用java語言編寫的程序是類型安全的,C或者C++則不是。結果就是使用了JNI的程序員必須額外注意這部分代碼,行為不端的本地方法可能擾亂整個應用,出于這個原因考慮,在執行jni功能前,相應使用到jni的應用一定要負責它的安全性檢查。
原則上講,應盡可能少地使用本地方法,并做好這部分代碼與java應用的隔離,作者看來,unsafe后門包是一個典型的案例。
在HotSpot虛擬機中,jni方法的實現相對直接,它使用各種vm內部原生規則來執行諸如對象創建方法調用等行為,通常情況,相應的如解釋器等子系統也使用了這些運行時規則。
可使用命令行選項-Xcheck:jni來幫助debug那些使用了本地方法的應用,該選項會使得JNI調用時用到一組debug接口。這些接口會更加嚴格地進行JNI調用的參數驗證,同時還會做一些額外的內部一致性檢查。
HotSpot對于執行本地方法的線程進行了額外“照顧”,對于一些vm的工作,比如gc過程中,一部分線程必須保證在安全點阻塞,從而保證java堆在這些敏感過程中不會再次更改。當我們希望把一個安全點上的線程帶入到本地代碼執行時,它會被允許進入本地方法,但是禁止從該方法返回java代碼或者執行JNI調用。
13.虛擬機致命故障處理
毫無疑問,提供致命故障的處理對jvm來說是非常之必需的。以oom為例,它是一個典型的致命錯誤。當發生這類錯誤時,一定要給用戶提供一些合理且友好的方式來理解致命錯誤成因,從而能快速修復問題,這方面的問題不僅包含應用本身,也包含jvm本身。
第一,一般當jvm在致命故障發生時crash掉,它會轉儲一個hotspot的錯誤日志文件,格式為:hs_err_pid
第二,也可以使用-XX:ErrorFile=選項來指定錯誤日志的位置。
第三,發生oom時,也會觸發生成該錯誤文件。
還有一個重要的功能,可以指定一個選項:-XX:OnError="cmd1 args...;com2 ...",這樣當發生了crash時會執行這些指令,相應的指令就比較自由,比如我們可以指定此時執行一些諸如dbx或Windbg之類的debugger執行相應的操作。早于jdk6的應用可使用-XX:+ShowMessageBoxOnError來指定發生crash時使用的debugger。以下是jvm內部處理致命錯誤的一些摘要:
首先,用VMError類聚合和轉儲hs_err_pid
第二,vm使用信號來進行內部的交流,當出現未識別的信號,致命錯誤處理器被執行。而這個信號可能源自一個應用的jni代碼,操作系統本地庫,jre本地庫,甚至是jvm本身。
第三,致命錯誤處理器是慎重編寫的,這也是為了避免它自己也出現錯誤,比如在出現StackOverFlow時,或在持有重要的鎖期間發生crash(如持有分配鎖)。
死鎖是一種常見的錯誤,一般發生在應用程序在申請多個鎖時順序不正確的情況。當死鎖發生時,找出相應的點也是比較困難的,此時可以抓出java進程id,發送SIGQUIT到該進程(Solaris/Linux),會在標準輸出中輸出java級別的棧信息,這對分析死鎖幫助極大,不過在jdk6之上的版本,已經可以使用Jconsole來輕松處理該問題。
順便簡單提一提除了Jconsole/VisualVM等集成工具之外,一些單一目的的自帶工具。
jps:jvm進程工具,可以查看各jvm進程,名稱和編號。
jstat:虛擬機統計信息。比如發生了多少次full gc等。
jinfo:java配置信息工具,運行時查看jvm進程的配置。
jmap:內存映像工具,可以將當前內存情況轉儲一個快照文件。
jhat:堆轉儲快照分析工具。
jstack:java堆棧跟蹤工具。
總結
本文簡述了包含運行時參數處理,線程管理,類加載,類數據共享,運行時編譯,異常處理,重大錯誤處理等java運行時技術。參考資料主要源自官方的若干文檔,一部分資料是專屬性的,如專門描述JVM或JIT,但根本無法確定成作于哪個版本(關于cds作者判斷與JAVA8中的描述相同,但顯然早已不適用),一部分資料是依托于較新版本的,因為新舊版本的文檔并未保持同一目錄結構,有些組件未能在新版中找到詳盡的文檔,因此難免會有不準確或過時的內容,作者爭取在后面找到最新且更加權威的資料以修正。
作者個人認為有兩點重要的收獲,一是宏觀上了解了官方出品的HotSpot虛擬機在運行時的框架設計,理解java在運行時為我們竭盡全力做了哪些事;二是了解某些具體模塊在新版中的優化和取舍,從而間接了解接下來java的使用趨勢。如“云友好”,“多適應”,“開放”等。
這三點是作者個人不成熟的簡單總結,寫到這里,也順便對這三點進行一個“簡單總結”。
云友好其實體現的方面很多,cds就是重要一點,它在最新幾個版本的更新用一句俗化表示:幫用戶省錢。G1定時釋放無用內存的新特性也體現了這一點。
多適應和開放也很好理解,不止是gc方面,前面簡單提過的zgc等針對超大堆的gc,以及G1這種放權讓用戶指定目標的gc,綜合此前的各種gc,基本涵蓋了我們所有可能的應用環境。同樣的,模塊化系統也天然匹配了中小型到大型項目的需求,一個項目從初創到逐漸壯大,或許最終就是模塊不斷擴充的過程,模塊化系統甚至允許對jdk本身進行按需定制,對于小型設備用戶也無益于是一個福音。JIT本身就具備自適應的編譯思想,最優化最常執行的代碼,graal是新出的基于java的JIT編譯器。同步機制也引入了“自適應”自旋鎖,G1中對cs的選擇也具備自適應性等。
再一次,膜拜前輩。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/74922.html
摘要:由于的自動內存管理系統要求對象起始地址必須是字節的整數倍,換句話說,就是對象的大小必須是字節的整數倍。對象大小計算要點在位系統下,存放指針的空間大小是字節,是字節,對象頭為字節。靜態屬性不算在對象大小內。 jvm系列 垃圾回收基礎 JVM的編譯策略 GC的三大基礎算法 GC的三大高級算法 GC策略的評價指標 JVM信息查看 GC通用日志解讀 jvm的card table數據結構 Ja...
摘要:如同其它虛擬機,虛擬機為字節碼提供了一個運行時環境。編譯是一個混合模式的虛擬機,也就是說它既可以解釋字節碼,又可以將代碼編譯為本地機器碼以更快的執行。解決此問題一般是在進程啟動后,對代碼進行預熱以使它們被強制編譯。 Java HotSpot虛擬機是Oracle收購Sun時獲得的,JVM和開源的OpenJDK都是以此虛擬機為基礎發展的。如同其它虛擬機,HotSpot虛擬機為字節碼提供了一...
摘要:對字節碼文件進行解釋執行,把字節碼翻譯成相關平臺上的機器指令。使用命令可對字節碼文件以及配置文件進行打包可對一個由多個字節碼文件和配置文件等資源文件構成的項目進行打包。和不存在永久代這種說法。 Java技術體系 從廣義上講,Clojure、JRuby、Groovy等運行于Java虛擬機上的語言及其相關的程序都屬于Java技術體系中的一員。如果僅從傳統意義上來看,Sun官方所定義的Jav...
摘要:編譯參見深入理解虛擬機節走進之一自己編譯源碼內存模型運行時數據區域根據虛擬機規范的規定,的內存包括以下幾個運運行時數據區域程序計數器程序計數器是一塊較小的內存空間,他可以看作是當前線程所執行的字節碼的行號指示器。 點擊進入我的博客 1.1 基礎知識 1.1.1 一些基本概念 JDK(Java Development Kit):Java語言、Java虛擬機、Java API類庫JRE(...
閱讀 951·2021-09-27 13:36
閱讀 907·2021-09-08 09:35
閱讀 1077·2021-08-12 13:25
閱讀 1447·2019-08-29 16:52
閱讀 2918·2019-08-29 15:12
閱讀 2737·2019-08-29 14:17
閱讀 2625·2019-08-26 13:57
閱讀 1022·2019-08-26 13:51