1. 程式人生 > >深入理解JVM

深入理解JVM

垃圾收集 解釋 適合 由來 周期 min CA 語言 行為

最近在看周誌明的《深入理解Java虛擬機》,寫的真是太棒了,簡直是讓我打開了新世界的大門,JVM 的世界真是豐富多彩啊!還有......特別的復雜。

運行時數據區域

首先從 JVM 運行時數據區域的劃分來說起

技術分享圖片

程序計數器

程序計數器是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器 ,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼命令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

Java 虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,在任意的一個確定的時刻,一個處理器(對於多核處理器來說就是一個內核)都只會執行一條線程中的指令,為了能使線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有

”的內存。

特別的,如果正在執行的是 Native 方法,這個計數器值則為空。

Java虛擬機棧

與程序計數器一樣,Java虛擬機棧也是線程私有的,它的生命周期與線程相同,虛擬機棧是描述 Java 方法執行的內存模型。
每個方法在執行的時候會創建一個棧幀,用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。

很多人把 Java 內存區分為堆內存和棧內存,這種方法比較粗糙,這裏的棧指的就是虛擬機棧了,或者說是虛擬機棧中局部變量表部分。

局部變量表存放了編譯期可知的各種基本數據類型、對象引用和 returnAddress 類型(指向了一條字節碼指令的地址)

局部變量所需的內存空間在編譯期間完成分配

,當進入一個方法時,這個方法需要在棧中分配多大的局部變量空間是完全確定的,在方法的運行期間不會改變局部變量表的大小

在 Java 虛擬機規範中,對這個區域規定了兩種異常狀況,一種是線程請求棧深度超過了允許的最大深度拋出 StackOverflowError 異常;現在的虛擬機大多支持動態擴展,如果擴展時無法申請到足夠的內存就會拋出 OutOfMemory 異常。

本地方法棧

本地方法棧與虛擬機棧所發揮的作用是非常相似的,他們之間的區別不過是虛擬機棧為虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務。

虛擬機規範中對本地方法棧中方法使用的語言、使用方式和數據結構沒有強制規定,虛擬機可自由實現它,有的虛擬機(比如 Sun HotSpot)直接就把本地方法棧和虛擬機棧合二為一

與虛擬機棧一樣,也會拋出兩個異常,和上面一致。

Java堆

對於多大數的應用,Java 堆是 Java 虛擬機所管理的內存中的最大的一塊

Java 堆是被所有線程共享的一塊內存區域在虛擬機啟動時創建,此內存區域的唯一目的就是來存放對象實例,幾乎所有的對象實例都在這裏分配內存。

Java 虛擬機規範中:所有對象實例以及數組都要在堆上分配

但是隨著 JIT 編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有對象都分配在堆上也漸漸變得不是那麽絕對了。

Java 堆是 GC 管理的主要區域,因此很多時候稱作 GC堆(不叫垃圾堆),現在的收集器大部分都采用的是分代收集算法,所以 Java 堆中還可以細分為:新生代和老年代

線程共享的 Java 堆中可能劃分出多個線程私有的分配緩沖區,進一步劃分的目的是為了更好的垃圾回收,或者更快的分配內存。

根據 Java 虛擬機的規範:Java 堆可以處於物理上不連續的內存空間中,只要邏輯上連續就可以了,就像我們的磁盤空間一樣;

在實現時,可以是固定的大小,也可以是可擴展的,不過主流的虛擬機都是安裝可擴展來實現的(通過 -Xmx 和 -Xms 控制)

方法區

方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

Java 虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap (非堆),目的是與 Java 堆區分開來。

很多開發者更願意把方法區稱為“永久代”,本質上兩者並不等價,僅僅是因為 HotSpot 虛擬機的設計團隊選擇把 GC 分代收集擴展至方法區,或者說使用永久代來實現方法區而已,這樣 HotSpot 的 GC 就可以像管理 Java 堆一樣來管理這部分內存了,能夠省去·專門為方法區編寫內存管理代碼的工作。

對於其他虛擬機來說(比如 J9),是不存在永久代的概念的。

使用永久代來實現方法區,現在看來並不是一個好主意,因為這樣更容易遇到內存溢出問題

對於 HotSpot 現在也基本放棄永久代並逐步改為采用 Native Memory 來實現方法區,在 JDK7 的 HotSpot 中,已經把原本放在永久代的字符串常量池移出到了堆中。

Java 虛擬機規範對方法區的限制非常寬松,除了和 Java 堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。

