十分良心!全网最详细的Java 自动内存管理机制及性能优化教程
同樣的,先來(lái)個(gè)思維導(dǎo)圖預(yù)覽一下本文結(jié)構(gòu)。
?
一圖帶你看完本文
?
一、運(yùn)行時(shí)數(shù)據(jù)區(qū)域
首先來(lái)看看Java虛擬機(jī)所管理的內(nèi)存包括哪些區(qū)域,就像我們要了解一個(gè)房子,我們得先知道這個(gè)房子大體構(gòu)造。根據(jù)《Java虛擬機(jī)規(guī)范(Java SE 7 版)》的規(guī)定,請(qǐng)看下圖:
?
Java 虛擬機(jī)運(yùn)行時(shí)數(shù)據(jù)區(qū)
?
1.1 程序計(jì)數(shù)器
程序計(jì)數(shù)器是一塊較小的內(nèi)存空間,它可以看作是當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。
- 由于 Java 虛擬機(jī)的多線程是通過(guò)線程輪流切換并分配處理器執(zhí)行時(shí)間的方式來(lái)實(shí)現(xiàn)的,在任何一個(gè)確定的時(shí)刻,一個(gè)處理器(對(duì)于多核處理器來(lái)說(shuō)是一個(gè)內(nèi)核)都只會(huì)執(zhí)行一條線程中的指令。
- 為了線程切換后能恢復(fù)到正確的執(zhí)行位置,每條線程都需要有一個(gè)獨(dú)立的程序計(jì)數(shù)器,各條線程之間計(jì)數(shù)器互不影響,獨(dú)立存儲(chǔ),我們稱這類(lèi)內(nèi)存區(qū)域?yàn)椤熬€程私有”的內(nèi)存。
- 此內(nèi)存區(qū)域是唯一一個(gè)在 Java 虛擬機(jī)規(guī)范中沒(méi)有規(guī)定任何 OutOfMemoryError 情況的區(qū)域。
1.2 Java 虛擬機(jī)棧
與程序計(jì)數(shù)器一樣,Java 虛擬機(jī)棧也是線程私有的,它的生命周期與線程相同。虛擬機(jī)棧描述的是 Java 方法執(zhí)行的內(nèi)存模型:每個(gè)方法在執(zhí)行的同時(shí)都會(huì)創(chuàng)建一個(gè)棧幀用于存儲(chǔ)局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接、方法出口等信息。每一個(gè)方法從調(diào)用直至執(zhí)行完成的過(guò)程,就對(duì)應(yīng)著一個(gè)棧幀在虛擬機(jī)棧中入棧到出棧的過(guò)程。請(qǐng)看下圖:
?
Java 虛擬機(jī)棧
?
- 有人把 Java 內(nèi)存區(qū)分為堆內(nèi)存和棧內(nèi)存,而所指的“棧”就是這里的虛擬機(jī)棧,或者說(shuō)是虛擬機(jī)棧中局部變量表部分。
- 局部變量表存放了編譯期可知的各種基本數(shù)據(jù)類(lèi)型(boolean、byte、char、short、int、float、long、double)、對(duì)象引用和 returnAddress 類(lèi)型(指向了一條字節(jié)碼指令的地址),其中64位長(zhǎng)度的 long 和 double 類(lèi)型的數(shù)據(jù)占用2個(gè)局部變量空間,其余數(shù)據(jù)類(lèi)型只占用1個(gè)。
- 操作數(shù)棧也常被稱為操作棧,它是一個(gè)后入先出棧。當(dāng)一個(gè)方法剛剛執(zhí)行的時(shí)候,這個(gè)方法的操作數(shù)棧是空的,在方法執(zhí)行的過(guò)程中,會(huì)有各種字節(jié)碼指向操作數(shù)棧中寫(xiě)入和提取值,也就是入棧與出棧操作。
- 每個(gè)棧幀都包含一個(gè)指向運(yùn)行時(shí)常量池中該棧幀所屬方法的引用,持有這個(gè)引用是為了支持方法調(diào)用過(guò)程中的動(dòng)態(tài)連接。在Class文件的常量池中存有大量的符號(hào)引用,字節(jié)碼中的方法調(diào)用指令就以常量池中指向方法的符號(hào)引用為參數(shù)。這些符號(hào)引用一部分會(huì)在類(lèi)加載階段或第一次使用的時(shí)候轉(zhuǎn)化為直接引用,這種轉(zhuǎn)化稱為靜態(tài)解析。另外一部分將在每一次的運(yùn)行期期間轉(zhuǎn)化為直接引用,這部分稱為動(dòng)態(tài)連接。
- 當(dāng)一個(gè)方法執(zhí)行完畢之后,要返回之前調(diào)用它的地方,因此在棧幀中必須保存一個(gè)方法返回地址。方法退出的過(guò)程實(shí)際上等同于把當(dāng)前棧幀出棧,因此退出時(shí)可能執(zhí)行的操作有:恢復(fù)上層方法的局部變量表和操作數(shù)棧,把返回值(如果有的話)壓入調(diào)用都棧幀的操作數(shù)棧中,調(diào)用PC計(jì)數(shù)器的值以指向方法調(diào)用指令后面的一條指令等。
- 虛擬機(jī)規(guī)范允許具體的虛擬機(jī)實(shí)現(xiàn)增加一些規(guī)范里沒(méi)有描述的信息到棧幀中,例如與高度相關(guān)的信息,這部分信息完全取決于具體的虛擬機(jī)實(shí)現(xiàn)。在實(shí)際開(kāi)發(fā)中,一般會(huì)把動(dòng)態(tài)連接,方法返回地址與其它附加信息全部歸為一類(lèi),稱為棧幀信息。
- 在 Java 虛擬機(jī)規(guī)范中,規(guī)定了兩種異常狀況:如果線程請(qǐng)求的棧深度大于虛擬機(jī)所允許的深度,將拋出 StackOverflowError 異常;如果虛擬機(jī)棧可以動(dòng)態(tài)擴(kuò)展,當(dāng)擴(kuò)展時(shí)無(wú)法申請(qǐng)到足夠的內(nèi)存,就會(huì)拋出 OutOfMemoryError 異常。
1.2.1 虛擬機(jī)棧溢出
- 當(dāng)棧空間無(wú)法繼續(xù)分配時(shí),到底是內(nèi)存太小,還是已使用的棧空間太大,其本質(zhì)上只是對(duì)同一件事情的兩種描述而已。
- 系統(tǒng)分配給每個(gè)進(jìn)程的內(nèi)存是有限制的,除去 Java 堆、方法區(qū)、程序計(jì)數(shù)器,如果虛擬機(jī)進(jìn)程本身耗費(fèi)的內(nèi)存不計(jì)算在內(nèi),剩下內(nèi)存就由虛擬機(jī)棧和本地方法棧“瓜分”了。每個(gè)線程分配到的棧容量越大,可以建立的線程數(shù)量自然就越少,建立線程時(shí)就越容易把剩下的內(nèi)存耗盡。
- 出現(xiàn) StackOverflowError 異常時(shí)有錯(cuò)誤棧可以閱讀,棧深度在大多數(shù)情況下達(dá)到1000~2000完全沒(méi)有問(wèn)題,對(duì)于正常的方法調(diào)用(包括遞歸),這個(gè)深度應(yīng)該完全夠用了。
- 但是,如果是建立過(guò)多線程導(dǎo)致的內(nèi)存溢出,在不能減少線程數(shù)或者更換 64 位虛擬機(jī)的情況下,就只能通過(guò)減少最大堆和減少棧容量來(lái)?yè)Q取更多的線程。
1.3 本地方法棧
- 本地方法棧與虛擬機(jī)棧所發(fā)揮的作用非常相似,它們之間的區(qū)別是虛擬機(jī)棧為虛擬機(jī)執(zhí)行 Java 方法服務(wù),而本地方法棧則為虛擬機(jī)棧使用到的 Native 方法服務(wù)。
- 與虛擬機(jī)棧一樣,本地方法棧區(qū)域也會(huì)拋出 StackOverflowError 和 OutOfMemoryError 異常。
1.4 Java 堆
Java 堆是被所有線程共享的一塊內(nèi)存區(qū)域,在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建。此內(nèi)存區(qū)域的唯一目的就是存放對(duì)象實(shí)例,幾乎所有的對(duì)象實(shí)例都在這里分配內(nèi)存(但是,隨著技術(shù)發(fā)展,所有對(duì)象都分配在堆上也漸漸變得不是那么“絕對(duì)”了)。請(qǐng)看下圖:
?
Generational Heap Memory 模型
?
- 對(duì)于大多數(shù)應(yīng)用來(lái)說(shuō),Java 堆是 Java 虛擬機(jī)所管理的內(nèi)存中最大的一塊。
- Java 堆是垃圾收集器管理的主要區(qū)域,也被稱為“GC堆”。
- Java 堆可以細(xì)分為新生代、老年代、永久代;再細(xì)致一點(diǎn)可以分為 Eden、From Survivor、To Survivor、Tenured、Permanent 。
- Java 堆可以處于物理上不連續(xù)的內(nèi)存空間中,只要邏輯上是連續(xù)的即可,就像磁盤(pán)空間一樣。
- 從內(nèi)存分配的角度來(lái)看,線程共享的 Java 堆中可能劃分出多個(gè)線程私有的分配緩沖區(qū)(TLAB)。
- 如果在堆中沒(méi)有內(nèi)存完成實(shí)例分配,并且堆也無(wú)法再擴(kuò)展時(shí),將會(huì)拋出 OutOfMemoryError 異常。
1.4.1 Java 堆溢出
- Java 堆用于存儲(chǔ)對(duì)象實(shí)例,只要不斷地創(chuàng)建對(duì)象,并且保證 GC Roots 到對(duì)象之間有可達(dá)路徑來(lái)避免垃圾回收機(jī)制清除這些對(duì)象,那么在對(duì)象數(shù)量到達(dá)最大堆的容量限制后就會(huì)產(chǎn)生內(nèi)存溢出異常。
- Java 堆內(nèi)存的 OOM 異常是實(shí)際應(yīng)用中常見(jiàn)的內(nèi)存溢出異常情況。當(dāng)出現(xiàn) Java 堆內(nèi)存溢出時(shí),異常堆棧信息 “java.lang.OutOfMemoryError” 會(huì)跟著進(jìn)一步提示 “Java heap space” 。
- 通常是先通過(guò)內(nèi)存映像分析工具對(duì) Dump 出來(lái)的堆轉(zhuǎn)儲(chǔ)快照進(jìn)行分析,重點(diǎn)是確認(rèn)內(nèi)存中的對(duì)象是否是必要的,也就是要先分清楚到底是出現(xiàn)了內(nèi)存泄漏還是內(nèi)存溢出。
- 如果是內(nèi)存泄漏,可進(jìn)一步通過(guò)工具查看泄露對(duì)象到 GC Roots 的引用鏈。于是就能找到泄露對(duì)象的類(lèi)型信息及 GC Roots 引用鏈的信息,就可以比較準(zhǔn)確地定位出泄露代碼的位置。
- 如果不存在泄露,就是內(nèi)存中的對(duì)象確實(shí)都還必須存活著,那就應(yīng)當(dāng)檢查虛擬機(jī)的堆參數(shù)(-Xmx 與 -Xms),與機(jī)器物理內(nèi)存對(duì)比看是否還可以調(diào)大,從代碼上檢查是否存在某些對(duì)象生命周期過(guò)長(zhǎng)、持有狀態(tài)時(shí)間過(guò)長(zhǎng)的情況,嘗試減少程序運(yùn)行期的內(nèi)存消耗。
1.5 方法區(qū)
方法區(qū)與 Java 堆一樣,是各個(gè)線程共享的內(nèi)存區(qū)域,它用于存儲(chǔ)已被虛擬機(jī)加載的類(lèi)信息、常量、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù)。
- Java 虛擬機(jī)規(guī)范對(duì)方法區(qū)的限制非常寬松,除了和 Java 堆一樣不需要連續(xù)的內(nèi)存和可以選擇固定大小或者可擴(kuò)展外,還可以選擇不實(shí)現(xiàn)垃圾收集。
- 這區(qū)域的內(nèi)存回收目標(biāo)主要是針對(duì)常量池的回收和對(duì)類(lèi)型的卸載。
- 當(dāng)方法區(qū)無(wú)法滿足內(nèi)存分配需求時(shí),將拋出 OutOfMemoryError 異常。
1.5.1 運(yùn)行時(shí)常量池
- 運(yùn)行時(shí)常量池是方法區(qū)的一部分。
- 常量池用于存放編譯期生成的各種字面量和符號(hào)引用,這部分內(nèi)容將在類(lèi)加載后進(jìn)入方法區(qū)的運(yùn)行時(shí)常量池中存放。
- 運(yùn)行時(shí)常量池相對(duì)于 Class 文件常量池的一個(gè)重要特征是具備動(dòng)態(tài)性,Java 語(yǔ)言并不要求常量一定只有編譯期才能產(chǎn)生,也就是并非預(yù)置入 Class 文件中常量池的內(nèi)容才能進(jìn)入方法區(qū)運(yùn)行時(shí)常量池,運(yùn)行期間也可能將新的常量放入池中,這種特性被開(kāi)發(fā)人員利用得比較多的便是 String 類(lèi)的 intern() 方法。
- 當(dāng)常量池?zé)o法再申請(qǐng)到內(nèi)存時(shí)會(huì)拋出 OutOfMemoryError 異常。在 OutOfMemoryError 后面跟隨的提示信息時(shí) “PermGen space” 。
1.6 直接內(nèi)存
- 直接內(nèi)存并不是虛擬機(jī)運(yùn)行時(shí)數(shù)據(jù)區(qū)的一部分,也不是 Java 虛擬機(jī)規(guī)范中定義的內(nèi)存區(qū)域。但是這部分內(nèi)存也被頻繁地使用,而且也可能導(dǎo)致 OutOfMemoryError 異常出現(xiàn)。
- NIO 類(lèi),一種基于通道與緩沖區(qū)的 I/O 方式,它可以使用 Native 函數(shù)庫(kù)直接分配堆外內(nèi)存,然后通過(guò)一個(gè)存儲(chǔ)在 Java 堆中的 DirectByteBuffer 對(duì)象作為這塊內(nèi)存的引用進(jìn)行操作。這樣能在一些場(chǎng)景中顯著提高性能,因?yàn)楸苊饬嗽?Java 堆和 Native 堆中來(lái)回復(fù)制數(shù)據(jù)。
- 本機(jī)直接內(nèi)存的分配不會(huì)受到 Java 堆大小的限制,但是,既然是內(nèi)存,肯定還是會(huì)受到本機(jī)總內(nèi)存(包括 RAM 以及 SWAP 區(qū)或者分頁(yè)文件)大小以及處理器尋址空間的限制。
- 由 DirectMemory 導(dǎo)致的內(nèi)存溢出,一個(gè)明顯的特征是在 Heap Dump 文件中不會(huì)看見(jiàn)明顯的異常,如果我們發(fā)現(xiàn) OOM 之后 Dump 文件很小,而程序中有直接或間接使用了 NIO ,那就可以考慮檢查一下是不是這方面的原因。
二、內(nèi)存分配策略
對(duì)象的內(nèi)存分配,往大方向講,就是在堆上分配(但也可能經(jīng)過(guò) JIT 編譯后被拆散為標(biāo)量類(lèi)型并間接地棧上分配),對(duì)象主要分配在新生代的 Eden 區(qū)上,如果啟動(dòng)了本地線程分配緩沖,將按線程優(yōu)先在 TLAB 上分配。少數(shù)情況下也可能會(huì)直接分配在老年代中,分配的規(guī)則并不是固定的,其細(xì)節(jié)取決于當(dāng)前使用的是哪一種垃圾收集器組合,還有虛擬機(jī)中與內(nèi)存相關(guān)的參數(shù)的設(shè)置。
2.1 對(duì)象優(yōu)先在 Eden 分配
大多數(shù)情況下,對(duì)象在新生代 Eden 區(qū)中分配。當(dāng) Eden 區(qū)沒(méi)有足夠的空間進(jìn)行分配時(shí),虛擬機(jī)將發(fā)起一次 Minor GC 。舉個(gè)例子,看下面的代碼:
?
執(zhí)行上面的testAllocation() 代碼,當(dāng)分配 allocation4 對(duì)象的語(yǔ)句時(shí)會(huì)發(fā)生一次 Minor GC ,這次 GC 的結(jié)果是新生代 6651KB 變?yōu)?148KB ,而總內(nèi)存占用量則幾乎沒(méi)有減少(因?yàn)?allocation1、allocation2、allocation3 三個(gè)對(duì)象都是存活的,虛擬機(jī)幾乎沒(méi)有找到可回收的對(duì)象)。這次 GC 發(fā)生的原因是給 allocation4 分配內(nèi)存時(shí),發(fā)現(xiàn) Eden 已經(jīng)被占用了 6MB ,剩余空間已不足以分配 allocation4 所需的 4MB 內(nèi)存,因此發(fā)生 Minor GC 。GC 期間虛擬機(jī)又發(fā)現(xiàn)已有的 3 個(gè) 2MB 大小的對(duì)象全部無(wú)法放入 Survivor 空間(從上圖中可看出 Survivor 空間只有 1MB 大小),所以只好通過(guò)分配擔(dān)保機(jī)制提前轉(zhuǎn)移到老年代去。
2.2 大對(duì)象直接進(jìn)入老年代
- 所謂的對(duì)象是指,需要大量連續(xù)內(nèi)存空間的 Java 對(duì)象,最典型的大對(duì)象就是那種很長(zhǎng)的字符串以及數(shù)組。經(jīng)常出現(xiàn)大對(duì)象容易導(dǎo)致內(nèi)存還有不少空間時(shí)就提前觸發(fā)垃圾收集以獲取足夠的連續(xù)空間來(lái)“安置”它們。
- 虛擬機(jī)提供了一個(gè) -XX:PretenureSizeThreshold 參數(shù),令大于這個(gè)設(shè)置值的對(duì)象直接在老年代分配。這樣做的目的是避免在 Eden 區(qū)及兩個(gè) Survivor 區(qū)之間發(fā)生大量的內(nèi)存復(fù)制(新生代采用復(fù)制算法收集內(nèi)存)。
2.3 長(zhǎng)期存活的對(duì)象將進(jìn)入老年代
既然虛擬機(jī)采用了分代收集的思想來(lái)管理內(nèi)存,那么內(nèi)存回收時(shí)就必須能識(shí)別到哪些對(duì)象應(yīng)放在新生代,哪些對(duì)象應(yīng)放在老年代中。為了做到這點(diǎn),虛擬機(jī)給每個(gè)對(duì)象定義了一個(gè)對(duì)象年齡計(jì)數(shù)器。如果對(duì)象在 Eden 出生并經(jīng)過(guò)第一次 Minor GC 后仍然存活,并且能被 Survivor 容納的話,將被移動(dòng)到 Survivor 空間中,并且對(duì)象年齡設(shè)為 1 。對(duì)象在 Survivor 區(qū)中每“熬過(guò)”一次 Minor GC,年齡就增加1歲,當(dāng)它的年齡增加到一定程度(默認(rèn)15歲),就會(huì)被晉升到老年代中。對(duì)象晉升老年代的年齡閾值,可以通過(guò)參數(shù) -XX:MaxTenuringThreshold 設(shè)置。
2.4 動(dòng)態(tài)對(duì)象年齡判定
為了能更好地適應(yīng)不同程序的內(nèi)存狀況,虛擬機(jī)并不是永遠(yuǎn)地要求對(duì)象的年齡必須達(dá)到了 MaxTenuringThreshold 才能晉升老年代,如果在 Survivor 空間中相同年齡所有對(duì)象大小的總和大于 Survivor 空間的一半,年齡大于或等于該年齡的對(duì)象就可以直接進(jìn)入老年代,無(wú)須等到 MaxTenuringThreshold 中的要求的年齡。
2.5 空間分配擔(dān)保機(jī)制
- 在發(fā)生 Minor GC 之前,虛擬機(jī)會(huì)先檢查老年代最大可用的連續(xù)空間是否大于新生代所有對(duì)象總空間,如果這個(gè)條件成立,那么 Minor GC 可以確保是安全的。如果不成立,則虛擬機(jī)會(huì)查看 HandlePromotionFailure 設(shè)置值是否允許擔(dān)保失敗。如果允許,那么會(huì)繼續(xù)檢查老年代最大可用的連續(xù)空間是否大于歷次晉升到老年代對(duì)象的平均大小,如果大于,將嘗試著進(jìn)行一次 Minor GC ,盡管這次 Minor GC 是有風(fēng)險(xiǎn)的;如果小于,或者 HandlePromotionFailure 設(shè)置不允許冒險(xiǎn),那這次也要改為進(jìn)行一次 Full GC。
- 上面提到的“冒險(xiǎn)”指的是,由于新生代使用復(fù)制收集算法,但為了內(nèi)存利用率,只使用其中一個(gè) Survivor 空間來(lái)作為輪換備份,因此當(dāng)出現(xiàn)大量對(duì)象在 Minor GC 后仍然存活的情況,把 Survivor 無(wú)法容納的對(duì)象直接進(jìn)入老年代。老年代要進(jìn)行這樣的擔(dān)保,前提是老年代本身還有容納這些對(duì)象的剩余空間,一共有多少對(duì)象會(huì)活下來(lái)在實(shí)際完成內(nèi)存回收之前是無(wú)法明確知道的,所以只好取之前每一次回收晉升到老年代對(duì)象容量的平均大小值作為經(jīng)驗(yàn)值,與老年代的剩余空間進(jìn)行比較,決定是否進(jìn)行 Full GC 來(lái)讓老年代騰出更多空間。
- 取平均值進(jìn)行比較其實(shí)仍然是一種動(dòng)態(tài)概率的手段,也就是說(shuō),如果某次 Minor GC 存活后的對(duì)象突增,遠(yuǎn)遠(yuǎn)高于平均值的話,依然會(huì)導(dǎo)致?lián)J ?/li>
- 如果出現(xiàn)了HandlePromotionFailure 失敗,那就只好在失敗后重新發(fā)起一次 Full GC。雖然擔(dān)保失敗時(shí)繞的圈子是最大的,但大部分情況下都還是會(huì)將 HandlePromotionFailure 開(kāi)關(guān)打開(kāi),避免 Full GC 過(guò)于頻繁。
- 但在 JDK 6 Update 24 之后,HandlePromotionFailure 參數(shù)不會(huì)再影響到虛擬機(jī)的控件分配擔(dān)保策略,只要老年代的連續(xù)空間大于新生代對(duì)象總大小或者歷次晉升的平均大小就會(huì)進(jìn)行 Minor GC ,否則將進(jìn)行 Full GC。
三、內(nèi)存回收策略
- 新生代 GC(Minor GC) :指發(fā)生在新生代的垃圾收集動(dòng)作,因?yàn)?Java 對(duì)象大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
- 老年代 GC(Major GC / Full GC):值發(fā)生在老年代的 GC,出現(xiàn)了 Major GC,經(jīng)常會(huì)伴隨至少一次的 Minor GC(但非絕對(duì))。Major GC 的速度一般會(huì)比 Minor GC 慢 10 倍以上。
3.1 內(nèi)存回收關(guān)注的區(qū)域
- 上面已經(jīng)介紹 Java 內(nèi)存運(yùn)行時(shí)區(qū)域的各個(gè)部分,其中程序計(jì)數(shù)器、虛擬機(jī)棧、本地方法棧3個(gè)區(qū)域隨線程而生,隨線程而滅。
- 棧中的棧幀隨著方法的進(jìn)入和退出而有條不紊地執(zhí)行者出棧和入棧操作。每一個(gè)棧幀中分配多少內(nèi)存基本上是在類(lèi)結(jié)構(gòu)確定下來(lái)時(shí)就已知的。
- 因此這幾個(gè)區(qū)域的內(nèi)存分配和回收都具備確定性,在這幾個(gè)區(qū)域內(nèi)就不需要過(guò)多考慮回收的問(wèn)題,因?yàn)榉椒ńY(jié)束或者線程結(jié)束時(shí),內(nèi)存自然就跟隨著回收了。
- 而 Java 堆和方法區(qū)則不一樣,一個(gè)接口中的多個(gè)實(shí)現(xiàn)類(lèi)需要的內(nèi)存可能不一樣,一個(gè)方法中的多個(gè)分支需要的內(nèi)存也可能不一樣,我們只有在程序處于運(yùn)行期間時(shí)才能知道會(huì)創(chuàng)建哪些對(duì)象,這部分內(nèi)存的分配和回收都是動(dòng)態(tài)的,垃圾收集器所關(guān)注的是這部分內(nèi)存。
3.2 對(duì)象存活判斷
3.2.1 引用計(jì)數(shù)算法
- 給對(duì)象添加一個(gè)引用計(jì)數(shù)器,每當(dāng)有一個(gè)地方引用它時(shí),計(jì)數(shù)器值就加1;當(dāng)引用失效時(shí),計(jì)數(shù)器值就減1;任何時(shí)刻計(jì)數(shù)器為 0 的對(duì)象就是不可能再被使用的。
- 這種算法的實(shí)現(xiàn)簡(jiǎn)單,判定效率也很高,在大部分情況下它都是一個(gè)不錯(cuò)的算法,但它很難解決對(duì)象之間相互循環(huán)引用的問(wèn)題。
- 舉個(gè)例子,對(duì)象 objA 和 objB 都有字段 instance,賦值令 objA.instance = objB 及 objB.instance = objA ,除此之外,這兩個(gè)對(duì)象再無(wú)任何引用,實(shí)際上,這兩個(gè)對(duì)象已經(jīng)不可能再被訪問(wèn),但是它們因?yàn)橄嗷ヒ弥鴮?duì)方,導(dǎo)致它們的引用計(jì)數(shù)都不為 0,于是引用計(jì)數(shù)算法無(wú)法通知 GC 收集器回收它們。
3.2.2 可達(dá)性分析算法
- 這個(gè)算法的基本思路就是通過(guò)一系列額稱為“GC Roots” 的對(duì)象作為起始點(diǎn),從這些節(jié)點(diǎn)開(kāi)始向下搜索,搜索所走過(guò)的路徑稱為引用鏈,當(dāng)一個(gè)對(duì)象到 GC Roots 沒(méi)有任何引用鏈相連或者說(shuō)這個(gè)對(duì)象不可達(dá)時(shí),則證明此對(duì)象是不可用的。
- 在 Java 語(yǔ)言中,可作為 GC Roots 的對(duì)象包括以下:
- 虛擬機(jī)棧(棧幀中的本地變量表)中引用的對(duì)象
- 方法區(qū)中類(lèi)靜態(tài)屬性引用的對(duì)象
- 方法區(qū)中常量引用的對(duì)象
- 本地方法棧中 JNI 引用的對(duì)象
請(qǐng)看下圖:
?
可達(dá)性分析算法
?
3.3 方法區(qū)的回收
- 方法區(qū)(HotSpot 虛擬機(jī)中的永久代)的垃圾收集主要回收兩部分內(nèi)容:廢棄常量和無(wú)用的類(lèi)。回收廢棄常量與回收 Java 堆的對(duì)象非常類(lèi)似。
- 判定一個(gè)類(lèi)是否是“無(wú)用的類(lèi)”需要同時(shí)滿足下面3個(gè)條件:
- 該類(lèi)的所有的實(shí)例都已經(jīng)被回收,也就是 Java 堆中不存在該類(lèi)的任何實(shí)例。
- 加載該類(lèi)的 ClassLoader 已經(jīng)被回收。
- 該類(lèi)對(duì)應(yīng)的 java.lang.Class 對(duì)象沒(méi)有在任何地方被引用,無(wú)法在任何地方通過(guò)反射訪問(wèn)該類(lèi)的方法。
- 虛擬機(jī)可以對(duì)滿足上述3個(gè)條件的無(wú)用類(lèi)進(jìn)行回收,這里說(shuō)的僅僅是“可以”,而并不是和對(duì)象一樣,不使用了就必然回收。
3.4 垃圾收集算法
3.4.1 標(biāo)記—清除算法
- 算法分為 “標(biāo)記” 和 “清除” 兩個(gè)階段:首先標(biāo)記出所有需要回收的對(duì)象,在標(biāo)記完成后統(tǒng)一回收所有被標(biāo)記的對(duì)象。
- 它主要有兩個(gè)不足的地方:一個(gè)是效率問(wèn)題,標(biāo)記和清除兩個(gè)過(guò)程的效率都不高;另一個(gè)是空間問(wèn)題,標(biāo)記清除之后會(huì)產(chǎn)生大量不連續(xù)的內(nèi)存碎片,空間碎片太多可能會(huì)導(dǎo)致以后在程序運(yùn)行過(guò)程中需要分配較大對(duì)象時(shí),無(wú)法找到足夠的連續(xù)內(nèi)存而得不到提前觸發(fā)另一次垃圾收集動(dòng)作。
- 這是最基礎(chǔ)的收集算法,后續(xù)的收集算法都是基于這種思路并對(duì)其不足進(jìn)行改進(jìn)而得到的。
?
“標(biāo)記—清除”算法示意圖
?
3.4.2 復(fù)制算法
- 為了解決效率問(wèn)題,“復(fù)制”算法應(yīng)運(yùn)而生,它將可用內(nèi)存按容量劃分為大小相等的兩塊,每次只使用其中一塊。
- 當(dāng)這一塊的內(nèi)存用完了,就將還存活著的對(duì)象復(fù)制到另外一塊上面,然后再把已使用過(guò)的內(nèi)存空間一次清理掉。
- 這樣使得每次都是對(duì)整個(gè)半?yún)^(qū)進(jìn)行內(nèi)存回收,內(nèi)存分配時(shí)也就不用考慮內(nèi)存碎片等復(fù)雜情況,只要移動(dòng)堆頂指針,按順序分配內(nèi)存即可,實(shí)現(xiàn)簡(jiǎn)單,運(yùn)行高效。
- 不足之處是,將內(nèi)存縮小為原來(lái)的一半,代價(jià)太高。
?
復(fù)制算法示意圖
?
舉個(gè)優(yōu)化例子:新生代中的對(duì)象98%是“朝生夕死”的,所以并不需要按照 1:1 的比例來(lái)劃分內(nèi)存空間,而是將內(nèi)存分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor。當(dāng)回收時(shí),將 Eden 和 Survivor 中還存活著的對(duì)象一次性地復(fù)制到另一塊 Survivor 空間上,最后清理掉 Eden 和剛才用過(guò)的 Survivor 空間。再舉個(gè)優(yōu)化例子:將 Eden 和 Survivor 的大小比例設(shè)為 8:1 ,也就是每次新生代中可用內(nèi)存空間為整個(gè)新生代容器的 90%,只有10% 的內(nèi)存作為保留區(qū)域。當(dāng)然 98% 的對(duì)象可回收只是一般場(chǎng)景下的數(shù)據(jù),我們沒(méi)有辦法保證每次回收都只有不多于 10% 的對(duì)象存活,當(dāng) Survivor 空間不夠用時(shí),需要依賴其他內(nèi)存(這里指老年代)進(jìn)行分配擔(dān)保(空間分配擔(dān)保機(jī)制在上面,了解一下)。
3.4.3 標(biāo)記—整理算法
復(fù)制收集算法在對(duì)象存活率較高時(shí)就要進(jìn)行較多的復(fù)制操作,效率將會(huì)變低。所以在老年代一般不能直接選用復(fù)制收集算法。
- 根據(jù)老年代的特點(diǎn),“標(biāo)記—整理” 算法應(yīng)運(yùn)而生。
- 標(biāo)記過(guò)程仍然與 “標(biāo)記—清除” 算法一樣,但后續(xù)步驟不是直接對(duì)可回收對(duì)象進(jìn)行清理,而是讓所有存活的對(duì)象都向一端移動(dòng),然后直接清理掉端邊界以外的內(nèi)存。
?
“標(biāo)記—整理”算法示意圖
?
3.4.4 分代收集算法
- 根據(jù)對(duì)象存活周期的不同將內(nèi)存劃分為幾塊,一般是把 Java 堆分為新生代和老年代,這樣就可以根據(jù)各個(gè)年代的特點(diǎn)采用最適當(dāng)?shù)氖占惴ā?/li>
- 在新生代中,每次垃圾收集時(shí)都發(fā)現(xiàn)有大批對(duì)象死去,只有少量存活,那就選用復(fù)制算法,只需要付出少量存活對(duì)象的復(fù)制成本就可以完成收集。
- 而老年代中因?yàn)閷?duì)象存活率高、沒(méi)有額外空間對(duì)它進(jìn)行分配擔(dān)保,就必須使用 “標(biāo)記—清除” 或者 “標(biāo)記—整理” 算法來(lái)進(jìn)行回收。
- 當(dāng)前商業(yè)虛擬機(jī)的垃圾收集都采用 “分代收集” 算法。
四、編程中的內(nèi)存優(yōu)化
相信大家在編程中都會(huì)注意到內(nèi)存使用的問(wèn)題,下面我就簡(jiǎn)單列一下在實(shí)際操作當(dāng)中需要注意的地方。
4.1 減小對(duì)象的內(nèi)存占用
- 使用更加輕量的數(shù)據(jù)結(jié)構(gòu)
- 避免使用 Enum
- 減小 Bitmap 對(duì)象的內(nèi)存占用
- inSampleSize :縮放比例,在把圖片載入內(nèi)存之前,我們需要先計(jì)算出一個(gè)合適的縮放比例,避免不必要的大圖載入。
- decode format:解碼格式,選擇 ARGB_8888 / RBG_565 / ARGB_4444 / ALPHA_8,存在很大差異。
- 使用更小的圖片:盡量使用更小的圖片不僅僅可以減少內(nèi)存的使用,還可以避免出現(xiàn)大量的 InflationException。
4.2 內(nèi)存對(duì)象的重復(fù)利用
- 復(fù)用系統(tǒng)自帶的資源:Android系統(tǒng)本身內(nèi)置了很多的資源,例如字符串/顏色/圖片/動(dòng)畫(huà)/樣式以及簡(jiǎn)單布局等等,這些資源都可以在應(yīng)用程序中直接引用。
- 注意在 ListView / GridView 等出現(xiàn)大量重復(fù)子組件的視圖里面對(duì) ConvertView 的復(fù)用
- Bitmap 對(duì)象的復(fù)用
- 避免在 onDraw 方法里面執(zhí)行對(duì)象的創(chuàng)建:類(lèi)似 onDraw() 等頻繁調(diào)用的方法,一定需要注意避免在這里做創(chuàng)建對(duì)象的操作,因?yàn)樗麜?huì)迅速增加內(nèi)存的使用,而且很容易引起頻繁的 GC,甚至是內(nèi)存抖動(dòng)。
- StringBuilder:在有些時(shí)候,代碼中會(huì)需要使用到大量的字符串拼接的操作,這種時(shí)候有必要考慮使用 StringBuilder 來(lái)替代頻繁的 “+” 。
4.3 避免對(duì)象的內(nèi)存泄露
- 注意 Activity 的泄漏
- 內(nèi)部類(lèi)引用導(dǎo)致 Activity 的泄漏
- Activity Context 被傳遞到其他實(shí)例中,這可能導(dǎo)致自身被引用而發(fā)生泄漏。
- 考慮使用 Application Context 而不是 Activity Context :對(duì)于大部分非必須使用 Activity Context 的情況(Dialog 的 Context 就必須是 Activity Context),我們都可以考慮使用 Application Context 而不是 Activity 的 Context,這樣可以避免不經(jīng)意的 Activity 泄露。
- 注意臨時(shí) Bitmap 對(duì)象的及時(shí)回收:例如臨時(shí)創(chuàng)建的某個(gè)相對(duì)比較大的 bitmap 對(duì)象,在經(jīng)過(guò)變換得到新的 bitmap 對(duì)象之后,應(yīng)該盡快回收原始的 bitmap,這樣能夠更快釋放原始 bitmap 所占用的空間。
- 注意監(jiān)聽(tīng)器的注銷(xiāo):在 Android 程序里面存在很多需要 register 與 unregister 的監(jiān)聽(tīng)器,我們需要確保在合適的時(shí)候及時(shí) unregister 那些監(jiān)聽(tīng)器。自己手動(dòng) add 的 listener,需要記得及時(shí) remove 這個(gè) listener。
- 注意緩存容器中的對(duì)象泄漏:我們?yōu)榱颂岣邔?duì)象的復(fù)用性把某些對(duì)象放到緩存容器中,可是如果這些對(duì)象沒(méi)有及時(shí)從容器中清除,也是有可能導(dǎo)致內(nèi)存泄漏的。
- 注意 WebView 的泄漏:通常根治這個(gè)問(wèn)題的辦法是為 WebView 開(kāi)啟另外一個(gè)進(jìn)程,通過(guò) AIDL 與主進(jìn)程進(jìn)行通信,WebView 所在的進(jìn)程可以根據(jù)業(yè)務(wù)的需要選擇合適的時(shí)機(jī)進(jìn)行銷(xiāo)毀,從而達(dá)到內(nèi)存的完整釋放。
- 注意 Cursor 對(duì)象是否及時(shí)關(guān)閉
4.4 內(nèi)存使用策略優(yōu)化
- 資源文件需要選擇合適的文件夾進(jìn)行存放
- Try catch 某些大內(nèi)存分配的操作:在某些情況下,我們需要事先評(píng)估那些可能發(fā)生 OOM 的代碼,對(duì)于這些可能發(fā)生 OOM 的代碼,加入 catch 機(jī)制,可以考慮在 catch 里面嘗試一次降級(jí)的內(nèi)存分配操作。例如 decode bitmap 的時(shí)候,catch 到 OOM,可以嘗試把采樣比例再增加一倍之后,再次嘗試 decode。
- 謹(jǐn)慎使用 static 對(duì)象:因?yàn)閟tatic的生命周期過(guò)長(zhǎng),和應(yīng)用的進(jìn)程保持一致,使用不當(dāng)很可能導(dǎo)致對(duì)象泄漏。
- 特別留意單例對(duì)象中不合理的持有:因?yàn)閱卫纳芷诤蛻?yīng)用保持一致,使用不合理很容易出現(xiàn)持有對(duì)象的泄漏。
- 珍惜Services資源:建議使用 IntentService
- 優(yōu)化布局層次,減少內(nèi)存消耗:越扁平化的視圖布局,占用的內(nèi)存就越少,效率越高。我們需要盡量保證布局足夠扁平化,當(dāng)使用系統(tǒng)提供的 View 無(wú)法實(shí)現(xiàn)足夠扁平的時(shí)候考慮使用自定義 View 來(lái)達(dá)到目的。
- 謹(jǐn)慎使用 “抽象” 編程
- 使用 nano protobufs 序列化數(shù)據(jù)
- 謹(jǐn)慎使用依賴注入框架
- 謹(jǐn)慎使用多進(jìn)程
- 使用 ProGuard 來(lái)剔除不需要的代碼
- 謹(jǐn)慎使用第三方 libraries
- 考慮不同的實(shí)現(xiàn)方式來(lái)優(yōu)化內(nèi)存占用
五、內(nèi)存檢測(cè)工具
最后給推薦幾個(gè)內(nèi)存檢測(cè)的工具,具體使用方法,可以自行搜索。當(dāng)然除了下面這些工具,應(yīng)該還有更多更好用的工具,只是我還沒(méi)有發(fā)現(xiàn),如有建議,可以在文章下面評(píng)論留言,大家一起學(xué)習(xí)分享一下。
- Systrace
- Traceview
- Android Studio 3.0 的 Android Profiler 分析器
- LeakCanary
轉(zhuǎn)載于:https://www.cnblogs.com/CQqf2019/p/11043684.html
總結(jié)
以上是生活随笔為你收集整理的十分良心!全网最详细的Java 自动内存管理机制及性能优化教程的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: Python数据分析学习笔记
- 下一篇: Maven父子工程配置文件详解