深入理解JVM-記憶體模型(jmm)和GC

weixin_33890499發表於2018-09-18

1 CPU和記憶體的互動

瞭解jvm記憶體模型前,瞭解下cpu和計算機記憶體的互動情況。【因為Java虛擬機器記憶體模型定義的訪問操作與計算機十分相似】

有篇很棒的文章,從cpu講到記憶體模型:什麼是java記憶體模型


在計算機中,cpu和記憶體的互動最為頻繁,相比記憶體,磁碟讀寫太慢,記憶體相當於高速的緩衝區。

但是隨著cpu的發展,記憶體的讀寫速度也遠遠趕不上cpu。因此cpu廠商在每顆cpu上加上快取記憶體,用於緩解這種情況。現在cpu和記憶體的互動大致如下。

10006199-d3fc8462f127a2c7.jpg
cpu、快取、記憶體

cpu上加入了快取記憶體這樣做解決了處理器和記憶體的矛盾(一快一慢),但是引來的新的問題 - 快取一致性

在多核cpu中,每個處理器都有各自的快取記憶體(L1,L2,L3),而主記憶體確只有一個 。

CPU要讀取一個資料時,首先從一級快取中查詢,如果沒有找到再從二級快取中查詢,如果還是沒有就從三級快取或記憶體中查詢,每個cpu有且只有一套自己的快取。

如何保證多個處理器運算涉及到同一個記憶體區域時,多執行緒場景下會存在快取一致性問題,那麼執行時保證資料一致性?

為了解決這個問題,各個處理器需遵循一些協議保證一致性。【如MSI,MESI啥啥的協議。。】

大概如下

10006199-0a3299bca20f13ad.png
cpu與記憶體.png

在CPU層面,記憶體屏障提供了個充分必要條件

1.1.1 記憶體屏障(Memory Barrier)

CPU中,每個CPU又有多級快取【上圖統一定義為快取記憶體】,一般分為L1,L2,L3,因為這些快取的出現,提高了資料訪問效能,避免每次都向記憶體索取,但是弊端也很明顯,不能實時的和記憶體發生資訊交換,分在不同CPU執行的不同執行緒對同一個變數的快取值不同。

  • 硬體層的記憶體屏障分為兩種:Load BarrierStore Barrier即讀屏障和寫屏障。【記憶體屏障是硬體層的】
為什麼需要記憶體屏障
由於現代作業系統都是多處理器作業系統,每個處理器都會有自己的快取,可能存再不同處理器快取不一致的問題,而且由於作業系統可能存在重排序,導致讀取到錯誤的資料,因此,作業系統提供了一些記憶體屏障以解決這種問題.
簡單來說:
1.在不同CPU執行的不同執行緒對同一個變數的快取值不同,為了解決這個問題。
2.用volatile可以解決上面的問題,不同硬體對記憶體屏障的實現方式不一樣。java遮蔽掉這些差異,通過jvm生成記憶體屏障的指令。
對於讀屏障:在指令前插入讀屏障,可以讓快取記憶體中的資料失效,強制從主記憶體取。
記憶體屏障的作用
cpu執行指令可能是無序的,它有兩個比較重要的作用
1.阻止屏障兩側指令重排序
2.強制把寫緩衝區/快取記憶體中的髒資料等寫回主記憶體,讓快取中相應的資料失效。

volatile型變數

當我們宣告某個變數為volatile修飾時,這個變數就有了執行緒可見性,volatile通過在讀寫操作前後新增記憶體屏障。

用程式碼可以這麼理解

//相當於讀寫時加鎖,保證及時可見性,併發時不被隨意修改。
public class SynchronizedInteger {
  private long value;

  public synchronized int get() {
    return value;
  }

  public synchronized void set(long value) {
    this.value = value;
  }
}

volatile型變數擁有如下特性

1.可見性,對於一個該變數的讀,一定能看到讀之前最後的寫入。
2.原子性,對volatile變數的讀寫具有原子性,即單純讀和寫的操作,都不會受到干擾。

2. Java記憶體區域

前提:本文講的基本都是以Sun HotSpot虛擬機器為基礎的,Oracle收購了Sun後目前得到了兩個【Sun的HotSpot和JRockit(以後可能合併這兩個),還有一個是IBM的IBMJVM】

之所以扯了那麼多計算機記憶體模型,是因為java記憶體模型的設定符合了計算機的規範。

Java程式記憶體的分配是在JVM虛擬機器記憶體分配機制下完成

Java記憶體模型(Java Memory Model ,JMM)就是一種符合記憶體模型規範的,遮蔽了各種硬體和作業系統的訪問差異的,保證了Java程式在各種平臺下對記憶體的訪問都能保證效果一致的機制及規範。

簡要言之,jmm是jvm的一種規範,定義了jvm的記憶體模型。它遮蔽了各種硬體和作業系統的訪問差異,不像c那樣直接訪問硬體記憶體,相對安全很多,它的主要目的是解決由於多執行緒通過共享記憶體進行通訊時,存在的本地記憶體資料不一致、編譯器會對程式碼指令重排序、處理器會對程式碼亂序執行等帶來的問題。可以保證併發程式設計場景中的原子性、可見性和有序性。

從下面這張圖可以看出來,Java資料區域分為五大資料區域。這些區域各有各的用途,建立及銷燬時間。

其中方法區和堆是所有執行緒共享的,棧,本地方法棧和程式虛擬機器則為執行緒私有的。

根據java虛擬機器規範,java虛擬機器管理的記憶體將分為下面五大區域。

10006199-a4108d8fb7810a71.jpeg
jmm

2.1 五大記憶體區域

2.1.1 程式計數器

程式計數器是一塊很小的記憶體空間,它是執行緒私有的,可以認作為當前執行緒的行號指示器。

為什麼需要程式計數器

我們知道對於一個處理器(如果是多核cpu那就是一核),在一個確定的時刻都只會執行一條執行緒中的指令,一條執行緒中有多個指令,為了執行緒切換可以恢復到正確執行位置,每個執行緒都需有獨立的一個程式計數器,不同執行緒之間的程式計數器互不影響,獨立儲存。

