Java 虛擬機屏蔽了與具體操作系統平臺相關的信息,使得 Java 語言編譯程序只需生成在 Java 虛擬機上運行的目標代碼(字節碼),就可以在多種平臺上不加修改地運行。Java 虛擬機在執行字節碼時,實際上最終還是把字節碼解釋成具體平臺上的機器指令執行。
Java 的優點
- 是一門結構嚴謹、面向對象的編程語言。
- 擺脫了硬件平臺的束縛,實現了“一次編寫,到處運行”的理想。
- 提供了一種相對安全的內存管理和訪問機制,避免了絕大部分的內存泄漏和指針越界問題。
- 實現了熱點代碼檢測和運行時編譯及優化,使得 Java 應用能隨著運行時間的增加而獲得更高的性能。
- 有一套完善的應用程序接口和無數的來自商業機構和開源社區的第三方類庫來幫助實現各種各樣的功能。
Java 平臺的邏輯結構
JVM
- JVM 是一種基于下層的操作系統和硬件平臺并利用軟件方法來實現的抽象的計算機,可以在上面執行 Java 的字節碼程序。簡單的說,JVM 就是 Java 的虛擬機,有了 JVM 才能運行 Java 程序。
-
Java 編譯器只需面向 JVM,生成 JVM 能理解的代碼或字節碼文件。Java 源文件經編譯器,編譯成字節碼程序,通過 JVM 將每一條指令翻譯成不同平臺機器碼,通過特定平臺運行。
JVM 自身的物理結構
class 文件的組成
- 結構信息。包括 class 文件格式版本號及各部分的數量與大小的信息。
- 元數據。對應于 Java 源碼中聲明與常量的信息。包含類/繼承的超類/實現的接口的聲明信息、域與方法聲明信息和常量池。
- 方法信息。對應 Java 源碼中語句和表達式對應的信息。包含字節碼、異常處理器表、求值棧與局部變量區大小、求值棧的類型記錄、調試符號信息。
類的層次關系和加載順序
類執行機制
- JVM 是基于棧的體系結構來執行 class 字節碼的。線程創建后,都會產生程序計數器(PC)和棧(Stack),程序計數器存放下一條要執行的指令在方法內的偏移量,棧中存放一個個棧幀,每個棧幀對應著每個方法的每次調用,而棧幀又是有局部變量區和操作數棧兩部分組成,局部變量區用于存放方法中的局部變量和參數,操作數棧中用于存放方法執行過程中產生的中間結果。
內存區域
- Java 虛擬機在執行 Java 程序的過程中會把他所管理的內存劃分為若干個不同的數據區域。
-
Java 虛擬機規范將 JVM 所管理的內存分為以下幾個運行時數據區:程序計數器、Java 虛擬機棧、本地方法棧、Java 堆、方法區。
內存區域圖
程序計數器
- 一塊較小的內存空間,它是當前線程所執行的字節碼的行號指示器,字節碼解釋器工作時通過改變該計數器的值來選擇下一條需要執行的字節碼指令,分支、跳轉、循環等基礎功能都要依賴它來實現。
- 每條線程都有一個獨立的的程序計數器,各線程間的計數器互不影響,因此該區域是線程私有的。
- 當線程在執行一個 Java 方法時,該計數器記錄的是正在執行的虛擬機字節碼指令的地址,當線程在執行的是 Native 方法(調用本地操作系統方法)時,該計數器的值為空。
- 該內存區域是唯一一個在 Java 虛擬機規范中么有規定任何 OOM(內存溢出:OutOfMemoryError)情況的區域。
Java 虛擬機棧
- 該區域也是線程私有的,它的生命周期也與線程相同。
- 虛擬機棧描述的是 Java 方法執行的內存模型:每個方法被執行的時候都會同時創建一個棧幀,棧它是用于支持續虛擬機進行方法調用和方法執行的數據結構。
- 對于執行引擎來講,活動線程中,只有棧頂的棧幀是有效的,稱為當前棧幀,這個棧幀所關聯的方法稱為當前方法,執行引擎所運行的所有字節碼指令都只針對當前棧幀進行操作。
- 棧幀用于存儲局部變量表、操作數棧、動態鏈接、方法返回地址和一些額外的附加信息。
- 在編譯程序代碼時,棧幀中需要多大的局部變量表、多深的操作數棧都已經完全確定了,并且寫入了方法表的 Code 屬性之中。因此,一個棧幀需要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決于具體的虛擬機實現。
Java 虛擬機棧的異常
- 在 Java 虛擬機規范中,對這個區域規定了兩種異常情況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出StackOverflowError異常。如果虛擬機在動態擴展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常。
- 這兩種情況存在著一些互相重疊的地方:當棧空間無法繼續分配時,到底是內存太小,還是已使用的??臻g太大,其本質上只是對同一件事情的兩種描述而已。在單線程的操作中,無論是由于棧幀太大,還是虛擬機??臻g太小,當??臻g無法分配時,虛擬機拋出的都是 StackOverflowError 異常,而不會得到 OutOfMemoryError 異常。而在多線程環境下,則會拋出 OutOfMemoryError 異常。
局部變量表
- 局部變量表是一組變量值存儲空間,用于存放方法參數和方法內部定義的局部變量,其中存放的數據的類型是編譯期可知的各種基本數據類型、對象引用(reference)和 returnAddress 類型(它指向了一條字節碼指令的地址)。局部變量表所需的內存空間在編譯期間完成分配,即在 Java 程序被編譯成 Class 文件時,就確定了所需分配的最大局部變量表的容量。當進入一個方法時,這個方法需要在棧中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。
- 局部變量表的容量以變量槽(Slot)為最小單位。在虛擬機規范中并沒有明確指明一個 Slot 應占用的內存空間大?。ㄔ试S其隨著處理器、操作系統或虛擬機的不同而發生變化),一個 Slot 可以存放一個32位以內的數據類型:boolean、byte、char、short、int、float、reference 和 returnAddresss。reference 是對象的引用類型,returnAddress 是為字節指令服務的,它執行了一條字節碼指令的地址。對于 64 位的數據類型(long和double),虛擬機會以高位在前的方式為其分配兩個連續的 Slot 空間。
- 虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從 0 開始到局部變量表最大的 Slot 數量,對于 32 位數據類型的變量,索引 n 代表第 n 個 Slot,對于 64 位的,索引 n 代表第 n 和第 n+1 兩個 Slot。
- 在方法執行時,虛擬機是使用局部變量表來完成參數值到參數變量列表的傳遞過程的,如果是實例方法(非static),則局部變量表中的第 0 位索引的 Slot 默認是用于傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字“this”來訪問這個隱含的參數。其余參數則按照參數表的順序來排列,占用從1開始的局部變量 Slot,參數表分配完畢后,再根據方法體內部定義的變量順序和作用域分配其余的 Slot。
- 局部變量表中的 Slot 是可重用的,方法體中定義的變量,作用域并不一定會覆蓋整個方法體,如果當前字節碼PC計數器的值已經超過了某個變量的作用域,那么這個變量對應的 Slot 就可以交給其他變量使用。這樣的設計不僅僅是為了節省空間,在某些情況下 Slot 的復用會直接影響到系統的而垃圾收集行為。
操作數棧
- 操作數棧又常被稱為操作棧,操作數棧的最大深度也是在編譯的時候就確定了。32 位數據類型所占的棧容量為 1,64 位數據類型所占的棧容量為 2。當一個方法開始執行時,它的操作棧是空的,在方法的執行過程中,會有各種字節碼指令(比如:加操作、賦值元算等)向操作棧中寫入和提取內容,也就是入棧和出棧操作。
- Java 虛擬機的解釋執行引擎稱為“基于棧的執行引擎”,其中所指的“棧”就是操作數棧。因此我們也稱 Java 虛擬機是基于棧的,這點不同于 Android 虛擬機,Android 虛擬機是基于寄存器的。
- 基于棧的指令集最主要的優點是可移植性強,主要的缺點是執行速度相對會慢些;而由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的優點是執行速度快,主要的缺點是可移植性差。
動態連接
- 每個棧幀都包含一個指向運行時常量池(在方法區中,后面介紹)中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接。Class 文件的常量池中存在有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數。這些符號引用,一部分會在類加載階段或第一次使用的時候轉化為直接引用(如 final、static 域等),稱為靜態解析,另一部分將在每一次的運行期間轉化為直接引用,這部分稱為動態連接。
方法返回地址
- 當一個方法被執行后,有兩種方式退出該方法:執行引擎遇到了任意一個方法返回的字節碼指令或遇到了異常,并且該異常沒有在方法體內得到處理。無論采用何種退出方式,在方法退出之后,都需要返回到方法被調用的位置,程序才能繼續執行。方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的 PC 計數器的值就可以作為返回地址,棧幀中很可能保存了這個計數器值,而方法異常退出時,返回地址是要通過異常處理器來確定的,棧幀中一般不會保存這部分信息。
- 方法退出的過程實際上等同于把當前棧幀出站,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,如果有返回值,則把它壓入調用者棧幀的操作數棧中,調整 PC 計數器的值以指向方法調用指令后面的一條指令。
本地方法棧
- 該區域與虛擬機棧所發揮的作用非常相似,只是虛擬機棧為虛擬機執行 Java 方法服務,而本地方法棧則為使用到的本地操作系統(Native)方法服務。
Java 堆
- Java Heap 是 Java 虛擬機所管理的內存中最大的一塊,它是所有線程共享的一塊內存區域。幾乎所有的對象實例和數組都在這類分配內存。Java Heap 是垃圾收集器管理的主要區域,因此很多時候也被稱為“GC堆”。
- 根據 Java 虛擬機規范的規定,Java 堆可以處在物理上不連續的內存空間中,只要邏輯上是連續的即可。如果在堆中沒有內存可分配時,并且堆也無法擴展時,將會拋出 OutOfMemoryError 異常。
方法區
- 方法區也是各個線程共享的內存區域,它用于存儲已經被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。
- 方法區域又被稱為“永久代”,但這僅僅對于 Sun HotSpot 來講,JRockit 和 IBM J9 虛擬機中并不存在永久代的概念。
- Java 虛擬機規范把方法區描述為 Java 堆的一個邏輯部分,而且它和 Java Heap 一樣不需要連續的內存,可以選擇固定大小或可擴展,另外,虛擬機規范允許該區域可以選擇不實現垃圾回收。相對而言,垃圾收集行為在這個區域比較少出現。該區域的內存回收目標主要針是對廢棄常量的和無用類的回收。
- 運行時常量池是方法區的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Class文件常量池),用于存放編譯器生成的各種字面量和符號引用,這部分內容將在類加載后存放到方法區的運行時常量池中。
- 運行時常量池相對于 Class 文件常量池的另一個重要特征是具備動態性,Java 語言并不要求常量一定只能在編譯期產生,也就是并非預置入 Class 文件中的常量池的內容才能進入方法區的運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用比較多的是 String 類的 intern()方法。
直接內存
- 直接內存并不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規范中定義的內存區域,它直接從操作系統中分配,因此不受 Java 堆大小的限制,但是會受到本機總內存的大小及處理器尋址空間的限制,因此它也可能導致 OutOfMemoryError 異常出現。
- 在 JDK1.4 中新引入了 NIO 機制,它是一種基于通道與緩沖區的新 I/O 方式,可以直接從操作系統中分配直接內存,即在堆外分配內存,這樣能在一些場景中提高性能,因為避免了在 Java 堆和 Native 堆中來回復制數據。
- 根據 Java 虛擬機規范的規定,當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError 異常。
內存溢出
- 在多線程情況下,給每個線程的棧分配的內存越大,反而越容易產生內存溢出異常。操作系統為每個進程分配的內存是有限制的,虛擬機提供了參數來控制 Java 堆和方法區這兩部分內存的最大值,忽略掉程序計數器消耗的內存(很?。?,以及進程本身消耗的內存,剩下的內存便給了虛擬機棧和本地方法棧,每個線程分配到的棧容量越大,可以建立的線程數量自然就越少。因此,如果是建立過多的線程導致的內存溢出,在不能減少線程數的情況下,就只能通過減少最大堆和每個線程的棧容量來換取更多的線程。
- 內存泄露是指分配出去的內存沒有被回收回來,由于失去了對該內存區域的控制,因而造成了資源的浪費。Java 中一般不會產生內存泄露,因為有垃圾回收器自動回收垃圾,但這也不絕對,當我們 new 了對象,并保存了其引用,但是后面一直沒用它,而垃圾回收器又不會去回收它,這邊會造成內存泄露,
- 內存溢出是指程序所需要的內存超出了系統所能分配的內存(包括動態擴展)的上限。
對象實例化分析
Object obj = new Object();
- 假設該語句出現在方法體中,obj 會作為引用類型(reference)的數據保存在 Java 棧的本地變量表中,而會在 Java 堆中保存該引用的實例化對象,Java 堆中還包含能查找到此對象類型數據的地址信息(如對象類型、父類、實現的接口、方法等),這些類型數據則保存在方法區中。
-
由于 reference 類型在 Java 虛擬機規范里面只規定了一個指向對象的引用,并沒有定義這個引用應該通過哪種方式去定位,以及訪問到 Java 堆中的對象的具體位置,因此不同虛擬機實現的對象訪問方式會有所不同,主流的訪問方式有兩種:使用句柄池和直接使用指針。這兩種對象的訪問方式各有優勢,使用句柄訪問方式的最大好處就是 reference 中存放的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。使用直接指針訪問方式的最大好處是速度快,它節省了一次指針定位的時間開銷。目前 Java 默認使用的 HotSpot 虛擬機采用的便是是第二種方式進行對象訪問的。
句柄池訪問
直接指針訪問
類文件結構
- Class 文件是一組以8位字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地排列在 Class 文件中,中間沒有添加任何分隔符,這使得整個 Class 文件中存儲的內容幾乎全部都是程序運行的必要數據。
- 根據 Java 虛擬機規范的規定,Class 文件格式采用一種類似于 C 語言結構體的偽結構來存儲,這種偽結構中只有兩種數據類型:無符號數和表。無符號數屬于基本數據類型,以 u1、u2、u4、u8 來分別代表 1、2、4、8 個字節的無符號數。表是由多個無符號數或其他表作為數據項構成的符合數據類型,所有的表都習慣性地以“_info”結尾。
magic 與 version
- 每個 Class 文件的頭 4 個字節稱為魔數(magic),它的唯一作用是判斷該文件是否為一個能被虛擬機接受的 Class 文件。它的值固定為 0xCAFEBABE。緊接著 magic 的 4 個字節存儲的是 Class 文件的次版本號和主版本號,高版本的 JDK 能向下兼容低版本的 Class 文件,但不能運行更高版本的 Class 文件。
類初始化
- 遇到 new、getstatic、putstatic、invokestatic 這四條字節碼指令時,如果類還沒有進行過初始化,則需要先觸發其初始化。生成這四條指令最常見的 Java 代碼場景是:使用 new 關鍵字實例化對象時、讀取或設置一個類的靜態字段(static)時(被 static 修飾又被 final 修飾的,已在編譯期把結果放入常量池的靜態字段除外)、以及調用一個類的靜態方法時。
- 使用 Java.lang.refect 包的方法對類進行反射調用時,如果類還沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類,虛擬機會先執行該主類。
- 通過子類引用父類中的靜態字段,這時對子類的引用為被動引用,因此不會初始化子類,只會初始化父類:
- 常量在編譯階段會存入調用它的類的常量池中,本質上沒有直接引用到定義該常量的類,因此不會觸發定義常量的類的初始化
- 通過數組定義來引用類,不會觸發類的初始化但是會觸發了另一個名為“LLConst”的類的初始化,它是一個由虛擬機自動生成的、直接繼承于java.lang.Object 的子類,創建動作由字節碼指令 newarray 觸發,很明顯,這是一個對數組引用類型的初初始化,而該數組中的元素僅僅包含一個對 Const 類的引用,并沒有對其進行初始化。如果我們加入對 con 數組中各個 Const 類元素的實例化代碼,便會觸發 Const 類的初始化
- 接口也有初始化過程,在接口中不能使用“static{}”語句塊,但編譯器仍然會為接口生成類構造器,用于初始化接口中定義的成員變量(實際上是 static final 修飾的全局常量)。二者在初始化時最主要的區別是:當一個類在初始化時,要求其父類全部已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量),才會初始化該父接口。這點也與類初始化的情況很不同,調用類中的 static final 常量時并不會 觸發該類的初始化,但是調用接口中的 static final 常量時便會觸發該接口的初始化。
類加載機制 ???
多態性實現機制——靜態分派與動態分派 ???
Java 語法糖
語法糖(Syntactic Sugar),也稱糖衣語法,是由英國計算機學家 Peter.J.Landin 發明的一個術語,指在計算機語言中添加的某種語法,這種語法對語言的功能并沒有影響,但是更方便程序員使用。
Java 中最常用的語法糖主要有泛型、變長參數、條件編譯、自動拆裝箱、內部類等。虛擬機并不支持這些語法,它們在編譯階段就被還原回了簡單的基礎語法結構,這個過程成為解語法糖。
泛型是 JDK1.5 之后引入的一項新特性,Java 語言在還沒有出現泛型時,只能通過 Object 是所有類型的父類和類型強制轉換這兩個特點的配合來實現泛型的功能,這樣實現的泛型功能要在程序運行期才能知道 Object 真正的對象類型,在 javac 編譯期,編譯器無法檢查這個 Object 的強制轉型是否成功,這便將一些風險轉接到了程序運行期中。Java 語言在 JDK1.5 之后引入的泛型實際上只在程序源碼中存在,在編譯后的字節碼文件中,就已經被替換為了原來的原生類型,并且在相應的地方插入了強制轉型代碼,所以泛型技術實際上是 Java 語言的一顆語法糖,Java 語言中的泛型實現方法稱為類型擦除,基于這種方法實現的泛型被稱為偽泛型。
javac 編譯
- javac 編譯器稱為前端編譯器,將.java文件編譯成為.class文件。相對應的還有后端編譯器,它在程序運行期間將字節碼轉變成機器碼(現在的 Java 程序在運行時基本都是解釋執行加編譯執行),如 HotSpot 虛擬機自帶的 JIT(Just In Time Compiler)編譯器(分 Client 端和 Server 端)。
詞法、語法分析
- 詞法分析是將源代碼的字符流轉變為標記(Token)集合。單個字符是程序編寫過程中的的最小元素,而標記則是編譯過程的最小元素,關鍵字、變量名、字面量、運算符等都可以成為標記,比如整型標志 int 由三個字符構成,但是它只是一個標記,不可拆分。
- 語法分析是根據Token序列來構造抽象語法樹的過程。抽象語法樹是一種用來描述程序代碼語法結構的樹形表示方式,語法樹的每一個節點都代表著程序代碼中的一個語法結構,如 bao、類型、修飾符、運算符等。經過這個步驟后,編譯器就基本不會再對源碼文件進行操作了,后續的操作都建立在抽象語法樹之上。
填充符號表
- 完成了語法分析和詞法分析之后,下一步就是填充符號表的過程。符號表是由一組符號地址和符號信息構成的表格。符號表中所登記的信息在編譯的不同階段都要用到,在語義分析中,符號表所登記的內容將用于語義檢查和產生中間代碼,在目標代碼生成階段,黨對符號名進行地址分配時,符號表是地址分配的依據。
語義分析
- 語法樹能表示一個結構正確的源程序的抽象,但無法保證源程序是符合邏輯的。而語義分析的主要任務是讀結構上正確的源程序進行上下文有關性質的審查。語義分析過程分為標注檢查和數據及控制流分析兩個步驟:
- 標注檢查步驟檢查的內容包括諸如變量使用前是否已被聲明、變量和賦值之間的數據類型是否匹配等。
- 數據及控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。
字節碼生成
- 字節碼生成是 javac 編譯過程的最后一個階段。字節碼生成階段不僅僅是把前面各個步驟所生成的信息轉化成字節碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。 實例構造器()方法和類構造器()方法就是在這個階段添加到語法樹之中的(這里的實例構造器并不是指默認的構造函數,而是指我們自己重載的構造函數,如果用戶代碼中沒有提供任何構造函數,那編譯器會自動添加一個沒有參數、訪問權限與當前類一致的默認構造函數,這個工作在填充符號表階段就已經完成了)。
JIT 編譯
- Java 程序最初是僅僅通過解釋器解釋執行的,即對字節碼逐條解釋執行,這種方式的執行速度相對會比較慢,尤其當某個方法或代碼塊運行的特別頻繁時,這種方式的執行效率就顯得很低。于是后來在虛擬機中引入了 JIT 編譯器(即時編譯器),當虛擬機發現某個方法或代碼塊運行特別頻繁時,就會把這些代碼認定為“Hot Spot Code”(熱點代碼),為了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成與本地平臺相關的機器碼,并進行各層次的優化,完成這項任務的正是 JIT 編譯器。
- HotSpot 虛擬機中內置了兩個JIT編譯器:Client Complier 和 Server Complier,分別用在客戶端和服務端,目前主流的 HotSpot 虛擬機中默認是采用解釋器與其中一個編譯器直接配合的方式工作。
運行過程中會被即時編譯器編譯的“熱點代碼”有兩類:
- 被多次調用的方法。
- 被多次調用的循環體。
目前主要的熱點判定方式
- 基于采樣的熱點探測:采用這種方法的虛擬機會周期性地檢查各個線程的棧頂,如果發現某些方法經常出現在棧頂,那這段方法代碼就是“熱點代碼”。這種探測方法的好處是實現簡單高效,還可以很容易地獲取方法調用關系,缺點是很難精確地確認一個方法的熱度,容易因為受到線程阻塞或別的外界因素的影響而擾亂熱點探測。
- 基于計數器的熱點探測:采用這種方法的虛擬機會為每個方法,甚至是代碼塊建立計數器,統計方法的執行次數,如果執行次數超過一定的閥值,就認為它是“熱點方法”。這種統計方法實現復雜一些,需要為每個方法建立并維護計數器,而且不能直接獲取到方法的調用關系,但是它的統計結果相對更加精確嚴謹。
在 HotSpot 虛擬機的熱點判定方式
- 在 HotSpot 虛擬機中使用的是基于計數器的熱點探測方法,因此它為每個方法準備了兩個計數器:方法調用計數器和回邊計數器。
- 方法調用計數器用來統計方法調用的次數,在默認設置下,方法調用計數器統計的并不是方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間內方法被調用的次數。
- 回邊計數器用于統計一個方法中循環體代碼執行的次數(準確地說,應該是回邊的次數,因為并非所有的循環都是回邊),在字節碼中遇到控制流向后跳轉的指令就稱為“回邊”。
- 在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閥值,當計數器的值超過了閥值,就會觸發JIT編譯。觸發了 JIT 編譯后,在默認設置下,執行引擎并不會同步等待編譯請求完成,而是繼續進入解釋器按照解釋方式執行字節碼,直到提交的請求被編譯器編譯完成為止(編譯工作在后臺線程中進行)。當編譯工作完成后,下一次調用該方法或代碼時,就會使用已編譯的版本。
對象引用
Java 中的垃圾回收一般是在 Java 堆中進行,因為堆中幾乎存放了 Java 中所有的對象實例。在 JDK1.2 之前,Java 中的引用定義很很純粹:如果 reference 類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊數據代表著一個引用。但在 JDK1.2 之后,Java 對引用的概念進行了擴充,將其分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種,引用強度依次減弱。
- 強引用:如“Object obj = new Object()”,這類引用是 Java 程序中最普遍的。只要強引用還存在,垃圾收集器就永遠不會回收掉被引用的對象。
- 軟引用:它用來描述一些可能還有用,但并非必須的對象。在系統內存不夠用時,這類引用關聯的對象將被垃圾收集器回收。JDK1.2 之后提供了 SoftReference 類來實現軟引用。
- 弱引用:它也是用來描述非需對象的,但它的強度比軟引用更弱些,被弱引用關聯的對象只能生存島下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在 JDK1.2 之后,提供了 WeakReference 類來實現弱引用。
- 虛引用:最弱的一種引用關系,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的是希望能在這個對象被收集器回收時收到一個系統通知。JDK1.2 之后提供了 PhantomReference 類來實現虛引用。
垃圾對象的判定
引用計數算法
- 給對象添加一個引用計數器,每當有一個地方引用它時,計數器值就加 1,當引用失效時,計數器值就減1,任何時刻計數器都為 0 的對象就是不可能再被使用的。
- 引用計數算法的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的選擇,當 Java 語言并沒有選擇這種算法來進行垃圾回收,主要原因是它很難解決對象之間的相互循環引用問題。
根搜索算法
- 這種算法的基本思路是通過一系列名為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連時,就證明此對象是不可用的。Java 和 C# 中都是采用根搜索算法來判定對象是否存活的。
- 在根搜索算法中,要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行根搜索后發現沒有與 GC Roots 相連接的引用鏈,那它會被第一次標記并且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize()方法。當對象沒有覆蓋 finalize()方法,或 finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視為沒有必要執行。如果該對象被判定為有必要執行 finalize()方法,那么這個對象將會被放置在一個名為 F-Queue 隊列中,并在稍后由一條由虛擬機自動建立的、低優先級的 Finalizer 線程去執行 finalize()方法。finalize()方法是對象逃脫死亡命運的最后一次機會(因為一個對象的 finalize()方法最多只會被系統自動調用一次),稍后 GC 將對 F-Queue 中的對象進行第二次小規模的標記,如果要在 finalize()方法中成功拯救自己,只要在 finalize()方法中讓該對象重引用鏈上的任何一個對象建立關聯即可。而如果對象這時還沒有關聯到任何鏈上的引用,那它就會被回收掉。
垃圾收集算法
標記—清除算法
- 標記—清除算法是最基礎的收集算法,它分為“標記”和“清除”兩個階段:首先標記出所需回收的對象,在標記完成后統一回收掉所有被標記的對象,它的標記過程其實就是前面的根搜索算法中判定垃圾對象的標記過程。(會造成大量的內存碎片)
標記—整理算法
- 復制算法比較適合于新生代,在老年代中,對象存活率比較高,如果執行較多的復制操作,效率將會變低,所以老年代一般會選用其他算法,如標記—整理算法。該算法標記的過程與標記—清除算法中的標記過程一樣,但對標記后出的垃圾對象的處理情況有所不同,它不是直接對可回收對象進行清理,而是讓所有的對象都向一端移動,然后直接清理掉端邊界以外的內存。(不會產生內存碎片,成本相對較高)
分代收集
- 當前商業虛擬機的垃圾收集 都采用分代收集,它根據對象的存活周期的不同將內存劃分為幾塊,一般是把 Java 堆分為新生代和老年代。在新生代中,每次垃圾收集時都會發現有大量對象死去,只有少量存活,因此可選用復制算法來完成收集,而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用標記—清除算法或標記—整理算法來進行回收。
垃圾收集器
內存的分配策
- 對象優先在 Eden 分配。
- 大對象直接進入老年代。
- 長期存活的對象將進入老年代。
垃圾回收策略
- 新生代 GC(Minor GC):發生在新生代的垃圾收集動作,因為 Java 對象大多都具有朝生夕滅的特性,因此Minor GC 非常頻繁,一般回收速度也比較快。
- 老年代 GC(Major GC/Full GC):發生在老年代的 GC,出現了 Major GC,經常會伴隨至少一次 Minor GC。由于老年代中的對象生命周期比較長,因此 Major GC 并不頻繁,一般都是等待老年代滿了后才進行 Full GC,而且其速度一般會比 Minor GC 慢 10 倍以上。另外,如果分配了 Direct Memory,在老年代中進行 Full GC時,會順便清理掉 Direct Memory 中的廢棄對象。
性能調優
- 我們可以通過給 Java 虛擬機分配超大堆(前提是物理機的內存足夠大)來提升服務器的響應速度,但分配超大堆的前提是有把握把應用程序的 Full GC 頻率控制得足夠低,因為一次 Full GC 的時間造成比較長時間的停頓。控制 Full GC 頻率的關鍵是保證應用中絕大多數對象的生存周期不應太長,尤其不能產生批量的、生命周期長的大對象,這樣才能保證老年代的穩定。
- Direct Memory 在堆內存外分配,而且二者均受限于物理機內存,且成負相關關系,因此分配超大堆時,如果用到了 NIO 機制分配使用了很多的 Direct Memory,則有可能導致 Direct Memory 的 OutOfMemoryError 異常,這時可以通過 -XX:MaxDirectMemorySize 參數調整 Direct Memory 的大小。