JVM基礎(技術分享筆記)

JVM基礎


     JVM是指Java Virtual Machine,Java虛擬機。

     JVM首先是一個Machine,可以理解爲一個操作系統。它可以提供CPU、內存、硬盤、網絡等組件對應的管理能力:例如CPU的時間片管理、內存分頁管理、硬盤上的文件管理、網絡輸入輸出管理等等。從這些方面來看,JVM和一臺正常的操作系統沒有什麼兩樣。    

     但是JVM是一個Virtual Machine,是虛擬的操作系統,不是真實的操作系統。操作系統是用來溝通底層硬件和軟件的,例如Windows,Linux,MacOs,手機上的Android和ios。但JVM是用來溝通這些真實操作系統與使用Java的軟件的:所以它並不是運行在硬件上、而是運行在操作系統軟件上的一個虛擬操作系統。

     爲什麼要搞這麼一個虛擬操作系統?這會涉及到計算機組成原理和一些編譯原理。簡單的說,不同的操作系統有不同的“接口”,而JVM提供了一套適配器,把不同操作系統中的不同“接口”適配成同一套“接口”,從而方便了軟件的編寫和運行。

     JVM就是Java提供的這樣一套虛擬機。Java提供了一套JRE(Java Runtime Environment)來運行虛擬機,並提供了一套JDK(Java Develop Kit)用於開發Java軟件。一般情況下,JDK中包含了一套JRE。


     爲什麼我們要了解、深入JVM?

     個人理解,JVM是Java生態的基礎,甚至是核心。JVM爲Java注入了非常強大的生命力——它有多個不同廠商或社區各自獨立實現的版本;JVM甚至成爲了很多其它語言的運行平臺,例如Scala,Kotlin,Ceylon,Xtend,Groovy,Clojure和Fantom。換句話說,即使有一天Java語言過時了、再也沒有人用了,JVM也會繼續在軟件行業中呼風喚雨。

     另一方面,在Java開發實踐中,有一種思路叫做“面向JVM編程”(也叫“面向JIT編程”、“面向GC編程”)。這種思路就是在充分了解JVM的基礎上,利用JVM特性中的優點、規避存在的缺點,提高代碼和系統的效率、減少潛在的問題。雖然幾行代碼帶來的提高比較有限;但是當系統的併發量和運行時間達到一定規模時,效率優化累積得到的性能提高就很可觀了。

        

     JVM的基本結構如下圖所示:


JVM內存結構

     上圖中的“方法區”、“堆”、“Java棧”、“PC寄存器”、“本地方法棧”一起組成的“運行時數據區,就是JVM的內存區域。

     其中,方法區和堆是直接提供給應用軟件使用的內存,也叫非堆內存(non-heap)和堆內存(heap);考慮到堆內存一般會分爲年輕代(Young Gen)和老年代(Old Gen),非堆內存又被稱作永久代(Perm Gen),這些細節在後面講;Java棧、PC寄存器、本地方法棧是提供給JVM使用的,也叫棧內存。


     非堆內存和堆內存空間不夠用了,一般會拋出OutOfMemoryError;棧內存空間不夠了,一般會拋出StackOverflowError。

問題:爲什麼是Error而不是Exception?

     從線程的角度來看,非堆內存和堆內存是線程共享的,因此需要代碼來處理其線程安全性。棧內存都是線程私有的,天然就是線程安全的。


方法區

     方法區用來存儲JVM加載的類(結構、方法等)信息、常量、靜態變量、即時編譯器(JIT = Just-In-Time,在運行中記錄程序運行特徵並據此進行優化,例如分支預測。這個東西以後再細說)的編譯信息等等。

     類結構都存儲在方法區,所以在反射的時候,我們獲取到的Class、Method、Filed等定義都是從方法區拿到的。

     有些情況下,GC也會對方法區做垃圾回收。