注意:如果執行緒執行的是個java方法,那麼計數器記錄虛擬機器位元組碼指令的地址。如果為native【底層方法】,那麼計數器為空。這塊記憶體區域是虛擬機器規範中唯一沒有OutOfMemoryError的區域

2.1.2 Java棧(虛擬機器棧)

同計數器也為執行緒私有,生命週期與相同,就是我們平時說的棧,棧描述的是Java方法執行的記憶體模型

每個方法被執行的時候都會建立一個棧幀用於儲存區域性變數表,操作棧,動態連結,方法出口等資訊。每一個方法被呼叫的過程就對應一個棧幀在虛擬機器棧中從入棧到出棧的過程。【棧先進後出,下圖棧1先進最後出來】

對於棧幀的解釋參考 Java虛擬機器執行時棧幀結構

棧幀: 是用來儲存資料和部分過程結果的資料結構。
棧幀的位置:  記憶體 -> 執行時資料區 -> 某個執行緒對應的虛擬機器棧 -> here[在這裡]
棧幀大小確定時間: 編譯期確定,不受執行期資料影響。

通常有人將java記憶體區分為棧和堆,實際上java記憶體比這複雜,這麼區分可能是因為我們最關注,與物件記憶體分配關係最密切的是這兩個。

平時說的棧一般指區域性變數表部分。

區域性變數表:一片連續的記憶體空間,用來存放方法引數,以及方法內定義的區域性變數,存放著編譯期間已知的資料型別(八大基本型別和物件引用(reference型別),returnAddress型別。它的最小的區域性變數表空間單位為Slot,虛擬機器沒有指明Slot的大小,但在jvm中,long和double型別資料明確規定為64位,這兩個型別佔2個Slot,其它基本型別固定佔用1個Slot。

reference型別:與基本型別不同的是它不等同本身,即使是String,內部也是char陣列組成,它可能是指向一個物件起始位置指標,也可能指向一個代表物件的控制程式碼或其他與該物件有關的位置。

returnAddress型別:指向一條位元組碼指令的地址【深入理解Java虛擬機器】怎麼理解returnAddress

10006199-728567b81e7abff5.png
棧幀

需要注意的是,區域性變數表所需要的記憶體空間在編譯期完成分配,當進入一個方法時,這個方法在棧中需要分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表大小。

Java虛擬機器棧可能出現兩種型別的異常:

  1. 執行緒請求的棧深度大於虛擬機器允許的棧深度,將丟擲StackOverflowError。
  2. 虛擬機器棧空間可以動態擴充套件,當動態擴充套件是無法申請到足夠的空間時,丟擲OutOfMemory異常。

2.1.3 本地方法棧

本地方法棧是與虛擬機器棧發揮的作用十分相似,區別是虛擬機器棧執行的是Java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的native方法服務,可能底層呼叫的c或者c++,我們開啟jdk安裝目錄可以看到也有很多用c編寫的檔案,可能就是native方法所呼叫的c程式碼。

2.1.4 堆

對於大多數應用來說,堆是java虛擬機器管理記憶體最大的一塊記憶體區域,因為堆存放的物件是執行緒共享的,所以多執行緒的時候也需要同步機制。因此需要重點了解下。

java虛擬機器規範對這塊的描述是:所有物件例項及陣列都要在堆上分配記憶體,但隨著JIT編譯器的發展和逃逸分析技術的成熟,這個說法也不是那麼絕對,但是大多數情況都是這樣的。

即時編譯器:可以把把Java的位元組碼,包括需要被解釋的指令的程式)轉換成可以直接傳送給處理器的指令的程式)

逃逸分析:通過逃逸分析來決定某些例項或者變數是否要在堆中進行分配,如果開啟了逃逸分析,即可將這些變數直接在棧上進行分配,而非堆上進行分配。這些變數的指標可以被全域性所引用,或者其其它執行緒所引用。

參考逃逸分析

注意:它是所有執行緒共享的,它的目的是存放物件例項。同時它也是GC所管理的主要區域,因此常被稱為GC堆,又由於現在收集器常使用分代演算法,Java堆中還可以細分為新生代和老年代,再細緻點還有Eden(伊甸園)空間之類的不做深究。

根據虛擬機器規範,Java堆可以存在物理上不連續的記憶體空間,就像磁碟空間只要邏輯是連續的即可。它的記憶體大小可以設為固定大小,也可以擴充套件。

當前主流的虛擬機器如HotPot都能按擴充套件實現(通過設定 -Xmx和-Xms),如果堆中沒有記憶體記憶體完成例項分配,而且堆無法擴充套件將報OOM錯誤(OutOfMemoryError)

2.1.5 方法區

方法區同堆一樣,是所有執行緒共享的記憶體區域,為了區分堆,又被稱為非堆。

用於儲存已被虛擬機器載入的類資訊、常量、靜態變數,如static修飾的變數載入類的時候就被載入到方法區中。

執行時常量池

是方法區的一部分,class檔案除了有類的欄位、介面、方法等描述資訊之外,還有常量池用於存放編譯期間生成的各種字面量和符號引用。

在老版jdk,方法區也被稱為永久代【因為沒有強制要求方法區必須實現垃圾回收,HotSpot虛擬機器以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分割槽域,從而不需要專門為這部分設計垃圾回收機制。不過自從JDK7之後,Hotspot虛擬機器便將執行時常量池從永久代移除了。】

jdk1.7開始逐步去永久代。從String.interns()方法可以看出來
String.interns()
native方法:作用是如果字串常量池已經包含一個等於這個String物件的字串,則返回代表池中的這個字串的String物件,在jdk1.6及以前常量池分配在永久代中。可通過 -XX:PermSize和-XX:MaxPermSize限制方法區大小。
public class StringIntern {
    //執行如下程式碼探究執行時常量池的位置
    public static void main(String[] args) throws Throwable {
        //用list保持著引用 防止full gc回收常量池
        List<String> list = new ArrayList<String>();
        int i = 0;
        while(true){
            list.add(String.valueOf(i++).intern());
        }
    }
}
//如果在jdk1.6環境下執行 同時限制方法區大小 將報OOM後面跟著PermGen space說明方法區OOM,即常量池在永久代
//如果是jdk1.7或1.8環境下執行 同時限制堆的大小  將報heap space 即常量池在堆中