相對而言,GC 在這裏較少出現,但並非數據進入方法區就如同永久代名字一樣永久存在了,這個區域的內存回收主要針對常量池的回收和類型的卸載(回收效果比較難令人滿意),所以這部分區域的回收確實是必要的。

運行時常量池

運行時常量池是方法區的一部分

Class 文件中除了有類的版本、字段、方法、接口、等描述信息,還有一項信息是常量池,用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放

一般來說,除了保存 Class 文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中

運行時常量池對於 Class 文件常量池的另外一個重要特征是具備動態性,Java 語言並不是要求常量一定只有編譯期才能產生,也就是並非預置入 Class 文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中

直接內存

直接內存並不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的內存區域,但是這部分內存也被頻繁的使用,也會導致 OOM。

在 JDK1.4 加入了 NIO,可以使用 Native 函數庫直接分配堆外內存,通過一個存儲在堆中的 DirectByteBuffer 對象作為這塊內存的引用進行操作,性能有了顯著提高,因為避免了在 Java 堆和 Native 堆中來回復制數據。

顯然,本機直接內存的分配不會受到 Java 堆大小的限制(很多管理員經常忽略直接內存的配置,導致出現 OOM)

對象的創建

這裏討論的是普通的 Java 對象,不包括數組和 Class 對象。

當虛擬機遇到一條 new 指令時,首先去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被加載、解析、和初始化過,如果沒有,那必須先執行相應的類加載過程。

在類加載檢查通過後,然後就是給新生對象分配內存了,對象所需的內存大小在類加載完成後便可以完全確定,那麽接下來就是從堆中劃分一塊空間了:

  • 假設 Java 堆中的內存是絕對規整的,就是所有用過的在一邊,空閑的在另一邊,中間放個指針作為指示器,這樣只需要移動這個指針就可以分配指定的內存,這種方式也稱為“指針碰撞”。
  • 如果 Java 堆中的內存並不是規整的,虛擬機就必須維護一個列表,記錄那些內存塊是可用的,在分配時,從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種方式稱為“空閑列表”。

選擇那種方式由 Java 堆是否規整來決定,是否規整又與采用的垃圾收集器是否帶有壓縮整理的功能決定,對象創建在虛擬機中是非常頻繁的行為

(虛擬機采用 CAS 配上失敗重試的方式保證更新操作的原子性)

內存分配完成後,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),保證了對象的實例字段在 Java 代碼中可以不賦初始值就可以使用。

接下來,虛擬機要對對象進行必要的設置,例如這個對象是那個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息,這些信息存放在對象的對象頭中。

上面的工作完成後,從虛擬機的角度來看,一個新的對象就已經產生了,但從 Java 程序的視角來看,對象的創建才剛剛開始,然後會執行 init 方法,把對象按照程序員的意願進行初始化。

對象的訪問定位

建立對象是為了使用對象,我們的 Java 程序需要通過棧上的 reference 數據來操作堆上的具體對象。

目前主流的訪問方式由使用句柄和直接指針兩種:

技術分享圖片


技術分享圖片

  • 如果使用句柄訪問的話,那麽 Java 堆中將會劃分出一塊內存來作為句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的地址信息。
  • 如果使用直接指針訪問,那麽 Java 堆對象的布局中就必須考慮如何放置訪問類型數據的相關信息,而 reference 中存儲的直接就是對象地址。

這兩種對象訪問方式各有優勢,使用句柄最大的好處是 reference 中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。

使用直接指針訪問方式最大的好處就是速度更快,它節省了一次指針定位的時間開銷(對象訪問在 Java 中非常頻繁,這類開銷極少成多是很可觀的執行成本)。

就 Sun HotSpot 來說,使用的是第二種方式進行對象訪問,第一種也很常見。

在 Windows 平臺的虛擬機中,Java 的線程是映射到操作系統的內核線程上的

垃圾收集器

其中,程序計數器、虛擬機棧、本地方法棧這三個區域隨線程而生,隨線程而滅;這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或者線程結束時,內存自然就隨著回收了。

所以我們著重來研究下 Java 堆的垃圾收集

判斷對象死活

常見的判斷對象是否還有用的方法有兩種,引用計數算法和可達性分析算法;在流行的 JVM 中,都是選用的後者,前者的實現更簡單些。

引用計數算法

很多教科書中是這樣寫的,給對象添加一個引用計數器,當一個地方引用它時,計數器的值就加一,當引用失效時就減一,任何時刻計數器為 0 的對象就是不可能再被使用的。

