Java核心技術系列 點選檢視第二章 點選檢視第三章 JVM G1源碼分析和調優

第1章
垃圾回收概述
Java的發展已經超過了20年,已是最流行的程式設計語言。為了更好地了解和使用Java,越來越多的開發人員開始關注Java虛拟機(JVM)的實作技術,其中垃圾回收(也稱垃圾收集)是最熱門的技術點之一。目前G1作為JVM中最新、最成熟的垃圾回收器受到很多的人關注,本書從G1的原理出發,介紹新生代收集、混合收集、Full GC、并發标記、Ref?ine、Evacuation等内容。本章先回顧Java語言的發展曆程,然後介紹JVM中一些常用的概念以便與讀者統一術語,随後介紹垃圾回收的主要算法以及JVM中實作了哪些垃圾回收的算法。
1.1 Java發展概述
Java平台和語言最開始是SUN公司在1990年12月進行的一個内部研究項目,我們通常所說的Java一般泛指JDK(Java Developer Kit),它既包含了Java語言和開發工具,也包含了執行Java的虛拟機(Java Virtual Machine,JVM)。從1996年1月23日開始,JDK 1.0版本正式釋出,到如今Java已經經曆了23個春秋。以下是Java發展曆程中值得紀念的幾個時間點:
□1998年12月4日JDK迎來了一個裡程碑版本1.2。其技術體系被分為三個方向,J2SE、J2EE、J2ME。代表技術包括EJB、Java Plug-in、Swing;虛拟機第一次内置了JIT編譯器;語言上引入了Collections集合類等。
□2000年5月8日,JDK1.3釋出。在該版本中Hotspot正式成為預設的虛拟機,Hotspot是1997年SUN公司收購LongView Technologies公司而獲得的。
□2002年2月13日,JDK1.4釋出。該版本是Java走向成熟的一個版本。從此之後,每一個新的版本都會增加新的特性,比如JDK5改進了記憶體模型、支援泛型等;JDK6增強了鎖同步等;JDK7正式支援G1垃圾回收、更新類加載的架構等;JDK8支援函數式程式設計等。
□2006年11月13日的JavaOne大會上,SUN公司宣布最終會把Java開源,由OpenJDK組織對這些源碼獨立管理,從此之後Java程式員多了一個研究JVM的官方管道。
□2009年4月20日,Oracle公司宣布正式以74億美元的價格收購SUN公司,Java商标從此正式歸Oracle所有,自此Oracle對Java的管理和釋出進入了一個新的時期。
随着時間的推移,JDK 9和JDK 10也已經正式釋出,但是JDK 9和JDK 10并不是Oracle長期支援的版本(Long Term Support),這意味着JDK 9和JDK 10隻是JDK 11的一個過渡版本,它們隻用于整合新的特性,當下一個版本釋出之後,這些過渡版本将不再更新維護。2018年9月25日JDK 11正式釋出,随着新版本的釋出,Oracle公司未來對JDK的支援也會變化。按照現在的聲明,從2019年1月起對于商業使用者,Oracle公司對JDK 8不再提供公共的更新,從2020年12月起對個人使用者也不再提供公共的更新。
G1作為CMS的替代者,一直吸引着衆多Java開發者的目光,自從JDK 7正式推出以來,G1不斷地增強,并從JDK 8開始越來越成熟,在JDK 9、JDK 10、JDK 11中都成為預設的垃圾回收器。實際上也有越來越多的公司開始在生産環境中使用G1作為垃圾回收器,有一篇文章描述了JDK 9中GC的基準測試(benchmark),表明G1已經優于其他的GC。可以預見随着JDK 11的推出,會有越來越多的公司和個人使用G1作為生産環境中的垃圾回收器。
G1的目标是在滿足短時間停頓的同時達到一個高的吞吐量,适用于多核處理器、大記憶體容量的系統。其實作特點為:
□短停頓時間且可控:G1對記憶體進行分區,可以應用在大記憶體系統中;設計了基于部分記憶體回收的新生代收集和混合收集。
□高吞吐量:優化GC工作,使其盡可能與Mutator并發工作。設計了新的并發标記線程,用于并發标記記憶體;設計了Ref?ine線程并發處理分區之間的引用關系,加快垃圾回收的速度。
新生代收集指針對全部新生代分區進行垃圾回收;混合收集指不僅僅回收新生代分區,同時回收一部分老生代分區,這通常發生在并發标記之後;Full GC指記憶體不足時需要對全部記憶體進行垃圾回收。
并發标記是G1新引入的部分,指的是在Mutator運作的同時标記哪些對象是垃圾,看到這裡大家一定非常好奇G1到底是怎麼實作的,舉一個簡單的例子。比如你的媽媽正在打掃房間,掃房房間需要識别哪些物品有用哪些無用,無用的物品就是垃圾。同時你正在房間活動,活動的同時你可能往房間增加了新的物品,也可能把房間的物品重新組合,也可能産生新的無用物品。最簡單的垃圾回收器如串行回收器的做法就是在打掃房間辨別物品的時候,你要暫停一切活動,這個時候你的媽媽就能完美地識别哪些物品有用哪些無用。但最大的問題就是需要你暫停一切活動直到房間裡面的物品識别完畢,在實際系統中意味着這段時間應用程式不能提供服務。G1的并發标記就是在打掃房間識别物品有用或者無用的同時,你還可以繼續活動,怎麼正确做标記呢?一個簡單的辦法就是在打掃房間識别垃圾物品開始的時候記錄你增加了哪些物品,動過哪些物品。然後在物品标記結束的時候對這些變更過的物品重新标記一次,當然在這一次标記時需要你暫停一切活動,否則永遠也沒有盡頭,這通常稱為再标記(Remark)。這個就是所謂的增量并發标記,在G1中具體的算法是Snapshot-At-The-Beginning(SATB),關于這個算法我們會在第6章詳細介紹。Ref?ine線程也是G1新引入的,它的目的是為了在進行部分收集的時候加速識别活躍對象,具體介紹參見第4章。
本書依托于jdk8u的源代碼來介紹JVM如何實作G1,通過源代碼的分析了解算法以及了解G1提供的參數的具體意義;最後還會給出一些例子,通過日志,分析該如何調整參數以達到性能優化。
這裡提到的jdk8u是指OpenJDK的代碼,OpenJDK是SUN公司(現Oracle)推出的JDK開源代碼,因為标準的JDK(這裡指Oracle版的JDK)會有一些内部功能的代碼,那些代碼在開源的時候并未公開。在2017年9月Oracle公司宣布Oracle JDK和OpenJDK将能自由切換,Oracle JDK也會依賴OpenJDK的代碼進行建構,是以通常都是使用OpenJDK的代碼進行分析和研究。讀者可以自行到OpenJDK的官網上下載下傳源代碼,值得一提的是,JDK的代碼會随着bug修複不斷改變,是以為了保持閱讀的一緻性,我把本書使用的代碼推送到GitHub上,也使用該版本進行編譯調試。
1.2 本書常見術語
JVM系統非常複雜,市面上有很多中英文書籍從不同的角度來介紹JVM,其中都用到了很多術語,但是大家對某些術語的解釋并不完全相同。為了便于讀者的了解,在這裡統一定義和解釋本書使用的一些術語。這些術語有些是我們約定俗成的叫法,有些是JVM裡面的特别約定,還有一些是G1算法引入的。為了保持準确性,這裡僅僅解釋這些術語的含義,後續會進一步解釋相關内容,本書将盡量使用這裡定義的術語。
□并行(parallelism),指兩個或者多個事件在同一時刻發生,在現代計算機中通常指多台處理器上同時處理多個任務。
□并發(concurrency),指兩個或多個事件在同一時間間隔内發生,在現代計算機中一台處理器“同時”處理多個任務,那麼這些任務隻能交替運作,從處理器的角度上看任務隻能串行執行,從使用者的角度看這些任務“并行”執行,實際上是處理器根據一定的政策不斷地切換執行這些“并行”的任務。
在JVM中,我們也常看到并行和并發。比如,典型的ParNew一般稱為并行收集器,CMS一般稱為并發标記清除(Concurrent Mark Sweep)。這看起來很奇怪,因為并行和并發是從處理器角度出發,但是這裡明顯不是,實際上并行和并發在JVM被重新定義了。
JVM中的并行,指多個垃圾回收相關線程在作業系統之上并發運作,這裡的并行強調的是隻有垃圾回收線程工作,Java應用程式都暫停執行,是以ParNew工作的時候一定發生了STW。本書提到的ParTask(例如G1ParTask)指的就是在這些任務運作的時候應用程式都必須暫停。
JVM中的并發,指垃圾回收相關的線程并發運作(如果啟動多個線程),同時這些線程會和Java應用程式并發運作。本書提到的ConcurrentThread(例如ConcurrentG1RefineThread)就是指這些線程和Java應用程式同時運作。
□Stop-the-world(STW),直譯就是停止一切,在JVM中指停止一切Java應用線程。
□安全點(Safepoint),指JVM在執行一些操作的時需要STW,但并不是任何線程在任何地方都能進入STW,例如我們正在執行一段代碼時,線程如何能夠停止?設計安全點的目的是,當線程進入到安全點時,線程就會主動停止。
□Mutator,在很多英文文獻和JVM源碼中,經常看到這個單詞,它指的是我們的Java應用線程。Mutator的含義是可變的,在這裡的含義是因為線程運作,導緻了記憶體的變化。GC中通常需要STW才能使Mutator暫停。
□記憶集(Remember Set),簡稱為RSet。主要記錄不同代際對象的引用關系。
□Refine,尚未有統一的翻譯,有時翻譯為細化,但是不太準确,本書中不做翻譯。G1中的ConcurrentG1Ref?ineThread主要指處理RSet的線程。
□Evacuation,轉移、撤退或者回收,簡稱為Evac,本書中不做翻譯。在G1中指的是發現活躍對象,并将對象複制到新位址的過程。
□回收(Reclaim),通常指的是分區對象已經死亡或者已經完成Evac,分區可以被JVM再次使用。
□Closure,閉包,本書中不做翻譯。在JVM中是一種輔助類,類似于我們已知的iterator,它通常提供了對記憶體的通路。
□GC Root,垃圾回收的根。在JVM的垃圾回收過程中,需要從GC Root出發标記活躍對象,確定正在使用的對象在垃圾回收後都是存活的。
□根集合(Root Set)。在JVM的垃圾回收過程中,需要從不同的GC Root出發,這些GC Root有線程棧、monitor清單、JNI對象等,而這些GC Root就構成了Root Set。
□Full GC,簡稱為FGC,整個堆的垃圾回收動作。通常Full GC是串行的,G1的
Full GC不僅有串行實作,在JDK10中還有并行實作。
□再标記(Remark)。在本書中指的是并發标記算法中,處理完并發标記後,需要更新并發标記中Mutator變更的引用,這一步需要STW。
1.3 回收算法概述
垃圾回收(Garbage Collection,GC)指的是程式不用關心對象在記憶體中的生存周期,建立後隻需要使用對象,不用關心何時釋放以及如何釋放對象,由JVM自動管理記憶體并釋放這些對象所占用的空間。GC的曆史非常悠久,從1960年Lisp語言開始就支援GC。垃圾回收針對的是堆空間,目前垃圾回收算法主要有兩類:
□引用計數法:在堆記憶體中配置設定對象時,會為對象配置設定一段額外的空間,這個空間用于維護一個計數器,如果對象增加了一個新的引用,則将增加計數器。如果一個引用關系失效則減少計數器。當一個對象的計數器變為0,則說明該對象已經被廢棄,處于不活躍狀态,可以被回收。引用計數法需要解決循環依賴的問題,在我們衆所周知的Python語言裡,垃圾回收就使用了引用計數法。
□可達性分析法(根引用分析法),基本思路就是将根集合作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鍊,當一個對象沒有被任何引用鍊通路到時,則證明此對象是不活躍的,可以被回收。
這兩種算法各有優缺點,具體可以參考其他文獻。JVM的垃圾回收采用了可達性分析法。垃圾回收算法也一直不斷地演化,主要有以下分類:
□垃圾回收算法實作主要分為複制(Copy)、标記清除(Mark-Sweep)和标記壓縮(Mark-Compact)。
□在回收方法上又可以分為串行回收、并行回收、并發回收。
□在記憶體管理上可以分為代管理和非代管理。
我們首先看一下基本的收集算法。
1.3.1 分代管理算法
分代管理就是把記憶體劃分成不同的區域進行管理,其思想來源是:有些對象存活的時間短,有些對象存活的時間長,把存活時間短的對象放在一個區域管理,把存活時間長的對象放在另一個區域管理。那麼可以為兩個不同的區域選擇不同的算法,加快垃圾回收的效率。我們假定記憶體被劃分成2個代:新生代和老生代。把容易死亡的對象放在新生代,通常采用複制算法回收;把預期存活時間較長的對象放在老生代,通常采用标記清除算法。
1.3.2 複制算法
複制算法的實作也有很多種,可以使用兩個分區,也可以使用多個分區。使用兩個分區時記憶體的使用率隻有50%;使用多個分區(如3個分區),則可以提高記憶體的使用率。我們這裡示範把堆空間分為1個新生代(分為3個分區:Eden、Survivor0、Survivor1)、1個老生代的收集過程。
普通對象建立的時候都是放在Eden區,S0和S1分别是兩個存活區。第一次垃圾收集前S0和S1都為空,在垃圾收集後,Eden和S0裡面的活躍對象(即可以通過根集合到達的對象)都放入了S1區,如圖1-1所示。
回收後Mutator繼續運作并産生垃圾,在第二次運作前Eden和S1都有活躍對象,在垃圾收集後,Eden和S1裡面的活躍對象(即可以通過根節點到達的對象)都被放入到S0區,一直這樣循環收集,如圖1-2所示。
1.3.3 标記清除
從根集合出發,周遊對象,把活躍對象入棧,并依次處理。處理方式可以是廣度優先搜尋也可以是深度優先搜尋(通常使用深度優先搜尋,節約記憶體)。标記出活躍對象之後,就可以把不活躍對象清除。下面示範一個簡單的例子,從根集合出發查找堆空間的活躍對象,如圖1-3所示。
這裡僅僅示範了如何找到對象,沒有進一步介紹找到對象後如何處理。對于标記清除算法其實還需要額外的資料結構(比如一個連結清單)來記錄可用空間,在對象配置設定的時候從這個連結清單中尋找能夠容納對象的空間。當然這裡還有很多細節都未涉及,比如在配置設定時如何找到最合适的記憶體空間,有First Fit、Best Fit和Worst Fit等方法,這裡不再贅述。标記清除算法最大的缺點就是使記憶體碎片化。
1.3.4 标記壓縮
标記壓縮算法是為了解決标記清除算法中使記憶體碎片化的問題,除了上述的标記動作之外,還會把活躍對象重新整理從頭開始排列,減少記憶體碎片。
1.3.5 算法小結
垃圾回收的基礎算法自提出以來并沒有大的變化。表1-1對幾種算法的優缺點進行了比較,更加詳細的介紹請參考其他書籍。
1.4 JVM垃圾回收器概述
為了達到最大性能,基于分代管理和回收算法,結合回收的時機,JVM實作垃圾回收器了:串行回收、并行回收、并發标記回收(CMS)和垃圾優先回收。
1.4.1 串行回收
串行回收使用單線程進行垃圾回收,在回收的時候Mutator需要STW。新生代通常采用複制算法,老生代通常采用标記壓縮算法。串行回收典型的線程互動圖如圖1-4所示。
1.4.2 并行回收
并行回收使用多線程進行垃圾回收,在回收的時候Mutator需要暫停,新生代通常采用複制算法,老生代通常采用标記壓縮算法。線程互動如圖1-5所示。
1.4.3 并發标記回收
并發标記回收(CMS)的整個回收期間劃分成多個階段:初始标記、并發标記、重新标記、并發清除等。在初始标記和重新标記階段需要暫停Mutator,在并發标記和并發清除期間可以和Mutator并發運作,如圖1-6所示。這個算法通常适用于老生代,新生代可以采用并行回收。
1.4.4 垃圾優先回收
垃圾優先回收器(Garbage-First,也稱為G1)從JDK7 Update 4開始正式提供。G1
緻力于在多CPU和大記憶體伺服器上對垃圾回收提供軟實時目标和高吞吐量。G1垃圾回收器的設計和前面提到的3種回收器都不一樣,它在并行、串行以及CMS GC針對堆空間的管理方式上都是連續的,如圖1-7所示。
連續的記憶體将導緻垃圾回收時收集時間過長,停頓時間不可控。是以G1将堆拆成一系列的分區(Heap Region),這樣在一個時間段内,大部分的垃圾收集操作隻針對一部分分區,而不是整個堆或整個(老生)代,如圖1-8所示。
在G1裡,新生代就是一系列的記憶體分區,這意味着不用再要求新生代是一個連續的記憶體塊。類似地,老生代也是由一系列的分區組成。這樣也就不需要在JVM運作時考慮哪些分區是老生代,哪些是新生代。事實上,G1通常的運作狀态是:映射G1分區的虛拟記憶體随着時間的推移在不同的代之間切換。例如一個G1分區最初被指定為新生代,經過一次新生代的回收之後,會将整個新生代分區都劃入未使用的分區中,那它可以作為新生代分區使用,也可以作為老生代分區使用。很可能在完成一個新生代收集之後,一個新生代的分區在未來的某個時刻可用于老生代分區。同樣,在一個老生代分區完成收集之後,它就成為了可用分區,在未來某個時候可作為一個新生代分區來使用。
G1新生代的收集方式是并行收集,采用複制算法。與其他JVM垃圾回收器一樣,一旦發生一次新生代回收,整個新生代都會被回收,這也就是我們常說的新生代回收(Young GC)。但是G1和其他垃圾回收器不同的地方在于:
□G1會根據預測時間動态改變新生代的大小。
注意:其他垃圾回收新生代的大小也可以動态變化,但這個變化主要是根據記憶體的使用情況進行的。G1中則是以預測時間為導向,根據記憶體的使用情況調整新生代分區的數目。
□G1老生代的垃圾回收方式與其他JVM垃圾回收器對老生代處理有着極大的不同。G1老生代的收集不會為了釋放老生代的空間對整個老生代做回收。相反,在任意時刻隻有一部分老生代分區會被回收,并且,這部分老生代分區将在下一次增量回收時與所有的新生代分區一起被收集。這就是我們所說的混合回收(Mixed GC)。在選擇老生代分區的時候,優先考慮垃圾多的分區,這也正是垃圾優先這個名字的由來。後續我們将逐一介紹這些内容。
在G1中還有一個概念就是大對象,指的是待配置設定的對象大小超過一定的門檻值之後,為了減少這種對象在垃圾回收過程的複制時間,直接把對象配置設定到老生代分區中而不是新生代分區中。
從實作角度來看,G1算法是複合算法,吸收了以下算法的優勢:
□列車算法,對記憶體進行分區,參見圖1-8。
□CMS,對分區進行并發标記。
□最老優先,最老的資料(通常也是垃圾)優先收集。
關于列車算法、CMS和最老優先可以參考其他的書籍,這裡不再贅述。