idea設定相關記憶體大小設定

這邊不用全域性的方式,設定main方法的vm引數。

做相關設定,比如說這邊設定堆大小。(-Xmx5m -Xms5m -XX:-UseGCOverheadLimit)

這邊如果不設定UseGCOverheadLimit將報java.lang.OutOfMemoryError: GC overhead limit exceeded,
這個錯是因為GC佔用了多餘98%(預設值)的CPU時間卻只回收了少於2%(預設值)的堆空間。目的是為了讓應用終止,給開發者機會去診斷問題。一般是應用程式在有限的記憶體上建立了大量的臨時物件或者弱引用物件,從而導致該異常。雖然加大記憶體可以暫時解決這個問題,但是還是強烈建議去優化程式碼,後者更加有效,也可通過UseGCOverheadLimit避免[不推薦,這裡是因為測試用,並不能解決根本問題]
10006199-b55cc68293d1807d.png
10006199-76054110706ff110.png

jdk8真正開始廢棄永久代,而使用元空間(Metaspace)

java虛擬機器對方法區比較寬鬆,除了跟堆一樣可以不存在連續的記憶體空間,定義空間和可擴充套件空間,還可以選擇不實現垃圾收集。

2.2 物件的記憶體佈局

在HotSpot虛擬機器中。物件在記憶體中儲存的佈局分為

1.物件頭
2.例項資料
3.對齊填充
2.2.1 物件頭【markword】

在32位系統下,物件頭8位元組,64位則是16個位元組【未開啟壓縮指標,開啟後12位元組】。

markword很像網路協議報文頭,劃分為多個區間,並且會根據物件的狀態複用自己的儲存空間。
為什麼這麼做:省空間,物件需要儲存的資料很多,32bit/64bit是不夠的,它被設計成非固定的資料結構以便在極小的空間儲存更多的資訊,
假設當前為32個位元組,在物件未被鎖定情況下。25bit為儲存物件的雜湊碼、4bit用於儲存分代年齡,2bit用於儲存鎖標誌位,1bit固定為0。

不同狀態下存放資料

10006199-b0fd456c33c09fce.png

這其中鎖標識位需要特別關注下。鎖標誌位與是否為偏向鎖對應到唯一的鎖狀態

鎖的狀態分為四種無鎖狀態偏向鎖輕量級鎖重量級鎖

不同狀態時物件頭的區間含義,如圖所示。

10006199-9b3fe05daab42136.jpg
物件頭.jpg

HotSpot底層通過markOop實現Mark Word,具體實現位於markOop.hpp檔案。

markOop中提供了大量方法用於檢視當前物件頭的狀態,以及更新物件頭的資料,為synchronized鎖的實現提供了基礎。[比如說我們知道synchronized鎖的是物件而不是程式碼,而鎖的狀態儲存在物件頭中,進而實現鎖住物件]。

關於物件頭和鎖之間的轉換,網上大神總結

10006199-318ad80ccb29abe4.png
偏向鎖輕量級鎖重量級鎖.png
2.2.2 例項資料
存放物件程式中各種型別的欄位型別,不管是從父類中繼承下來的還是在子類中定義的。
分配策略:相同寬度的欄位總是放在一起,比如double和long
2.2.3 對齊填充

這部分沒有特殊的含義,僅僅起到佔位符的作用滿足JVM要求。

由於HotSpot規定物件的大小必須是8的整數倍,物件頭剛好是整數倍,如果例項資料不是的話,就需要佔位符對齊填充。

2.3 物件的訪問定位

java程式需要通過引用(ref)資料來操作堆上面的物件,那麼如何通過引用定位、訪問到物件的具體位置。

物件的訪問方式由虛擬機器決定,java虛擬機器提供兩種主流的方式
1.控制程式碼訪問物件
2.直接指標訪問物件。(Sun HotSpot使用這種方式)

參考Java物件訪問定位

2.3.1 控制程式碼訪問

簡單來說就是java堆劃出一塊記憶體作為控制程式碼池,引用中儲存物件的控制程式碼地址,控制程式碼中包含物件例項資料、型別資料的地址資訊。

優點:引用中儲存的是穩定的控制程式碼地址,在物件被移動【垃圾收集時移動物件是常態】只需改變控制程式碼中例項資料的指標,不需要改動引用【ref】本身。
10006199-27ef5c978077ed1c.jpg
訪問方式2.jpg
2.3.2 直接指標

與控制程式碼訪問不同的是,ref中直接儲存的就是物件的例項資料,但是型別資料跟控制程式碼訪問方式一樣。

優點:優勢很明顯,就是速度快,相比於控制程式碼訪問少了一次指標定位的開銷時間。【可能是出於Java中物件的訪問時十分頻繁的,平時我們常用的JVM HotSpot採用此種方式】

10006199-6cefc46d23c2d549.jpg
訪問方式1.jpg

3.記憶體溢位

兩種記憶體溢位異常[注意記憶體溢位是error級別的]
1.StackOverFlowError:當請求的棧深度大於虛擬機器所允許的最大深度
2.OutOfMemoryError:虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間[一般都能設定擴大]

java -verbose:class -version 可以檢視剛開始載入的類,可以發現這兩個類並不是異常出現的時候才去載入,而是jvm啟動的時候就已經載入。這麼做的原因是在vm啟動過程中我們把類載入起來,並建立幾個沒有堆疊的物件快取起來,只需要設定下不同的提示資訊即可,當需要丟擲特定型別的OutOfMemoryError異常的時候,就直接拿出快取裡的這幾個物件就可以了。

比如說OutOfMemoryError物件,jvm預留出4個物件【固定常量】,這就為什麼最多出現4次有堆疊的OutOfMemoryError異常及大部分情況下都將看到沒有堆疊的OutOfMemoryError物件的原因。

參考OutOfMemoryError解讀

10006199-07496d628d676815.png
Snip20180904_8.png

兩個基本的例子