問題:下面哪些東西是存在方法區裏的?


     由於類定義是放在方法區裏的,我們可以簡單的理解:class文件大小與方法區內存使用情況是息息相關的。class文件越大,方法區佔用越多;class文件越小,方法區佔用越少。

     所以,定義類(比如枚舉)時,不要把代碼中不需要使用的信息(例如“desc”之類的中文描述)定義爲字段,而應該把它們寫到註釋中。以下面這兩個枚舉爲例,用JavaDoc方式寫的枚舉,編譯完成後的class文件大小爲59KB;而用字段方式寫的枚舉,編譯完成後的class文件大小爲87KB。


     堆內存就是我們常說、常用的JVM內存。它用來存儲運行時的實例。也就是說我們new一個對象時,這個對象就會存儲在堆上。一般來說,GC(Garbage Collection)機制也是運行在堆內存上的:當堆內存中某個對象“不可達”時,GC會釋放它的空間。

     堆內存上分配空間的性能較差;也就是說,new一個對象的性能較差。所以很多代碼規範會建議不要在循環體內new對象、只在最必要的時候去new對象等;並且設計模式中也有FlyWeight、對象池等模式來減少new對象的性能消耗。

     例如,我們建議只在最必要的時候去new對象:


     堆內存還會被分爲青年代、老年代等等,這些在介紹GC時再說。



Java棧

     也叫JVM棧。這是專門用來存儲線程私有數據的空間。可以理解爲:每一個線程都有一個棧;線程每調用一個方法,就會向這個棧裏push一個“棧幀”;方法退出,則pop該棧幀。方法的嵌套調用、上下文信息的保存和恢復,就是通過這個棧來實現的。棧幀裏存儲存儲局部變量(Local Variables),操作棧(Operand Stack),方法出口(Return Value),動態連接(Current Class Constant Pool Reference)等信息。

     代碼中,我們可以從異常(Throwable)類中獲取到當前線程的Java棧信息:


     不過,這個操作的性能非常差。所以,建議儘量不要用異常來控制代碼流程;日誌中也可以酌情關閉這些Location信息的輸出。


     局部變量就是在方法體內聲明和初始化的變量。如果是基本類型,就直接存在Java棧上;如果是對象類型,就在堆內存上存儲,Java棧中存一個引用。一般來說棧內存的分配效率要比堆內存更好,這也是爲什麼一般建議使用基本類型的原因之一。

     動態連接是指在運行時,把變量、方法等鏈接到實際的堆/非堆內存中的地址上。這是Java語言多態能力的來源。最常見的就是這種:


     有動態鏈接自然就有靜態鏈接。靜態鏈接則是在編譯期就決定好變量、方法等指向的地址了。靜態鏈接比鏈接的性能要好,因爲它把在運行期間做的事情放到了編譯期間來處理。所以,有些規範會建議我們儘量讓JVM對代碼做靜態鏈接。例如,靜態方法、私有方法、實例構造器、父類方法這四種方法都可以在編譯器就確定到底要使用哪個方法,因此JVM會對它們做靜態鏈接。這也是爲什麼我們應當儘可能縮小方法的可見性,以及應當把一些方法設定爲靜態方法的原因。例如:


     操作棧、方法出口跟代碼沒太直接的關係,有興趣自己查吧。


     然後,考慮這樣一個問題:我們每次調用一個方法,都會創建一個棧幀並將其入棧;方法退出時再將其出棧並銷燬。這裏的創建、入棧、出棧、銷燬操作都會消耗一些性能。那麼,是不是說我們應當儘可能減少方法之間的嵌套調用?或者說寫一個長方法是不是比寫多個短方法更好呢?

     當然不是。一方面,Java代碼是寫給程序員看的。一個長方法的複雜度太高,在後續維護和擴展方面都有很大問題。另一方面,JVM提供了一種機制,用來在編譯和運行期間把小方法組合成大方法,從而減少前面所說的入棧/出棧和創建/銷燬帶來的開銷。這種機制就是方法內聯。雖然我們沒法明確要求JVM一定對某個方法做內聯處理,但是可以通過一些手段讓JVM“更傾向於”對我們的方法做內聯處理。例如,把方法聲明爲private、final、static;或者在JVM參數中指定一些JIT優化參數;或者就是前面討論的:減少方法行數。

問題:爲什麼private/final/static的方法更容易被內聯?


     一般情況下,Java棧深度超出允許範圍(例如遞歸嵌套太深)時,會拋出StackOverFlowError。如果內存擴展時無法爲Java棧申請到足夠內存,也會拋OutOfMemoryError。


PC寄存器

     PC寄存器又叫程序計數器,類似於原生CPU中的程序計數器。每個線程有一個獨立的PC寄存器,用來存儲當前線程執行到字節碼的哪一行(實際應該是字節碼在方法區內的內存地址)。分支、循環、跳轉、異常處理、線程上下文恢復等功能都要依賴它來完成 。

     如果某個方法是native方法,PC寄存器中對應的值爲空(Undefined)。

     按JVM規範,PC寄存器上不拋任何Error;各廠商自己實現了的除外。所以,一般來說,即使PC寄存器空間不夠用了,也不會拋出StackOverFlowError或者OutOfMemoryError。


