java大廠面試題整理(八)JVM內存溢出和垃圾回收機制

元空間概念

其實說到這還是要簡單說下java8、雖然是版本迭代,但是JAVA8相對於之前來說是個大版本的迭代,改了很多東西。首先,在Java8中,永久代已經被移除,被一個稱爲元空間的取間所取代。元空間的本質和永久代類似。
元空間與永久代最大的區別在於:永久代使用的JVM的堆內存。但是Java8以後的元空間並不在虛擬機中而是使用本機物理內存。
因此,默認情況下,元空間的大小僅僅受本地內存限制。類的元數據放入native memory,字符串池和類的靜態變量放入java堆中,這樣可以加載多少類的元數據就不再由MaxPermSize控制,而是由系統的實際可用空間來控制。


其實JVM的報錯還是有挺多的,下面我們一個個介紹:

java.lang.StackOverflowError

這個是棧異常。而棧是存方法區的。顯示這個異常很容易:死遞歸就可以了:



我們思考一下這個到底是異常還是錯誤:雖然口語上我們一般都說報錯了,但是其實本質上java中的報錯分兩種:Exception異常和Error錯誤。
而幾乎後綴帶Error的都是錯誤。後綴的Exception的都是異常。

java.lang.OutOfMemoryError:java.heap.spack

這個錯誤其實也比較容易理解。如其名內存溢出:堆空間。
而如何實現堆的溢出呢?這個其實我們上面也測試過。測試弱引用和軟引用gc 的時候創建了大對象。


java.lang.OutOfMemoryError:GC overhead limit exceeded

這個錯誤的中文翻譯:超出GC開銷限制。
就是說某一個時刻,GC回收時間過長會拋出這個異常。過長的指:超過百分之九十八的時間用來做GC,並且回收了不到百分之二的堆內存。連續GC多次都只回收了不到百分之二的極端情況纔會拋出這個異常。假如不拋出這個異常會產生的情況:很快內存滿了,繼續GC,GC又收不到東西,然後又很快滿,再GC。。。如此惡行循環下去。所以纔會有這個錯誤。下面是代碼的測試:

    public static void main(String[] args) throws Exception {
        List<String> list = new ArrayList<String>();
        int i = 0;

        try {
            while (true) {
                list.add(String.valueOf(i++));
            }
        } catch (Exception e) {
            System.out.println(i);
            e.printStackTrace();
            // TODO: handle exception
        }
    }

java.lang.OutOfMemoryError:Direct buffer memory

這個錯誤指直接內存掛了。元空間並不在虛擬機中,而是使用本地內存。理論上大小僅受硬件大小限制。而這個錯誤的原因是指硬件內存受限了。簡而言之總結爲:jvm好好的,本地的內存用光了,導致程序崩潰。
我們常用的i/o ByteBuffer有兩個方法:
ByteBuffer.allocate()是分配JVM堆內存。屬於GC管轄範圍。由於需要拷貝所以速度相對較慢。
ByteBuffer.allocateDirect()是分配os的本地內存,不屬於GC管轄範圍。由於不需要內存拷貝所以速度相對較快。
而這個報錯就是指物理內存的崩盤,也就是一直用allocateDirect不斷創建對象。如下代碼:



上圖第一個打印語句是查詢當前可用物理內存多大。我這邊打印是5.5M.所以我創建一個6M的對象就直接報錯了。(這裏物理內存的大小最好調小一點。不然不容易出效果)

java.lang.OutOfMemoryError:unable to create new native thread

這個錯誤的字面意思:不能再創建更多新的本地線程了。
首先這個錯一般都是高併發的時候報出來的,導致原因:

  1. 一個應用進程創建了太多的線程。超過系統承載極限。
  2. 服務器並不允許你的應用程序創建這麼多線程。linux默認允許單個進程創建的線程數是1024.

解決辦法:

  1. 降低應用的線程數量。
  2. 修改linux的默認配置。

java.lang.OutOfMemoryError:Metaspace

這個也比較好理解:元空間溢出。元空間是方法區。它與永久代最大的區別就是它在本地內存而不是虛擬機內存中。下面是demo:

public class OOMTest {

    public static void test() {
        
    }
    public static void main(String[] args){
        int i = 0;
        try {
            while(true) {
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(OOMTest.class);
                enhancer.setUseCache(false);
                enhancer.setCallback(new org.springframework.cglib.proxy.MethodInterceptor() {                  
                    @Override
                    public Object intercept(Object arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
                        return arg3.invokeSuper(0, args);
                    }
                });
                enhancer.create();
                i++;
            }
        } catch (Exception e) {
            System.out.println(i);
            e.printStackTrace();
        }
    }

}