public class MemErrorTest {
    public static void main(String[] args) {
        try {
            List<Object> list = new ArrayList<Object>();
            for(;;) {
                list.add(new Object()); //建立物件速度可能高於jvm回收速度
            }
        } catch (OutOfMemoryError e) {
            e.printStackTrace();
        }

        try {
            hi();//遞迴造成StackOverflowError 這邊因為每執行一個方法將建立一個棧幀,棧幀建立太多無法繼續申請到記憶體擴充套件
        } catch (StackOverflowError e) {
            e.printStackTrace();
        }

    }

    public static void hi() {
        hi();
    }
}
10006199-04a7ea1247b98809.png

4.GC簡介

GC(Garbage Collection):即垃圾回收器,誕生於1960年MIT的Lisp語言,主要是用來回收,釋放垃圾佔用的空間。


java GC泛指java的垃圾回收機制,該機制是java與C/C++的主要區別之一,我們在日常寫java程式碼的時候,一般都不需要編寫記憶體回收或者垃圾清理的程式碼,也不需要像C/C++那樣做類似delete/free的操作。

4.1.為什麼需要學習GC

物件的記憶體分配在java虛擬機器的自動記憶體分配機制下,一般不容易出現記憶體洩漏問題。但是寫程式碼難免會遇到一些特殊情況,比如OOM神馬的。。儘管虛擬機器記憶體的動態分配與記憶體回收技術很成熟,可萬一出現了這樣那樣的記憶體溢位問題,那麼將難以定位錯誤的原因所在。

對於本人來說,由於水平有限,而且作為小開發,並沒必要深入到GC的底層實現,但至少想要說學會看懂gc及定位一些記憶體洩漏問題。

從三個角度切入來學習GC

1.哪些記憶體要回收

2.什麼時候回收

3.怎麼回收

哪些記憶體要回收

java記憶體模型中分為五大區域已經有所瞭解。我們知道程式計數器虛擬機器棧本地方法棧,由執行緒而生,隨執行緒而滅,其中棧中的棧幀隨著方法的進入順序的執行的入棧和出棧的操作,一個棧幀需要分配多少記憶體取決於具體的虛擬機器實現並且在編譯期間即確定下來【忽略JIT編譯器做的優化,基本當成編譯期間可知】,當方法或執行緒執行完畢後,記憶體就隨著回收,因此無需關心。

Java堆方法區則不一樣。方法區存放著類載入資訊,但是一個介面中多個實現類需要的記憶體可能不太一樣,一個方法中多個分支需要的記憶體也可能不一樣【只有在執行期間才可知道這個方法建立了哪些物件沒需要多少記憶體】,這部分記憶體的分配和回收都是動態的,gc關注的也正是這部分的記憶體。

Java堆是GC回收的“重點區域”。堆中基本存放著所有物件例項,gc進行回收前,第一件事就是確認哪些物件存活,哪些死去[即不可能再被引用]

4.2 堆的回收區域

為了高效的回收,jvm將堆分為三個區域
1.新生代(Young Generation)NewSize和MaxNewSize分別可以控制年輕代的初始大小和最大的大小
2.老年代(Old Generation)
3.永久代(Permanent Generation)【1.8以後採用元空間,就不在堆中了】

GC為什麼要分代-R大的回答

關於元空間

5 判斷物件是否存活演算法

1.引用計數演算法
早期判斷物件是否存活大多都是以這種演算法,這種演算法判斷很簡單,簡單來說就是給物件新增一個引用計數器,每當物件被引用一次就加1,引用失效時就減1。當為0的時候就判斷物件不會再被引用。
優點:實現簡單效率高,被廣泛使用與如python何遊戲指令碼語言上。
缺點:難以解決迴圈引用的問題,就是假如兩個物件互相引用已經不會再被其它其它引用,導致一直不會為0就無法進行回收。

