簡單的介紹一下JVM(Java Virtual Machine)吧,它也叫Java虛擬機。雖然它叫虛擬機,可是實際上不是咱們所理解的虛擬機,它更像操做系統中的一個進程。JVM屏蔽了各個操做系統底層的相關的東西,Java程序只須要生成對應的字節碼文件,而後由JVM來負責解釋運行。html
介紹幾個容易混淆的概念,JDK(Java Development Kit) 能夠算是整個Java的核心,其中有編譯、調試的工具包和基礎類庫,它也包含了JRE。git
JRE(Java Runtime Environment),包含了JVM和基礎類庫。而JVM就是咱們今天要聊的主角,開篇聊到,JVM負責解釋運行,它會將本身的指令映射到當前不一樣設備的CPU指令集上,因此只須要在不一樣的操做系統上裝不一樣版本的虛擬機便可。這也給了Java跨平臺的能力。github
就跟咱們用三方庫同樣,一樣的功能有不一樣的實現。JVM也是同樣的,第一款JVM是Sun公司的Classic VM,JDK1.2以前JVM都是採用的Classic VM,而以後,逐漸被咱們都知道的HotSpot給替代,直到JDK1.4,Classic VM才徹底被棄用。算法
HotSpot應該是目前使用最普遍的虛擬機(自信點,把應該去掉),也是OpenJDK中所帶的虛擬機。可是你可能不知道,HotSpot最開始並非由Sun公司開發,而是由一家小公司設計並實現的,並且最初也不是爲Java語言設計的。Sun公司看到了這個虛擬機在JIT上的優點,因而就收購了這家公司,從而得到了HotSpot VM。安全
可能你經歷過被靈魂拷問是什麼滋味,若是線上發生了OOM(Out Of Memory),該怎麼排查?若是要你來對一個JVM的運行參數進行調優,你該怎麼作?多線程
不像C++能夠本身來主宰內存,同時扮演上帝和最底層勞工的角色,Java裏咱們把內存管理交給了JVM,若是咱們不能瞭解其中具體的運行時內存分佈以及垃圾回收的原理,那等到問題真正出現了,極可能就無從查起。這也是要深刻的瞭解JVM的必要性。併發
Java在運行時會將內存分紅以下幾個區域進行管理,堆、方法區、虛擬機棧、本地方法棧和程序計數器。jvm
堆
-函數
堆(Java Heap)是JVM所管理的內存中最大的一塊了。咱們日常開發中使用new
關鍵字來進行實例化的對象幾乎都會在堆中分配內存,全部線程均可以共享被分配在堆上的對象。工具
堆也是JVM垃圾回收的主要區域,正由於垃圾回收的分代機制,其實堆中還能夠分爲更細的新生代、老年代。GC這塊後面會細講。
那爲何是幾乎呢?在JVM自己的規範中是規定了全部的對象都會在堆上分配內存的,可是隨着JIT(Just In Time)編譯器和逃逸分析技術的成熟,全部對象都在堆上分配內存就變得沒有那麼絕對了。
不知道你有沒有據說過,二八定律在咱們的程序中也一樣適用,那就是20%的代碼佔用了系統運行中80%的資源。在咱們寫的代碼中,就可能會存在一些熱點代碼,頻繁的被調用。除了被頻繁的調用的代碼,還有被執行屢次的循環體也算熱點代碼。
那此時JIT編譯器就會對這部分的代碼進行優化,將它們編譯成Machine Code,並作一些對應的優化。不熟悉的同窗可能會說,咱們的代碼不都已經被編譯成了字節碼了嗎?怎麼又被編譯成了Machine Code?
由於字節碼只是一箇中間狀態,真正的運行是JVM在運行的時候,就跟解釋型語言同樣將字節碼逐條的翻譯成了Machine Code,這個Machine Code纔是操做系統可以識別直接運行的指令。而JIT就會把編譯好的熱點代碼所對應的Machine Code保存下來, 下載再調用時就省去了從字節碼編譯到Machine Code的過程,效率天然也就提升了。
咱們剛剛提到過,Java中幾乎全部的對象都在堆上分配空間,堆中的內存空間是全部線程共享的,因此在多線程下才須要去考慮同步的相關問題。那若是這個變量是個局部變量,只會在某個函數中被訪問到呢?
這種局部變量就是未逃逸的變量,而這個變量若是在其餘的地方也能被訪問到呢?這說明這個變量逃逸出了當前的做用域。經過逃逸分析咱們能夠知道哪些變量沒有逃逸出當前做用域,那這個對象內存就能夠在棧中分配,隨着調用的結束,隨着線程的繼續執行完成,棧空間被回收,這個局部變量分配的內存也會一塊兒被回收。
方法區存放了被加載的Class信息、常量、靜態變量和JIT編譯以後的結果等數據,與堆同樣,方法區也是被全部線程共享的內存區域。但與堆不一樣,相對於堆的GC力度,這塊的垃圾回收力度能夠說是小了很是多,可是仍然有針對常量的GC。
虛擬機棧是線程私有的,因此在多線程下不須要作同步的操做,是線程安全的。當每一個方法執行時,就會在當前線程中虛擬機棧中建立一個棧幀,每一個方法從調用到結束的過程,就對應了棧幀在虛擬機棧中的入棧、出棧的過程。那天然而然,棧幀中應該存放的就是方法的局部變量、操做數棧、動態連接和對應的返回信息。
不知道你遇到過在方法內寫遞歸時,因爲退出條件一直沒有達到,致使程序陷入了無限循環,而後就會看到程序拋出了一個StackOverflow
的錯誤。其所對應的棧就是上面提到的操做數棧。
固然這是在內存足夠的狀況下,若是內存不夠,則會直接拋出OutOfMemory
,也就是常說的OOM。
本地方法棧的功能與虛擬機棧相似,區別在於虛擬機棧是服務於JVM中的Java方法,而本地方法棧則服務於Native的方法。
其實堆中的區域還能夠劃分爲新生代和老年代,再分割的細一點,能夠到Eden、From Survivor、To Survivor。首先分配的對象實例會到Eden區,在新生代這塊區域通常是最大的,與From Survivor的比例是8:1,固然這個比例能夠經過JVM參數來改變。並且當分配的對象實體很大的時候將會直接進入到老年代。
爲何要對堆進行更加細緻的內存區域劃分,實際上是爲了讓垃圾回收更加的高效。
那JVM是如何判斷哪些對象是「垃圾」須要被回收呢?咱們就須要來了解一下JVM是如何來判斷哪些內存須要進行回收的。
實現的思路是,給每一個對象添加一個引用計數器,每當有其餘的對象引用了這個對象,就把引用計數器的值+1,若是一個對象的引用計數爲0則說明沒有對象引用它。
乍一看是沒有問題的,那爲何Java並無採起這種呢?
想象一下這個場景,一個函數中定義了兩個對象O1和O2,而後O1引用了O2,O1又引用了O1,這樣一來,兩個對象的引用計數器都不爲0,可是實際上這兩個對象不再會被訪問到了。
因此咱們須要另一種方案來解決這個問題。
可達性分析能夠理解爲一棵樹的遍歷,根節點是一個對象,而其子節點是引用了當前對象的對象。從根節點開始作遍歷,若是發現從全部根節點出發的遍歷都已經完成了,可是仍然有對象沒有被訪問到,那麼說明這些對象是不可用的,須要將內存回收掉。
這些根節點有個名字叫作GC Roots,哪些資源能夠被看成GC Roots呢?
咱們剛剛聊過,在引用計數中,若是其引用計數器的值爲0,則佔用的內存會被回收掉。而在可達性分析中,若是沒有某個對象沒有任何引用,它也不必定會被回收掉。
聊完了JVM如何判斷一個對象是否須要回收,接下來咱們再聊一下JVM是如何進行回收的。
顧名思義,其過程分爲兩個階段,分別是標記和清除。首先標記出全部須要回收的對象,而後統一對標記的對象進行回收。這個算法的十分的侷限,首先標記和清除的兩個過程效率都不高,並且這樣的清理方式會產生大量的內存碎片,什麼意思呢?
就是雖然整體看起來還有足夠的剩餘內存空間,可是他們都是以一塊很小的內存分散在各個地方。若是此時須要爲一個大對象申請空間,即便整體上的內存空間足夠,可是JVM沒法找到一塊這麼大的連續內存空間,就會致使觸發一次GC。
其大體的思路是,將現有的內存空間分爲兩半A和B,全部的新對象的內存都在A中分配,而後當A用完了以後,就開始對象存活判斷,將A中還存活的對象複製到B去,而後一次性將A中的內存空間回收掉。
這樣一來就不會出現使用標記-清除所形成的內存碎片的問題了。可是,它仍然有本身的不足。那就是之內存空間縮小了一半爲代價,而在某些狀況下,這種代價實際上是很高的。
堆中新生代就是採用的複製算法。剛剛提到過,新生代被分爲了Eden、From Survivor、To Survivor,因爲幾乎全部的新對象都會在這裏分配內存,因此Eden區比Survivor區要大不少。所以Eden區和Survivor區就不須要按照複製算法默認的1:1的來分配內存。
在HotSpot中Eden和Survivor的比例默認是8:1,也就意味着只有10%的空間會被浪費掉。
看到這你可能會發現一個問題。
既然你的Eden區要比Survivor區大這麼多,要是一次GC以後的存活對象的大小 大於Survivor區的總大小該怎麼處理?
的確,在新生代GC時,最壞的狀況就是Eden區的全部對象都是存活的,那這個JVM會怎麼處理呢?這裏須要引入一個概念叫作內存分配擔保。
當發生了上面這種狀況,新生代須要老年代的內存空間來作擔保,把Survivor存放不下的對象直接存進老年代中。
標記-整理其GC的過程與標記-清楚是同樣的,只不過會讓全部的存活對象往同一邊移動,這樣一來就不會像標記-整理那樣留下大量的內存碎片。
這也是當前主流虛擬機所採用的算法,其實就是針對不一樣的內存區域的特性,使用上面提到過的不一樣的算法。
例如新生代的特性是大部分的對象都是須要被回收掉的,只有少許對象會存活下來。因此新生代通常都是採用複製算法。
而老年代屬於對象存活率都很高的內存空間,則採用標記-清除和標記-整理算法來進行垃圾回收。
聊完了垃圾回收的算法,咱們須要再瞭解一下GC具體是經過什麼落地的, 也就是上面的算法的實際應用。
Serial採用的是複製算法的垃圾收集器,並且是單線程運做的。也就是說,當Serial進行垃圾收集時,必需要暫停其餘全部線程的工做,直到垃圾收集完成,這個動做叫STW(Stop The World) 。Golang中的GC也會存在STW,在其標記階段的準備過程當中會暫停掉全部正在運行的Goroutine。
並且這個暫停動做對用戶來講是不可見的,用戶可能只會知道某個請求執行了好久,沒有經驗的話是很難跟GC掛上鉤的。
可是從某些方面來看,若是你的系統就只有單核,那麼Serial就不會存在線程之間的交互的開銷,能夠提升GC的效率。這也是爲何Serial仍然是Client模式下的默認新生代收集器。
ParNew與Serial只有一個區別,那就是ParNew是多線程的,而Serial是單線程的。除此以外,其使用的垃圾收集算法和收集行爲徹底同樣。
該收集器若是在單核的環境下,其性能可能會比Serial更差一些,由於單核沒法發揮多線程的優點。在多核環境下,其默認的線程與CPU數量相同。
Parallel Scavenge是一個多線程的收集器,也是在server模式下的默認垃圾收集器。上面的兩種收集器關注的重點是如何減小STW的時間,而Parallel Scavenge則更加關注於系統的吞吐量。
例如JVM已經運行了100分鐘,而GC了1分鐘,那麼此時系統的吞吐量爲(100 - 1)/100 = 99%
。
吞吐量和短停頓時間其側重的點不同,須要根據本身的實際狀況來判斷。
GC的總時間越短,系統的吞吐量則越高。換句話說,高吞吐量則意味着,STW的時間可能會比正常的時間多一點,也就更加適合那種不存在太多交互的後臺的系統,由於對實時性的要求不是很高,就能夠高效率的完成任務。
STW的時間短,則說明對系統的響應速度要求很高,由於要跟用戶頻繁的交互。由於低響應時間會帶來較高的用戶體驗。
Serial Old是Serial的老年代版本,使用的標記-整理算法, 其實從這看出來,新生代和老年代收集器的一個差異。
新生代:大部分的資源都是 須要被回收老年代:大部分的資源都不須要被回收
因此,新生代收集器基本都是用的複製算法,老年代收集器基本都是用的標記-整理算法。
Serial Old也是給Client模式下JVM使用的。
Parallel Old是Parallel Scavenge的老年代版本,也是一個多線程的、採用標記-整理算法的收集器,剛剛討論過了系統吞吐量,那麼在對CPU的資源十分敏感的狀況下, 能夠考慮Parallel Scavenge和Parallel Old這個新生代-老年代的垃圾收集器組合。
CMS全稱(Concurrent Mark Sweep),使用的是標記-清除的收集算法。重點關注於最低的STW時間的收集器,若是你的應用很是注重與響應時間,那麼就能夠考慮使用CMS。
從圖中能夠看出其核心的步驟:
- 首先會進行初始標記,標記從GCRoots出發可以關聯到的全部對象,此時須要STW,可是不須要不少時間
- 而後會進行併發標記,多線程對全部對象經過GC Roots Tracing進行可達性分析,這個過程較爲耗時
- 完成以後會從新標記,因爲在併發標記的過程當中,程序還在正常運行,此時有些對象的狀態可能已經發生了變化,因此須要STW,來進行從新標記,所用的時間大小關係爲
初始標記 < 從新標記 < 併發標記
。- 標記階段完成以後,開始執行併發清楚。
CMS是一個優勢很明顯的的垃圾收集器,例如能夠多線程的進行GC,且擁有較低的STW的時間。可是一樣的,CMS也有不少缺點。
咱們開篇也提到過,使用標記-清除算法會形成不連續的內存空間,也就是內存碎片。若是此時須要給較大的對象分配空間,會發現內存不足,從新觸發一次Full GC。
其次,因爲CMS可能會比注重吞吐量的收集器佔用更多的CPU資源,可是若是應用程序自己就已經對CPU資源很敏感了,就會致使GC時的可用CPU資源變少,GC的整個時間就會變長,那麼就會致使系統的吞吐量下降。
G1全稱Garbage First,業界目前對其評價很高,JDK9中甚至提議將其設置爲默認的垃圾收集器。咱們前面講過,Parallel Scavenge更加關注於吞吐量,而CMS更加關注於更短的STW時間,那麼G1就是在實現高吞吐的同時,儘量的減小STW的時間。
咱們知道,上面聊過的垃圾收集器都會把連續的堆內存空間分爲新生代、老年代,新生代則被劃分的更加的細,有Eden和兩個較小的Survivor空間,並且都是連續的內存空間。而G1則不同凡響,它引入了新的概念,叫Region。
Region是一堆大小相等可是不連續的內存空間,一樣是採用了分代的思想,可是不存在其餘的收集器的物理隔離,屬於新生代和老年代的region分佈在堆的各個地方。
上面H則表明大對象,也叫Humongous Object。爲了防止大對象的頻繁拷貝,會直接的將其放入老年代。G1相比於其餘的垃圾收集器有什麼特色呢?
從宏觀上來看,其採用的是標記-整理算法, 而從region到region來看,其採用的是複製算法的,因此G1在運行期間不會像CMS同樣產生內存碎片。
除此以外,G1還能夠經過多個CPU,來縮短STW的時間,與用戶線程併發的執行。而且能夠創建可預測的停頓時間模型,讓使用者知道在某個時間片內,消耗在GC上的時間不得超過多少毫秒。之因此G1可以作到這點,是由於沒像其他的收集器同樣收集整個新生代和老年代,而是在有計劃的避免對整個堆進行全區域的垃圾收集。
這個圖來自於參考中的博客,總結的很到位。