本地方法棧

     本地方法棧與Java棧很相似,不過本地方法棧是爲Native方法服務的。


直接內存

     直接內存不是JVM內存的一部分。它是Java通過Native函數直接從底層操作系統獲取的一塊內存。直接內存最早出現在NIO中,用於提高IO性能。

     直接內存大小不受JVM配置的限制,而受到底層操作系統的限制。但是,直接內存不足時,也會拋出OutOfMemoryError。


堆和棧的區別

     這個留作問題,大家來回答吧。


Java內存模型


     JVM內存結構是一套靜態的架構,它可以解釋JVM如何在單線程環境下管理內存。但是在多線程環境下,各線程之間是如何通信與同步的?這就涉及到Java內存模型了。

     Java併發採用的是共享內存模型。也就是說,多個線程之間通過對一段共享內存做讀寫操作來交換信息和控制順序。

問題:除了共享內存模型之外,併發編程還有什麼方式來處理線程通信和線程同步?


     在Java的這套內存模型中,JVM的內存被劃分成了兩部分:線程棧和堆內存。每一個線程都有自己的線程棧,並且只能訪問自己的線程棧而不能訪問別人的。堆內存則保存了Java程序中創建的幾乎所有對象,無論是哪個線程創建的。

問題:線程棧和堆內存分別對應JVM內存結構中的哪些部分?


     對JMM來說,我們除了要關注局部變量/成員變量、基本類型/複雜對象之外,需要特別注意一點:雖然複雜對象都是存在堆內存中的,但是一般來說,當線程使用它時,也會在線程棧上創建一個私有的副本。如下圖所示:


     這個副本只有本線程可見,而且,出於性能考慮,一個線程會優先去讀/寫這個本地副本。這就像緩存一樣,會出現髒數據的問題:無論是主內存變了而本地副本沒來得及變、還是本地副本變了而主內存和其它線程的副本沒來得及變,都有可能出現併發問題:例如重排序問題、競態條件(Race Conditions)問題等。上次飛霄已經講過,不再贅述。


垃圾回收


     程序中的每一個對象都有其作用域,超出這個作用域之後,這個對象就不再可用。“不再可用”不僅是指在代碼中我們不能再次使用這個對象,還包括了JVM可以釋放這個對象所佔用的內存空間。這個釋放內存空間的操作/算法/線程,就是GC(Garbage Collection)。

     GC的基本思路其實就是標記-刪除。首先標記好哪些對象可用、哪些對象不可用,然後在某個時間點上把標記爲不可用的對象從內存中“刪除”掉——也就是釋放它的內存空間。在做完這一步之後,GC可能還會做一些內存壓縮、整理等操作,以減少分頁碎片。


     因爲Java只會回收不可用的對象,所以,爲了儘快釋放內存,我們可以有兩種做法。第一種是在適當的位置把代碼中不再使用的對象賦值爲null。但這種做法不太“優雅”,而且一不留神還可能引發NPE。另一種做法是減小變量的作用域。例如,把一個大方法拆分成幾個小方法,從而把大方法中的變量變成小方法中的變量。例如下面這段代碼:


     垃圾回收的基本思路是標記-刪除。


標記算法

     標記算法的核心是找出不可用的對象。

     Java中的實例都是對象,一般情況下,一個對象是否還有用的標誌就是它是否被別的對象引用到了:如果還被引用,說明這個對象還有用;否則就是沒有用了。

問題:哪些情況下,即使一個對象被引用了,它也可以被GC回收?


     但是,還有一個特殊情況:循環引用。對象A引用對象B,對象B引用對象A。除此之外沒有任何其它對象會引用他們倆。這種對象實際上也是不可用的,也會被GC回收掉。

     考慮以上兩種情況,GC一般有兩種方式來檢查對象是否可用。第一種是計數器方式,即在記錄並更新每個對象被引用的次數。一旦被引用次數變成0了,那麼這個對象就可以被回收了。這種方式執行效率很高,但是它很難處理循環引用的問題。另一種是有向圖方式,即在內存中維護一個根節點,用有向圖的結構串聯和更新引用者和被引用者。在這個有向圖中,從根節點出發不可到達的對象就可以被GC回收了。這種方式效率比較低,但是處理精度很高。

     目前大部分JVM都會用有向圖來檢查內存對象是否可用。這也是爲什麼在堆內存上new一個對象的效率比較低的原因之一。