2.可達性分析演算法
目前主流的商用語言[如java、c#]採用的是可達性分析演算法判斷物件是否存活。這個演算法有效解決了迴圈利用的弊端。
它的基本思路是通過一個稱為“GC Roots”的物件為起始點,搜尋所經過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用跟它連線則證明物件是不可用的。
10006199-854e1de91f66764b.png
gc.png

可作為GC Roots的物件有四種

①虛擬機器棧(棧楨中的本地變數表)中的引用的物件,就是平時所指的java物件,存放在堆中。
②方法區中的類靜態屬性引用的物件,一般指被static修飾引用的物件,載入類的時候就載入到記憶體中。
③方法區中的常量引用的物件,
④本地方法棧中JNI(native方法)引用的物件

即使可達性演算法中不可達的物件,也不是一定要馬上被回收,還有可能被搶救一下。網上例子很多,基本上和深入理解JVM一書講的一樣物件的生存還是死亡

要真正宣告物件死亡需經過兩個過程。
1.可達性分析後沒有發現引用鏈
2.檢視物件是否有finalize方法,如果有重寫且在方法內完成自救[比如再建立引用],還是可以搶救一下,注意這邊一個類的finalize只執行一次,這就會出現一樣的程式碼第一次自救成功第二次失敗的情況。[如果類重寫finalize且還沒呼叫過,會將這個物件放到一個叫做F-Queue的序列裡,這邊finalize不承諾一定會執行,這麼做是因為如果裡面死迴圈的話可能會時F-Queue佇列處於等待,嚴重會導致記憶體崩潰,這是我們不希望看到的。]

HotSpot虛擬機器如何實現可達性演算法

5 垃圾收集演算法

jvm中,可達性分析演算法幫我們解決了哪些物件可以回收的問題,垃圾收集演算法則關心怎麼回收。

5.1 三大垃圾收集演算法

1.標記/清除演算法【最基礎】
2.複製演算法
3.標記/整理演算法
jvm採用`分代收集演算法`對不同區域採用不同的回收演算法。

參考GC演算法深度解析

新生代採用複製演算法

新生代中因為物件都是"朝生夕死的",【深入理解JVM虛擬機器上說98%的物件,不知道是不是這麼多,總之就是存活率很低】,適用於複製演算法【複製演算法比較適合用於存活率低的記憶體區域】。它優化了標記/清除演算法的效率和記憶體碎片問題,且JVM不以5:5分配記憶體【由於存活率低,不需要複製保留那麼大的區域造成空間上的浪費,因此不需要按1:1【原有區域:保留空間】劃分記憶體區域,而是將記憶體分為一塊Eden空間和From Survivor、To Survivor【保留空間】,三者預設比例為8:1:1,優先使用Eden區,若Eden區滿,則將物件複製到第二塊記憶體區上。但是不能保證每次回收都只有不多於10%的物件存貨,所以Survivor區不夠的話,則會依賴老年代年存進行分配】。

GC開始時,物件只會存於Eden和From Survivor區域,To Survivor【保留空間】為空。

GC進行時,Eden區所有存活的物件都被複制到To Survivor區,而From Survivor區中,仍存活的物件會根據它們的年齡值決定去向,年齡值達到年齡閾值(預設15是因為物件頭中年齡戰4bit,新生代每熬過一次垃圾回收,年齡+1),則移到老年代,沒有達到則複製到To Survivor。

老年代採用標記/清除演算法標記/整理演算法

由於老年代存活率高,沒有額外空間給他做擔保,必須使用這兩種演算法。

5.2 列舉根節點演算法

GC Roots 被虛擬機器用來判斷物件是否存活

可作為GC Roos的節點主要是在一些全域性引用【如常量或靜態屬性】、執行上下文【如棧幀中本地變數表】中。那麼如何在這麼多全域性變數和本地變數表找到【列舉】根節點將是個問題。

可達性分析演算法需考慮

1.如果方法區幾百兆,一個個檢查裡面的引用,將耗費大量資源。

2.在分析時,需保證這個物件引用關係不再變化,否則結果將不準確。【因此GC進行時需停掉其它所有java執行執行緒(Sun把這種行為稱為‘Stop the World’),即使是號稱幾乎不會停頓的CMS收集器,列舉根節點時也需停掉執行緒】

解決辦法:實際上當系統停下來後JVM不需要一個個檢查引用,而是通過OopMap資料結構【HotSpot的叫法】來標記物件引用。

虛擬機器先得知哪些地方存放物件的引用,在類載入完時。HotSpot把物件內什麼偏移量什麼型別的資料算出來,在jit編譯過程中,也會在特定位置記錄下棧和暫存器哪些位置是引用,這樣GC在掃描時就可以知道這些資訊。【目前主流JVM使用準確式GC】

OopMap可以幫助HotSpot快速且準確完成GC Roots列舉以及確定相關資訊。但是也存在一個問題,可能導致引用關係變化。

這個時候有個safepoint(安全點)的概念。

HotSpot中GC不是在任意位置都可以進入,而只能在safepoint處進入。 GC時對一個Java執行緒來說,它要麼處在safepoint,要麼不在safepoint。

safepoint不能太少,否則GC等待的時間會很久

safepoint不能太多,否則將增加執行GC的負擔

安全點主要存放的位置

1:迴圈的末尾 
2:方法臨返回前/呼叫方法的call指令後 
3:可能拋異常的位置

參考:關於安全點safepoint

6.垃圾收集器

如果說垃圾回收演算法是記憶體回收的方法論,那麼垃圾收集器就是具體實現。jvm會結合針對不同的場景及使用者的配置使用不同的收集器。
年輕代收集器
Serial、ParNew、Parallel Scavenge
老年代收集器
Serial Old、Parallel Old、CMS收集器
特殊收集器
G1收集器[新型,不在年輕、老年代範疇內]
10006199-975ca350889de014.jpg
收集器,連線代表可結合使用

新生代收集器

6.1 Serial

最基本、發展最久的收集器,在jdk3以前是gc收集器的唯一選擇,Serial是單執行緒收集器,Serial收集器只能使用一條執行緒進行收集工作,在收集的時候必須得停掉其它執行緒,等待收集工作完成其它執行緒才可以繼續工作。

雖然Serial看起來很坑,需停掉別的執行緒以完成自己的gc工作,但是也不是完全沒用的,比如說Serial在執行在Client模式下優於其它收集器[簡單高效,不過一般都是用Server模式,64bit的jvm甚至沒Client模式]

JVM的Client模式與Server模式

優點:對於Client模式下的jvm來說是個好的選擇。適用於單核CPU【現在基本都是多核了】
缺點:收集時要暫停其它執行緒,有點浪費資源,多核下顯得。

6.2 ParNew收集器

可以認為是Serial的升級版,因為它支援多執行緒[GC執行緒],而且收集演算法、Stop The World、回收策略和Serial一樣,就是可以有多個GC執行緒併發執行,它是HotSpot第一個真正意義實現併發的收集器。預設開啟執行緒數和當前cpu數量相同【幾核就是幾個,超執行緒cpu的話就不清楚了 - -】,如果cpu核數很多不想用那麼多,可以通過-XX:ParallelGCThreads來控制垃圾收集執行緒的數量。

優點:
1.支援多執行緒,多核CPU下可以充分的利用CPU資源
2.執行在Server模式下新生代首選的收集器【重點是因為新生代的這幾個收集器只有它和Serial可以配合CMS收集器一起使用】

缺點: 在單核下表現不會比Serial好,由於在單核能利用多核的優勢,線上程收集過程中可能會出現頻繁上下文切換,導致額外的開銷。

6.3 Parallel Scavenge

採用複製演算法的收集器,和ParNew一樣支援多執行緒。

但是該收集器重點關心的是吞吐量【吞吐量 = 程式碼執行時間 / (程式碼執行時間 + 垃圾收集時間) 如果程式碼執行100min垃圾收集1min,則為99%】

對於使用者介面,適合使用GC停頓時間短,不然因為卡頓導致互動介面卡頓將很影響使用者體驗。

對於後臺

高吞吐量可以高效率的利用cpu儘快完成程式運算任務,適合後臺運算

Parallel Scavenge注重吞吐量,所以也成為"吞吐量優先"收集器。

老年代收集器

6.4 Serial Old

和新生代的Serial一樣為單執行緒,Serial的老年代版本,不過它採用"標記-整理演算法",這個模式主要是給Client模式下的JVM使用。

如果是Server模式有兩大用途

1.jdk5前和Parallel Scavenge搭配使用,jdk5前也只有這個老年代收集器可以和它搭配。

2.作為CMS收集器的後備。

6.5 Parallel Old

支援多執行緒,Parallel Scavenge的老年版本,jdk6開始出現, 採用"標記-整理演算法"【老年代的收集器大都採用此演算法】

在jdk6以前,新生代的Parallel Scavenge只能和Serial Old配合使用【根據圖,沒有這個的話只剩Serial Old,而Parallel Scavenge又不能和CMS配合使用】,而且Serial Old為單執行緒Server模式下會拖後腿【多核cpu下無法充分利用】,這種結合並不能讓應用的吞吐量最大化。

Parallel Old的出現結合Parallel Scavenge,真正的形成“吞吐量優先”的收集器組合。

6.6 CMS

CMS收集器(Concurrent Mark Sweep)是以一種獲取最短回收停頓時間為目標的收集器。【重視響應,可以帶來好的使用者體驗,被sun稱為併發低停頓收集器】

啟用CMS:-XX:+UseConcMarkSweepGC

正如其名,CMS採用的是"標記-清除"(Mark Sweep)演算法,而且是支援併發(Concurrent)的

它的運作分為4個階段

1.初始標記:標記一下GC Roots能直接關聯到的物件,速度很快
2.併發標記:GC Roots Tarcing過程,即可達性分析
3.重新標記:為了修正因併發標記期間使用者程式運作而產生變動的那一部分物件的標記記錄,會有些許停頓,時間上一般 初始標記 < 重新標記 < 併發標記
4.併發清除

以上初始標記和重新標記需要stw(停掉其它執行java執行緒)

之所以說CMS的使用者體驗好,是因為CMS收集器的記憶體回收工作是可以和使用者執行緒一起併發執行。

總體上CMS是款優秀的收集器,但是它也有些缺點。

1.cms堆cpu特別敏感,cms執行執行緒和應用程式併發執行需要多核cpu,如果cpu核數多的話可以發揮它併發執行的優勢,但是cms預設配置啟動的時候垃圾執行緒數為 (cpu數量+3)/4,它的效能很容易受cpu核數影響,當cpu的數目少的時候比如說為為2核,如果這個時候cpu運算壓力比較大,還要分一半給cms運作,這可能會很大程度的影響到計算機效能。

2.cms無法處理浮動垃圾,可能導致Concurrent Mode Failure(併發模式故障)而觸發full GC

3.由於cms是採用"標記-清除“演算法,因此就會存在垃圾碎片的問題,為了解決這個問題cms提供了 -XX:+UseCMSCompactAtFullCollection選項,這個選項相當於一個開關【預設開啟】,用於CMS頂不住要進行full GC時開啟記憶體碎片合併,記憶體整理的過程是無法併發的,且開啟這個選項會影響效能(比如停頓時間變長)

浮動垃圾:由於cms支援執行的時候使用者執行緒也在執行,程式執行的時候會產生新的垃圾,這裡產生的垃圾就是浮動垃圾,cms無法當次處理,得等下次才可以。

6.7 G1收集器

G1(garbage first:儘可能多收垃圾,避免full gc)收集器是當前最為前沿的收集器之一(1.7以後才開始有),同cms一樣也是關注降低延遲,是用於替代cms功能更為強大的新型收集器,因為它解決了cms產生空間碎片等一系列缺陷。

摘自甲骨文:適用於 Java HotSpot VM 的低暫停、伺服器風格的分代式垃圾回收器。G1 GC 使用併發和並行階段實現其目標暫停時間,並保持良好的吞吐量。當 G1 GC 確定有必要進行垃圾回收時,它會先收集存活資料最少的區域(垃圾優先)

g1的特別之處在於它強化了分割槽,弱化了分代的概念,是區域化、增量式的收集器,它不屬於新生代也不屬於老年代收集器。

用到的演算法為標記-清理、複製演算法

jdk1.7,1.8的都是預設關閉的,更高版本的還不知道
開啟選項 -XX:+UseG1GC 
比如在tomcat的catania.sh啟動引數加上

g1是區域化的,它將java堆記憶體劃分為若干個大小相同的區域【region】,jvm可以設定每個region的大小(1-32m,大小得看堆記憶體大小,必須是2的冪),它會根據當前的堆記憶體分配合理的region大小。

jdk7中計算region的原始碼,這邊博主看了下也看不怎麼懂,也翻了下openjdk8的看了下關於region的處理似乎不太一樣。。

g1通過併發(並行)標記階段查詢老年代存活物件,通過並行複製壓縮存活物件【這樣可以省出連續空間供大物件使用】。

g1將一組或多組區域中存活物件以增量並行的方式複製到不同區域進行壓縮,從而減少堆碎片,目標是儘可能多回收堆空間【垃圾優先】,且儘可能不超出暫停目標以達到低延遲的目的。

g1提供三種垃圾回收模式 young gc、mixed gc 和 full gc,不像其它的收集器,根據區域而不是分代,新生代老年代的物件它都能回收。

幾個重要的預設值,更多的檢視官方文件oracle官方g1中文文件

g1是自適應的回收器,提供了若干個預設值,無需修改就可高效運作
-XX:G1HeapRegionSize=n  設定g1 region大小,不設定的話自己會根據堆大小算,目標是根據最小堆記憶體劃分2048個區域
-XX:MaxGCPauseMillis=200 最大停頓時間 預設200毫秒

7 Minor GC、Major GC、FULL GC、mixed gc

7.1 Minor GC

在年輕代Young space(包括Eden區和Survivor區)中的垃圾回收稱之為 Minor GC,Minor GC只會清理年輕代.

7.2 Major GC

Major GC清理老年代(old GC),但是通常也可以指和Full GC是等價,因為收集老年代的時候往往也會伴隨著升級年輕代,收集整個Java堆。所以有人問的時候需問清楚它指的是full GC還是old GC。

7.3 Full GC

full gc是對新生代、老年代、永久代【jdk1.8後沒有這個概念了】統一的回收。

【知乎R大的回答:收集整個堆,包括young gen、old gen、perm gen(如果存在的話)、元空間(1.8及以上)等所有部分的模式】

7.4 mixed GC【g1特有】

混合GC

收集整個young gen以及部分old gen的GC。只有G1有這個模式

8 檢視GC日誌

8.1 簡單日誌檢視

要看得懂並理解GC,需要看懂GC日誌。

這邊我在idea上試了個小例子,需要在idea配置引數(-XX:+PrintGCDetails)。

10006199-239c6e3a1d84a447.png
public class GCtest {
    public static void main(String[] args) {
        for(int i = 0; i < 10000; i++) {
            List<String> list = new ArrayList<>();
            list.add("aaaaaaaaaaaaa");
        }
        System.gc();
    }
}
[GC (System.gc()) [PSYoungGen: 3998K->688K(38400K)] 3998K->696K(125952K), 0.0016551 secs[本次回收時間]] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 688K->0K(38400K)] [ParOldGen: 8K->603K(87552K)] 696K->603K(125952K), [Metaspace: 3210K->3210K(1056768K)], 0.0121034 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen[年輕代]      total 38400K, used 333K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 1% used [0x0000000795580000,0x00000007955d34a8,0x0000000797600000)
  from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
  to   space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
 ParOldGen[老年代]       total 87552K, used 603K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 0% used [0x0000000740000000,0x0000000740096fe8,0x0000000745580000)
 Metaspace[元空間]      used 3217K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