引用計數法的實現很簡單,判定率也很高,微軟的 COM 技術用的就是它,但是至少主流的 JVM 裏面沒有選用引用計數算法來管理內存,其中主要的原因是它很難解決對象之間互相循環引用的問題。

比如:對象 A 和對象 B 都有字段 instance ,賦值 A.instance = B; B.instance = A; ,除此之外兩個對象再無任何引用,實際上這兩個對象已經不可能再被訪問了,但是它們互相引用著對方,導致引用計數器不為 0 ,於是引用計數算法無法通知 GC 回收它們。

可達性分析算法

在主流的商用程序語言的主流實現中,都是通過可達性分析來判定對象是否存活的,基本思路是:

通過一系列稱為 GC Roots 的對象作為起始點,從這些節點開始往下搜索,搜索所走過的路徑稱為引用鏈,當一個對象到 GC Roots 沒有任何相連的引用鏈(不可達),則證明此對象是不可用的。

在 Java 語言中,可作為 GC Roots 的對象包括下面幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象
  • 本地方法棧中 JNI(即一般說的 Native 方法)引用的對象

關於引用

判定對象是否存活都與引用有關

還有一類對象:當內存空間還足夠時,則能保留在內存中;如果內存空間在進行垃圾回收後還是非常緊張,則可以拋棄這些對象,緩存功能就很符合這樣的場景。

在 JDK1.2 後,Java 對引用的概念進行了擴充,將引用分為:強引用、軟引用、弱引用、虛引用

  • 強引用是指在程序代碼中普遍存在的,類似 Object o = new Object(); 這類引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。

  • 軟引用來描述一些還有用但非必須的對象
    在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收,在 JDK1.2 後,提供了 WeakReference 類來實現弱引用。

  • 虛引用也稱為幽靈引用或者幻影引用,是最弱的一種引用關系。
    一個對象是否有虛引用的存在,完全不會對其生存時間構成影響;也無法通過虛引用來取得一個對象實例。
    設置虛引用的唯一目的就是能在這個對象被垃圾收集器回收時收到一個系統通知。
    在 JDK1.2 之後,提供了 PhantomReference 類來實現虛引用。

生存還是死亡

即使在可達性算法中不可達的對象也並非是“非死不可”的,這時候他們暫時處於“緩刑”階段,要真正宣告一個對象的死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與 GC Roots 相連接的引用鏈,那它就會被第一次標記且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法,當對象沒有覆蓋 finalize 方法或者此方法已經被虛擬機調用過,虛擬機將這兩種情況視為“沒有必要執行”。

如果這個對象被判定為有必要執行 finalize 方法,那麽這個對象將會放置在一個叫做 F-Queue 的隊列中,並在稍後由一個虛擬機自動建立、低級優先級的 Finalizer 線程去執行它。但是並不會承諾會等待方法運行結束

如果一個對象在 finalize 方法執行緩慢,或者發生了死循環,可能會導致 F-Queue 隊列中其他對象永久處於等待,甚至整個內存回收系統的崩潰。

finalize 方法是對象逃脫死亡命運的最後一次機會,稍後 GC 會對隊列中的對象進行第二次小規模的標記,如果對象在 finalize 方法中成功拯救自己(比如把自己 this 賦值給某個類變量或者對象的成員變量),那麽在第二次標記時就會把它移出“即將回收”的集合,但是一個對象的 finalize 方法只會被調用一次

建議盡量避免使用它,它的運行代價高昂,不確定性大,無法保證各個對象的調用順序;

有些人說適合做“關閉外部資源”之類的工作,這其實是一種自我安慰,使用 try-finally 或者其他方式都可以做到更好、更及時。

回收方法區

在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收 70% - 95% 的空間,永久代(方法區)的效率遠低於此。

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

例如一個字符串 “abc” 進入了常量池中,但是當前系統沒有任何一個 String 對象叫做 "abc" 的,就是沒有任何對象引用它,這時候就可以進行回收了,常量池中的其他類(接口)、方法、字段的符號引用也與此類似。

如何判斷一個類是無用類呢:

  • 該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例
  • 加載該類的類加載器已經被回收了
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機可以對滿足上述三個條件的無用類進行回收,這裏僅僅是說的可以,而並不是和對象一樣,不使用了就必然會被回收。

在大量使用反射、動態代理、CGLib 等字節碼技術的框架、動態生成 JSP 等場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。