刪除算法

     GC的刪除操作有幾種不同的算法。

        

清除算法

     最簡單的算法。直接把不可用對象的內存空間回收回來。效率不高,並且可能產生大量內存碎片。

複製算法

     將內存劃分爲大小相等的兩塊,每次只使用其中一塊。使用中的這塊用完了的時候,把可用對象複製到另一塊上面,然後把當前這塊全部清理掉。

     這個算法簡單、高效,而且不會產生內存碎片,但是會導致JVM實際只能使用一半內存。

整理算法

     將所有可用對象都向內存地址的一端移動,然後直接清理掉空出來的內存。


分代收集算法

     要理解分代收集算法,首先要理解它對JVM內存的劃分:


     分代收集算法會把JVM堆內存分爲兩大部分:年輕代(Young Gen)和老年代(Old Gen)。與之對應的,非堆內存被稱作永久代(Perm Gen)。其中,年輕代又被劃分爲三個部分:Eden(伊甸園)、S0和S1(兩個存活區)。S0和S1也會被稱作From和To,不過誰是From、誰是To不是固定的,而是會隨着GC而變化:有可能這次GC時S0是From、S1是To,那麼下次GC時S0就是To、而S1變成了From。如前面提到的,GC主要運行在堆內存上,也就是年輕代和老年代上。

     對非堆內存也會做GC,不過條件比較苛刻。如前所說,非堆內存存儲的主要是類結構、常量、靜態變量等。這些信息都與類直接相關,因此,只有當某個類徹底不再使用了的時候,它存儲在非堆內存中的信息纔會被回收。而一個類不再使用必須同時滿足以下三點:類的所有實例都已經被回收、加載類的ClassLoader已經被回收、類對象的Class對象沒有被引用(即沒有通過反射引用該類的地方)。這是非常苛刻的三個條件,通常只有自定義ClassLoader等情況下會出現,所以我們很少關注對非堆內存的GC。


     然後我們來看看一個對象從new出來、到被GC回收並徹底釋放內存的過程。

     絕大多數情況下,一個對象被new出來以後,會被分配到JVM內存的Eden區域中。只有一些超大對象會直接分配到Old Gen上去。當Eden中的內存空間不足時,就會觸發GC(對Eden的GC叫做Minor GC)。這次GC會把Eden和From中的可達對象全部複製到To中,然後釋放Eden和From中的空間;最後把這次的From標記爲To、把這次的To標記爲From,以方便下一次GC時的複製處理。每一次Minor GC都會把仍然存活在To中的對象的“年齡”加一。當一個對象的年齡達到閾值時(默認是8)、或者To空間不足時,它就會在下一次GC時被轉移到Old Gen中。當Old Gen空間不足時,JVM就會啓動一次Major GC(它有一個更令人聞風喪膽的名字叫Full GC),來釋放Old Gen的空間。如果Major GC後Old Gen空間仍然不足,JVM就會嘗試向底層操作系統申請更多內存;如果無法擴容了,就會拋出OutOfMemoryError。


     顯然,對Young Gen的GC應用到了複製算法。但是這裏沒有把Young Gen平分成兩個部分,而是分成了Eden、From、To這三部分,默認情況下它們的大小比例是8:1:1。之所以會這樣設置,是因爲在Young Gen中,80%以上的對象都是朝生暮死的,一次Minor GC後能存活下來的對象通常都很少。所以Eden和From/To的比例沒有必要設計爲1:1。當然,也有超過10%的情況,這時就需要把一部分對象存入Old Gen了。

     同時,也因爲Young Gen中大部分對象的生命週期都很短,所以每次Minor GC需要複製的對象都很少,因此Minor GC的用時非常短,即使它要“Stop The World”,對應用軟件來說影響也非常非常小。但是,Major GC的時間就非常久了,甚至可以說Major GC是Java應用的一個噩夢。

問題:一次Minor GC的時間大約是多久?一次Major GC的時間大約是多久?


     這也是爲什麼我們要縮短變量的生命週期、要寫短方法的又一個原因。仍然可以用前面的代碼來做例子:


內存泄露

     GC只回收“不可達”對象,也造成了一個隱患:如果一個對象始終可達,那麼GC就永遠不會回收它;如果程序已經不需要使用這個對象了,那麼我們就可以說這個對象所佔用的內存發生了泄露;如果這個對象佔用的內存空間不斷增加,那麼JVM內存遲早會被它“吃光”。這時,內存泄漏就演變成了問題。

     比如說,用以下代碼實現一個簡單的緩存,就可能產生內存泄露:


     Java自身也出過一些導致內存泄漏的bug,例如String#subString()方法。不過很多後來都修復了。

     內存泄露可以有很多方法和工具來檢測,但是最好還是在寫代碼的時候就多加註意,避免出現這樣的問題。


垃圾收集器

Serial GC 

     串行收集器。單線程處理,一般就是簡單的標記-刪除-壓縮。性能較差。

Parallel GC

     並行收集器。可以簡單的理解爲Serial GC的並行版本。但是它只會並行處理Minor GC,而仍然會單線程的處理Major GC。

     在多核平臺上可以提高CPU利用率。

Parallel Old GC

     Parallel GC的加強版,在Major GC時也使用多線程來處理。具體做法是把Old Gen劃分爲若干個獨立單元,線程以獨立單元爲單位進行並行處理。

     能夠提高一點Old Gen的回收效率,但是據稱實踐中的性能提高並不明顯。

Concurrent Mark Sweep (CMS) Collector

     使用Parallel GC來處理Young Gen。對Old Gen則採用三次標記法來處理:初始標記、併發標記、再次標記、併發清除。

     停頓時間短;但是很吃CPU,並且CMS不做內存壓縮,因此容易產生內存碎片。


G1 Garbage Collector

     G1的全稱是Garbage First,垃圾優先。

     前面幾種GC都是基於分代收集算法的。但是G1用了一種不同的算法——或者說是一種改進的分代收集算法。從Java9開始,G1成爲了默認垃圾收集器。JDK10又對G1做了較大幅度的優化。不過下面的內容主要是基於JDK8的。

     在內存分配策略上,G1不會簡單的把JVM內存劃分爲固定的、邏輯連續的Young/Old Gen,而是把它劃分爲若干個大小相同、內部邏輯連續但相互之間不一定連續的內存塊(Region)。每一個內存塊會有一個固定的分代角色——Eden,Survivor,Old。但是每一代所佔用的內存總大小(也就是內存塊的個數)不固定。除了這三個角色之外,G1還有一個新的內存角色:Thread Local Allocation Buffer(本地線程緩衝區)。它是對Eden的一個細分,用於在多線程環境下存放一些線程本地的變量。

     大體上G1在管理對象時也會按Eden、Survivor、Old等分代進行內存分配、晉升、回收。分配時也是優先分配在Eden中(會優先分配在TLAB中,非線程私有的再在非TLAB的Eden中找個地方),只有超過Region一半的巨型對象會直接分配到Old Gen上去。Minor GC也會將Eden和From中的可達對象複製、壓縮到To中,並對它們的年齡加1;超齡的對象會放到Old Gen中。Old Gen空間不足時觸發Major GC。


     G1算法的改進是這樣的:首先併發地對各內存塊做一次標記,由此可以得知哪些內存塊中的垃圾最多、可以釋放的空間最多。然後優先對這些內存塊做垃圾回收——所以這個算法叫做Garbage First,也就是“垃圾優先”算法。並且,G1算法會根據用戶定義的暫停時間來計算一次處理多少個內存塊、處理哪些內存塊。決定好了之後,它會把這些內存塊中的存活對象統一拷貝到另一個內存塊中,並同時做一些內存壓縮和碎片整理等工作。


     G1垃圾收集器提供了兩種GC模式:YoungGC和MixedGC。YoungGC只對Eden和Survivor做垃圾回收,MixedGC會同時對三代內存都做垃圾回收,並且對Old Gen的回收操作中會複用一部分對Young Gen的結果,所以它是Mixed。如果MixedGC都無法釋放足夠的Old Gen空間了,最終會觸發Serial GC。

     G1的優點主要有:利用多CPU來縮短Stop The World時間,這一點與CMS類似。但是G1能夠壓縮內存空間,減少內存碎片;而CMS由於不壓縮內存,因此容易產生內存碎片。另外,G1會根據用戶定義的停頓時間來控制一次垃圾回收的工作範圍,因此GC停頓更加可控——雖然並非嚴格“可控”;等等。一般大型服務器上推薦使用G1收集器。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章