8.2 離線工具檢視

比如sun的gchistogcviewer離線分析工具,做個筆記先了解下還沒用過,視覺化好像很好用的樣子。

8.3 自帶的jconsole工具、jstat命令

終端輸入jconsole就會出現jdk自帶的gui監控工具

10006199-d409f452f8364937.png
jconsole

可以根據記憶體使用情況間接瞭解記憶體使用和gc情況

10006199-c0eb6418cf4bade9.png
jconsole

jstat命令

比如jstat -gcutil pid檢視對應java程式gc情況

10006199-6ed3ee78469592e8.png
jstat
s0: 新生代survivor space0簡稱 就是準備複製的那塊 單位為%
s1:指新生代s1已使用百分比,為0的話說明沒有存活物件到這邊
e:新生代eden(伊甸園)區域(%)
o:老年代(%)
ygc:新生代  次數
ygct:minor gc耗時
fgct:full gc耗時(秒)
GCT: ygct+fgct 耗時

幾個疑問

1.GC是怎麼判斷物件是被標記的

通過列舉根節點的方式,通過jvm提供的一種oopMap的資料結構,簡單來說就是不要再通過去遍歷記憶體裡的東西,而是通過OOPMap的資料結構去記錄該記錄的資訊,比如說它可以不用去遍歷整個棧,而是掃描棧上面引用的資訊並記錄下來。