垃圾收集算法

常見的垃圾收集算法有四種,準確的說其實是三個,他們都各有用處

標記清除算法

最基礎的收集算法是 “標記-清除” 算法,如它的名字,分為標記和清除兩個階段:

首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象,標記過程在上面已經說了(可達性分析),它主要有兩個不足的地方:

  • 效率問題,標記和清除兩個過程的效率都不高;
  • 空間問題,標記清除之後會產生大量的不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大的對象時,無法找到足夠連續的內存而不得不提前觸發另一次垃圾收集動作。

技術分享圖片

復制算法

為了解決效率問題,一種稱為復制的收集算法出現了,它將可用內存按照容量劃分為大小相等的兩塊,每次只使用其中的一塊。

當這一塊的內存用完了,就將還存活著的對象復制到另一塊上面,然後把已使用過的內存空間一次清理掉。

這樣使得每次都對整個半區進行內存回收,不用考慮碎片等復雜情況,實現簡單運行高效。

但是這種算法的代價是將內存縮小為了原來的一半,未免也太高了點。

技術分享圖片

現在商業虛擬機都采用這樣收集算法來回收新生代,IBM 公司研究表明,新生代中的對象 98% 是“朝生夕死”的,所以並不需要 1:1 的比例來分配內存空間,而是將內存分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor (IBM 的研究只是說明了這種布局的意義所在,HotSpot 一開始就是這種布局)。

當回收時,將 Eden 和 Survivor 中還存活的對象一次性的復制到另一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間,HotSpot 虛擬機默認 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用內存空間為整個新生代的 90%(80% + 10%),也就是說只有 10% 的內存會被浪費。

我們沒有辦法保證每次回收都只有不多於 10% 的對象存活,當 Survivor 空間不夠用時,需要依賴其他內存(老年代)進行分配擔保。

如果有一塊 Survivor 空間沒有足夠的空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代,這個我們稍後再繼續說。

標記整理算法

復制收集算法在對象存活率較高時就要進行較多的復制操作,效率將會變低(所以它適合存活率較低的情景)。

在老年代一般不能直接選用這種算法,因為沒地方做擔保了,根據老年代的特點,有人提出了另外一種“標記-整理”算法,標記過程是一樣的,在對對象回收時,是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

技術分享圖片

分代收集算法

當前商業虛擬機的垃圾收集都采用“分代收集”算法,這種算法並沒有什麽新的思想,只是根據對象存活的周期不同將內存劃分為幾塊,一般是把 Java 堆分為新生代和老年代,根據各個年代特點采用最適當的收集算法。

在新生代中,每次垃圾收集時都發現有大批的對象死去,只有少量存活,那就選用復制算法;

在老年代中,對象的存活率較高、沒有額外的空間對它進行分配擔保,就必須使用“標記-清理”或者“標記-整理”算法來進行回收。

HotSpot的實現

枚舉根節點

可達性分析中,主要是以 GC Roots 開始檢查,但是現在很多應用,僅僅方法區就有數百兆,如果逐個檢查這裏面的引用,那麽必然會消耗很多的時間。

另外,可達性分析對執行時間的敏感還體現在 GC 停頓上,因為這項工作必須在一個能確保一致性的快照中進行,也就是說整個分析期間,整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關系還在不斷變化的情況,如果不滿足就無法保證準確性。

這點是導致 GC 進行時必須停頓所有的 Java 執行線程的其中一個重要原因,也就是說,在枚舉根節點時必須要停頓的。(Sun 將這件事情稱為 Stop The World)

目前主流的 JVM 使用的都是準確式 GC,當執行系統停頓下來後,並不需要一個不漏的檢查完所有執行上下文和全局引用的位置,虛擬機應當是有辦法直接得知那些地方存放著對象的引用。

在 HotSpot 實現中,是使用一組稱為 OopMap 的數據結構來達到這個目的的,在類加載完成的時候,HotSpot 就把對象內什麽類型的數據給計算出來了,在 JIT 編譯過程中也會確定

安全點

在 OopMap 的幫助下,HotSpot 可以快速準確的完成 GC Roots 枚舉,為了解決引用關系的變化問題,HotSpot 並不是為每條指令都生成 OopMap ,只是在特定的位置記錄了這些信息,這些位置稱之為“安全點”,即程序執行時並非在所有地方都能停頓下來開始 GC,只有在到達這些安全點時才能暫停。

