• <menu id="sssag"></menu>
  • <menu id="sssag"></menu>
  • JVM垃圾回收閱讀筆記

    Java內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧3個區域隨線程而生,隨線程而滅,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。
    每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的(盡管在運行期會由即時編譯器進行一些優化,但在基于概念模型的討論里,大體上可以認為是編譯期可知的),因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者線程結束時,內存自然就跟隨著回收了。
    Java堆和方法區這兩個區域則有著很顯著的不確定性:一個接口的多個實現類需要的內存可能會不一樣,一個方法所執行的不同條件分支所需要的內存也可能不一樣
    只有處于運行期間,我們才能知道程序究竟會創建哪些對象,創建多少個對象,這部分內存的分配和回收是動態的。
    垃圾收集器所關注的正是這部分內存該如何管理,本文后續討論中的“內存”分配與回收也僅僅特指這一部分內存。

    如何判斷對象已死?

    計數算法

    很多教科書判斷對象是否存活的算法是這樣的:在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。
    ...
    客觀地說,引用計數算法(Reference Counting)雖然占用了一些額外的內存空間來進行計數,但它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的算法。也有一些比較著名的應用案例,例如微軟COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在游戲腳本領域得到許多應用的Squirrel中都使用了引用計數算法進行內存管理。

    但是,在Java領域,至少主流的Java虛擬機里面都沒有選用引用計數算法來管理內存,主要原因是,這個看似簡單的算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地工作,譬如單純的引用計數就很難解決對象之間相互循環引用的問題。

    一句話,計數算法很不錯,但是Java不用

    可達性分析算法

    當前主流的商用程序語言(Java、C#,上溯至前面提到的古老的Lisp)的內存管理子系統,都是通過可達性分析(Reachability Analysis)算法來判定對象是否存活的。這個算法的基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

    對象object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的,因此它們將會被判定為可回收的對象。

    在Java技術體系里面,固定可作為GC Roots的對象包括以下幾種:

    • 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
    • 在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。
    • 在方法區中常量引用的對象,譬如字符串常量池(String Table)里的引用。
    • 在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
    • Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。
    • 所有被同步鎖(synchronized關鍵字)持有的對象。
    • 反映Java虛擬機內部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。

    除了這些固定的GC Roots集合以外,根據用戶所選用的垃圾收集器以及當前回收的內存區域不同,還可以有其他對象“臨時性”地加入,共同構成完整GC Roots集合。

    finalize(),死前最后的波紋

    即使在可達性分析算法中判定為不可達的對象,也不是“非死不可”的,這時候它們暫時還處于“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:

    1. 如果對象在進行可達性分析后發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記,
    2. 隨后進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。假如對象:
      1. 沒有覆蓋finalize()方法,
      2. 或者finalize()方法已經被虛擬機調用過了一次,

    那么虛擬機將這兩種情況都視為“沒有必要執行”,這時候這個對象就是必死無疑。

    如果這個對象被判定為確有必要執行finalize()方法,那么該對象將會被放置在一個名為F-Queue的隊列之中,并在稍后由一條由虛擬機自動建立的、低調度優先級的Finalizer線程去執行它們的finalize()方法。這里所說的“執行”是指虛擬機會觸發這個方法開始運行,但并不承諾一定會等待它運行結束。這樣做的原因是,如果某個對象的finalize()方法執行緩慢,或者更極端地發生了死循環,將很可能導致F-Queue隊列中的其他對象永久處于等待,甚至導致整個內存回收子系統的崩潰。
    finalize()方法是對象逃脫死亡命運的最后一次機會,稍后收集器將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——

    • 只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移出“即將回收”的集合;

    如果對象這時候還沒有逃脫,那基本上它就真的要被回收了。

    演示代碼:

    //finalize()方法
    class FinalizeEscapeGC {
        public static FinalizeEscapeGC SAVE_HOOK = null;
        public void isAlive(){
            System.out.println("耶,我還活著!");
        }
    
        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            FinalizeEscapeGC.SAVE_HOOK = this;
            System.out.println("逃過一劫!");
        }
    }
    
    public class JavaGcTest {
        public static void main(String[] args) throws InterruptedException, Exception {
    
            FinalizeEscapeGC.SAVE_HOOK = new FinalizeEscapeGC();
    
            //第一次拯救自己
            FinalizeEscapeGC.SAVE_HOOK = null;
            System.gc();
            // 因為Finalizer方法優先級很低,暫停0.5秒,以等待它
            Thread.sleep(500);
            if(FinalizeEscapeGC.SAVE_HOOK != null){
                FinalizeEscapeGC.SAVE_HOOK.isAlive();
            }
            else{
                System.out.println("日,我還是死了!");
            }
    
            //第二次拯救自己
            FinalizeEscapeGC.SAVE_HOOK = null;
            System.gc();
            // 因為Finalizer方法優先級很低,暫停0.5秒,以等待它
            Thread.sleep(500);
            if(FinalizeEscapeGC.SAVE_HOOK != null){
                FinalizeEscapeGC.SAVE_HOOK.isAlive();
            }
            else{
                System.out.println("啊,我還是死了!");
            }
    
        }
    }
    

    運行結果:

    逃過一劫!
    耶,我還活著!
    啊,我還是死了!
    

    驗證了如果對象第一次要被gc殺死的時候,如果他有重寫finalize()方法,而且重寫之后讓他能產生與其他對象的引用,那么此時的finalize()就是他的免死金牌,但是第二次gc再來他還是會死就是了。

    還有一點需要特別說明,上面關于對象死亡時finalize()方法的描述可能帶點悲情的藝術加工,筆者并不鼓勵大家使用這個方法來拯救對象。相反,筆者建議大家盡量避免使用它,因為它并不能等同于C和C++語言中的析構函數,而是Java剛誕生時為了使傳統C、C++程序員更容易接受Java所做出的一項妥協。它的運行代價高昂,不確定性大,無法保證各個對象的調用順序,如今已被官方明確聲明為不推薦使用的語法。有些教材中描述它適合做“關閉外部資源”之類的清理性工作,這完全是對finalize()方法用途的一種自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及時,所以筆者建議大家完全可以忘掉Java語言里面的這個方法。

    回收方法區

    在Java堆中,尤其是在新生代中,對常規應用進行一次垃圾收集通??梢曰厥?0%至99%的內存空間,相比之下,方法區回收囿于苛刻的判定條件,其區域垃圾收集的回收成果往往遠低于此。

    方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型?;厥諒U棄常量與回收Java堆中的對象非常類似。

    舉個常量池中字面量回收的例子,假如一個字符串“java”曾經進入常量池中,但是當前系統又沒有任何一個字符串對象的值是“java”,換句話說,已經沒有任何字符串對象引用常量池中的“java”常量,且虛擬機中也沒有其他地方引用這個字面量。
    如果在這時發生內存回收,而且垃圾收集器判斷確有必要的話,這個“java”常量就將會被系統清理出常量池。常量池中其他類(接口)、方法、字段的符號引用也與此類似。

    判定一個常量是否“廢棄”還是相對簡單,而要判定一個類型是否屬于“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:

    • 該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實例。
    • 加載該類的類加載器已經被回收,這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的。
    • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

    垃圾收集算法

    分代收集理論

    人們在設計垃圾收集器(GC)的時候,提出了一個原則:收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。
    因此JVM設計者往往吧Java堆劃分為新生代(Young Generation)和老年代(Old Gerneration)兩個主要的區域:
    image.png
    這么設計的初衷,《深入理解JVM》是這么解釋的:

    當前商業虛擬機的垃圾收集器,大多數都遵循了“分代收集”(Generational Collection) 的理論進行設計,分代收集名為理論,實質是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:

    1)弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。

    2)強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。

    這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。顯而易見,如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那么把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間;如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和內存的空間有效利用。

    在Java堆劃分出不同的區域之后,垃圾收集器才可以每次只回收其中某一個或者某些部分的區域——因而才有了“Minor GC”“Major GC”“Full GC”這樣的回收類型的劃分;也才能夠針對不同的區域安排與里面存儲對象存亡特征相匹配的垃圾收集算法——因而發展出了“標記-復制算法”“標記-清除算法”“標記-整理算法”等針對性的垃圾收集算法。

    一句話總結就是:大體上將Java堆分為存儲容易“殺死”的對象不容易“殺死”的對象的兩塊區域,對前者我們可以高效的“殺死”,而后者因為不容易“殺死”,所以就少浪費時間,低頻地“殺”。

    什么叫不容易“殺死”?就是說一個對象在gc多次開“殺戒”的時候都因為這個那個原因沒被清理掉,所以gc采取的策略就是算了,能不殺就不浪費時間殺。

    這時候就要考慮一個問題:一個對象A他雖然可能在新生代區,但是卻有可能被老生代的對象B所引用,那如果要殺對象A,GC就要先去對B進行可達性分析,看看他是不是“孤立”的,這一切就使得對象A成了一個事實上的不容易“殺死”的對象。關于這個情況,《深入理解JVM》是這么解釋的:

    假如要現在進行一次只局限于新生代區域內的收集(Minor GC),但新生代中的對象是完全有可能被老年代所引用的,為了找出該區域中的存活對象,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有對象來確??蛇_性分析結果的正確性,反過來也是一樣 [3] 。遍歷整個老年代所有對象的方案雖然理論上可行,但無疑會為內存回收帶來很大的性能負擔。為了解決這個問題,就需要對分代收集理論添加第三條經驗法則:

    3)跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對于同代引用來說僅占極少數。

    這其實是可根據前兩條假說邏輯推理得出的隱含推論:存在互相引用關系的兩個對象,是應該傾向于同時生存或者同時消亡的。舉個例子,如果某個新生代對象存在跨代引用,由于老年代對象難以消亡,該引用會使得新生代對象在收集時同樣得以存活,進而在年齡增長之后晉升到老年代中,這時跨代引用也隨即被消除了。

    依據這條假說,我們就不應再為了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,只需在新生代上建立一個全局的數據結構(該結構被稱為“記憶集”,Remembered Set),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊內存會存在跨代引用。此后當發生Minor GC時,只有包含了跨代引用的小塊內存里的對象才會被加入到GC Roots進行掃描。雖然這種方法需要在對象改變引用關系(如將自己或者某個屬性賦值)時維護記錄數據的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是劃算的。

    也就是jvm在新生代上維護一個記憶集,對這種有“免死金牌”的新生代對象背后的老生代對象標記起來,每次要“殺”他們的時候就可以直接去(而不用大范圍掃描)把他們背后的老生代對象找出來,雖然有了一些開銷,但整體上是劃算的。

    剛才我們已經提到了“Minor GC”,后續文中還會出現其他針對不同分代的類似名詞,為避免讀者產生混淆,在這里統一定義:

    • 部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分為:
      • 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
      • 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為。另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按上下文區分到底是指老年代的收集還是整堆收集。
      • 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
    • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。

    標記-清除算法

    image.png
    如它的名字一樣,算法分為“標記”和“清除”兩個階段:

    • 首先標記出所有需要回收的對象
    • 在標記完成后,統一回收掉所有被標記的對象,

    也可以反過來,標記存活的對象,統一回收所有未被標記的對象。

    之所以說它是最基礎的收集算法,是因為后續的收集算法大多都是以標記-清除算法為基礎,對其缺點進行改進而得到的。它的主要缺點有兩個:第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;第二個是內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

    標記-復制算法

    image.png
    將內存按容量分為大小相等的兩塊,每次只使用其中一塊(也就是只在其中一塊)分配內存,比如當前在使用的內存稱為A,保留的空閑內存稱為B,那么當A用完了,我們就把存活對象(不會被GC的)對象復制到B,然后把不要的回收了,讓A稱為新的空閑內存,循環往復。

    為什么要這么做?

    1. 雖然有內存空間復制的開銷,但如果多數都是可回收的內存,那么只需復制占少數的存活對象就行了
    2. 分配內存的時候比較方便,因為存活對象最后都會規整的儲存在內存中,此時只要移動堆頂指針,就可以按順序分配即可。

    缺點是什么?

    內存空間一下子沒了一半,事實上太浪費了

    在1989年,Andrew Appel針對具備“朝生夕滅”特點的對象,提出了一種更優化的半區復制分代策略,現在稱為“Appel式回收”。HotSpot虛擬機的Serial、ParNew等新生代收集器均采用了這種策略來設計新生代的內存布局 [1] 。Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。當然,98%的對象可被回收僅僅是“普通場景”下測得的數據,任何人都沒有辦法百分百保證每次回收都只有不多于10%的對象存活,因此Appel式回收還有一個充當罕見情況的“逃生門”的安全設計,當Survivor空間不足以容納一次Minor GC之后存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。

    內存的分配擔保好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,于是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有什么風險了。內存的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象便將通過分配擔保機制直接進入老年代,這對虛擬機來說就是安全的。

    標記-整理算法

    image.png
    非常類似于標記-清除算法,可以理解為是進行了標記-清除算法之后,又進行了“緊湊”,使得內存規整。

    缺點是什么?

    這種對象移動的操作是需要阻塞程序運行的(Stop the World),這就更加讓使用者不得不小心翼翼地權衡其弊端了

    那為什么這么做?

    因為事實上,內存訪問是非常頻繁的,一個規整的內存會更收操作系統的“歡迎”,而如果不進行“緊湊”,雖然GC效率提高了,但之后的內存訪問吞吐量就會變低,因此權衡利弊之下就這么做了。

    另外,還有一種“和稀泥式”解決方案可以不在內存分配和訪問上增加太大額外負擔,做法是讓虛擬機平時多數時間都采用標記-清除算法,暫時容忍內存碎片的存在,直到內存空間的碎片化程度已經大到影響對象分配時,再采用標記-整理算法收集一次,以獲得規整的內存空間。前面提到的基于標記-清除算法的CMS收集器面臨空間碎片過多時采用的就是這種處理辦法。

    • 最新的ZGC和Shenandoah收集器使用讀屏障(Read Barrier)技術實現了整理過程與用戶線程的并發執行

    垃圾收集器

    image.png
    圖中展示了七種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用,圖中收集器所處的區域,則表示它是屬于新生代收集器抑或是老年代收集器。

    Serial 收集器

    Serial(串行)收集器是最基本、歷史最悠久的垃圾收集器了。大家看名字就知道這個收集器是一個單線程收集器了。它的 “單線程” 的意義不僅僅意味著它只會使用一條垃圾收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程( "Stop The World" ),直到它收集結束。

    對這個特點,《深入理解JVM》有段有趣的描述:

    對于“Stop The World”帶給用戶的惡劣體驗,早期HotSpot虛擬機的設計者們表示完全理解,但也同時表示非常委屈:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待著,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完?”(筆者注:所以才會設計成stop the world,讓線程先停一停,不要再產生垃圾了)這確實是一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬于一個工種,但實際上肯定還要比打掃房間復雜得多!

    • 新生代采用標記-復制算法
    • 老年代采用標記-整理算法。

    image.png
    虛擬機的設計者們當然知道 Stop The World 帶來的不良用戶體驗,所以在后續的垃圾收集器設計中停頓時間在不斷縮短(仍然還有停頓,尋找最優秀的垃圾收集器的過程仍然在繼續)。
    但是 Serial 收集器有沒有優于其他垃圾收集器的地方呢?當然有,它簡單而高效(與其他收集器的單線程相比)。Serial 收集器由于沒有線程交互的開銷,自然可以獲得很高的單線程收集效率。Serial 收集器對于運行在 客戶端模式下的虛擬機來說是個不錯的選擇。

    在用戶桌面的應用場景以及近年來流行的部分微服務應用中,分配給虛擬機管理的內存一般來說并不會特別大,收集幾十兆甚至一兩百兆的新生代(僅僅是指新生代使用的內存,桌面應用甚少超過這個容量),垃圾收集的停頓時間完全可以控制在十幾、幾十毫秒,最多一百多毫秒以內,只要不是頻繁發生收集,這點停頓時間對許多用戶來說是完全可以接受的。

    ParNew 收集器

    ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多線程進行垃圾收集外,其余行為(控制參數、收集算法、回收策略等等)和 Serial 收集器完全一樣。

    • 新生代采用標記-復制算法(Stop the world)
    • 老年代采用標記-整理算法。(Stop the world)

    image.png
    它是許多運行在 服務端模式下的虛擬機的首要選擇,除了 Serial 收集器外,只有它能與 CMS 收集器(真正意義上的并發收集器,后面會介紹到)配合工作。

    并行和并發概念補充:

    • 并行(Parallel) :指多條垃圾收集線程并行工作,但此時用戶線程仍然處于等待狀態。
    • 并發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是并行,可能會交替執行),用戶程序在繼續運行,而垃圾收集器運行在另一個 CPU 上。

    CMS:
    在JDK 5發布時,HotSpot推出了一款在強交互應用中幾乎可稱為具有劃時代意義的垃圾收集器——CMS收集器。這款收集器是HotSpot虛擬機中第一款真正意義上支持并發的垃圾收集器,它首次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。
    遺憾的是,CMS作為老年代的收集器,卻無法與JDK 1.4.0中已經存在的新生代收集器Parallel Scavenge配合工作 [1] ,所以在JDK 5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或者Serial收集器中的一個。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC選項)的默認新生代收集器,也可以使用-XX:+/-UseParNewGC選項來強制指定或者禁用它。

    Parallel Scavenge 收集器

    Parallel Scavenge收集器也是一款新生代收集器,它同樣是基于標記-復制算法實現的收集器,也是能夠并行收集的多線程收集器……Parallel Scavenge的諸多特性從表面上看和ParNew非常相似,那它有什么特別之處呢?

    Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是盡可能地縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。


    如果虛擬機完成某個任務,用戶代碼加上垃圾收集總共耗費了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。
    停頓時間越短就越適合需要與用戶交互或需要保證服務響應質量的程序,良好的響應速度能提升用戶體驗;而高吞吐量則可以最高效率地利用處理器資源,盡快完成程序的運算任務,主要適合在后臺運算而不需要太多交互的分析任務。

    需要注意的是,即使Parellel Scavenge收集器可以通過參數-XX:MaxGCPauseMillis人為設置停頓時間長度,但是不以為著設置越小吞吐量越大,因為他的底層是縮小新生代空間為代價的,新生代空間越小,會使得需要回收空間的次數變多,也就是收集的頻率變高,經常要出現stop the world,實質上會導致吞吐量下降。

    • 新生代采用標記-復制算法
    • 老年代采用標記-整理算法。

    image.png
    注意:這個收集器是jdk8默認的版本,可通過命令查看:

    java -XX:+PrintCommandLineFlags -version
    
    -XX:InitialHeapSize=510248320 -XX:MaxHeapSize=8163973120 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC 
    java version "1.8.0_202"
    Java(TM) SE Runtime Environment (build 1.8.0_202-b08)
    Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)
    
    

    其中的-XX:+UseParallelGC就指明了收集器。

    Serial Old 收集器

    Serial 收集器的老年代版本,它同樣是一個單線程收集器。它主要有兩大用途:一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途是作為 CMS 收集器的后備方案。
    image.png

    Parallel Old 收集器

    Parallel Scavenge 收集器的老年代版本。使用多線程和“標記-整理”算法。在注重吞吐量以及 CPU 資源的場合,都可以優先考慮 Parallel Scavenge 收集器和 Parallel Old 收集器。
    image.png

    CMS 收集器

    CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在互聯網網站或者基于瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間盡可能短,以給用戶帶來良好的交互體驗。CMS收集器就非常符合這類應用的需求。

    從名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于標記-清除算法實現的,它的運作過程相對于前面幾種收集器來說要更復雜一些。

    流程

    整個過程分為四個步驟,包括:

    1. 初始標記(CMS initial mark)
      :需要Stop the world,僅僅只是記錄下直接與GC Roots 相連的對象,速度很快 ;
    2. 并發標記(CMS concurrent mark)
      :從GC Roots直接關聯對象中開始遍歷整個對象圖,過程耗時,但是不需要stop the world,可以與GC線程并發運行;
    3. 重新標記(CMS remark)
      :修正并發標記時期之間,用戶線程繼續運行而導致標記發生變化的記錄(可以結合Serial收集器的例子,媽媽打掃衛生的時候,本來你說不要的東西,你又突然說要了,那GC就會把一開始的標記給去掉),停頓時間的長度介于初始標記與并發標記之間。
    4. 并發清除(CMS concurrent sweep):清理刪除掉標記階段判斷的已經死亡的對象,由于不需要移動存活對象,所以這個階段也是可以與用戶線程同時并發的。

    image.png

    優點

    • 并發收集
    • 低停頓

    缺點

    • 對處理器資源敏感,CMS默認的啟動GC線程數是(處理器核心數量+3)/4,我們定性的做個計算,假設處理器核心數量為x,處理器運算資源占用率計算按 線程數/處理器核心數量 來看:
      ,所以定性的看,x越大,占用率越小,和我們的直觀感受是匹配的;
    • CMS收集器無法處理“浮動垃圾”(Floating Garbage)。關于這一點,書中有一段可以說是非常深刻的描述:

    在CMS的并發標記和并發清理階段,用戶線程是還在繼續運行的,程序在運行自然就還會伴隨有新的垃圾對象不斷產生,但這一部分垃圾對象是出現在標記過程結束以后,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉。這一部分垃圾就稱為“浮動垃圾”。
    ...
    同樣也是由于在垃圾收集階段用戶線程還需要持續運行,那就還需要預留足夠內存空間提供給用戶線程使用,因此CMS收集器不能像其他收集器那樣等待到老年代幾乎完全被填滿了再進行收集,必須預留一部分空間供并發收集時的程序運作使用。
    在JDK 5的默認設置下,CMS收集器當老年代使用了68%的空間后就會被激活,這是一個偏保守的設置,如果在實際應用中老年代增長并不是太快,可以適當調高參數-XX:CMSInitiatingOccu-pancyFraction的值來提高CMS的觸發百分比,降低內存回收頻率,獲取更好的性能。
    到了JDK 6時,CMS收集器的啟動閾值就已經默認提升至92%。但這又會更容易面臨另一種風險:要是CMS運行期間預留的內存無法滿足程序分配新對象的需要,就會出現一次“并發失敗”(Concurrent Mode Failure),這時候虛擬機將不得不啟動后備預案:凍結用戶線程的執行,臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,但這樣停頓時間就很長了。所以參數-XX:CMSInitiatingOccupancyFraction設置得太高將會很容易導致大量的并發失敗產生,性能反而降低,用戶應在生產環境中根據實際應用情況來權衡設置。

    • CMS是一款基于“標記-清除”算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很多剩余空間,但就是無法找到足夠大的連續空間來分配當前對象,而不得不提前觸發一次Full GC的情況,JVM將收集整個Java堆和方法區的垃圾收集,時間開銷就很大了。

    G1(Garbage First) 收集器

    G1是一款主要面向服務端應用的垃圾收集器。HotSpot開發團隊最初賦予它的期望是(在比較長期的)未來可以替換掉JDK 5中發布的CMS收集器?,F在這個期望目標已經實現過半了,JDK 9發布之日,G1宣告取代Parallel Scavenge加Parallel Old組合,成為服務端模式下的默認垃圾收集器,而CMS則淪落至被聲明為不推薦使用(Deprecate)的收集器。

    可以看出來G1被賦予很高的期望,為什么這么說,因為從G1開始,GC的設計導向不再是單純追求一次性把Java堆清理干凈,以追求更少的Stop the world,而是追求能夠應付應用的內存分配速率(Allocation Rate),也就是GC的速度能跟上對象分配的速度。

    特性

    1. 內存空間劃分思想上進行了轉變:垃圾收集的目標范圍要么是整個新生代(Minor GC),要么就是整個老年代(Major GC),再要么就是整個Java堆(Full GC)。而G1跳出了這個樊籠,它可以面向堆內存任何部分來組成回收集(Collection Set,一般簡稱CSet)進行回收,衡量標準不再是它屬于哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。
      分代不再是最重要的,而是回收的價值:
      價值即回收所獲得的空間大小以及回收所需時間,也就是要看劃不劃得來

    G1開創的基于Region的堆內存布局是它能夠實現這個目標的關鍵。雖然G1也仍是遵循分代收集理論設計的,但其堆內存的布局與其他收集器有非常明顯的差異:G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region采用不同的策略去處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。
    image.png
    雖然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它們都是一系列區域(不需要連續)的動態集合。G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的內存空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。更具體的處理思路是讓G1收集器去跟蹤各個Region里面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。

    1. 使用記憶集避免全堆作為GC Roots掃描,但在G1收集器上記憶集的應用其實要復雜很多,它的每個Region都維護有自己的記憶集,這些記憶集會記錄下別的Region指向自己的指針,并標記這些指針分別在哪些卡頁的范圍之內。這是回答:“將Java堆分成多個獨立Region后,Region里面存在的跨Region引用對象如何解決?”的答案。

    G1的記憶集在存儲結構的本質上是一種哈希表,Key是別的Region的起始地址,Value是一個集合,里面存儲的元素是卡表的索引號。這種“雙向”的卡表結構(卡表是“我指向誰”,這種結構還記錄了“誰指向我”)比原來的卡表實現起來更復雜,同時由于Region數量比傳統收集器的分代數量明顯要多得多,因此G1收集器要比其他的傳統垃圾收集器有著更高的內存占用負擔。根據經驗,G1至少要耗費大約相當于Java堆容量10%至20%的額外內存來維持收集器工作。

    1. 通過原始快照(SATB)算法來實現并發標記。
    2. G1為每一個Region設計了兩個名為TAMS(Top at Mark Start)的指針,把Region中的一部分空間劃分出來用于并發回收過程中的新對象分配,并發回收時新分配的對象地址都必須要在這兩個指針位置以上。
    3. 可預測停頓時間,甚至可讓用戶自己定義。

    G1收集器的停頓預測模型是以衰減均值(Decaying Average)為理論基礎來實現的,在垃圾收集過程中,G1收集器會記錄每個Region的回收耗時、每個Region記憶集里的臟卡數量等各個可測量的步驟花費的成本,并分析得出平均值、標準偏差、置信度等統計信息。這里強調的“衰減平均值”是指它會比普通的平均值更容易受到新數據的影響,平均值代表整體平均狀態,但衰減平均值更準確地代表“最近的”平均狀態。換句話說,Region的統計狀態越新越能決定其回收的價值。然后通過這些信息預測現在開始回收的話,由哪些Region組成回收集才可以在不超過期望停頓時間的約束下獲得最高的收益。

    流程

    G1 收集器的運作大致分為以下幾個步驟:

    • 初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的對象,并且修改TAMS指針的值,讓下一階段用戶線程并發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際并沒有額外的停頓。
    • 并發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序并發執行。當對象圖掃描完成以后,還要重新處理SATB記錄下的在并發時有引用變動的對象。****類似CMS的重新標記。
    • 最終標記(Final Marking)(Final Marking):對用戶線程做另一個短暫的暫停,用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄。
    • 篩選回收(Live Data Counting and Evacuation):
      • 對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃
      • 可以自由選擇任意多個Region構成回收集,然后把決定回收的那一部分Region的存活對象復制到空的Region中,再清理掉整個舊Region的全部空間。
      • 這里的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程并行完成的。

    *注意:這里除了并發標記,其余階段都是要Stop the world的,體現了并非純粹追求低停頓(但是用戶可自定義),而追求盡可能高的吞吐量的設計思想。

    優點

    1. 可指定最大停頓時間
    2. 分Region的內存布局
    3. 按收益動態確定回收集
    4. G1從整體來看是基于“標記-整理”算法(進行“緊湊”)實現的收集器,但從局部(兩個Region之間)上看又是基于“標記-復制”算法實現,G1運作期間不會產生內存空間碎片,垃圾收集完成之后能提供規整的可用內存。

    Shenandoah 收集器

    Shenandoah摒棄了在G1中耗費大量內存和計算資源去維護的記憶集,改用名為“連接矩陣”(Connection Matrix)的全局數據結構來記錄跨Region的引用關系,降低了處理跨代指針時的記憶集維護消耗,也降低了偽共享問題(見3.4.4節)的發生概率。連接矩陣可以簡單理解為一張二維表格,如果Region N有對象指向Region M,就在表格的N行M列中打上一個標記,如圖3-15所示,如果Region 5中的對象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那連接矩陣中的5行3列、3行1列就應該被打上標記。在回收時通過這張表格就可以得出哪些Region之間產生了跨代引用。
    image.png
    *筆者按:書上的圖好像畫錯了?

    流程

    1. 初始標記 (Initial Marking):與G1一樣,首先標記與GC Roots直接關聯的對象,這個階段仍是“Stop The World”的,但停頓時間與堆大小無關,只與GC Roots的數量相關。
    2. 并發標記 (Concurrent Marking):與G1一樣,遍歷對象圖,標記出全部可達的對象,這個階段是與用戶線程一起并發的,時間長短取決于堆中存活對象的數量以及對象圖的結構復雜程度。
    3. 最終標記 (Final Marking):與G1一樣,處理剩余的SATB掃描,并在這個階段統計出回收價值最高的Region,將這些Region構成一組回收集(Collection Set)。最終標記階段也會有一小段短暫的停頓。
    4. 并發清理 (Concurrent Cleanup):這個階段用于清理那些整個區域內連一個存活對象都沒有找到的Region(這類Region被稱為Immediate Garbage Region)。
    5. 并發回收 (Concurrent Evacuation):Shenandoah要把回收集里面的存活對象先復制一份到其他未被使用的Region之中。并發回收階段運行的時間長短取決于回收集的大小。

    復制對象這件事情如果將用戶線程凍結起來再做那是相當簡單的,但如果兩者必須要同時并發進行的話,就變得復雜起來了。其困難點是在移動對象的同時,用戶線程仍然可能不停對被移動的對象進行讀寫訪問,移動對象是一次性的行為,但移動之后整個內存中所有指向該對象的引用都還是舊對象的地址,這是很難一瞬間全部改變過來的。對于并發回收階段遇到的這些困難,Shenandoah將會通過讀屏障和被稱為“Brooks Pointers”的轉發指針來解決

    1. 初始引用更新 (Initial Update Reference):并發回收階段復制對象結束后,還需要把堆中所有指向舊對象的引用修正到復制后的新地址,這個操作稱為引用更新。初始引用更新時間很短,會產生一個非常短暫的停頓。

    引用更新的初始化階段實際上并未做什么具體的處理,設立這個階段只是為了建立一個線程集合點,確保所有并發回收階段中進行的收集器線程都已完成分配給它們的對象移動任務而已。

    1. 并發引用更新 (Concurrent Update Reference):真正開始進行引用更新操作,這個階段是與用戶線程一起并發的,時間長短取決于內存中涉及的引用數量的多少。并發引用更新與并發標記不同,它不再需要沿著對象圖來搜索,只需要按照內存物理地址的順序,線性地搜索出引用類型,把舊值改為新值即可。
    2. 最終引用更新 (Final Update Reference):解決了堆中的引用更新后,還要修正存在于GC Roots中的引用。這個階段是Shenandoah的最后一次停頓,停頓時間只與GC Roots的數量相關。
    3. 并發清理 (Concurrent Cleanup):經過并發回收和引用更新之后,整個回收集中所有的Region已再無存活對象,這些Region都變成Immediate Garbage Regions了,最后再調用一次并發清理過程來回收這些Region的內存空間,供以后新對象分配使用。

    image.png

    • 藍色區域代表用戶線程可以用來分配對象的內存Region
    • 黃色區域代表初始標記后會出現被選入回憶集對象的Region
    • 綠色區域代表存活的對象

    因此根據這幅圖:

    • 在初始標記(Init Mark)、并發標記(Concurrent Mark)與最后標記(Final Mark)之后,將決定最終被選入回憶集對象的Region

    • 在進行并發清除(Concurrent evacuation)之后,黃色區域里非回憶集對象將被復制到一個未被使用的Region中

    • 初始化引用更新(Init Update Reference)和并發引用更新(concurrent update reference)做了這么一件事:并發回收階段復制對象結束后,堆中所有指向**舊對象的引用修正到復制后的新地址

      ,也就是說此時原本黃色區域內的綠色都已經到了橙色處,此時可以把原本的都標黃,他們也可以進回憶集了**

    它不再需要沿著對象圖來搜索,只需要按照內存物理地址的順序,線性地搜索出引用類型,把舊值改為新值即可。

    • 最終引用更新還需修改GC Roots
    • 最后經過并發清理,將回憶集中的Region(即黃色區域)清理即可。

    ZGC收集器

    ZGC和Shenandoah的目標是高度相似的,都希望在盡可能對吞吐量影響不太大的前提下,實現在任意堆內存大小下都可以把垃圾收集的停頓時間限制在十毫秒以內的低延遲。
    ...
    ZGC收集器是一款基于Region內存布局的,(暫時)不設分代的,使用了讀屏障、染色指針和內存多重映射等技術來實現可并發的標記-整理算法的,以低延遲為首要目標的一款垃圾收集器。

    特性:

    1. ZGC也采用基于Region的堆內存布局,但與它們不同的是,ZGC的Region具有動態性——動態創建和銷毀,以及動態的區域容量大小

    ZGC的堆內存布局:
    image.png

    1. ZGC收集器有一個標志性的設計是它采用的染色指針技術(Colored Pointer,其他類似的技術中可能將它稱為Tag Pointer或者Version Pointer),看起來有點像InnoDB用于MVCC的隱藏字段的回滾指針。它直接把標記信息記在引用對象的指針上,這時,與其說可達性分析是遍歷對象圖來標記對象,還不如說是遍歷“引用圖”來標記“引用”了。

    image.png

    染色指針可以使得一旦某個Region的存活對象被移走之后,這個Region立即就能夠被釋放和重用掉,而不必等待整個堆中所有指向該Region的引用都被修正后才能清理。這點相比起Shenandoah是一個頗大的優勢,使得理論上只要還有一個空閑Region,ZGC就能完成收集,而Shenandoah需要等到引用更新階段結束以后才能釋放回收集中的Region,這意味著堆中幾乎所有對象都存活的極端情況,需要1∶1復制對象到新Region的話,就必須要有一半的空閑Region來完成收集。

    流程

    1. 并發標記 (Concurrent Mark):與G1、Shenandoah一樣,并發標記是遍歷對象圖做可達性分析的階段,前后也要經過類似于G1、Shenandoah的初始標記、最終標記(盡管ZGC中的名字不叫這些)的短暫停頓,而且這些停頓階段所做的事情在目標上也是相類似的。與G1、Shenandoah不同的是,ZGC的標記是在指針上而不是在對象上進行的,標記階段會更新染色指針中的Marked 0、Marked 1標志位。
    2. 并發預備重分配 (Concurrent Prepare for Relocate):這個階段需要根據特定的查詢條件統計得出本次收集過程要清理哪些Region,將這些Region組成重分配集(Relocation Set)。ZGC每次回收都會掃描所有的Region,用范圍更大的掃描成本換取省去G1中記憶集的維護成本。因此,ZGC的重分配集只是決定了里面的存活對象會被重新復制到其他的Region中,里面的Region會被釋放,而并不能說回收行為就只是針對這個集合里面的Region進行,因為標記過程是針對全堆的。
    3. 并發重分配 (Concurrent Relocate):重分配是ZGC執行過程中的核心階段,這個過程要把重分配集中的存活對象復制到新的Region上,并為重分配集中的每個Region維護一個轉發表(Forward Table),記錄從舊對象到新對象的轉向關系。

    益于染色指針的支持,ZGC收集器能僅從引用上就明確得知一個對象是否處于重分配集之中,如果用戶線程此時并發訪問了位于重分配集中的對象,這次訪問將會被預置的內存屏障所截獲,然后立即根據Region上的轉發表記錄將訪問轉發到新復制的對象上,并同時修正更新該引用的值,使其直接指向新對象,ZGC將這種行為稱為指針的“自愈”(Self-Healing)能力。
    ...
    這樣做的好處是只有第一次訪問舊對象會陷入轉發,也就是只慢一次,對比Shenandoah的Brooks轉發指針,那是每次對象訪問都必須付出的固定開銷,簡單地說就是每次都慢,因此ZGC對用戶程序的運行時負載要比Shenandoah來得更低一些。還有另外一個直接的好處是由于染色指針的存在,一旦重分配集中某個Region的存活對象都復制完畢后,這個Region就可以立即釋放用于新對象的分配(但是轉發表還得留著不能釋放掉),哪怕堆中還有很多指向這個對象的未更新指針也沒有關系,這些舊指針一旦被使用,它們都是可以自愈的。

    1. 并發重映射 (Concurrent Remap):重映射所做的就是修正整個堆中指向重分配集中舊對象的所有引用,這一點從目標角度看是與Shenandoah并發引用更新階段一樣的。

    但是ZGC的并發重映射并不是一個必須要“迫切”去完成的任務,因為前面說過,即使是舊引用,它也是可以自愈的,最多只是第一次使用時多一次轉發和修正操作。重映射清理這些舊引用的主要目的是為了不變慢(還有清理結束后可以釋放轉發表這樣的附帶收益),所以說這并不是很“迫切”。因此,ZGC很巧妙地把并發重映射階段要做的工作,合并到了下一次垃圾收集循環中的并發標記階段里去完成,反正它們都是要遍歷所有對象的,這樣合并就節省了一次遍歷對象圖的開銷。一旦所有指針都被修正之后,原來記錄新舊對象關系的轉發表就可以釋放掉了。

    參考

    1. 《深入理解Java虛擬機》
    2. JavaGuide
    posted @ 2022-03-12 22:08  Excelsiorly  閱讀(0)  評論(0編輯  收藏  舉報
    国产在线码观看超清无码视频,人妻精品动漫H无码,十大看黄台高清视频,国产在线无码视频一区二区三区,国产男女乱婬真视频免费,免费看女人的隐私超爽,狠狠色狠狠色综合久久蜜芽