Java內存分配與垃圾收集

Java內存分配與垃圾收集

  • Java運行時數據區域
  • HotSpot虛擬機在Java堆中對象分配、佈局和訪問的全過程
  • Java垃圾收集器
  • Java內存分配策略

Java運行時數據區域

  Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域。這些區域有各自的用途,以及創建和銷燬時間。

程序計數器
  • 它可以看成是當前線程所執行的字節碼的行號指示器。
  • 由於Java虛擬機的多線程是通過線程的輪流切換並分配處理器的執行時間的方式來實現的,爲了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,所以這部分內存是“線程私有”的。生命週期同線程相同。
  • 如果線程正在執行Java方法,則該計數器記錄的是正在執行的虛擬機字節碼指令的地址。如果正在執行的是Native方法,則該計數器值爲空。
  • 該內存區域是唯一在Java虛擬機規範中沒有規定任何OutOfMemoryError的區域。
Java虛擬機棧
  • 爲虛擬機執行Java方法服務。
  • Java虛擬機棧是“線程私有”的,它的生命週期與線程一樣。
  • 每個方法在執行的同時都會創建一個“棧幀”,用於存儲局部變量表,操作數棧,動態鏈接,方法出口等信息。每一個方法從調用直至完成的過程,就對應一個“棧幀”在虛擬機棧中入棧和出棧過程。
  • 局部變量表所需空間在編譯期間完成分配,其存放了編譯期可知的各種基本數據類型,對象引用和return類型。
  • 如果線程請求的棧深度超過虛擬機所允許的深度,將拋出StackOverflowError。
  • 如果虛擬機棧可以動態擴展,當擴展時申請不到足夠的內存,就會拋出OutOfMemoryError。
本地方法棧
  • 爲虛擬機執行Native方法服務。
  • 有的虛擬機(如 HotSpot)直接就把本地方法棧和虛擬機棧合二爲一。
  • 本地方法棧會拋出StackOverflowError和OutOfMemoryError。
  • 生命週期同線程相同。
Java堆(GC堆)
  • Java堆是被所有線程共享的一塊內存區域。
  • 此內存區域用來存放對象實例,幾乎所有的對象實例都在這裏分配。
  • 從內存回收的角度來看,由於現在收集器基本都採用分代收集算法,所以Java堆可以細分爲:新生代和老年代。
  • 從內存分配角度來看,線程共享的Java堆可能劃分出多個線程私有的分配緩衝區(TLAB)。
  • Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可。
  • 如果在堆中沒有內存完成實例分配,並且堆也無法在擴展時,將會拋出OutOfMemoryError。
方法區
  • 它是線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。
  • 不需要連續的內存,可以選擇固定的大小或者可擴展,還可以選擇不實現垃圾收集。
  • 這部分區域的內存回收的目標主要是針對常量池的回收和對類型的卸載。
  • 當方法區無法滿足內心分配需求時,會拋出OutOfMemoryError。
運行時常量
  • 運行時常量池是方法區的一部分。
  • Class文件中有類的版本,字段,方法,接口,常量池等信息,常量池用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載進入方法區的運行時常量池中存放。
  • 運行時常量池相對於Class文件常量池的另一個重要特徵是具備動態性,也就是說並非預置入Class文件中常量池的內容才能進入方法區運行時常量池。
  • 當常量池無法再申請到內存時,就會拋出OutOfMemoryError。
直接內存
  • 在JDK 1.4中新加入了NIO類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用Native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作爲這塊內存的應用進行操作。
  • 當使用直接內存導致總內存超過物理內存時,會出現OutOfMemoryError。

HotSpot虛擬機在Java堆中對象分配、佈局和訪問的全過程

對象的創建
  1. 虛擬機執行一條new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,則必須先執行相應的類的加載過程。
  2. 類加載通過檢測後,接下來虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,爲對象分配空間的任務等同於把一塊確定大小的內存從Java堆中劃分出來。根據Java堆中內存是否絕對規整,內存分配方式可以分爲“指針碰撞”和“空閒列表”。對於分配對象內存空間時的線程安全性問題,有兩種解決方案:一種是對分配內存空間的動作進行同步處理-實際上虛擬機採用CAS配上失敗重試的方式保證更新操作的原子性;另一種是把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存(本地線程分配緩衝TLAB)。
  3. 內存分配後,虛擬機需要將分配到的內存空間都初始化爲零值(不包括對象頭),如果使用TLAB,這一工作可以提前到TLAB分配時進行。
  4. 接下來,虛擬機對對象進行必要的設置。例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼和對象的GC分代年齡等信息。這些信息存放在對象的對象頭中。