安全點的選定基本上是以程序“是否具有讓程序長時間執行的特征”為標準進行選定的,比如方法調用、循環跳轉、異常跳轉等。

另外一個需要考慮的問題是如果在 GC 發生時讓所有線程都跑到最近的安全點上再停頓下來。

  • 搶先式中斷。
    不需要線程的執行代碼去主動配合,在 GC 發生時,首先把所有的線程中斷,如果發現有的線程不在安全點上就恢復這個線程,讓它跑到安全點上。現在幾乎沒有虛擬機實現采用搶占式中斷來解決響應 GC 事件的。

  • 主動式中斷。
    不直接對線程操作,僅僅簡單的設置一個標誌,各個線程執行的時候主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起,輪詢標誌的地方和安全點是重合的(還包括創建對象分配內存的地方,會產生一個自陷異常信號)

安全區域

使用安全點機制保證了程序執行時,在不太長的時間內就會遇到可進入 GC 的安全點,但是程序“不執行”的時候呢?

也就是沒有分配 CPU 時間的時候,經典的例子就是當線程處於 sleep 或者 Blocked 狀態,這時候線程無法響應 JVM 的中斷請求,JVM 也不太可能等待線程重新分配 CPU 時間。對於這種問題就需要安全區域來解決。

安全區域就是指在一段代碼中,引用關系不會發生改變,在這個區域的任何地方開始 GC 都是安全的。可以看作是把安全點擴展了一下。

線程執行到安全區域中的代碼時,首先標識自己已經進入安全區域,當在這段時間 JVM 要發起 GC 的時候,就不需要管標識自己為安全區域狀態的線程了。

在線程要離開安全區域時,它要檢查系統是否已經完成了 GC 過程,如果完成了就繼續執行,否則就必須等待直到收到可以安全離開安全區域的信號為止。

垃圾收集器

虛擬機中不止有一種垃圾收集器,不同的廠商、不同的版本也會有一定的差別,這裏的以 HotSpot 為例:

技術分享圖片

這些垃圾收集器作用於不同的分代,如果兩個收集器之間連線,就說明他們可以搭配使用。

直到現在為止還沒有最好的收集器出現,更加沒有萬能的收集器,所以我們選擇的只是對具體應用最合適的收集器,這也是提供這麽多收集器的原因。

Serial收集器

Serial 收集器是最基本、發展歷史最悠久的收集器。

看名字就知道,這是一個單線程收集器,它在進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。

(如果你的計算機每運行一個小時就會暫停響應五分鐘,那真是不太好)

但是,到現在為止,它依然是虛擬機運行在 Client 模式下的默認新生代收集器。它簡單高效(與其他收集器的單線程比),對於限定的單個 CPU 的環境來說,它沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。

在用戶桌面應用場景中,分配給虛擬機管理的內存一般來說不會很大,停頓時間完全可以控制在幾十毫秒最多一百多毫秒內,只要不是頻繁的發生,這點停頓還是可以接受的,所以 Serial 收集器對於運行在 Client 模式下的虛擬機來說是一個很好的選擇。

技術分享圖片

ParNew收集器

ParNew 收集器是 Serial 收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其他方面基本和 Serial 保持一致,事實上這兩種收集器也共用了很多代碼。

它是許多運行在 Server 模式下的虛擬機中首選的新生代收集器,另外一個原因是,除了 Serial 外,目前只有它能與 CMS 收集器配合工作。

ParNew 收集器在單 CPU 的環境中絕對不會比 Serial 收集器更好的效果,隨著可以使用的 CPU 的數量的增加,他對於 GC 時系統資源的有效利用還是有好處的。

它默認開啟的收集線程數與 CPU 的數量相同,在 CPU 非常多(如 32 個,服務器超過 32 個邏輯 CPU 的情況越來越多了)的環境下,可以使用 -XX:ParallelGCThreads 參數來限制垃圾收集的線程數。

技術分享圖片

Parallel Scavenge收集器

Parallel Scavenge 收集器是一個新生代收集器,它也是使用復制算法的收集器,又是並行的多線程收集器

這裏的並發和並行結合語境應該是:

並行:多條垃圾收集器並行工作,但此時用戶線程仍然處於等待狀態。

並發:用戶線程與垃圾收集線程同時執行(但不一定是並行的,也有可能是交替執行),用戶程序在繼續運行,而垃圾收集程序運行在另一個 CPU 上。