總結:通過OOPMap把棧上代表引用的位置全部記錄下來,避免全棧掃描,加快列舉根節點的速度,除此之外還有一個極為重要的作用,可以幫HotSpot實現準確式GC【這邊的準確關鍵就是型別,可以根據給定位置的某塊資料知道它的準確型別,HotSpot是通過oopMap外部記錄下這些資訊,存成對映表一樣的東西】。

2.什麼時候觸發GC

簡單來說,觸發的條件就是GC演算法區域滿了或將滿了。

minor GC(young GC):當年輕代中eden區分配滿的時候觸發[值得一提的是因為young GC後部分存活的物件會已到老年代(比如物件熬過15輪),所以過後old gen的佔用量通常會變高]

full GC:
①手動呼叫System.gc()方法 [增加了full GC頻率,不建議使用而是讓jvm自己管理記憶體,可以設定-XX:+ DisableExplicitGC來禁止RMI呼叫System.gc]
②發現perm gen(如果存在永久代的話)需分配空間但已經沒有足夠空間
③老年代空間不足,比如說新生代的大物件大陣列晉升到老年代就可能導致老年代空間不足。
④CMS GC時出現Promotion Faield[pf]
⑤統計得到的Minor GC晉升到舊生代的平均大小大於老年代的剩餘空間。
這個比較難理解,這是HotSpot為了避免由於新生代晉升到老年代導致老年代空間不足而觸發的FUll GC。
比如程式第一次觸發Minor GC後,有5m的物件晉升到老年代,姑且現在平均算5m,那麼下次Minor GC發生時,先判斷現在老年代剩餘空間大小是否超過5m,如果小於5m,則HotSpot則會觸發full GC(這點挺智慧的)
Promotion Faield:minor GC時 survivor space放不下[滿了或物件太大],物件只能放到老年代,而老年代也放不下會導致這個錯誤。
Concurrent Model Failure:cms時特有的錯誤,因為cms時垃圾清理和使用者執行緒可以是併發執行的,如果在清理的過程中
可能原因:
1 cms觸發太晚,可以把XX:CMSInitiatingOccupancyFraction調小[比如-XX:CMSInitiatingOccupancyFraction=70 是指設定CMS在對記憶體佔用率達到70%的時候開始GC(因為CMS會有浮動垃圾,所以一般都較早啟動GC)]
2 垃圾產生速度大於清理速度,可能是晉升閾值設定過小,Survivor空間小導致跑到老年代,eden區太小,存在大物件、陣列物件等情況
3.空間碎片過多,可以開啟空間碎片整理併合理設定週期時間

full gc導致了concurrent mode failure,而不是因為concurrent mode failure錯誤導致觸發full gc,真正觸發full gc的原因可能是ygc時發生的promotion failure。

3.cms收集器是否會掃描年輕代

會,在初始標記的時候會掃描新生代。

雖然cms是老年代收集器,但是我們知道年輕代的物件是可以晉升為老年代的,為了空間分配擔保,還是有必要去掃描年輕代。

4.什麼是空間分配擔保

在minor gc前,jvm會先檢查老年代最大可用空間是否大於新生代所有物件總空間,如果是的話,則minor gc可以確保是安全的,

如果擔保失敗,會檢查一個配置(HandlePromotionFailire),即是否允許擔保失敗。