以上對象的創建只是虛擬機完成的部分,從Java程序角度看,程序員還需要按意願去完成自己需要的初始化過程。

對象的內存佈局

  對象在內存中的存儲的佈局可以分爲3塊區域:對象頭(Header),實例數據(Instance Data)和對齊填充(Padding)。

  • 對象頭。包括兩部分信息:一部分用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡等。該部分會根據對象的狀態複用自己的存儲空間。另一部分是類型指針,即對象指向它類元數據的指針,虛擬機通過這個指針來確實這個對象是哪個類的實例。
  • 實例數據。對象真正存儲的有效信息,也是在程序中定義的各種類型字段內容。這部分的存儲順序會受到虛擬機分配策略參數和字段在Java源碼中定義順序的影響。
  • 對齊填充。並不一定存在,它只起到佔位符的作用。
對象的訪問定位

  Java程序需要通過棧上的reference數據來操作堆上的具體對象。Java虛擬機規範中只規定了reference類型是一個指向對象的引用。主流的訪問方式有使用“句柄”和“直接指針”兩種。

  • 使用句柄,則Java堆中將會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。優勢是穩定句柄地址。
  • 使用直接指針,那麼Java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,而reference中存儲的直接就是對象地址,速度更快。

垃圾收集器

  研究垃圾收集(GC)的原因是,當需要排查各種內存溢出、內存泄露問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,就需要對這些“自動化”的垃圾收集技術實施必要的監控和調節。

對象能否回收判斷
引用計數算法

  簡單說,引用計數就是,應用對象則計數器加1,當引用失效時,計數器值就減1,當計數器爲0就不再被使用。
  主流的Java虛擬機沒有選用引用計數算法來管理內存,主要原因是它很難解決對象間相互循環引用的問題。

可達性分析算法

  這個算法的基本思路就是通過一系列的稱爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連接時,則證明該對象是不可用的。
Java中可作爲GC Roots的對象包括:

  • 虛擬機棧中引用的對象。
  • 方法區中類靜態屬性引用的對象。
  • 方法區中常量引用的對象。
  • 本地方法棧中JNI(即Native方法)引用的對象。
引用劃分

  Java將引用分爲強引用(Strong Reference),軟引用(Soft Reference),弱引用(Weak Reference)和虛引用(Phantom Reference)。

  • 強引用。只要強引用在,垃圾收集器就不會回收掉被引用對象。
  • 軟引用。用SoftReference來實現軟引用。在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收。
  • 弱引用。用WeakReference來實現弱引用。在進行垃圾收集時,被回收。
  • 虛引用。用PhantomReference來實現虛引用。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。
對象的最後希望

  即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並進行一次篩選,篩選的條件是該對象是否有必要執行finalize()方法。如果對象沒有覆蓋finalize()方法或者finalize()方法已經被虛擬機調用過,則虛擬機視爲“沒有必要執行”。
  如果這個對象被判定爲有必要執行finalize()方法,那麼這個對象將會放置在一個叫做F-Queue的隊列中,並在稍後由一個由虛擬機自動建立的、低優先級的Finalizer線程去執行它。所謂的“執行”是指虛擬機會觸發這個方法,但並不會承諾等待它運行結束,這是因爲,如果一個對象在finalize()方法中執行緩慢,或者發生了死循環,將可能會導致F-Queue對列中其他對象永久處於等待,甚至導致整個內存回收系統崩潰。finalize()方法是對象跳脫死亡命運的最後一次機會,如果想拯救自己,只要在finalize()方法中重新建立關聯就好,如果這個時候沒有拯救,基本上就真被回收了。
  注意任何一個對象的finalize()方法都只會被系統自動調用一次,而且該方法沒有任何好處,建議忘記它的存在,不要使用它。

回收方法區

  永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。
  回收廢棄常量與回收Java堆中的對象非常相似,當常量池中的常量沒有對象引用它的時候,在必要的情況下,它就可以被回收。
判定一個類是否是“無用類”需要同時滿足下面3個條件:

  • 該類所有的實例都已經被回收。
  • 加載該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

“無用類”僅僅可以被回收,而不是必然被回收。

垃圾收集算法
標記-消除算法

  過程:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。
  兩點不足:效率不高和清除後產生大量不連續內存碎片。

複製算法

  它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊,當一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這種算法進行內存回收,不會出現內存碎片,而且運行高效,但其將內存縮小爲原來的一半。
  注意現在的商業虛擬機都採用這種算法來回收新生代。
  經過研究表明,新生代中的對象98%是“朝生夕死”的,所以出現了將內存空間劃分爲一塊較大的Eden空間和兩塊較小的Survivor空間的分配方法。每次使用Eden和其中一塊Suvivor。當回收時,將Eden空間和Survivor中還存活着的對象一次性複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1。當Survivor空間不夠用時,需要依賴其他內存(這裏指老年代)進行分配擔保。