CMS 等收集器關註點是盡可能縮短垃圾收集時用戶線程的停頓時間;而 Parallel Scavenge 收集器的目標則是達到一個可控制的吞吐量。

吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)

停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗,而高的吞吐量則可以高效率的利用 CPU 時間,盡快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。

通過一些參數可以設置 GC 的停頓時間等,GC 的停頓時間縮短是以犧牲吞吐量和新生代空間來換取的。

比如:系統把新生代設置的小一點,收集速度肯定會快吧,但這也導致垃圾收集發生的更頻繁一些,停頓時間的確在下降,但是吞吐量也降下來了。

PS:它支持 GC 自適應調節策略,如果對其不太了解,手動設置存在困難的時候可以使用這種模式,這也是它與 ParNew 收集器的一個重要區別。

Serial Old收集器

Serial Old 是 Serial 收集器的老年代版本,它同樣是一個單線程的收集器,使用“標記-整理”算法,主要意義也是給 Client 模式下的虛擬機使用。

在 Server 模式下,它作為 CMS 收集器的後備預案

技術分享圖片

Parallel Old收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多線程和“標記-整理”算法,從 JDK1.6 開始提供,從這開始“吞吐量優先”收集器終於有了比較名副其實的應用組合。

在註重吞吐量以及 CPU 資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。

技術分享圖片

CMS收集器

CMS 收集器是一種以獲取最短回收停頓時間為目標的收集器

目前很大一部分 Java 應用集中在 B/S 系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,CMS 就非常符合這類應用的需求。

CMS 收集器是基於“標記-清除”算法實現的,運作比較復雜,整個過程可分為四個步驟:

  1. 初始標記
  2. 並發標記
  3. 重新標記
  4. 並發清除

其中,初始標記、重新標記這兩個步驟仍然需要“Stop The World”。

初始標記僅僅只是標記一下 GC Roots 能直接關聯到的對象,速度很快;

並發標記階段就是進行 GC Roots Tracing 的過程;

而重新標記階段則是為了修正並發標記期間因為用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠遠比並發標記的時間短。

由於整個過程中耗時最長的並發標記和並發清除過程收集器線程都可以與用戶線程一起工作,所以從總體上來講,CMS 收集器的內存回收過程是與用戶線程一起並發執行的。

技術分享圖片

CMS 是一款優秀的收集器,它的優點有並發收集、低停頓,但 CMS 還遠遠達不到完美的程度,它有 3 個明顯的缺點:

  1. CMS 收集器對 CPU 資源非常敏感。
    其實,面向並發設計的程序都對 CPU 資源比較敏感,在並發階段,它雖然不會導致用戶線程停頓,但是會占用一部分線程或者說 CPU 資源從而導致應用程序變慢,總吞吐量降低。
    CMS 默認啟動的回收線程數是 (CPU 數量 + 3)/ 4,也就是說當 4 個 CPU 以上時,並發回收時垃圾收集線程不少於 25% 的 CPU 資源,並且隨著 CPU 數量的增加而下降;當 CPU 數量達不到 4 個時,CMS 對用戶程序的影響就可能變得很大(執行速度可能會瞬間降低 50%)。

  2. CMS 收集器無法處理浮動垃圾。
    可能出現 “Concurrent Mode Failure” 失敗而導致另一次 Full GC 的產生,由於 CMS 並發清理階段用戶線程還在運行著,伴隨程序運行自然就會還有新的垃圾不斷產生,這一部分垃圾未被標記過,CMS 無法在本次處理它們,只能等到下次 GC,這一部分垃圾就稱為浮動垃圾。
    由於在垃圾收集階段用戶線程還需要運行,那麽就得預留有足夠的內存空間給用戶線程使用,因此 CMS 不像其他收集器哪樣等到老年代幾乎被填滿了再進行收集,需要預留一部分空間提供並發收集時的程序運作使用。
    在 JDK1.6 中,CMS 收集器的啟動閥值已經提升到 92% ,要是 CMS 運行期間預留的內存無法滿足程序的需要,就會出現一次 “Concurrent Mode Failure” 失敗,這時虛擬機就會啟動後備預案:臨時啟用 Serial Old 收集器來重新進行老年代的垃圾收集,這樣停頓的時間就更長了。

  3. 會產生大量空間碎片。
    CMS 是基於“標記-清除”算法實現的,如果還記得這個算法,那麽就會理解了。
    空間碎片過多時,將會給大對象分配帶來很大的麻煩,往往是老年代還有很大的空間剩余,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次 Full GC。
    為了解決這個問題,CMS 提供了一個默認開啟的參數用於在 CMS 收集器頂不住要進行 FullGC 的時候開啟內存碎片的整理合並過程,內存整理的過程是無法並發的,空間碎片的問題沒有了,但停頓時間不得不變長。

