這是一篇有關JVM內存管理的文章。這裏將會簡單的分析一下Java如何使用從物理內存上申請下來的內存,以及如何來劃分它們,後面還會介紹JVM的核心技術:如何分配和回收內存。java
要理解JVM的內存管理策略,首先就要熟悉Java的運行時數據區,如上圖所示,在執行Java程序的時候,虛擬機會把它所管理的內存劃分爲多個不一樣的數據區,稱爲運行時數據區。在程序執行過程當中對內存的分配、垃圾的回收都在運行時數據區中進行。對於Java程序員來講,其中最重要的就是堆區和JVM棧區了。注意圖中的圖形面積比例並不表明實際的內存比例。程序員
下面來簡單的講一下圖中的區塊。算法
方法區:存儲虛擬機運行時加載的類信息、常量、靜態變量和即時編譯的代碼,所以能夠把這一部分考慮爲一個保存相對來講數據較爲固定的部分,常量和靜態變量在編譯時就肯定下來進入這部份內存,運行時類信息會直接加載到這部份內存,因此都是相對較早期進入內存的。編程
- **運行時常量池**:在JVM規範中是這樣定義運行時常量池這個數據結構的:Runtime Constant Pool表明運行時每一個class文件的常量表。它包含幾種常量:編譯期的數字常量、方法和域的引用(在運行時解析)。它的功能相似於傳統編程語言的符號表,儘管它包含的數據比典型的符號表要豐富得多。每一個Runtime Constant Pool都是在JVM的Method area中分配的,每一個Class或者Interface的Constant Pool都是在JVM建立class或接口時建立的。它是**屬於方法區**的一部分,因此它的存儲也受方法區的規範約束,若是常量池沒法分配,一樣會拋出OutOfMemoryError。
堆區:是JVM所管理的內存中最大的一塊。主要用於存放對象實例,每個存儲在堆中的Java對象都會是這個對象的類的一個副本,它會複製包括繼承自它父類的全部非靜態屬性。而所謂的垃圾回收也主要是在堆區進行。 根據Java虛擬機規範的規定,Java堆能夠處於物理上不連續的內存空間中,只要邏輯是上連續的便可,就像咱們的磁盤空間同樣。在實現上,既能夠實現成固定大小的,也能夠是可擴展的:數組
- 若是是固定大小的,那麼堆的大小在JVM啓動時就一次向操做系統申請完成,旦分配完成,堆的大小就將固定,不能在內存不夠時再向操做系統從新申請,同時當內存空閒時也不能將多餘的空間交還給操做系統。 - 若是是可擴展的,則經過 -Xmx和 -Xms兩個選項來控制大小,Xmx來表示堆的最大大小,Xms表示初始大小。
JVM棧區:則主要存放一些對象的引用和編譯期可知的基本數據類型,這個區域是線程私有的,即每一個線程都有本身的棧。在Java虛擬機規範中,對這個區域規定了兩種異常狀況:緩存
StackOverflowError
異常OutOfMemoryError
異常StackOverflowError
和OutOfMemoryError
異常。一般咱們定義一個基本數據類型的變量,一個對象的引用,還有就是函數調用的現場保存都使用內存中的棧空間;而經過new關鍵字和構造器建立的對象放在堆空間;程序中的字面量(literal)如直接書寫的100、「hello」和常量都是放在靜態存儲區中。棧空間操做最快可是也很小,一般大量的對象都是放在堆空間,整個內存包括硬盤上的虛擬內存均可以被當成堆空間來使用。安全
String str = new String(「hello」);
在分析JVM內存分配策略以前,咱們先介紹一下一般狀況下操做系統都是採用哪些策略來分配內存的。數據結構
在操做系統中,將內存分配策略分爲三種,分別是:編程語言
靜態內存分配 是指在程序編譯時鋸能肯定每一個數據在運行時的存儲空間需求,所以在編譯時就能夠給它們分配固定的內存空間。這種分配策略不容許在程序代碼中有可變數據結構(如可變數組)的存在,也不容許有嵌套或者遞歸的結構出現,由於它們都會致使編譯程序沒法計算機準確的存儲空間需求。函數
棧內存分配 也可稱爲動態存儲分配,是由一個相似於堆棧的運行棧來實現的。和靜態內存分配相反,在棧式內存方案執行宏,程序對數據區的需求在編譯時是徹底無知的,只有運行時才能知道,可是規定在運行中進入一個程序模塊時,必須知道該程序模塊所需數據區大小才能爲其分配內存。和咱們所數值的數據結構中的棧同樣,棧式內存分配按照先進後出的原則進行分配。
堆內存分配 當程序真正運行到相應代碼時纔會知道空間大小。
JVM內存分配主要基於兩種:堆和棧。
先來講說 棧 。
Java棧的分配是和線程綁定在一塊兒的,當咱們建立一個線程時,很顯然,JVM就會爲這個線程建立一個新的Java棧,一個線程的方法的調用和返回對應這個Java棧的壓棧和出棧。當線程激活一個Java方法時,JVM就會在線程的Java棧裏新壓入一個幀,這個幀天然成了當前幀。在此方法執行期間,這個幀將用來保存參數、局部變量、中間計算過程和其餘數據。
棧中主要存放一些基本類型的變量數據和對象句柄(引用)。存取速度比堆要快,僅次於寄存器,棧數據能夠共享。缺點是,存在棧中的數據大小與生存期必須是肯定的,這也致使缺少了其靈活性。
Java的 堆 是一個運行時數據區,它們不須要程序代碼來顯示地釋放。堆是由垃圾回收來負責的,堆的優點是能夠動態地分配內存大小,生存期也沒必要事先告訴編譯器,由於它是在運行時動態分配內存的,Java的垃圾收集器會自動收走這些再也不使用的數據。但缺點是,因爲要運行時動態分配內存,存取速度慢。
從堆和棧的功能和做用通俗地比較,堆主要用來存放對象,棧主要用來執行程序,這種不一樣主要由堆和棧的特色決定的。
在編程中,如C/C++,全部的方法調用是經過棧進行的,全部的局部變量、形式參數都是從棧中分配內存空間的。實際上也不是什麼分配,只是從棧向上用就行,就好像工廠中的傳送帶同樣,棧指針會自動指引你到放東西的位置,你所要作的只是把東西放下來就行。在退出函數時,修改棧指針就能夠把棧中的內潤銷燬。這樣的模式速度最快,固然要用來運行程序了。須要注意的是,在分配時,如爲一個即將要調用的程序模塊分配數據區時,應事先知道這個數據區的大小,也就是說上雖然分配是在程序運行中進行的,可是分配的大小是肯定的、不變的,而這個「大小多少」是在編譯時肯定的,而不是在運行時。
堆在應用程序運行時請求操做系統給本身分配內存,因爲操做系統管理內存分配,因此在分配和銷燬時都要佔用時間,所以用堆的效率很是低。可是堆的優勢在於,編譯器沒必要知道從堆裏分配多少存儲空間,也沒必要知道存儲的數據要在堆裏停留多長時間。所以,用堆保存數據時會獲得更大的靈活性,事實上,因爲面向對象的多態性,堆內存分配是必不可少的,由於多態變量所需的存儲空間只有在運行時建立了對象以後才能肯定。在C++中,要求建立一個對象時,只需用new命令編制相關命令便可。執行這些代碼時,會在堆裏自動進行數據的保存。固然,爲達到這種靈活性,必然會付出必定的代價——在堆裏分配存儲空間會花掉更長的時間。
即須要回收的對象。做爲編寫程序的人,是能夠作出「這個對象已經再也不須要了」這樣的判斷,但計算機是作不到的。所以,若是程序(經過某個變量等等)可能會直接或間接地引用一個對象,那麼這個對象就被視爲「存活」;與之相反,已經引用不到的對象被視爲「死亡」。將這些「死亡」對象找出來,而後做爲垃圾進行回收,這就是GC的本質。
即判斷對象是否可被引用的起始點。至於哪裏纔是根,不一樣的語言和編譯器都有不一樣的規定,但基本上是將變量和運行棧空間做爲根。各位確定會好奇根對象集合中都是些什麼,下面就來簡單的講一講:
在連續剩餘空間中分配內存。用一個指針指向內存已用區和空閒區的分界點,須要分配新的內存時候,只須要將指針向空閒區移動相應的距離便可。
在不規整的剩餘空間中分配內存。若是剩餘內存是不規整的,就須要用一個列表記錄下哪些內存塊是可用的,當須要分配內存的時候就須要在這個列表中查找,找到一個足夠大的空間進行分配,而後在更新這個列表。
指針碰撞的分配方式明顯要優於空閒列表的方式,可是使用哪一種方式取決於堆內存是否規整,而堆內存是否規整則由使用的垃圾收集算法決定。若是堆內存是規整的,則採用指針碰撞的方式分配內存,而若是堆是不規整的,就會採用空閒列表的方式。
要對對象進行回收,首先須要找到哪些對象是垃圾,須要回收。有兩種方法能夠找到須要回收的對象,第一種叫作引用計數法。
具體方法就是給對象添加一個引用計數器,計數器的值表明着這個對象被引用的次數,當計數器的值爲0的時候,就表明沒有引用指向這個對象,那麼這個對象就是不可用的,因此就能夠對它進行回收。可是有一個問題就是當對象之間循環引用時,好比這樣:
public class Main { public static void main(String[] args) { MyObject object1 = new MyObject(); MyObject object2 = new MyObject(); object1.object = object2; object2.object = object1; //最後面兩句將object1和object2賦值爲null,也就是說object1和object2指向的對象已經不可能再被訪問, //可是因爲它們互相引用對方,致使它們的引用計數都不爲0,那麼垃圾收集器就永遠不會回收它們。 object1 = null; object2 = null; } } class MyObject{ public Object object = null; }
其中每一個對象的引用計數器的值都不爲0,可是這些對象又是做爲一個孤立的總體在內存中存在,其餘的對象不持有這些對象的引用,這種狀況下這些對象就沒法被回收,這也是主流的Java虛擬機沒有選用這種方法的緣由。
另外一種方法就是把堆中的對象和對象之間的引用分別看做有向圖的頂點和有向邊——即可達性分析法。這樣只須要從一些頂點開始,對有向圖中的每一個頂點進行可達性分析(深度優先遍歷是有向圖可達性算法的基礎),這樣就能夠把不可達的對象找出來,這些不可達的對象還要再進行一次篩選,由於若是對象須要執行finalize()方法,那麼它徹底能夠在finalize()方法中讓本身變的可達。這個方法解決了對象之間循環引用的問題。上面提到了「從一些對象開始」進行可達性分析,這些起始對象被稱爲GC Roots,能夠做爲GC Roots的對象有:
上文中提到的引用均是強引用,Java中還存在其餘三種引用,分別是,軟引用、弱引用和虛引用,當系統即將發生內存溢出時,纔會對軟引用所引用的對象進行回收;而被弱引用所引用的對象會在下一次觸發GC時被回收;虛引用則僅僅是爲了在對象被回收時可以收到系統通知。
即便在可達性分析算法中不可達的對象,也並不是是「非死不可」的,這時候它們暫時處於「緩刑」階段,要真正宣告一個對象死亡,至少要經歷再次標記過程。
標記的前提是對象在進行可達性分析後發現沒有與GC Roots相鏈接的引用鏈。
Finalize()
方法是對象脫逃死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模標記,若是對象要在finalize()中成功拯救本身————只要從新與引用鏈上的任何的一個對象創建關聯便可,譬如把本身賦值給某個類變量或對象的成員變量,那在第二次標記時它將移除出「即將回收」的集合。若是對象這時候還沒逃脫,那基本上它就真的被回收了。
/** * 此代碼演示了兩點 * 一、對象能夠在被GC時自我拯救 * 二、這種自救的機會只有一次,由於一個對象的finalize()方法最多隻能被系統自動調用一次。 */ public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, I am still alive"); } protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws InterruptedException { SAVE_HOOK = new FinalizeEscapeGC(); //對象第一次成功拯救本身 SAVE_HOOK = null; System.gc(); //由於finalize方法優先級很低,全部暫停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no ,I am dead QAQ!"); } //----------------------- //以上代碼與上面的徹底相同,但此次自救卻失敗了!!! SAVE_HOOK = null; System.gc(); //由於finalize方法優先級很低,全部暫停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no ,I am dead QAQ!"); } } }
最後想說的是:請不要使用finalize()方法,使用try-finalize能夠作的更好。這是一個歷史遺留的問題——當年爲了讓C/C++程序員更好的接受它而作出的妥協。
好了,咱們找到了垃圾。來談談如何處理這些垃圾吧。
標記清除(Mark and Sweep)是最先開發出的GC算法(1960年)。它的原理很是簡單,首先從根開始將可能被引用的對象用遞歸的方式進行標記,而後將沒有標記到的對象做爲垃圾進行回收。
經過可達性分析算法找到能夠回收的對象後,要對這些對象進行標記,表明它能夠被回收了。標記完成以後就統一回收全部被標記的對象。這就完成了回收,可是這種方式會產生大量的內存碎片,就致使了可用內存不規整,因而分配新的內存時就須要採用空閒列表的方法,若是沒有找到足夠大的空間,那麼就要提早觸發下一次垃圾收集。
做爲標記清除的變形,還有一種叫作標記整理(Mark and Compact)的算法。
標記的過程和標記-清除算法同樣,可是標記完成以後,讓全部存活的對象都向堆內存的一端移動,最後直接清除掉邊界之外的內存。這樣對內存進行回收以後,內存是規整的,因而可使用指針碰撞的方式分配新的內存。
「標記」系列的算法有一個缺點,就是在分配了大量對象,而且其中只有一小部分存活的狀況下,所消耗的時間會大大超過必要的值,這是由於在清除階段還須要對大量死亡對象進行掃描。複製收集(Copy and Collection)則試圖克服這一缺點。在這種算法中,會將從根開始被引用的對象複製到另外的空間中,而後,再將複製的對象所可以引用的對象用遞歸的方式不斷複製下去。
經過圖2咱們能夠發現,複製收集方式中,只存在至關於標記清除方式中的標記階段。因爲清除階段中須要對現存的全部對象進行掃描,在存在大量對象,且其中大部分都即將死亡的狀況下,所有掃描一遍的開銷實在是不小。而在複製收集方式中,就不存在這樣的開銷。
可是,和標記相比,將對象複製一份所須要的開銷則比較大,所以在「存活」對象比例較高的狀況下,反而會比較不利。這種算法的另外一個好處是它具備局部性(Lo-cality)。在複製收集過程當中,會按照對象被引用的順序將對象複製到新空間中。因而,關係較近的對象被放在距離較近的內存空間中的可能性會提升,這被稱爲局部性。局部性高的狀況下,內存緩存會更容易有效運做,程序的運行性能也可以獲得提升。
上文提到了幾種GC算法,可是各自的各自的優勢,必須放到適合的場景內才能發揮最大的效率。
在JVM堆裏分有兩部分:新生代(young generate)和老年代(old generation)。
在新生代中長期存活的對象會逐漸向老年代過渡,新生代中的對象每經歷一次GC,年齡就增長一歲,當年齡超過必定值時,就會被移動到老年代。
大部分的新建立對象分配在新生代。由於大部分對象很快就會變得不可達,因此它們被分配在新生代,而後消失再也不。當對象重新生代移除時,咱們稱之爲"Minor GC"。新生代使用的是複製收集算法。
新生代劃分爲三個部分:分別爲Eden、Survivor from、Survivor to,大小比例爲8:1:1(爲了防止複製收集算法的浪費內存過大)。每次只使用Eden和其中的一塊Survivor,回收時將存活的對象複製到另外一塊Survivor中,這樣就只有10%的內存被浪費,可是若是存活的對象總大小超過了Survivor的大小,那麼就把多出的對象放入老年代中。
在三個區域中有兩個是Survivor區。對象在三個區域中的存活過程以下:
如上所述,兩個Survivor區域在任什麼時候候一定有一個保持空白。若是同時有數據存在於兩個Survivor區或者兩個區域的的使用量都是0,則意味着你的系統可能出現了運行錯誤。
存活在新生代中但未變爲不可達的對象會被複制到老年代。通常來講老年代的內存空間比新生代大,因此在老年代GC發生的頻率較新生代低一些。當對象從老年代被移除時,咱們稱之爲 "Major GC"(或者Full GC)。 老年代使用標記-清理或標記-整理算法
MaxTenuringThreshold
中要求的年齡(默認是15)。在發生Minor GC前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代全部對象總空間。
若是小於,虛擬機會查看HandlePromotionFailure設置值是否容許擔任失敗。
若是容許,那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升老年代對象的平均大小
前面提到過,新生代使用複製收集算法,但爲了內存利用率,只使用其中一個Survivor空間來做爲輪換備份,所以當出現大量對象在Minor GC後仍然存活的狀況時(最極端就是內存回收後新生代中全部對象都存活),就須要老年代進行分配擔保,讓Survivor沒法容納的對象直接進入老年代。與生活中的貸款擔保相似,老年代要進行這樣的擔保,前提是老年代自己還有容納這些對象的剩餘空間,一共有多少對象會活下來,在實際完成內存回收以前是沒法明確知道的,因此只好取以前每一次回收晉升到老年代對象容量的平均大小值做爲經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。
取平均值進行比較其實仍然是一種動態機率的手段,也就是說若是某次Minor GC存活後的對象突增,遠遠高於平均值的話,依然會致使擔保失敗(Handle Promotion Failure)。若是出現了HandlePromotionFailure失敗,那就只好在失敗後從新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分狀況下都仍是會將HandlePromotionFailure開關打開,避免Full GC過於頻繁。