利用反射不斷創建類。然後就爆了。需要注意的是這個默認的元空間大小比較大,想要跑出效果一定要實現把元空間和最大元空間設置小點,如下參數:

-XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m

以上就是JVM常見報的錯誤!下面開始簡單的說一下垃圾回收算法和垃圾回收器。

垃圾回收

首先GC算法(引用計數/複製/標記清除/標記整理)是內存回收的方法論,而垃圾收集器是算法的落地實現。
目前爲止還沒有完美的收集器出現,更加沒有萬能的收集器,只能根據具體應用選擇最合適的收集器。
目前主要有四種垃圾收集器(java10以前的,java11以及以後出來了個新的ZGC。不過因爲比較新,所以先不談了):

  • 串行垃圾回收器(Serial)
    它爲單線程環境設計且只使用一個線程進行垃圾回收。會暫停所有的用戶線程,所以不適合服務器環境。
  • 並行垃圾回收器(Parallel)
    多個垃圾收集線程並行工作。此時用戶線程也是暫停的,適用於科學計算/大數據處理等弱交互場景。
    串行和並行都需要暫停用戶線程,也叫STW(stop the world)。
  • 併發垃圾回收器(CMS)
    用戶線程和垃圾收集線程同時執行(不一定是並行,可能是交替執行)不需要停頓用戶線程。互聯網公司多用它,適用對響應時間有要求的場景。
  • G1垃圾回收器
    歷經了十年的準備,在java8中開始使用。G1垃圾收集器將堆內存分割成不同的區域然後併發的對其進行垃圾回收。

注意,上面說的是四種垃圾收集器機制,但是落實到實際是有七大垃圾收集器的。

怎麼查看/修改程序的垃圾收集器呢?

其實這個上篇筆記記到了,有一個jvm的命令可以查看,如下:

java -XX:+PrintCommandLineFlags -version

這個命令是查看一些比較重要參數的,結果如下圖:



如圖所示,默認的是並行垃圾回收器。當然了我們也可以去修改這個默認。修改方法就是單純的修改jvm參數,也沒啥好說的。而這個我們可以設置的參數值有六種(注意上面說有七種垃圾收集器,這裏只有六種的原因:有個serialOldGC,但是因爲現在被廢棄了,都沒人用了。):

  • UserSerialGC
  • UseParallelGC
  • UseConcMarkSweepGC
  • UseParNewGC
  • UseParallelOldGC
  • UseG1GC

下面我們實際測試一下:
首先在啓動的時候添加參數,修改垃圾收集器:



然後我們在控制檯查看這個線程:



加號代表的是啓動,減號代表未啓用。所以說這個參數是起效果了的。

七大垃圾收集器

重複一遍:這塊的概念比較容易混淆,最上面說的四大垃圾收集器我們可以理解爲按照垃圾收集器的原理劃分的。
而六種垃圾收集器可設置參數是因爲其中串行老年代這種垃圾收集器已經被廢棄了,所以可設置的參數就只有六種了。
而這裏將的七大垃圾收集器是指java中出現過的垃圾收集器,包括已經廢棄了的那個,繼續往下說。垃圾收集器分兩種,這個也是java的內存結構決定的、因爲分爲年輕代和老年代。年輕代朝生夕死,迭代比較快,老年代一般都是比較穩定的或者大對象什麼的。所以針對這兩個區域我們要採取的垃圾收集方法也並不一樣。所以同理針對不同區垃圾收集器也是不同的。下面是一張圖來粗略的看下垃圾收集器的情況:



其實如其名:我們之前的參數就可以看出來:old一般都是用在老年區的。cms也是用在老年區的。new就是用在年輕代的。而G1比較特殊,它是既可以用在老年代,也可以用在年輕代的。

JVM的Server和Client模式
首先在生產環境下,一般都使用Server模式,Client模式基本不會用。二者的區別:

  • 32位window系統,無論硬件如何都默認使用Client的JVM模式
  • 32位其它操作系統,2G內存同時有2個CPU以上用Server模式,低於該配置還是Client模式。
  • 64位只能是Server模式。(做開發一定用64位的!!!)

下面繼續說垃圾回收的選擇,其實因爲垃圾收集器除了G1以外都是一對一對的,所以有時候我們選擇了新生代,老年代會自動選擇其匹配的。也就是新生代的垃圾收集器選擇很重要。