G1收集器

G1 收集器是當今收集器技術發展的最前沿成果之一,G1 是一款面向服務端應用的垃圾收集器(未來可能會替換掉 CMS 收集器)。

G1 所具有的特點有:

  • 並行與並發。
    G1 能夠充分利用多 CPU、多核環境下的硬件優勢,使用多個 CPU 來縮短 Stop-The-World 停頓時間,還可通過並發的方式讓 GC 工作時 Java 程序繼續執行。

  • 分代收集。
    與其他收集器類似,雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠采用不用的方式去處理新創建的對象和已經存活一段時間、熬過多次 GC 的舊對象以獲取更好的收集結果。

  • 空間整合。
    與 CMS 的“標記-清理”算法不同,G1 從整體來看是基於“標記-整理”算法實現的收集器,從局部上來看是基於“復制”算法實現的,無論如何,這兩種算法都意味著 G1 運作期間不會產生內存空間碎片。

  • 可預測的停頓。
    這是 G1 相對於 CMS 的另一大優勢,降低停頓時間是他們的共同關註點,能夠指定消耗在垃圾收集上的時間不能超過 N 毫秒。

G1 中的堆布局和其他收集器有很大差別,它將整個 Java 堆劃分為許多個大小相同的獨立區域,雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的,他們都是一部分 Region(不需要連續)的集合。

G1 之所以能建立可預測的停頓時間模型,是因為它可以有計劃的避免整個 Java 堆進行全區域的垃圾收集,它在後臺維護一個優先級列表,每次根據允許的收集時間,優先回收價值最大的 Region,保證了 G1 收集器在有限的時間內可以獲取盡可能高的收集效率。

當然聽著聽簡單的,實現起來遠遠沒有這麽簡單,其中還設計到了一個 Remembered 來避免全堆掃描,就不詳細說了。

G1 收集器的運作大致可劃分為:

  • 初識標記
  • 並發標記
  • 最終標記
  • 篩選回收

可以看出和 CMS 有很多的相似之處,最終標記階段需要停頓線程,但是可以並行執行,在篩選回收階段會先進行排序,然後根據用戶所期望的時間來制定回收計劃,這個階段其實可以做到與用戶程序一起並發執行,但是因為只回收一部分,時間是用戶控制的,而且停頓用戶線程將大幅提高收集效率。

技術分享圖片

日誌中的 GC 和 Full GC 說明了垃圾收集的停頓類型,如果有 Full 就說明這次 GC 是發生了 Stop-The-World 的。

方括號內部的 xxK->xxK(xxK),含義是 GC 前該內存區域已使用->GC 後該內存已使用的容量(該區域的總容量)

方括號之外的 xxK->xxK(xxK),表示 GC 前 Java 堆已使用容量 -> GC 後堆已使用容量(Java 堆總容量)

內存分配與回收策略

對象的內存分配,往大方向講就是在堆上分配,對象主要分配在新生代的 Eden 區上,少數情況下也可能會直接分配在老年代中,分配的規則並不是百分百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,以及虛擬機中與內存相關的參數設置。

對象優先在 Eden 分配,當 Eden 區沒有足夠的空間進行分配時,虛擬機會發起一次 Minor GC,如果期間 Survivor 空間不足,就會啟動擔保機制,把對象提前轉移到老年代去。

新生代GC(MinorGC):指發生在新生代的垃圾收集動作,因為 Java 對象大多數都具備朝生夕滅的特性,所以 MinorGC 非常頻繁,一般回收速度也比較快。

老年代GC(MajorGC/FullGC):指發生在老年代的 GC,出現 FullGC 經常會伴隨至少一次的 MinorGC,FullGC 的速度一般會比 MinorGC 慢十倍以上。

大對象直接進入老年代,所謂對象就是指需要大量連續內存空間的 Java 對象,最典型的大對象就是那種很長的字符串以及數組,大對象對虛擬機內存分配來說是一個壞消息,這會導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來安置它們。另外可以設置虛擬機參數來規定大於某個值的對象直接在老年代分配,但是只對部分收集器有效。