如果允許:繼續檢查老年代最大可用可用的連續空間是否大於之前晉升的平均大小,比如說剩10m,之前每次都有9m左右的新生代到老年代,那麼將嘗試一次minor gc(大於的情況),這會比較冒險。

如果不允許,而且還小於的情況,則會觸發full gc。【為了避免經常full GC 該引數建議開啟】

這邊為什麼說是冒險是因為minor gc過後如果出現大物件,由於新生代採用複製演算法,survivor無法容納將跑到老年代,所以才會去計算之前的平均值作為一種擔保的條件與老年代剩餘空間比較,這就是分配擔保。

這種擔保是動態概率的手段,但是也有可能出現之前平均都比較低,突然有一次minor gc物件變得很多遠高於以往的平均值,這個時候就會導致擔保失敗【Handle Promotion Failure】,這就只好再失敗後再觸發一次FULL GC,

5.為什麼複製演算法要分兩個Survivor,而不直接移到老年代

這樣做的話效率可能會更高,但是old區一般都是熬過多次可達性分析演算法過後的存活的物件,要求比較苛刻且空間有限,而不能直接移過去,這將導致一系列問題(比如老年代容易被撐爆)

分兩個Survivor(from/to),自然是為了保證複製演算法執行以提高效率。

6.各個版本的JVM使用的垃圾收集器是怎麼樣的

準確來說,垃圾收集器的使用跟當前jvm也有很大的關係,比如說g1是jdk7以後的版本才開始出現。

並不是所有的垃圾收集器都是預設開啟的,有些得通過設定相應的開關引數才會使用。比如說cms,需設定(XX:+UseConcMarkSweepGC)

這邊有幾個實用的命令,比如說server模式下

#UnlockExperimentalVMOptions UnlockDiagnosticVMOptions解鎖獲取jvm引數,PrintFlagsFinal用於輸出xx相關引數,以Benchmark類測試,這邊會有很多結果 大都看不懂- - 在這邊查(usexxxxxxgc會看到jvm不同收集器的開關情況)
java -server -XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal Benchmark

#後面跟| grep ":"獲取已賦值的引數[加:代表被賦值過]
java -server -XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal Benchmark| grep ":"

#獲得使用者自定義的設定或者jvm設定的詳細的xx引數和值
java -server -XX:+PrintCommandLineFlags Benchmark
10006199-a3524986c654e356.png

本人用的jdk8,這邊UseParallelGC為true,參考深入理解jvm那本書說這個是Parallel Scavenge+Serial old搭配組合的開關,但是網上又說8預設是Parallel Scavenge+Parallel Old,我還是信書的吧 - -。

更多相關引數來源

10006199-324780351133d59a.png
常用引數

據說更高版本的jvm預設使用g1

7 stop the world具體是什麼,有沒有辦法避免

stop the world簡單來說就是gc的時候,停掉除gc外的java執行緒。

無論什麼gc都難以避免停頓,即使是g1也會在初始標記階段發生,stw並不可怕,可以儘可能的減少停頓時間。

8 新生代什麼樣的情況會晉升為老年代

物件優先分配在eden區,eden區滿時會觸發一次minor GC

物件晉升規則
1 長期存活的物件進入老年代,物件每熬過一次GC年齡+1(預設年齡閾值15,可配置)。
2 物件太大新生代無法容納則會分配到老年代
3 eden區滿了,進行minor gc後,eden和一個survivor區仍然存活的物件無法放到(to survivor區)則會通過分配擔保機制放到老年代,這種情況一般是minor gc後新生代存活的物件太多。
4 動態年齡判定,為了使記憶體分配更靈活,jvm不一定要求物件年齡達到MaxTenuringThreshold(15)才晉升為老年代,若survior區相同年齡物件總大小大於survior區空間的一半,則大於等於這個年齡的物件將會在minor gc時移到老年代

8.怎麼理解g1,適用於什麼場景

G1 GC 是區域化、並行-併發、增量式垃圾回收器,相比其他 HotSpot 垃圾回收器,可提供更多可預測的暫停。增量的特性使 G1 GC 適用於更大的堆,在最壞的情況下仍能提供不錯的響應。G1 GC 的自適應特性使 JVM 命令列只需要軟實時暫停時間目標的最大值以及 Java 堆大小的最大值和最小值,即可開始工作。

g1不再區分老年代、年輕代這樣的記憶體空間,這是較以往收集器很大的差異,所有的記憶體空間就是一塊劃分為不同子區域,每個區域大小為1m-32m,最多支援的記憶體為64g左右,且由於它為了的特性適用於大記憶體機器。

10006199-8c124d281e0c6dd1.png
g1回收時堆記憶體情況

適用場景:

1.像cms能與應用程式併發執行,GC停頓短【短而且可控】,使用者體驗好的場景。

2.面向服務端,大記憶體,高cpu的應用機器。【網上說差不多是6g或更大】

3.應用在執行過程中經常會產生大量記憶體碎片,需要壓縮空間【比cms好的地方之一,g1具備壓縮功能】。

參考

深入理解Java虛擬機器

JVM記憶體模型、指令重排、記憶體屏障概念解析

Java物件頭

GC收集器

Major GC和Full GC的區別

JVM 垃圾回收 Minor gc vs Major gc vs Full gc

關於準確式GC、保守式GC

關於CMS垃圾收集演算法的一些疑惑

圖解cms

G1垃圾收集器介紹

詳解cms回收機制

總結

JMM 是一種規範,是解決由於多執行緒通過共享記憶體進行通訊時,存在的本地記憶體資料不一致、編譯器會對程式碼指令重排序、處理器會對程式碼亂序執行等帶來的問題,而且寫java程式碼的時候難免會經常和記憶體打交道,遇到各種記憶體溢位問題,有時候又難以定位問題,因此是一定要學習jmm以及GC的。

由於博主本人水平有限【目前還是小菜雞】,所以花了點時間,寫下這篇部落格當做為筆記總結歸納,但是寫部落格這種事如果全都是照抄別人的成果就很沒意思了,吸收別人的成果的同時,也希望自己有能力多寫點自己獨特的理解和乾貨後續繼續更新,所以如果有哪裡寫的不好或寫錯請指出,以便我繼續學習和改進。

相關文章