新生代可選的垃圾收集器(不考慮G1):
  • 串行GC(Serial)
    它是最古老,最穩定,效率最高的收集器。壞處就是收集過程中需要暫停其他工作線程。對於限定單核CPU的環境下(現在怎麼可能有單核的服務器!所以這個收集器也老了),沒有線程交互的開銷可以獲得最高的收集效率,因此Serial垃圾收集器是Client模式(低配纔會是Client模式)下默認的新生代垃圾收集器。
    開啓這個參數UseSerialGC以後,會默認採用Serial/SerialOld兩種垃圾收集器.
  • 並行GC(ParNew)
    一句話:使用多線程進行垃圾回收,在垃圾回收的時候也會STW直到它收集結束。
    ParNew其實是Serial收集器新生代的並行多線程版本。最常見的就是配合老年代的CMS 工作,其餘的行爲了Serial收集器完全一樣。它是很多java虛擬機在Server模式新生代的默認收集器。
    這個開啓這個垃圾回收器的參數-XX:+UseParNewGC,啓用ParNew收集器,隻影響新生代,不影響老年代。(注意ParNew+SerialOld聯合使用會JVM報warn,不推薦)。
    另外這個ParNew是可以設置垃圾收集器並行的最大線程數的。
  • 並行回收GC(Parallel Scavenge)
    注意,現在常用的默認的垃圾收集器不是上面的並行GC,而是更加進步了,變成了默認是UseParallelGC。這個代表的是ParallelScavenge。
    它是類似ParNew的一個新生代垃圾收集器,使用複製算法,也是一個並行的多線程的垃圾收集器。俗稱吞吐量優先收集器。並行收集器就是串行收集器在新生代和老年代的並行化。
    它的重點關注是:
    可控制的吞吐量。吞吐量計算公式= 代碼運行時間/(代碼運行時間+垃圾回收時間)。高吞吐量意味着高效利用CPU時間。
    自適應調節策略(相比於ParNew的重要區別)。虛擬機根據當前系統的運算情況收集性能監控信息。動態的調整這些參數以提供最合適的停頓時間(-XX:MaxGCPauseMillis)或最大吞吐量。
    這裏有個很重點的事項:Parallel和ParallelOld可以互相激活!也就是設置其中一個另一個也會相應生效。

同樣這個也是可以設置GC收集器的最大線程數。參數如下:
-XX:ParallelGCThreads = N.表示啓動N個GC線程。
這裏有個建議:

  • CPU>8, N = 5/8
  • CPU<8, N = cpu個數
老年代可選的垃圾收集器:
  • 串行GC
    jdk1.6之前默認老年代使用SerialOld。這個沒啥說的,和Serial是一樣的,只不過是用在老年代而已。而且是CMS收集器的後備收集器。
  • 並行GC
    這個垃圾收集器和版本關係很大。JDK1.6之前新生代用ParallelScavenge老年代用SerialOld
    ParallelOld是JDK1.6出現的
    JDK1.8及以後默認是ParallelScavenge+ParallelOld
  • 併發標記清除GC(CMS)
    這個也是比較經典的有個垃圾回收算法的落地實現了。標記清除上文提過了。先標記,再統一清除。好處是速度快,壞處是會產生內存碎片。
    這種方式非常適合應用在互聯網或者B/S系統的服務器上。這類應用尤其重視服務器響應速度,希望停頓時間最短。
    CMS非常適合堆內存大,CPU核數多的服務器上,也是G1出現之前大型應用的首選收集器。
    如果採用這個老年代收集器,會自動將ParNew收集器打開。
    開啓該參數後,會使用ParNew+CMS+SerialOld(作爲CMS出錯的後備收集器)組合。
    CMS的標記清除分爲四步:
  1. 初始標記:標記GC Roots能直接關聯的對象,速度很快,但需要暫停所有工作線程。
  2. 併發標記:進行GC Roots的跟蹤過程,和用戶線程一起工作,主要標記過程,標記全部對象。
  3. 重新標記:修正併發標記期間,因爲程序繼續運行而導致標記變動的那一部分的標記記錄。需要暫停所有工作線程。
  4. 併發清除:清除GC Roots不可達對象。和用戶線程一起工作。不需要暫停工作線程,基於標記結果清理對象。

和一次STW相比,因爲併發和清除是最耗時的,都可以和用戶線程一起用。所以感覺上停頓時間較短。這也是CMS最大的優點:併發收集停頓低。
而CMS的缺點也比較有意思:
由於併發執行,CMS在收集時與應用線程同時工作,會增加對堆內存的佔用。也就是說:CMS必須要在老年代堆內存用盡之前完成垃圾回收,否則CMS會回收失敗。回收失敗時會觸發擔保機制:也就是之間說的SerialOld。串行老年代收集器會以STW的方式進行一次GC從而造成較大的停頓時間。
同時因爲CMS採用的是標記清除,所以會產生大量的內存碎片。