長期存活的對象進入老年代,虛擬機給每個對象都定義了一個對象年齡計數器,如果對象在 Eden 出生並經過一次 MinorGC 後仍然存活,並且被 Survivor 容納的話,將被移動到 Survivor 空間中,並且將對象的年齡設為 1;對象在 Survivor 區每熬過一次 MinorGC 年齡就加一,當年齡超過一定程度(默認是 15),就會被轉移到老年代中,這個值可以通過 -XX:MaxTenuringThreshold 設置。

動態對象年齡判定,為了更好的適應不同程度的內存狀況,可以設置為如果在 Survivor 空間中相同年齡的所有對象大小的總和大於 Survivor 空間的一半,年齡大於或者等於該年齡的對象就可以直接進入老年代,無需等到規定的年齡。

空間分配擔保:發生 MinorGC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麽 MinorGC 可以確保是安全的,如果不成立,那麽就會檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於就嘗試進行一次 MinorGC,盡管這次 GC 是有風險的;如果小於那這時也要改為進行一次 FullGC。

因為一共有多少對象會存活下來在實際完成內存回收之前是無法明確知道的,所以只好取前幾次的平均值作為經驗,與老年代剩余的空間進行比較,決定是否進行 FullGC 來讓老年代騰出更多的空間。

虛擬機監控與分析

JDK 中不止帶了 javac 、 java,還有很多的有助於我們監控分析的軟件,這些工具(bin 目錄下),大多數是 lib/tools.jar 類庫的一層薄包裝而已,所以會非常的小,並且大多參考了 Linux 的命令模式。

  • jps
    虛擬機進程狀況工具

  • jstat
    虛擬機統計信息監視工具

  • jinfo
    Java配置信息工具

  • jmap
    Java內存映像工具

  • jhat
    虛擬機堆轉儲快照分析工具

  • hsdis
    JIT生成代碼反匯編

  • jstack
    Java堆棧跟蹤工具

然後還有兩個重量級工具,可視化的工具,應該也是用的最多的

  • JConsole
    Java監視與管理控制臺

  • VisualVM
    多合一故障處理工具,對應用程序的實際性能影響很小,使得它可以直接應用在生產環境中。
    支持插件擴展,並且支持在線一鍵安裝、生成、瀏覽堆轉儲快照、分析程序性能、BTrace 動態日誌追蹤。

這裏不做詳細解釋了,一般最後兩個就足夠了。

拓展

String.intern() 是一個 Native 方法,它的作用是:

如果字符串常量池中已經包含一個等於此 String 對象的字符串,則返回代表池中這個字符串的 String 對象;

否則將此 String 對象包含的字符串添加到常量池中,並返回此 String 對象的引用。

public class TestStringIntern{
  public static void main(String[] args){
    String s1 = new StringBuilder("計算機").append("軟件").toString();
    System.out.println(s1.intern() == s1);

    // 運行時常量池已經存在 Java
    String s2 = new StringBuilder("ja").append("va").toString();
    System.out.println(s2.intern() == s2);
  }
}

在 1.6 中得到兩個 false,在 1.7 中得到的是一個 true 一個 false;原因是:

  • JDK1.6 中,intern 方法會把首次遇到的字符串實例復制到永久代中,返回的也是永久代中這個字符串實例的引用,sb 創建的字符串實例在 Java 堆上。
  • JDK1.7 中,intern 的實現不會再復制實例,只是在常量池中記錄首次出現的實例引用,因此 intern 返回的引用和 sb 創建的字符串實例是同一個

Java 調用外部程序是非常耗費資源的操作,過程是:首先克隆一個和當前虛擬機擁有一樣環境變量的進程,再用這個新的進程去執行外部命令,最後再退出這個進程,尤其是反反復復的執行外部命令,更加的耗費資源。


在 JDK1.2 以後,虛擬機內置了兩個運行時編譯器,如果有一段 Java 代碼方法被調用次數達到一定程度,就會被判定為熱代碼交給 JIT 編譯器即時編譯為本地代碼,提高運行速度(這也是 HotSpot 名字的由來),甚至有可能在運行期動態編譯的比 C/C++ 的編譯器靜態編譯出來的代碼更優秀。

因為運行時期可以收集很多編譯器無法知道的信息,甚至可以采用一些很激進的優化手段,在優化條件不成立的時候再逆優化退回來,所以隨著代碼被編譯的越來越徹底,運行速度應當是越運行越快的;

Java 的運行期編譯最大的缺點就是它進行編譯需要消耗程序正常的運行時間。

深入理解JVM