本文概括一下對Java內存管理機制的理解,儘量通俗易懂,知識來自於深刻理解Java虛擬機一書。html
計算機簡單理解就是根據執行計劃,經過參數獲得結果。執行計劃就是程序了,參數就是實際變量,最終運行獲得咱們要的結果。磁盤因爲其廉價且持久化,用於保存程序和數據,可是受制於執行速度,內存的做用就顯現出來了。內存運行快,可是昂貴,易失數據(斷電),容量遠不及存儲介質,可是快就是硬道理,計算無非就是要快出結果。因爲容量的限制,致使不可能全部數據和程序代碼都被加載到內存,又因爲操做系統演化到了分時系統,對於內存的管理也就更顯突出了。算法
有些語言須要代碼自己完成內存的分配和釋放,典型的就是C++了。這種由開發人員決定如何利用內存無疑是高效、清楚的,可是對於大型項目開發帶來了災難,內存的分配不當,不釋放,協做開發都會致使一系列問題。並且內存問題又是難以發現,測試,對程序編寫人員我的能力提出了極高的要求,但顯然不能期望全部人都能作到。緩存
爲了解決上訴問題,有人就想出由另外一個程序來管理內存,對其進行釋放,這個典型的就是JVM了。其讓開發人員無需過於關注內存的分配和使用,所有搞定,固然徹底不關心也是不可能的。這就是單獨寫一個程序進行內存分配和回收的起源。tomcat
能夠想一下,要寫一個管理內存的程序,要考慮哪些問題:安全
1.如何管理內存,讓內存使用更高效數據結構
2.什麼狀況下對象能夠被回收多線程
3.什麼時間,如何進行回收併發
這篇文章將對上述問題進行梳理。性能
管理內存是個技術活,由於涉及到分配、回收和再分配的問題,好的設計才能提高效率。測試
有一個基本的概念須要理解,那就是順序讀寫確定比離散讀寫要快,因此爲了效率,須要提高的就是內存的連續性了。首先就是開闢一大塊內存,而後一塊塊的進行分配,初次是沒有什麼問題的,可是回收後就會出現大量的內存碎片,這個確定是不利於後續使用的。下面對幾種算法進行討論:
這個就是上面講的最簡單的算法了。標記出全部須要回收的對象,在標記完成後統一回收被標記的對象。這就會形成內存產生大量的片斷,不夠連續了,還會妨礙分配大對象。
這個方法就是解決上訴碎片化的一種方案了,簡單暴力。將內存分配成兩塊等大的區域,清理其中一塊時,所有按順序將存活的對象移動到另外一邊。這樣總能保證內存中的數據是連續的。
可是這個方法存在兩個問題,一個是太耗費內存,另外一個就是複製太多。這裏先擴展一下虛擬機對內存的一個基本定義,新生代和老年代。這麼劃分是有緣由的,虛擬機中大部分的對象都是臨時的,處於朝生夕死的狀態(98%),但又有些對象會持久存活,甚至貫穿整個運行週期。對於這些狀況,虛擬機定義了這麼兩個區域,能夠針對其特性採起不一樣的算法策略加速回收。
複製算法就很適合新生代的朝生夕死的狀況,這也意味着其不須要將內存等分。現代JVM將新生代劃分紅一塊Eden空間(伊甸園新生),2塊Survivor(倖存者,Eden倖存對象)。默認的大小比例是8:1:1。也就是Eden佔用80%的空間,Survivor各佔10%。具體操做就是:清理的時候,將當前使用的Eden和Survivor中倖存下來的對象,移動到另外一塊當前沒有使用的Survivor區域中,這樣浪費的空間也就是一塊survivor,10%而已,比一半節省了大量的空間。
這裏還有一些其餘細節:10%的survivor不必定徹底夠用,這個時候就會將一些內容刷新到老年代了。老年代不夠用那就是真的要拋出異常了。這裏面還涉及的概念有full gc和通常gc,後續進行說明。
上面複製算法的兩個問題,第一個內存雖然經過特性解決了,只浪費了10%的內存,可是存活對象不少的時候,複製的效率低下問題卻沒有獲得解決。上面所說的另外一個定義老年代,這個區域的對象大機率都會存活到下一次gc,因此顯然使用複製算法不太划算了。這個時候採起的一般就是標記整理算法。通俗的將就是將存活的對象向內存一端靠攏,最後統一清理掉存活對象後面的內存。
這個並非新的算法,而是上面所提到的思路:根據對象存活時間不一樣,儘可能減小操做,採起合適的回收算法。因此將內存分爲了新生代、老年代,而且新生代常採起復制算法,老年代常採起標記整理算法。固然,這個不是絕對的,要根據實際使用的回收器。
這個問題很好解答:不可能被再次使用的對象,便是無用對象,能夠被回收。問題就在於如何判斷一個對象再也不被使用呢?
這種方法很好理解:每一個對象有一個計數器,若是有一個地方引用了它,計數器加1,引用失效就減一。對象建立的時候引用固然不會爲0,爲0後再也找不到這個對象了,天然就能夠被回收了。這個方法實現簡單,判斷效率很高,Redis就是採用了這種算法,有一些語言好比Python也是使用這種方法,可是至少是主流的JVM沒有使用這種方法。
緣由在於循環引用:A引用了B,B引用了A,這兩者都不爲0,可是其餘任何地方都與這兩個地方不要緊。這種狀況下也是應該被清理的對象,可是實際上引用計數法沒法處理該狀況。
爲了解決上訴的問題,JVM採起了可達性分析的方法。從一系列稱爲「GC Roots」的對象做爲起始點,向下搜索全部能夠被訪問到的對象,若是有對象不能被搜索到,那麼其必定就不可用。AB對象互相引用,可是GC Roots沒法搜索到,這樣循環引用的無效對象問題就解決了。
GC Roots對象在我看來就是當前絕對不能對清理的對象,知足這一條件的對象有如下幾種:
1.虛擬機棧(棧幀中的本地變量表)中引用的對象。 馬上就要用了,怎麼能被清理
2.方法區中類靜態屬性引用對象,常量引用對象。 方法區的static和final基本上和類加載通生命週期了,妥妥的命久對象,不能清理
3.本地方法棧中JNI(Native方法)引用的對象。 舉個不知道對不對的例子,線程開啓完不保存就沒法被引用了,可是在本地方法棧中仍是被線程管理器持有,這個固然不能清理。
引用若是隻能被標記爲被引用和不被引用就比較狹窄,對於一些無關緊要的就難以抉擇了。好比設計一個緩存服務,有內存的時候固然好,緩存保留。可是內存不夠的時候,就會但願可以行之有效的減小這些緩存。由於緩存確定是要被使用的,確定回收算法沒法對其回收,這個時候想要丟棄都沒有辦法。JDK1.2以後提出了四種引用:強引用,軟引用,弱引用,虛引用。網上有不少對這些引用的使用的講解,這裏推薦篇文章,不作過多描述:這裏。
強引用:廣泛存在,new之類的。強引用存在,就不會被回收。
軟引用:用於有做用,可是非必須的對象。發生內存溢出以前,會進行回收,若是回收後還不夠,會拋出異常。SoftReference類。(用來作緩存)
弱引用:非必需的對象。比軟引用更弱,只能生存到下一次垃圾收集發生以前,當垃圾回收時都會清除掉。WeakReference類來實現。(一次性使用,自動回收,WeakHashMap實現,tomcat中用來作分代緩存:這裏)
虛引用:幽靈引用或者是幻影做用,最弱的關係。不會對該對象生存時間構成影響,惟一的做用就是在清除後會收到一個通知。PhantomReference來實現。(用來作對象銷燬後的一些操做,finalize也能作到相同的效果,可是因爲其相關的一些問題,很差使用:這裏)
不可達的對象並非必定會被回收,主要是由於要進行兩次標記。
若是不可達,首先進行第一次標記,並判斷是否須要執行finalize方法。沒有覆寫finalize方法,或者這個方法被執行過了,就不須要再次執行。這種狀況會被直接清理掉。
若是須要執行finalize方法,就會被放在F-Queue隊列中,由Finalizer線程執行,執行的含義是會觸發,但不必定會等運行結束:由於執行緩慢或者死循環會致使F-Queue中的其餘對象等待,形成回收系統崩潰。finalize方法中,使得這個對象被其餘對象引用,就能夠逃脫回收的命運。由於以後GC會對F-Queue進行第二次標記,若是被其餘對象持有,就會移除被回收的集合,若是沒有逃脫,這個對象就會真正被回收。
不少人認爲方法區(永久代)是不須要進行回收的,虛擬機規範中雖說了不要求在方法區回收,性價比也比較低,可是也有回收的。
回收的主要內容有兩部分:廢棄常量和無用類。JDK7將字符常量移除了永久代PermGen(很容易溢出),JDK8甚至移除了永久代,採用元數據Metaspace:這裏。
字符常量很容易判斷是否須要回收,可是類就比較麻煩了,須要同時知足如下3個條件:
1.全部該類的實例被回收了(確保沒有對象須要使用類的字節碼、方法等信息)
2.加載類的ClassLoader被回收了(確保再也不能經過new建立對象)
3.該類對象的Class對象沒有被引用,沒法經過反射訪問該類的方法。(補充2的反射狀況)
即使都知足了,也不必定會被回收。hotspot提供了-Xnoclassgc參數進行控制,或者使用-verbose:class和-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看類加載卸載信息。前兩個能夠在Product版使用,UnLoading參數要在FastDebug版才支持。
分析可達性的時候,系統必須保證凍結,即在這段時間內沒有新的對象產生等。這就是所說的stop the world,會暫停全部的操做,保持不變。目前也是在極力減小停頓時間。
HotSpot中使用OopMap的數據結構來達到直接得知哪些地方存放着對象引用的目的。在類加載完時,HotSpot就會將對象內什麼偏移量上是什麼數據類型計算出來,在JIT編譯過程當中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC掃描的時候就能夠直接獲得這些信息了。
在OopMap的幫助下,能夠很快完成GC Roots的枚舉。可是另一個問題是致使引用變化的指令不少,若是每條指令都生成OopMap開銷就很高了。實際上,也並無爲每條指令都生成OopMap,只有在特定的位置記錄了信息,這些位置被稱爲安全點,只有在到達安全點時才能暫停,開始GC。
安全點的選定基本上是以程序"是否具備讓程序長時間執行的特製"爲標準進行選定的——由於每條指令執行的時間都很是短暫,程序不太可能由於指令流長度太長這個緣由而過長時間運行,」長時間執行「的最明顯的特製就是指令序列複用,如方法調用,循環跳轉,異常跳轉等,因此具備這些功能的指令纔會產生Safepoint。
另外一個問題就是如何讓全部線程(不包括JNI調用的線程)都跑到安全點上再停頓下來。這裏就有兩種方式:
1.搶先式中斷:搶先式中斷不須要線程的執行代碼主動配合,在GC發生時,首先把全部線程所有中斷,若是發現有線程中斷的地方不在安全點上,就恢復,讓它跑到安全點。該方法再也不採用。
2.主動式中斷:GC須要中斷線程時,不直接對線程操做,僅僅簡單地設置一個標誌,各個線程執行時會主動輪詢這個標誌,發現爲真時,就本身中斷掛起。輪詢標誌的地方和安全點是重合的,另外加上建立對象須要分配內存的地方。test指令是生成的輪詢指令,線程執行到test指令時就會產生一個自陷異常信號,在預先註冊的異常處理器中暫停線程實現等待。
安全點看似解決了如何進入GC的問題,但實際上卻不必定。Safepoint機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的Safepoint。可是,程序不執行的時候呢?不執行的時候就是沒有分配CPU時間,典型的例子就是Sleep或者Blocked狀態,線程沒法響應JVM的中斷請求,到安全的地方去中斷掛起,JVM顯然也不可能等待線程被從新分配CPU時間,這種狀況就須要採起另外一種手段——安全區域safe region來解決了。
安全區域指的就是在一段代碼中,引用關係不會發生變化。在這個區域中任意地方GC都是安全的。線程執行到安全區域的時候,就會標識本身已經到了safe region,那麼GC的時候就不會管線程狀態了。在線程離開安全區域的時候,要檢查系統是否已經完成了根節點枚舉,若是完成了,線程繼續執行,不然必須等到收到能夠離開安全區域的信號才行。
上面說了那麼多,這裏看下JDK7的Hotspot虛擬機對於垃圾回收的實現。以前提到過JVM將內存人爲的分爲了年輕代和老年代,這也是爲了針對不一樣生命週期對象使用不一樣算法提高效率的策略,因此存在使用多種垃圾回收器的狀況,下圖是回收器所處的代及其能夠組合的回收器。
能夠看到年輕代的有:serial、ParNew、Parallel Scavenge
老年代的有:CMS、Serial Old、Parallel Old
G1通用於年輕代和老年代,另外連線就是能夠進行組合使用的意思了,可是年輕代只能選一個,對應選擇一個能夠組合的老年代。G1通用,因此選了它就不能選其餘的。另外,這個是早期版本的JVM提供的收集器了,近些年又有了極大的發展,好比JDK11提供的ZGC,號稱很強大。JDK9改進了G1收集器,並廢棄了幾種組合DefNew+CMS、ParNew+SerialOld、遞增的CMS。
這個是最基礎的收集器,歷史悠久,單線程(不只僅是隻會使用一個線程,並且要暫停其餘全部的線程)。這就是經典的stop the world了,十分糟糕。試想一下運行1小時,忽然中止5分鐘,對程序而言很是不利。下圖是serial/serial old處理示意圖。
可是這個收集器在1.7版本仍就是運行在client模式下的默認新生代收集器。優勢在於簡單高效。
這個就是serial進化版本,採起的是多線程的方式,其他的都和serial的同樣,好比控制參數:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure。工做示意圖以下:
這個收集器除了多線程以外,其餘的與serial並無太多創新之處,可是它倒是許多運行在Server模式下的虛擬機首選的新生代收集器,由於除了Serial收集器外,只有它可以與CMS收集器(這個是第一個真正意義的併發收集器,第一次實現了讓垃圾收集線程與用戶線程同時工做)配合工做。
不幸的是,CMS做爲老年代收集器,沒法與Parallel Scavenge配合工做。因此只能選擇ParNew或者Serial中的一個。若是使用參數-XX:+UseConcMarkSweepGC後,默認使用的就是ParNew,也可使用-XX:+UseParNewGC選項強制指定。
在單CPU上,ParNew因爲線程切換,效果確定不如Serial,通常線程數與CPU核數相關,能夠經過-XX:ParallelGCThreads來限制線程數。
這是一個新生代收集器,使用的也是複製算法,並行多線程,與ParNew有什麼區別呢?特色就是關注目標和其餘收集器不一樣。
收集器的通常目標是儘快的完成清理動做,停頓時間越短越好。可是Parallel不一樣,它關注的是吞吐量,即用戶代碼運行時間佔總運行時間的比例。計算公式是:運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),好比若是總共運行了10分鐘,垃圾回收用了1分鐘,吞吐量就是90%。
強交互性的任務就須要停頓時間越短越好,好比一個鼠標點擊事件,遇到垃圾回收等了1分鐘,那就受不了了。而高吞吐量就不適合交互性的任務了,可是其反應的是更高效的運用CPU,因此運算效率會高。
-XX:MaxGCPauseMillis設置最大垃圾回收的停頓時間,設置短是會犧牲吞吐量和新生代,會致使收集頻繁
-XX:GCTimeRatio設置吞吐量大小,這個值大於0小於100的整數。好比設置19,就是GC佔時是5%. (1/(1+19)),默認值是99,就是1%的GC時間。
-XX:+UseAdaptiveSizePolicy,設置這個就不須要設置-Xmn -XX:SurvivorRatio、-XX:PretenureSizeThreshold等細節參數了,會自適應。只須要設置Xmx最大堆,和上面兩個參數便可。
這是一個老年代收集器,Serial的Old版本。一樣的單線程,採用標記-整理算法,給Client模式下使用。
還有兩個用途:
1.與Parallel Scavenge收集器搭配使用
2.做爲CMS的後備方案,發生Concurrent Mode Failure時使用。
這個是用於解救以前新生代使用Parallel Scavenge時,老年代只能選擇Serial Old這個性能不佳的收集器的困境。
Parallel Scavenge + Serial Old的吞吐量不必定比ParNew+ CMS組合強,這個收集器彌補了這個尷尬之處,更適合用於注重吞吐量的場合。
Concurrent Mark Sweep收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前不少Java程序集中在互聯網站或者B/S系統的服務端上,這類服務尤爲注重響應速度,但願系統停頓時間最短,以給用戶帶來較好的體驗。
CMS採起的不像以前的老年代收集器採用的標記整理算法,其使用的是標記-清除算法,整個過程分爲4個步驟:
1.初始標記:暫停全部線程,標記一下GC Roots能直接關聯到的對象,速度很快
2.併發標記:不須要暫停全部線程,是進行GC Roots Tracing的過程
3.從新標記:暫停線程,修正併發標記期間因用戶程序繼續運做而致使標記產生變更的一部分對象的標記記錄,耗時比初始標記長,併發標記短
4.併發清除:不須要暫停線程,清除標記的對象
CMS有3個顯著的缺點:
1.對CPU資源敏感。併發階段雖然不會致使用戶線程停頓,可是會佔用一部分資源致使應用程序變慢,總吞吐量下降。默認的回收線程數是(CPU+3)/4,很多於25%的CPU資源。開發了一種增量式併發收集器,CMS的變種,在併發階段讓GC線程和用戶線程交替運行,減小GC獨佔時間,效果很差。以前也提到了JDK9中廢棄了。
2.沒法處理浮動垃圾,可能出現Concurrent Mode Failure,而致使另外一次Full GC的產生。併發清理階段用戶線程還在產生垃圾,這部分會被留到下一次GC處理,被稱爲浮動垃圾。因此CMS不能像其餘收集器那樣等待老年代幾乎徹底被填滿了再進行收集,須要留一部分空間應對這種狀況。JDK5中,老年代使用了68%會被觸發,能夠經過參數-XX:CMSInitiatingOccupancyFraction的值提升觸發百分比。若是預留空間不夠,就會觸發Concurrent Mode Failure,會使用預備方案,以前說的Serial Old收集器進行清理,停頓時間就更長了。
3.標記-清除會產生大量空間碎片,對大對象分配帶來很大的麻煩,會提早觸發Full GC。-XX:+UseCMSCompactAtFullCollection開關參數(默認開啓)用於在CMS收集器要Full GC時進行內存碎片的合併整理,碎片整理是沒辦法併發進行的,因此停頓時間更長了。-XX:CMSFullGCsBeforeCompaction,這個參數用於設置進行多少次不壓縮的Full GC後進行一次壓縮的,默認0表示每次都壓縮。
G1收集器在JDK9被設置成默認使用的收集器了,這幾年有了更好的發展。這是一款面向服務端應用的垃圾收集器。賦予它的使命就是在替換掉JDK1.5發佈的CMS收集器。也能夠看出經歷的時間很長才完成了這款收集器。
G1有如下特色:
1.併發與並行:充分利用多CPU、多核環境,縮短Stop-The-World時間
2.分代收集:分代概念仍保留在G1中,雖然它一個就管理了新生代和老年代。
3.空間整合:從總體上是基於標記-整理算法實現的收集器,局部上是基於複製算法實現的。不會產生大量碎片。
4.可預測的停頓:除了追求停頓外,還創建可預測的停頓時間模型,讓使用者指定一個長度爲M毫秒的時間片斷內,消耗在垃圾收集上的時間不超過N毫秒,幾乎是實時Java(RTSJ)的垃圾回收器的特製了。
雖然保留了分代概念,可是其是將堆劃分紅多個大小相等的獨立區域,再也不是物理隔離了。之因此可以創建停頓時間模型,也是由於它能夠有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個區域的垃圾堆積的價值大小,在後臺維護一個優先列表,每次根據容許的收集時間,優先收回價值最大的區域,保證在有限時間內達到效率最大。思路雖然簡單,實現起來很是複雜,由於區域並不是孤立,不可能只掃描一個區域,否則其餘區域引用瞭如何判斷?從04年G1的理論到如今才被設置成默認的收集器,可見其困難。
在G1中,使用Remembered Set來避免全堆掃描,每一個區域都有一個這個Set,虛擬機發現程序對Reference類型的數據進行寫操做時,會產生一個Write Barrier暫時中斷寫操做,檢查Reference引用的對象是否處於不一樣的Region之中,若是是,便經過CardTable將相關信息記錄到被引用對象所屬的區域的Remembered Set中。回收時,經過這個就能保證不進行全堆掃描也能知道該區域對象有沒有被其餘區域引用。
G1回收步驟與CMS類似,分爲如下階段:
1.初始標記:標記一下GC Roots能直接關聯到的對象,修改TAMS的值,讓下一階段用戶程序併發運行時,能在正確可用的區域建立新對象
2.併發標記:從堆中對象進行可達性分析,找出存活的對象,可併發執行
3.最終標記:修正併發標記過程當中因爲用戶程序執行致使標記產生變化的記錄,變化記錄在線程的Remembered Set Logs裏面,會將這部分數據合併到Remembered Set中。
4.篩選回收:對各個區域進行排序,根據用戶指望的GC停頓時間制定回收計劃。
時間:【GC類型【發生區域:GC前使用容量->GC後使用容量(該區域總容量),GC耗時】GC前Java堆使用容量->GC後Java堆使用容量(堆總容量),總GC耗時】
例如:33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]
發生區域根據不一樣的收集器,不一樣代命名不一樣。
DefNew就是serial的新生代
ParNew:是ParNew的新生代
PSYoungGen:是Parallel Scavenge的新生代
還有另外一種格式,例如:
100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456), [Perm: 2999K -> 2999K(21248K)],0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
時間上user就是用戶態消耗的CPU時間,sys是內核態消耗的CPU時間,real指從開始到結束所通過的牆鍾時間(Wall Clock Time)。CPU時間與牆鍾時間區別在於:牆鍾時間包含各類非運算的等待耗時,例如磁盤IO等,CPU不包含。可是若是是多核CPU,多線程操做會疊加這些時間,因此會看到user或sys時間超過real時間。
經過參數-XX:+PrintGCDetails這個參數打印內存回收日誌。
大部分狀況下,對象在新生代的Eden區進行分配,沒有足夠空間的時候,觸發一次Minor GC,內存依舊不夠,將對象移動到老年代這個擔保區域。
大對象須要大量的連續空間,好比byte[],會致使觸發垃圾回收,更糟糕的狀況是遇到一羣大對象。虛擬機提供了-XX:PretenureSizeThreshold參數,大於這個設置值的對象直接在老年代進行分配。避免在Eden和兩個Survivor區之間發生大量的內存複製。這個參數必須寫成字節數,不能直接寫MB。
另外一種進入老年代的方法就是知足了年齡閾值,每進行一次Minor GC後對象仍或者,其年齡就會加1,到達閾值就會進入老年代。經過-XX:MaxTenuringThreshold設置。
爲了更好的適用內存情況,不必定必須達到年齡才能晉升老年代,若是survivor空間中相同年齡全部對象大小總和大於survivor空間的一半,年齡大於等於這個年齡的對象就能夠直接進入老年代。
在執行Minor GC以前,會檢查老年代的最大可用連續空間是否大於新生代全部對象總和,成立那麼Minor GC就是安全的。由於老年代是在新生代空間不足時的擔保方,最差的狀況就是全部對象都進入了老年代,因此只要老年代的空間足夠,那麼Minor GC必定安全。若是不安全,就會查看HandlePromotionFailure設置值是否容許擔保失敗,若是容許,就會檢查老年代的連續最大可用空間是否大於歷次晉升到老年代對象的平均大小,若是大於,會嘗試進行Minor GC,儘管這次也有風險,小於或者不容許失敗,會改爲進行Full GC。JDK6 Update24後,這個參數沒有實際做用了,大於平均晉級大小,就會進行Minor GC,不然就是Full GC。