標記-整理算法

  當對象存活率高時(例如老年代),就不適合使用“複製算法”。
  “標記-整理算法”的標記過程與“標記-清除算法”一樣,但後續步驟不是直接對可回收對象就行清理,而是讓所有存活的對象向一端移動,然後直接清理掉端邊界意外的內存。

分代收集算法

  一般是把Java堆分爲新生代和老年代,因爲新生代和老年代的對象存活週期不同,所以使用不同的收集算法。

HotSpot算法實現
  1. 枚舉根節點
      可達性分析對執行時間的敏感不緊體現在查找GC Roots的節點的數據量上,還體現在GC停滯上,因爲這項分析必須在一個能確保一致性的快照中進行,以保證不會出現在分析過程中對象引用關係還在不斷變化的情況。所以這會導致GC進行時必須停滯所有的Java執行線程
      爲了減少停滯時間,HotSpot的實現中,使用了一組稱爲OopMap的數據結構來存放對象引用,在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,在JIT編譯過程中,也會在特定的位置(安全點)記錄下棧和寄存器中哪些位置是引用。
  2. 安全點
      可能導致引用關係變化的指令很多,如果爲每一條指令都生成對應的OopMap,那將會需要很大的額外空間,這樣GC的空間成本過高。所以HotSpot確實沒有爲每一條指令都生成OopMap,只是在“特定的位置”記錄下這些信息,這些位置成爲安全點。
      安全點的選定基本上是以程序“是否具有讓程序長時間執行的特徵”爲標準進行選定的。
      當GC發生時,如何讓所有的線程都“跑”到最近的安全點上再停頓下來,有兩種方案:搶斷式中斷和主動性中斷。
      幾乎沒有虛擬機實現搶斷式中斷。
      主動式中斷的思想是當GC需要中斷線程時,不直接對線程操作,僅僅簡單地設置一個標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起,輪詢標誌的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。
  3. 安全區域
      安全點機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的安全點,但當程序沒有分配到CPU“不執行”的時候,其就無法響應JVM的中斷請求,運行到安全的地方去中斷掛起,對於這種情況就需要“安全區域”來解決問題。
      安全區域是指在一段代碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。
      當線程執行到“安全區域”中的代碼時,首先標識自己已經進入了“安全區域”,當在這段時間裏JVM要發起GC時,就不用管標識自己爲“安全區域”的線程了,在線程要來開“安全區域”時,它要檢查系統是否已經完成了根節點枚舉,如果完成了,那線程就繼續執行,否則它就必須等待直到收到可以安全離開“安全區域”的信號爲止。

Java內存分配策略

  對象的內存分配,大方向上講,就是在堆上分配(也可能經過JIT編輯後被拆散爲變量類型並間接地棧上分配),對象主要分配在新生代的Eden區上,如果啓動了本地線程分配緩衝,將按線程優先在TLAB上分配。少數情況下也可能直接分配在老年代中,分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一中年垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。
1. 對象優先在Eden分配
  當Eden區沒有足夠空間分配時,虛擬機將發起一次Minor GC。
  Minor GC和Full GC的區別:
  新生代GC(Minor GC):指發生在新生代的垃圾收集動作,新生代對象存活週期短,所以Minor GC非常頻繁,一般回收速度也比較快。
  老年代GC(Major GC|Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC,回收速度比Minor GC慢很多。
2. 大對象直接進入老年代(參數控制)
  目的:避免在Eden區及兩個Survivor區之間發生大量的內存複製。
大對象指需要大量連續內存空間的Java對象,最典型的就是很長的字符串以及數組。
  大對象對虛擬機的內存分配來說就是一個壞消息,經常出現大對象,容易導致內存還有很多空間時,就提前觸發垃圾收集以獲取足夠的連續的空間來“安置”它們。
3. 長期存活的對象將進入老年代
  虛擬機給每個對象定義了一個對象年齡計數器,如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設置爲1,對象在Survivor區中每“熬過”一次Minor GC,年齡就增長1歲,當它的年齡增加到一定程度,就將會被晉升到老年代中。
4. 動態對象年齡判斷
  爲了更好適應不同程序的內存狀況,虛擬機並不是永遠地要求對象的年齡必須達到一定程度才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代。
5. 空間分配擔保
  在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次Minor GC,儘管這次Minor GC是有風險的,如果小於,或者HandlerPromotionFailure設置不允許冒險,那這時也要改爲進行一次Full GC。

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