1 CPU和內存的交互
了解jvm內存模型前,了解下cpu和計算機內存的交互情況?!疽驗镴ava虛擬機內存模型定義的訪問操作與計算機十分相似】
有篇很棒的文章,從cpu講到內存模型:什么是java內存模型
在計算機中,cpu和內存的交互最為頻繁,相比內存,磁盤讀寫太慢,內存相當于高速的緩沖區。
但是隨著cpu的發展,內存的讀寫速度也遠遠趕不上cpu。因此cpu廠商在每顆cpu上加上高速緩存,用于緩解這種情況?,F在cpu和內存的交互大致如下。
cpu上加入了高速緩存這樣做解決了處理器和內存的矛盾(一快一慢),但是引來的新的問題 - 緩存一致性
在多核cpu中,每個處理器都有各自的高速緩存(L1,L2,L3),而主內存確只有一個 。
以我的pc為例,因為cpu成本高,緩存區一般也很小。
CPU要讀取一個數據時,首先從一級緩存中查找,如果沒有找到再從二級緩存中查找,如果還是沒有就從三級緩存或內存中查找,每個cpu有且只有一套自己的緩存。
如何保證多個處理器運算涉及到同一個內存區域時,多線程場景下會存在緩存一致性問題,那么運行時保證數據一致性?
為了解決這個問題,各個處理器需遵循一些協議保證一致性?!救鏜SI,MESI啥啥的協議。?!?/p>
大概如下
在CPU層面,內存屏障提供了個充分必要條件
1.1.1 內存屏障(Memory Barrier)
CPU中,每個CPU又有多級緩存【上圖統一定義為高速緩存】,一般分為L1,L2,L3,因為這些緩存的出現,提高了數據訪問性能,避免每次都向內存索取,但是弊端也很明顯,不能實時的和內存發生信息交換,分在不同CPU執行的不同線程對同一個變量的緩存值不同。
硬件層的內存屏障分為兩種:
Load Barrier
和Store Barrier
即讀屏障和寫屏障?!緝却嫫琳鲜怯布拥摹?/p>
為什么需要內存屏障
由于現代操作系統都是多處理器操作系統,每個處理器都會有自己的緩存,可能存再不同處理器緩存不一致的問題,而且由于操作系統可能存在重排序,導致讀取到錯誤的數據,因此,操作系統提供了一些內存屏障以解決這種問題.簡單來說: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型變量擁有如下特性
可見性,對于一個該變量的讀,一定能看到讀之前最后的寫入。 防止指令重排序,執行代碼時,為了提高執行效率,會在不影響最后結果的前提下對指令進行重新排序,使用volatile可以防止,比如單例模式雙重校驗鎖的創建中有使用到,如(https://www.jianshu.com/p/b30a4d568be4)注意的是volatile不具有原子性,如volatile++這樣的復合操作,這里感謝大家的指正。
至于volatile底層是怎么實現保證不同線程可見性的,這里涉及到的就是硬件上的,被volatile修飾的變量在進行寫操作時,會生成一個特殊的匯編指令,該指令會觸發mesi協議,會存在一個總線嗅探機制的東西,簡單來說就是這個cpu會不停檢測總線中該變量的變化,如果該變量一旦變化了,由于這個嗅探機制,其它cpu會立馬將該變量的cpu緩存數據清空掉,重新的去從主內存拿到這個數據。簡單畫了個圖。
2. Java內存區域
前提:本文講的基本都是以Sun HotSpot虛擬機為基礎的,Oracle收購了Sun后目前得到了兩個【Sun的HotSpot和JRockit(以后可能合并這兩個),還有一個是IBM的IBMJVM】
之所以扯了那么多計算機內存模型,是因為java內存模型的設定符合了計算機的規范。
Java程序內存的分配是在JVM虛擬機內存分配機制下完成。
Java內存模型(Java Memory Model ,JMM)就是一種符合內存模型規范的,屏蔽了各種硬件和操作系統的訪問差異的,保證了Java程序在各種平臺下對內存的訪問都能保證效果一致的機制及規范。
簡要言之,jmm是jvm的一種規范,定義了jvm的內存模型。它屏蔽了各種硬件和操作系統的訪問差異,不像c那樣直接訪問硬件內存,相對安全很多,它的主要目的是解決由于多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題??梢员WC并發編程場景中的原子性、可見性和有序性。
從下面這張圖可以看出來,Java數據區域分為五大數據區域。這些區域各有各的用途,創建及銷毀時間。
其中方法區和堆是所有線程共享的,棧,本地方法棧和程序虛擬機則為線程私有的。
根據java虛擬機規范,java虛擬機管理的內存將分為下面五大區域。
2.1 五大內存區域
2.1.1 程序計數器
程序計數器是一塊很小的內存空間,它是線程私有的,可以認作為當前線程的行號指示器。
為什么需要程序計數器
我們知道對于一個處理器(如果是多核cpu那就是一核),在一個確定的時刻都只會執行一條線程中的指令,一條線程中有多個指令,為了線程切換可以恢復到正確執行位置,每個線程都需有獨立的一個程序計數器,不同線程之間的程序計數器互不影響,獨立存儲。
注意:如果線程執行的是個java方法,那么計數器記錄虛擬機字節碼指令的地址。如果為native【底層方法】,那么計數器為空。這塊內存區域是虛擬機規范中唯一沒有OutOfMemoryError的區域。
2.1.2 Java棧(虛擬機棧)
同計數器也為線程私有,生命周期與相同,就是我們平時說的棧,棧描述的是Java方法執行的內存模型。
每個方法被執行的時候都會創建一個棧幀用于存儲局部變量表,操作棧,動態鏈接,方法出口等信息。每一個方法被調用的過程就對應一個棧幀在虛擬機棧中從入棧到出棧的過程?!緱O冗M后出,下圖棧1先進最后出來】
對于棧幀的解釋參考 Java虛擬機運行時棧幀結構
棧幀: 是用來存儲數據和部分過程結果的數據結構。 棧幀的位置: 內存 -> 運行時數據區 -> 某個線程對應的虛擬機棧 -> here[在這里]棧幀大小確定時間: 編譯期確定,不受運行期數據影響。
通常有人將java內存區分為棧和堆,實際上java內存比這復雜,這么區分可能是因為我們最關注,與對象內存分配關系最密切的是這兩個。
平時說的棧一般指局部變量表部分。
局部變量表:一片連續的內存空間,用來存放方法參數,以及方法內定義的局部變量,存放著編譯期間已知的數據類型(八大基本類型和對象引用(reference類型),returnAddress類型。它的最小的局部變量表空間單位為Slot,虛擬機沒有指明Slot的大小,但在jvm中,long和double類型數據明確規定為64位,這兩個類型占2個Slot,其它基本類型固定占用1個Slot。
reference類型:與基本類型不同的是它不等同本身,即使是String,內部也是char數組組成,它可能是指向一個對象起始位置指針,也可能指向一個代表對象的句柄或其他與該對象有關的位置。
returnAddress類型:指向一條字節碼指令的地址【深入理解Java虛擬機】怎么理解returnAddress
需要注意的是,局部變量表所需要的內存空間在編譯期完成分配,當進入一個方法時,這個方法在棧中需要分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表大小。
Java虛擬機棧可能出現兩種類型的異常:
線程請求的棧深度大于虛擬機允許的棧深度,將拋出StackOverflowError。
虛擬機??臻g可以動態擴展,當動態擴展是無法申請到足夠的空間時,拋出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虛擬機便將運行時常量池從永久代移除了?!?/p>
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 即常量池在堆中
這邊不用全局的方式,設置main方法的vm參數。
做相關設置,比如說這邊設定堆大小。(-Xmx5m -Xms5m -XX:-UseGCOverheadLimit)
這邊如果不設置UseGCOverheadLimit將報java.lang.OutOfMemoryError: GC overhead limit exceeded, 這個錯是因為GC占用了多余98%(默認值)的CPU時間卻只回收了少于2%(默認值)的堆空間。目的是為了讓應用終止,給開發者機會去診斷問題。一般是應用程序在有限的內存上創建了大量的臨時對象或者弱引用對象,從而導致該異常。雖然加大內存可以暫時解決這個問題,但是還是強烈建議去優化代碼,后者更加有效,也可通過UseGCOverheadLimit避免[不推薦,這里是因為測試用,并不能解決根本問題]
jdk8真正開始廢棄永久代,而使用元空間(Metaspace)
java虛擬機對方法區比較寬松,除了跟堆一樣可以不存在連續的內存空間,定義空間和可擴展空間,還可以選擇不實現垃圾收集。
2.2 對象的內存布局
在HotSpot虛擬機中。對象在內存中存儲的布局分為
1.對象頭 2.實例數據 3.對齊填充
2.2.1 對象頭【markword】
在32位系統下,對象頭8字節,64位則是16個字節【未開啟壓縮指針,開啟后12字節】。
markword很像網絡協議報文頭,劃分為多個區間,并且會根據對象的狀態復用自己的存儲空間。 為什么這么做:省空間,對象需要存儲的數據很多,32bit/64bit是不夠的,它被設計成非固定的數據結構以便在極小的空間存儲更多的信息,
假設當前為32bit,在對象未被鎖定情況下。25bit為存儲對象的哈希碼、4bit用于存儲分代年齡,2bit用于存儲鎖標志位,1bit固定為0。
不同狀態下存放數據
這其中鎖標識位需要特別關注下。鎖標志位與是否為偏向鎖對應到唯一的鎖狀態。
鎖的狀態分為四種無鎖狀態
、偏向鎖
、輕量級鎖
和重量級鎖
不同狀態時對象頭的區間含義,如圖所示。
HotSpot底層通過markOop實現Mark Word,具體實現位于markOop.hpp
文件。
markOop中提供了大量方法用于查看當前對象頭的狀態,以及更新對象頭的數據,為synchronized鎖的實現提供了基礎。[比如說我們知道synchronized鎖的是對象而不是代碼,而鎖的狀態保存在對象頭中,進而實現鎖住對象]。
關于對象頭和鎖之間的轉換,網上大神總結
2.2.2 實例數據
存放對象程序中各種類型的字段類型,不管是從父類中繼承下來的還是在子類中定義的。 分配策略:相同寬度的字段總是放在一起,比如double和long
2.2.3 對齊填充
這部分沒有特殊的含義,僅僅起到占位符的作用滿足JVM要求。
由于HotSpot規定對象的大小必須是8的整數倍,對象頭剛好是整數倍,如果實例數據不是的話,就需要占位符對齊填充。
2.3 對象的訪問定位
java程序需要通過引用(ref)數據來操作堆上面的對象,那么如何通過引用定位、訪問到對象的具體位置。
對象的訪問方式由虛擬機決定,java虛擬機提供兩種主流的方式 1.句柄訪問對象 2.直接指針訪問對象。(Sun HotSpot使用這種方式)
2.3.1 句柄訪問
簡單來說就是java堆劃出一塊內存作為句柄池,引用中存儲對象的句柄地址,句柄中包含對象實例數據、類型數據的地址信息。
優點:引用中存儲的是穩定的句柄地址,在對象被移動【垃圾收集時移動對象是常態】只需改變句柄中實例數據的指針,不需要改動引用【ref】本身。
2.3.2 直接指針
與句柄訪問不同的是,ref中直接存儲的就是對象的實例數據,但是類型數據跟句柄訪問方式一樣。
優點:優勢很明顯,就是速度快,相比于句柄訪問少了一次指針定位的開銷時間。【可能是出于Java中對象的訪問時十分頻繁的,平時我們常用的JVM HotSpot采用此種方式】
3.內存溢出
兩種內存溢出異常[注意內存溢出是error級別的] 1.StackOverFlowError:當請求的棧深度大于虛擬機所允許的最大深度 2.OutOfMemoryError:虛擬機在擴展棧時無法申請到足夠的內存空間[一般都能設置擴大]
java -verbose:class -version 可以查看剛開始加載的類,可以發現這兩個類并不是異常出現的時候才去加載,而是jvm啟動的時候就已經加載。這么做的原因是在vm啟動過程中我們把類加載起來,并創建幾個沒有堆棧的對象緩存起來,只需要設置下不同的提示信息即可,當需要拋出特定類型的OutOfMemoryError異常的時候,就直接拿出緩存里的這幾個對象就可以了。
比如說OutOfMemoryError對象,jvm預留出4個對象【固定常量】,這就為什么最多出現4次有堆棧的OutOfMemoryError異常及大部分情況下都將看到沒有堆棧的OutOfMemoryError對象的原因。
兩個基本的例子
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(); }}
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以后采用元空間,就不在堆中了】
5 判斷對象是否存活算法
1.引用計數算法 早期判斷對象是否存活大多都是以這種算法,這種算法判斷很簡單,簡單來說就是給對象添加一個引用計數器,每當對象被引用一次就加1,引用失效時就減1。當為0的時候就判斷對象不會再被引用。 優點:實現簡單效率高,被廣泛使用與如python何游戲腳本語言上。 缺點:難以解決循環引用的問題,就是假如兩個對象互相引用已經不會再被其它其它引用,導致一直不會為0就無法進行回收。 2.可達性分析算法 目前主流的商用語言[如java、c#]采用的是可達性分析算法判斷對象是否存活。這個算法有效解決了循環利用的弊端。 它的基本思路是通過一個稱為“GC Roots”的對象為起始點,搜索所經過的路徑稱為引用鏈,當一個對象到GC Roots沒有任何引用跟它連接則證明對象是不可用的。
可作為GC Roots的對象有四種
①虛擬機棧(棧楨中的本地變量表)中的引用的對象。 ②方法區中的類靜態屬性引用的對象,一般指被static修飾引用的對象,加載類的時候就加載到內存中。 ③方法區中的常量引用的對象,④本地方法棧中JNI(native方法)引用的對象
即使可達性算法中不可達的對象,也不是一定要馬上被回收,還有可能被搶救一下。網上例子很多,基本上和深入理解JVM一書講的一樣對象的生存還是死亡
要真正宣告對象死亡需經過兩個過程。 1.可達性分析后沒有發現引用鏈 2.查看對象是否有finalize方法,如果有重寫且在方法內完成自救[比如再建立引用],還是可以搶救一下,注意這邊一個類的finalize只執行一次,這就會出現一樣的代碼第一次自救成功第二次失敗的情況。[如果類重寫finalize且還沒調用過,會將這個對象放到一個叫做F-Queue的序列里,這邊finalize不承諾一定會執行,這么做是因為如果里面死循環的話可能會時F-Queue隊列處于等待,嚴重會導致內存崩潰,這是我們不希望看到的。]
5 垃圾收集算法
jvm中,可達性分析算法幫我們解決了哪些對象可以回收的問題,垃圾收集算法則關心怎么回收。
5.1 三大垃圾收集算法
1.標記/清除算法【最基礎】2.復制算法3.標記/整理算法 jvm采用`分代收集算法`對不同區域采用不同的回收算法。
新生代采用復制算法
新生代中因為對象都是"朝生夕死的",【深入理解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.在分析時,需保證這個對象引用關系不再變化,否則結果將不準確?!疽虼薌C進行時需停掉其它所有java執行線程(Sun把這種行為稱為‘Stop the World’),即使是號稱幾乎不會停頓的CMS收集器,枚舉根節點時也需停掉線程】
解決辦法:實際上當系統停下來后JVM不需要一個個檢查引用,而是通過OopMap數據結構【HotSpot的叫法】來標記對象引用。
虛擬機先得知哪些地方存放對象的引用,在類加載完時。HotSpot把對象內什么偏移量什么類型的數據算出來,在jit編譯過程中,也會在特定位置記錄下棧和寄存器哪些位置是引用,這樣GC在掃描時就可以知道這些信息?!灸壳爸髁鱆VM使用準確式GC】
OopMap可以幫助HotSpot快速且準確完成GC Roots枚舉以及確定相關信息。但是也存在一個問題,可能導致引用關系變化。
這個時候有個safepoint(安全點)的概念。
HotSpot中GC不是在任意位置都可以進入,而只能在safepoint處進入。 GC時對一個Java線程來說,它要么處在safepoint,要么不在safepoint。
safepoint不能太少,否則GC等待的時間會很久
safepoint不能太多,否則將增加運行GC的負擔
安全點主要存放的位置
1:循環的末尾 2:方法臨返回前/調用方法的call指令后 3:可能拋異常的位置
6.垃圾收集器
如果說垃圾回收算法是內存回收的方法論,那么垃圾收集器就是具體實現。jvm會結合針對不同的場景及用戶的配置使用不同的收集器。
年輕代收集器 Serial、ParNew、Parallel Scavenge 老年代收集器 Serial Old、Parallel Old、CMS收集器 特殊收集器 G1收集器[新型,不在年輕、老年代范疇內]
新生代收集器
6.1 Serial
最基本、發展最久的收集器,在jdk3以前是gc收集器的唯一選擇,Serial是單線程收集器,Serial收集器只能使用一條線程進行收集工作,在收集的時候必須得停掉其它線程,等待收集工作完成其它線程才可以繼續工作。
雖然Serial看起來很坑,需停掉別的線程以完成自己的gc工作,但是也不是完全沒用的,比如說Serial在運行在Client模式下優于其它收集器[簡單高效,不過一般都是用Server模式,64bit的jvm甚至沒Client模式]
優點:對于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)是以一種獲取最短回收停頓時間為目標的收集器?!局匾曧憫梢詭砗玫挠脩趔w驗,被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)。
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的gchisto,gcviewer離線分析工具,做個筆記先了解下還沒用過,可視化好像很好用的樣子。
8.3 自帶的jconsole工具、jstat命令
終端輸入jconsole就會出現jdk自帶的gui監控工具
可以根據內存使用情況間接了解內存使用和gc情況
jstat命令
比如jstat -gcutil pid查看對應java進程gc情況
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
本人用的jdk8,這邊UseParallelGC為true,參考深入理解jvm那本書說這個是Parallel Scavenge+Serial old搭配組合的開關,但是網上又說8默認是Parallel Scavenge+Parallel Old,我還是信書的吧 - -。
更多相關參數來源
據說更高版本的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左右,且由于它為了的特性適用于大內存機器。
適用場景:
1.像cms能與應用程序并發執行,GC停頓短【短而且可控】,用戶體驗好的場景。
2.面向服務端,大內存,高cpu的應用機器?!揪W上說差不多是6g或更大】
3.應用在運行過程中經常會產生大量內存碎片,需要壓縮空間【比cms好的地方之一,g1具備壓縮功能】。
參考
深入理解Java虛擬機
JVM 垃圾回收 Minor gc vs Major gc vs Full gc
總結
JMM 是一種規范,是解決由于多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題,而且寫java代碼的時候難免會經常和內存打交道,遇到各種內存溢出問題,有時候又難以定位問題,因此是一定要學習jmm以及GC的。
由于博主本人水平有限【目前還是小菜雞】,所以花了點時間,寫下這篇博客當做為筆記總結歸納,但是寫博客這種事如果全都是照抄別人的成果就很沒意思了,吸收別人的成果的同時,也希望自己有能力多寫點自己獨特的理解和干貨后續繼續更新,所以如果有哪里寫的不好或寫錯請指出,以便我繼續學習和改進。