如何選擇合適的垃圾收集器呢?

我們知道了各種垃圾收集器的優缺點和運行機制,但是工作中如何選擇合適的垃圾收集組合呢?

  • 單CPU或小內存,單機程序 選擇-XX:+UseSerialGC
  • 多CPU,需要大量吞吐計算,如後臺計算型應用,選擇 -XX:+UseParallelGC(-XX:+UseParallelOldGC也可以,這兩者互相啓動的)
  • 多CPU,追求低停頓時間,需要快速響應如互聯網應用,選擇
    -XX:+UseConcMarkSweepGC和-XX:+ParNewGC


G1垃圾收集器

G1不同於上面說的那六種收集器。而是全新的一種模式。以前的收集器有以下特點:

  1. 年輕代和老年代是各自獨立且連續的內存塊。
  2. 年輕代收集使用eden+from+to進行復制算法
  3. 老年代手機必須掃描整個老年代區域。
  4. 都是以儘可能少而快速的執行GC爲設計原則。

而G1是一款面向服務端應用的收集器。應用在處理多處理器和大容量內存環境中,在實現高吞吐量的同時,儘可能的滿足收集器暫停時間的要求。它的特性:

  • 和CMS一樣可以與應用線程併發執行。
  • 整理空閒空間更快。
  • 需要更多的時間來預測GC停頓時間。
  • 不希望犧牲大量的吞吐性能。
  • 不需要更大的Java heap。

G1收集器的設計目標就是取代DMS收集器。它與CMS相比因爲是標整,不會產生很多內存碎片,而且STW更可控,G1的停頓時間是有預測機制的,所以用戶可以指定期望停頓時間。
G1是2012年,jdk1.7u4版本出現的。而jdk9中,G1變成了默認的垃圾收集器代替了CMS。G1的特點:

  1. G1充分利用多CPU,多核環境硬件優勢,儘量縮短STW。
  2. G1整體上採用標記整理算法,局部是通過複製算法,不會產生內存碎片。
  3. 宏觀上看G1之中不再區分年輕代和老年代,把內存劃分成多個獨立的子區域(Region)。可以近似理解爲一個魔方面或者棋盤。
  4. G1收集器裏面將整個內存都混合在一起了。但其本身依然在小範圍內要進行年輕代和老年代的區分。保留了新生代和老年代。但它們不再是物理隔離的,而是一部分子區域的集合且不要求是連續的。也就是說依然會採用不同的GC方式來處理不同區域。
  5. G1雖然也是分代收集器,但整個內存分區不存在物理上的年輕代和老年代的區別。也不需要完全獨立的survivor堆做複製準備。G1只有邏輯上的分代概念。或者說每個分區都可能隨着G1的運動在不同代之間切換。
G1的底層原理

區域化內存劃片Region。整體編爲了一些不連續的內存區域,避免了全內存區的GC操作。
核心思想是將整個堆內存區域分乘大小相同的子區域,在JVM啓動時會自動設置這些子區域的大小。
G1並不要求對象的存儲一定是物理上連續的。只要邏輯上連續就可以。每個分區也不會固定爲某個代服務。可以按需切換爲年輕代或者老念代。
Region的大小在1M-32M之間。最多能設置2048個區。所以能支持的內存大小最大爲322048 = 64G.*


一句話總結G1:區域化管理,最大的好處是化整爲零,避免全內存掃描,只需要按照區域來進行掃描即可。

G1回收步驟

針對eden區進行收集,eden區耗盡會觸發,主要是小區域收集+形成連續的內存塊。避免內存碎片。

  1. eden區的數據移動到Survivor。如果Survivor區空間不夠,eden區數據會晉升到old區。
  2. Survivor區的數據移動到新的Survivor區。部分數據晉升到old區。
  3. 最後eden區收拾乾淨了,GC結束,用戶的應用程序繼續執行。
G1參數配置

G1有很多特有的參數,常用的幾個如下:

  1. -XX:G1HeapRefionSize=n.設置G1區域大小,範圍是1-32m。
  2. -XX:MaxGCPauseMillis=n.最大GC停頓時間。JVM儘可能但不保證小於這個時間。
  3. -XX:InitiatingHeapOccupancyPercent=n.堆佔用多少的時候觸發GC,默認是45
  4. -XX:ConcGCThreads=n.併發GC使用的線程數
  5. -XX:G1ReservePercent=n.設置作爲空閒空間的預留內存百分比。默認是百分之十。

本篇筆記就記到這裏,如果稍微幫到你了記得點個喜歡點個關注。也祝大家工作順順利利,身體健康!願所有的努力都不會被辜負!

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