JVM(Java虛擬機)
JVM 內存模型 結構圖
jdk1.8 結構圖(極簡)
jdk1.8 結構圖(簡單)
JVM(Java虛擬機):
- 是一個抽象的計算模型。
- 如同一臺真實的機器,它有自己的指令集和執行引擎,可以在運行時操控內存區域。
- 目的是為構建在其上運行的應用程序提供一個運行環境,能夠運行 java 字節碼。
- JVM 可以解讀指令代碼并與底層進行交互:包括操作系統平臺和執行指令并管理資源的硬件體系結構。
jdk1.7 結構圖(詳細)
JVM 內存模型 組成元素
Java 內存模型主要包含線程私有的程序計數器、java虛擬機棧、本地方法棧和線程共享的堆空間、元數據區、直接內存。
-
Java運行時數據區域
Java 虛擬機在執行過程中會將所管理的內存劃分為不同的區域,有的隨著線程產生和消失,有的隨著 Java 進程產生和消失。
根據 JVM 規范,JVM 運行時區域大致分為程序計數器、虛擬機棧、本地方法棧、堆、方法區(jkd1.8廢棄)五個部分。
-
程序計數器(PC 寄存器、計數器)
程序計數器就是當前線程所執行的字節碼的行號指示器,通過改變計數器的值,來選取下一行指令,通過它主要實現跳轉、循環、恢復線程等功能。
在任何時刻,一個處理器內核只能運行一個線程,多線程是通過搶占 CPU,分配時間完成的。這時就需要有個標記,來標明線程執行到哪里,程序計數器便擁有這樣的功能,所以,每個線程都已自己的程序計數器。
可以理解為一個指針,指向方法區中的方法字節碼(用來存儲指向下一個指令的地址,也即將要執行的指令代碼),由執行引擎讀取下一條指令,是一個非常小的內存空間,幾乎可以忽略不計。
倘若執行的是 native 方法,則程序計數器中為空
-
Java 虛擬機棧(JVM Stacks)
虛擬機棧也就是平常所稱的棧內存,每個線程對應一個私有的棧,隨著線程的創建而創建。棧里面存著的是一種叫“棧幀”的東西,每個方法在執行的同時都會創建一個棧幀,方法被執行時入棧,執行完后出棧。
不存在垃圾回收問題,只要線程一結束該棧就釋放,生命周期和線程一致。
每個棧幀主要包含的內容如下:
-
局部變量表
存儲著 java 基本數據類型(byte/boolean/char/int/long/double/float/short)以及對象的引用
注意:這里的基本數據類型指的是方法內的局部變量
局部變量表隨著棧幀的創建而創建,它的大小在編譯時確定,創建時只需分配事先規定的大小即可。在方法運行過程中,局部變量表的大小不會發生改變。
-
操作數棧
-
動態連接
-
方法返回地址
虛擬機棧可能會拋出兩種異常:
-
棧溢出(StackOverFlowError):
若 Java 虛擬機棧的大小不允許動態擴展,那么當線程請求棧的深度超過當前 Java 虛擬機棧的最大深度時,拋出 StackOverFlowError 異常
-
內存溢出(OutOfMemoryError):
若虛擬機棧的容量允許動態擴展,那么當線程請求棧時內存用完了,無法再動態擴展時,拋出 OOM 異常
-
-
本地方法棧(Native Method Stacks)
本地方法棧是為 JVM 運行 Native 方法準備的空間,由于很多 Native 方法都是用 C 語言實現的,所以它通常又叫 C 棧。
本地方法棧與虛擬機棧的作用是相似的,都是線程私有的,只不過本地方法棧是描述本地方法運行過程的內存模型。
本地方法被執行時,在本地方法棧也會創建一塊棧幀,用于存放該方法的局部變量表、操作數棧、動態鏈接、方法出口信息等。方法執行結束后,相應的棧幀也會出棧,并釋放內存空間。也會拋出 StackOverFlowError 和 OutOfMemoryError 異常。
虛擬機棧和本地方法棧的主要區別:
- 虛擬機棧執行的是 java 方法
- 本地方法棧執行的是 native 方法
-
Java 堆(Java Heap)
Java 堆中是 JVM 管理的最大一塊內存空間。主要存放對象實例。
Java 堆是所有線程共享的一塊內存,在虛擬機啟動時創建,幾乎所有的對象實例都存放在這里,是垃圾收集器管理的主要區域。
Java 堆的分區:
-
在 jdk1.8 之前,分為新生代、老年代、永久代
-
在 jdk1.8 及之后,只分為新生代、老年代
永久代在 jdk1.8 已經被移除,被一個稱為 “元數據區”(元空間)的區域所取代
Java 堆內存大小:
- 堆內存大小 = 新生代 + 老年代(新生代占堆空間的1/3、老年代占堆空間2/3)
- 既可以是固定大小的,也可以是可擴展的(通過參數 -Xmx 和 -Xms 設定)
- 如果堆無法擴展或者無法分配內存時報 OOM
主要存儲的內容是:
-
對象實例
-
類初始化生成的對象
-
基本數據類型的數組也是對象實例
-
字符串常量池
字符串常量池原本存放在方法區,jdk8 開始放置于堆中
字符串常量池存儲的是 string 對象的直接引用,而不是直接存放的對象,是一張 string table
-
靜態變量
-
static 修飾的靜態變量,jdk8 時從方法區遷移至堆中
-
線程分配緩沖區(Thread Local Allocation Buffer)
線程私有,但是不影響 java 堆的共性
增加線程分配緩沖區是為了提升對象分配時的效率
-
堆和棧的區別:
- 管理方式,堆需要GC,棧自動釋放
- 大小不同,堆比棧大
- 碎片相關:棧產生的碎片遠小于堆,因為GC不是實時的
- 分配方式:棧支持靜態分配內存和動態分配,堆只支持動態分配
- 效率:棧的效率比堆高
-
-
方法區(邏輯上)
方法區是 JVM 的一個規范,所有虛擬機必須要遵守的。常見的 JVM 虛擬機有 Hotspot 、 JRockit(Oracle)、J9(IBM)
方法區邏輯上屬于堆的一部分,但是為了與堆區分,通常又叫非堆區
各個線程共享,主要用于存儲已經被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等。
方法區的大小決定了系統可以保存多少個類,如果系統定義了太多的類,導致方法區溢出,虛擬機同樣會拋出內存溢出錯誤。關閉 JVM 就會釋放這個區域的內存。
- Java8 以前是放在 JVM 內存中的,由堆空間中的永久代實現,受 JVM 內存大小參數限制
- Java8 移除了永久代和方法區,引入了元空間
拓展:
JDK版本 方法區的實現 運行時常量池所在的位置 JDK6 PermGen space(永久代) PermGen space(永久代) JDK7 PermGen space(永久代) Heap(堆) JDK8 Metaspace(元空間) Heap(堆) -
元空間(元數據區、Metaspace)
元空間是 JDK1.8 及之后,HotSpot 虛擬機對方法區的新實現。
元空間不在虛擬機中,而是直接用物理(本地)內存實現,不再受 JVM 內存大小參數限制,JVM 不會再出現方法區的內存溢出問題,但如果物理內存被占滿了,元空間也會報 OOM
元空間和方法區不同的地方在于編譯期間和類加載完成后的內容有少許不同,不過總的來說分為這兩部分:
-
類元信息(Class)
類元信息在類編譯期間放入元空間,里面放置了類的基本信息:版本、字段、方法、接口以及常量池表
常量池表:主要存放了類編譯期間生成的字面量、符號引用,這些信息在類加載完后會被解析到運行時常量池中
-
運行時常量池(Runtime Constant Pool)
運行時常量池主要存放在類加載后被解析的字面量與符號引用,但不止這些
運行時常量池具備動態性,可以添加數據,比較多的使用就是 String 類的 intern() 方法
-
-
直接內存(Direct Memory)
直接內存不是虛擬機運行時數據區的一部分,而是在 Java 堆外,直接向系統申請的內存區域。
常見于 NIO 操作時,用于數據緩沖區(比如 ByteBuffer 使用的就是直接內存)。
分配、回收成本較高,但讀寫性能高。
直接內存不受 JVM 內存回收管理(直接內存的分配和釋放是 Java 會通過 UnSafe 對象來管理的),但是系統內存是有限的,物理內存不足時會報OOM。
Java 程序內存 = JVM 內存 + 本地內存
-
JVM 內存(JVM 虛擬機數據區)
Java 虛擬機在執行的時候會把管理的內存分配到不同的區域,這些區域稱為虛擬機(JVM)內存。
JVM 內存受虛擬機內存大小的參數控制,當大小超過參數設置的大小時會報 OOM
-
本地內存(元空間 + 直接內存)
對于虛擬機沒有直接管理的物理內存,也會有一定的利用,這些被利用但不在虛擬機內存的地方稱為本地內存。
本地內存不受虛擬機內存參數的限制,只受物理內存容量的限制。
雖然不受參數的限制,如果所占內存超過物理內存,仍然會報 OOM
堆外內存
-
直接內存
直接內存不是虛擬機運行時數據區的一部分,而是在 Java 堆外,直接向系統申請的內存區域。
可通過 -XX:MaxDirectMemorySize 調整大小,默認和 Java 堆最大值一樣
內存不足時拋出OutOf-MemoryError或 者OutOfMemoryError:Direct buffer memory;
-
線程堆棧
可通過 -Xss 調整大小
內存不足時拋出
- StackOverflowError(如果線程請求的棧深度大于虛擬機所允許的深度)
- OutOfMemoryError(如果 Java 虛擬機棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存)
-
Socket 緩存區
每個 Socket 連接都 Receive 和 Send 兩個緩存區,分別占大約 37KB 和 25KB 內存,連接多的話這塊內存占用也比較可觀。
如果無法分配,可能會拋出 IOException:Too many open files異常
-
JNI 代碼
如果代碼中使用了 JNI 調用本地庫,那本地庫使用的內存也不在堆中,而是占用 Java 虛擬機的本地方法棧和本地內存
-
虛擬機和垃圾收集器
虛擬機、垃圾收集器的工作也是要消耗一定數量的內存
JVM 堆及各種 GC 詳解
參考:Java 中的新生代、老年代、永久代和各種 GC
結構圖(新生代、老年代、永久代)
JVM 中的堆,一般分為三大部分:新生代、老年代、永久代( Java8 中已經被移除)
新生代、MinorGC(Young GC)
新生代
-
主要是用來存放新生的對象。一般占據堆的 1/3 空間。由于頻繁創建對象,所以新生代會頻繁觸發 MinorGC 進行垃圾回收。
-
新生代又分為 Eden、S0、S1(SurvivorFrom、SurvivorTo)三個區:
-
Eden 區:Java 新對象的出生地(如果新創建的對象占用內存很大,則直接分配到老年代)。
當 Eden 區內存不夠的時候就會觸發 MinorGC,對新生代區進行一次垃圾回收。
-
SurvivorFrom 區:上一次 GC 的幸存者,作為這一次 GC 的被掃描者。
-
SurvivorTo 區:保留了一次 MinorGC 過程中的幸存者。
Eden 和 S0,S1 區的比例為 8 : 1 : 1
幸存者 S0,S1 區:復制之后發生交換,誰是空的,誰就是 SurvivorTo 區
JVM 每次只會使用 eden 和其中一塊 survivor 來為對象服務,所以無論什么時候,都會有一塊 survivor 是空的,因此新生代實際可用空間只有 90%
-
-
當 JVM 無法為新建對象分配內存空間的時候 (Eden 滿了),Minor GC 被觸發。因此新生代空間占用率越高,Minor GC 越頻繁。
MinorGC
-
MinorGC 的過程(采用復制算法):
- 首先,把 Eden 和 ServivorFrom 區域中存活的對象復制到 ServicorTo 區域(如果有對象的年齡以及達到了老年的標準,一般是 15,則賦值到老年代區)
- 同時把這些對象的年齡 + 1(如果 ServicorTo 不夠位置了就放到老年區)
- 然后,清空 Eden 和 ServicorFrom 中的對象;
- 最后,ServicorTo 和 ServicorFrom 互換,原 ServicorTo 成為下一次 GC 時的 ServicorFrom 區。
-
Minor GC 觸發機制:
當年輕代滿(指的是 Eden 滿,Survivor 滿不會引發 GC)時就會觸發 Minor GC(通過復制算法回收垃圾)
-
對象年齡(Age)計數器
虛擬機給每個對象定義了一個對象年齡(Age)計數器。
如果對象在 Eden 出生并經過第一次 Minor GC 后仍然存活,并且能被 Survivor 容納的話,將被移動到 Survivor 空間中,并將對象年齡設為 1。
對象在 Survivor 區中每熬過一次 Minor GC,年齡就增加 1 歲,當它的年齡增加到一定程度(默認為 15 歲)時,就會被晉升到老年代中。
對象晉升老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold (閾值) 來設置。
老年代、MajorGC(Old GC)
老年代
-
老年代的對象比較穩定,所以 MajorGC 不會頻繁執行。
-
在進行 MajorGC 前一般都先進行了一次 MinorGC,使得有新生代的對象晉身入老年代,導致空間不夠用時才觸發。
當無法找到足夠大的連續空間分配給新創建的較大對象時也會提前觸發一次 MajorGC 進行垃圾回收騰出空間。
-
MajorGC 采用標記-清除算法:
- 首先掃描一次所有老年代,標記出存活的對象
- 然后回收沒有標記的對象。
MajorGC 的耗時比較長(速度一般會比 Minor GC 慢10倍以上,STW 的時間更長),因為要掃描再回收。
MajorGC 會產生內存碎片,為了減少內存損耗,一般需要進行合并或者標記出來方便下次直接分配。
-
當老年代也滿了裝不下的時候,就會拋出 OOM(Out of Memory)異常。
永久代、元數據區(元空間)、常量池
永久代(PermGen)
-
是 JDK7 及之前, HotSpot 虛擬機基于 JVM 規范對方法區的一個落地實現,其他虛擬機如 JRockit(Oracle)、J9(IBM) 有方法區 ,但是沒有永久代。
在 JDK1.8 已經被移除,取而代之的是元數據區(元空間)
-
內存的永久保存區域,主要存放 Class 和 Meta(元數據)的信息,Class 在被加載的時候被放入永久區域。
和存放實例的區域不同,GC 不會在主程序運行期對永久區域進行清理。
所以這也導致了永久代的區域會隨著加載的 Class 的增多而脹滿,最終拋出 OOM 異常。
元數據區(元空間、Metaspace)
-
元空間的本質和永久代類似,都是對 JVM 規范中方法區的實現。
-
元空間與永久代之間最大的區別在于:元空間并不在虛擬機中,而是使用本地內存。
默認情況下,元空間的大小僅受本地內存限制,但可以通過以下參數來指定元空間的大小:
-
-XX:MetaspaceSize (初始空間大小):達到該值就會觸發垃圾收集進行類型卸載,同時GC會對該值進行調整
如果釋放了大量的空間,就適當降低該值;
如果釋放了很少的空間,那么在不超過 MaxMetaspaceSize時,適當提高該值。
-
-XX:MaxMetaspaceSize(最大空間)默認是沒有限制的。
除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:
- -XX:MinMetaspaceFreeRatio :在 GC 之后,最小的 Metaspace 剩余空間容量的百分比,減少為分配空間所導致的垃圾收集;
- -XX:MaxMetaspaceFreeRatio :在GC之后,最大的 Metaspace 剩余空間容量的百分比,減少為釋放空間所導致的垃圾收集;
類的元數據放入本地內存中,字符串池和類的靜態變量放入 java 堆中,這樣可以加載多少類的元數據就不再由虛擬機的 MaxPermSize 控制,而由系統的實際可用空間來控制。
-
元空間替換永久代的原因分析:
-
字符串存在永久代中,容易出現性能問題和內存溢出。
-
通常會使用 PermSize 和 MaxPermSize 設置永久代的大小就決定了永久代的上限,但是類及方法的信息等比較難確定其大小,因此對于永久代的大小指定比較困難,太小容易出現永久代溢出,太大則容易導致老年代溢出。
當使用元空間時,可以加載多少類的元數據就不再由 MaxPermSize 控制,而由系統的實際可用空間來控制。
-
永久代會為 GC 帶來不必要的復雜度,并且回收效率偏低。
-
Oracle 可能會將HotSpot 與 JRockit 合二為一。
類常量池、運行時常量池、字符串常量池
-
類常量池
在類編譯過程中,會把類元信息存放到元空間(方法區),類元信息其中一部分便是類常量池
主要存放字面量(字面量一部分便是文本字符)和符號引用
-
運行時常量池
在類加載時,會將字面量和符號引用解析為直接引用存儲在運行時常量池
(文本字符會在解析時查找字符串常量池,查出這個文本字符對應的字符串對象的直接引用,將直接引用存儲在運行時常量池)
- 在 JDK6,運行時常量池 存在于 方法區
- 在 JDK7,運行時常量池 存在于 Java 堆
-
字符串常量池
存儲的是字符串對象的引用,而不是字符串本身
字符串常量池在 jdk7 時就已經從方法區遷移到了 java 堆中(JDK8 時,方法區就是元空間)
拓展
-
字面量
java 代碼在編譯過程中是無法構建引用的,字面量就是在編譯時對于數據的一種表示:
int a=1; // 這個1便是字面量 String b="iloveu"; // iloveu便是字面量
-
符號引用
由于在編譯過程中并不知道每個類的地址,因為可能這個類還未加載,所以如果在一個類中引用了另一個類,被引用的類的全限定類名會作為符號引用,在類加載完后用這個符號引用去獲取它的內存地址。
比如:com.javabc.Solution 類中引用了 com.javabc.Quest,那么 com.javabc.Quest 作為符號引用就會存到類常量池,等類加載完后,就可以拿著這個引用去元空間找此類的內存地址
Full GC 、Major GC(Old GC)
Minor GC、Major GC、Full GC 的區別
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC ):只是老年代的垃圾收集
- 整堆收集(Full GC):收集整個 java 堆(young gen + old gen)和方法區的垃圾收集
Full GC 觸發機制:
- 調用 System.gc 時,系統建議執行 Full GC,但是不必然執行
- 老年代空間不足
- 方法區空間不足
- 通過 Minor GC 后進入老年代的平均大小大于老年代的可用內存
- 由 Eden 區、survivor space1(From Space)區向 survivor space2(To Space)區復制時,對象大小大于 To Space 可用內存,則把該對象轉存到老年代,且老年代的可用內存小于該對象大小
- 當永久代滿時也會引發 Full GC,會導致 Class、Method 元信息的卸載
堆空間分成不同區的原因
-
堆空間分為新生代和老年代的原因
根據對象存活的時間,有的對象壽命長,有的對象壽命短。應該將壽命長的對象放在一個區,壽命短的對象放在一個區。不同的區采用不同的垃圾收集算法。壽命短的區清理頻次高一點,壽命長的區清理頻次低一點。
-
新生代分為了 eden、Survivor 區的原因
為了更好的管理堆內存中的對象,方便GC算法(復制算法)來進行垃圾回收。
如果沒有 Survivor 區,那么 Eden 每次滿了清理垃圾,存活的對象被遷移到老年區,老年區滿了,就會觸發 Full GC,而 Full GC 是非常耗時的。
將 Eden 區滿了的對象,添加到 Survivor 區,等對象反復清理幾遍之后都沒清理掉,再放到老年區,這樣老年區的壓力就會小很多。即 Survivor 相當于一個篩子,篩掉生命周期短的,將生命周期長的放到老年代區,減少老年代被清理的次數。
-
新生代的 Survivor 區又分為 s0 和 s1 區的原因:
分兩個區的好處就是解決內存碎片化。
為什么一個 Survivor 區不行?
假設現在只有一個survivor區,模擬一下流程:
新建的對象在 Eden 中,一旦 Eden 滿了,觸發一次 Minor GC,Eden 中的存活對象就會被移動到 Survivor 區。這樣繼續循環下去,下一次 Eden 滿了的時候,問題來了,此時進行 Minor GC,Eden和 Survivor 各有一些存活對象,如果此時把 Eden 區的存活對象硬放到 Survivor 區,很明顯這兩部分對象所占有的內存是不連續的,也就導致了內存碎片化。
-
GC 優化的本質,也是為什么分代的原因:減少GC次數和GC時間,避免全區掃描。
堆不是對象存儲的唯一選擇(逃逸分析)
如果經過逃逸分析后發現,一個對象并沒有逃逸出方法的話,那么就可能被優化成棧上分配。這樣無需在堆上分配內存。也無須進行垃圾回收了。
逃逸分析概述: 一種可以有效減少 Java 程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。
逃逸分析的基本行為就是分析對象動態作用域:
- 當一個對象在方法中被定義后,對象只在方法內部引用,則認為沒有發生逃逸
- 當一個對象在方法中被定義后,它被外部方法所引用,則認為發生逃逸。
GC(垃圾回收)
System.gc()
-
GC(Garbage Collection)垃圾回收。
System.gc() 是用 Java,C#和許多其他流行的高級編程語言提供的API。
當它被調用時,它將盡最大努力從內存中清除垃圾(即未被引用的對象)。
-
在默認情況下,通過 System.gc() 或者Runtime.getRuntime().gc() 的調用,會顯式觸發 Full GC(完整的 GC 事件),對老年代和新生代進行回收,嘗試釋放被丟棄對象占用的內存。
-
在 GC 完成之前,整個 JVM 將凍結(即正在運行的所有服務將被暫停),通常完整的 GC 需要很長時間才能完成。
因此在不合適的時間運行 GC,將導致不良的用戶體驗,甚至是崩潰。
JVM 具有復雜的算法,該算法始終在后臺運行,進行所有計算以及有關何時觸發 GC 的計算。當顯式調用 System.gc() 調用時,所有這些計算都將被拋掉。
-
system.gc() 調用附帶一個免責聲明,無法保證對垃圾收集器的調用**(不能確保立即生效)**
-
System.gc() 可以從應用程序堆棧的各個部分調用:
- 開發的應用程序可以顯式的調用 System.gc() 方法
- System.gc() 也可以由第三方庫,框架觸發
- 可以由外部工具(如 VisualVM)通過使用 JMX 觸發
- 如果應用程序使用了RMI,RMI會定期調用 System.gc()
-
GC 操作應該由 JVM 自行控制,在絕大部分的場景都不建議程序員手動寫代碼顯式進行 System.gc() 操作。
但是也不排除其中個別例外:
在開發多個微服務時,每個服務都有多個備份節點。在非業務高峰時段,可以從微服務-負載均衡的節點池中取出其中一個 JVM 實例。然后通過該 JVM 上的 JMX 顯式觸發 System.gc() 調用,一旦 GC 事件完成并且從內存中清除了垃圾,將該 JVM 放回到微服務-負載均衡的節點池中。
當然這個過程需要很好的微服務管理及服務發布機制配合,這樣既能保證 JVM 垃圾內存的有效清理,又不影響業務的正常運行。
如何檢測應用程序正在進行 System.gc()?
-
System.gc() 可以從多個渠道進行的調用,而不僅僅是從應用程序源代碼進行的調用。因此,搜索應用程序代碼System.gc() 字符串,不足以知道 GC 是否正在被調用。
-
通過 GC 日志可以檢測應用程序是否正在進行垃圾回收
// java 8 啟動 GC 日志:-XX:+PrintGCDetails -Xloggc:<gc-log-file-path> -XX:+PrintGCDetails -Xloggc:/opt/tmp/myapp-gc.log// java 9 啟動 GC 日志:-Xlog:gc*:file=<gc-log-file-path> -Xlog:gc*:file=/opt/tmp/myapp-gc.log
-
建議始終在所有生產服務器中始終啟用 GC 日志,因為它有助于排除故障并優化應用程序性能。
啟用GC日志只會增加微不足道的開銷。
還可以將 GC 日志上傳到垃圾收集日志分析器工具,例如GCeasy,HP JMeter等。這些工具將生成豐富的垃圾收集分析報告。
如何禁止GC顯式調用或調整調用GC的頻率?
如果就是想避免程序員顯式調用GC,避免不成熟的程序員在不合適時間調用GC,避免人為造成的GC崩潰,可以通過如下方法:
-
搜索和替換
在代碼庫中搜索 System.gc() 和 Runtime.getRuntime().gc()
如果看到匹配項,則將其刪除。但是這種方法無法避免第三方庫、框架或通過外部源進行調用。
-
通過JVM參數強制禁止
通過傳遞 JVM 參數 -XX:+DisableExplicitGC 來強制禁止顯式調用。
這種方式強制、有效,應用程序內的任何 GC 顯式代碼調用 System.gc() 都將被禁止生效。
JVM 自身的 GC 策略不受此參數影響,只禁止人為的觸發 GC。
-
RMI
如果應用程序正在使用 RMI,則可以控制 GC 調用的頻率 。啟動應用程序時,可以使用以下JVM參數配置該頻率:
- -Dsun.rmi.dgc.server.gcInterval=n
- -Dsun.rmi.dgc.client.gcInterval=n
這些屬性的默認值在
- JDK 1.4.2 和 5.0 是 60000毫秒(即60秒)
- JDK 6 和更高版本是 3600000毫秒(即60分鐘)
如果應用主機內存資源非常富余,可以將這些屬性設置為很高的值,以便可以將GC帶來的對應用程序的影響最小化。這也是應用程序性能優化的一種方式之一。
STW(Stop The World)事件
stop-the-world,簡稱 STW,指的是 GC 事件發生過程中,會產生應用程序的停頓。停頓產生時整個應用程序線程都會被暫停,沒有任何響應,有點像卡死的感覺,這個停頓稱為 STW。
可達性分析算法中枚舉根節點(GC Roots)會導致所有 Java 執行線程停頓。
- 分析工作必須在一個能確保一致性的快照中進行
- 一致性指整個分析期間整個執行系統看起來像被凍結在某個時間點上
- 如果出現分析過程中對象引用關系還在不斷變化,則分析結果的準確性無法保證
被 STW 中斷的應用程序線程會在完成 GC 之后恢復,頻繁中斷會讓用戶感覺像是網速不快造成電影卡帶一樣,所以需要減少 STW 的發生。
STW 事件和采用哪款 GC 無關,所有的 GC 都有這個事件。哪怕是 G1 也不能完全避免 Stop-the-world 情況發生,只能說垃圾回收器越來越優秀,回收效率越來越高,盡可能地縮短了暫停時間。
STW 是 JVM 在后臺自動發起和自動完成的。在用戶不可見的情況下,把用戶正常的工作線程全部停掉。
開發中除非特殊情況,不要用 system.gc() 進行手動 GC,會導致 stop-the-world 的發生。
GC 常用算法
-
分代收集算法(現在的虛擬機垃圾收集大多采用這種方式)
它根據對象的生存周期,將堆分為新生代(Young)和老年代(Tenure)。
新生代中,由于對象生存期短,每次回收都會有大量對象死去,所以使用的是復制算法。
老年代里的對象存活率較高,沒有額外的空間進行分配擔保,所以使用的是標記-整理 或者 標記-清除。
-
標記-清除算法
每個對象都會存儲一個標記位,記錄對象的狀態(活著或是死亡)。
標記-清除算法分為兩個階段,一個是標記階段,這個階段內,為每個對象更新標記位,檢查對象是否死亡;第二個階段是清除階段,該階段對死亡的對象進行清除,執行 GC 操作。
優點是可以避免內存碎片。
-
標記-壓縮(標記-整理)算法
標記-壓縮法是標記-清除法的一個改進版,和標記清除算法基本相同。
不同的就是,在清除完成之后,會把存活的對象向內存的一邊進行壓縮(整理),然后把剩下的所有對象全部清除,這樣就可以解決內存碎片問題。
-
復制算法
復制算法將內存劃分為兩個區間,在任意時間點,所有動態分配的對象都只能分配在其中一個區間(稱為活動區間),而另外一個區間(稱為空閑區間)則是空閑的。
當有效內存空間耗盡時,JVM 將暫停程序運行,開啟復制算法 GC 線程。接下來 GC 線程會將活動區間內的存活對象,全部復制到空閑區間,且嚴格按照內存地址依次排列,與此同時,GC 線程將更新存活對象的內存引用地址指向新的內存地址。
此時,空閑區間已經與活動區間交換,而垃圾對象現在已經全部留在了原來的活動區間,也就是現在的空閑區間。事實上,在活動區間轉換為空間區間的同時,垃圾對象已經被一次性全部回收。
復制算法不會產生內存碎片。
直接內存(Direct Memory)詳解
參考:JVM 直接內存
文件的讀寫過程
-
傳統 io 方式
Java 本身不具備磁盤的讀寫能力,要想實現磁盤讀寫,必須調用操作系統提供的函數(即本地方法)。在這里 CPU 的狀態改變從用戶態(Java)切換到內核態(system)【調用系統提供的函數后】。
內存這邊也會有一些相關的操作,當切換到內核態以后,就可以由 CPU 的函數,去真正讀取磁盤文件的內容,在內核狀態時,讀取內容后,會在操作系統內存中劃出一塊兒緩沖區,其稱之為系統緩沖區,磁盤的內容先讀入到系統緩沖區中(分次進行讀取);系統的緩沖區是不能被 Java 代碼直接操作的,所以 Java 會先在堆內存中分配一塊兒 Java 的緩沖區,即代碼中的 new byte[大小],Java 的代碼要能訪問到剛才讀取的那個流中的數據,必須先從系統緩沖區的數據間接讀入到 Java 緩沖區,然后 CPU 的狀態又切換到用戶態了,然后再去調用 Java 的那個輸出流的寫入操作,就這樣反復進行讀寫讀寫,把整個文件復制到目標位置。
可以發現,由于有兩塊兒內存,兩塊兒緩沖區,即系統內存和 Java 堆內存都有緩沖區,那讀取的時候必然涉及到這數據存兩份,第一次先讀到系統緩沖區還不行,因為 Java 代碼不能直接訪問系統緩沖區,所以需要先把系統緩沖區數據讀入到 Java 緩沖區中,這樣就造成了一種不必要的數據的復制,效率因而不是很高。
-
directBuffer(直接緩存區)方式
當 ByteBuffer 調用 allocateDirect 方法后,操作系統這邊劃出一塊緩沖區,即 direct memory(直接內存),這段區域與之前不一樣的地方在于這個操作系統劃出來的內存可以被 Java 代碼直接訪問,即系統可以訪問它,Java 代碼也可以訪問它,它是 java 代碼和系統共享的一段內存區域,這就是直接內存。
磁盤文件讀到直接內存后,Java 代碼直接訪問直接內存,比傳統 io 方式少了一次緩沖區里的復制操作,所以速度得到了成倍的提高。
這也是直接內存帶來的好處,適合做較大文件拷貝的這種 io 操作。
演示案例(運行并比較時間后可以發現,尤其是讀寫大文件時使用 ByteBuffer 的讀寫性能非常高):
// 演示ByteBuffer作用
public class Demo {static final String FORM = "D:\\asd\\asd.mp4"; // 選比較大的文件,比如200多兆static final String TO = "D:\\asd.mp4";static final int _1Mb = 1024 * 1024;public static void main(String[] args) {// io 用時:3187.41008(大概用了3秒),多跑幾遍,多比較,跑一次不算。io();// directBuffer 用時:951.114625(不到1秒)derectBuffer();}private static void deirectBuffer() {long start = System.nanoTime();try (FileChannel from = new FileInputStream(FROM).getChannel();FileChannel to = new FileOutputStream(TO).getChannel();) {ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb); // 讀寫的緩沖區(分配一塊兒直接內存)while (true) {int len = from.read(bb);if (len == -1) {break;}bb.flip();to.write(bb);bb.clear();}}catch (IOException e) {e.printStackTrace();}long end = System.nanoTime();print("directBuffer用時:" + (end - start) / 1000_000.0);}// 用傳統的io方式做文件的讀寫private static void io() {long start = System.nanoTime();try ( // 網友1:寫到try()括號里就不用手動close了FileInputStream from = new FileInputStream(FROM);FileOutPutStream to = new FileOutputStream(TO);) {byte[] buf = new byte[_1Mb];// byte數組緩沖區(與上面的讀寫緩沖區設置大小一致,比較時公平)while (true) {int len = from.read(buf);// 用輸入流讀if (len == -1) {break;}to.write(buf, 0, len);// 用輸出流寫}}catch(IOException e) {e.printStackTrace();}long end = System.nanoTime();print("io用時:" + (end - start) / 1000_000.0);}
}
直接內存的分配和回收
直接內存的分配和釋放是 Java 通過 UnSafe 對象來管理的,并且回收需要主動調用 freeMemory() 方法,不直接受 JVM 內存回收管理。
ByteBuffer 底層分配和釋放直接內存的大概情況:
-
ByteBuffer 對象被創建時,調用 Unsafe 對象的 allocateMemory(_1Gb) 方法分配直接內存,返回 long base,即內存地址
-
ByteBuffer 對象被銷毀時,調用 unsafe 對象的 freeMemory(base) 方法釋放直接內存。
ByteBuffer 的實現類內部,使用了 Cleaner(虛引用)來檢測 ByteBuffer 對象,一旦 ByteBuffer 對象被(Java)垃圾回收,那么就會由 ReferenceHandler 線程通過 Cleaner 的 clean 方法調用 freeMemory() 方法來釋放直接內存。
演示案例(演示直接內存溢出)
-
運行后,輸出 36
即循環 36 次(一次 100 兆,循環 36 次也算 3 個 G 多了)后,爆出直接內存溢出異常:
Exception in thread “main” java.lang.OutOfMemoryError: Direct buffer memory
// 演示直接內存溢出
public class Demo {static int _100Mb = 1024 * 1024 * 100;public static void main(String[] args) {List<ByteBuffer> list = new ArrayList<>();int i = 0; try {while (true) {ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);// 每次分配100兆內存list.add(byteBuffer);// 把這玩意放到List中,一直循環i++;}}finally {print(i);}}
}
使用 System.gc() 間接進行直接內存的回收可能存在的問題
-
代碼案例
public class Demo {static int _1Gb = 1024 * 1024 * 1024;public static void main(String[] args) throws IOException {ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);print("分配完畢");print("開始釋放");byteBuffer = null;System.gc(); // 顯式的垃圾回收} }
-
System.gc() 觸發的是一次 Full GC,是比較影響性能的垃圾回收 ,不光要回收新生代,還要回收老年代,所以它造成的程序暫停時間比較長。
-
為了防止一些程序員不小心在代碼里經常寫 System.gc() 以觸發顯式的垃圾回收,做一些 JVM 調優時經常會加上 JVM 虛擬機參數
-XX:+DisableExplicitGC
,禁用這種顯式的垃圾回收,也就是讓 System.gc() 代碼無效。但是加上這個虛擬機參數后,可能會間接影響到直接內存的回收機制。-
沒加虛擬機參數的話,由于 byteBuffer 被 null 了,顯式觸發 Java 垃圾回收,byteBuffer 的堆內存被回收時,會調用 unsafe 對象的 freeMemory(base) 方法釋放直接內存,所以也導致了直接內存也被釋放掉。
-
加虛擬機參數之后,System.gc() 代碼失效,雖然 byteBuffer 被 null 了,但如果內存比較充足,那么它還會暫時存活著,其創建的直接內存(ByteBuffer.allocateDirect(-1Gb))也會在 byteBuffer 的堆內存被 JVM 自動進行垃圾回收前一直存在著。
所以禁用 System.gc() 之后,會發現別的代碼不受太大影響,但直接內存會受到影響,因為不能用顯式的方法回收掉Bytebuffer,所以 ByteBuffer 只能等到 JVM 自動進行垃圾回收時,才會被清理,從而它所對應的那塊兒直接內存在此之前也會一直不會被釋放掉,這就會造成直接內存可能占用較大,長時間得不到釋放這樣一個現象。
所以使用直接內存的情況比較多,由程序員直接手動的管理直接內存時,推薦用 Unsafe 的相關方法,直接調用 Unsafe 對象的 freeMemory() 方法來釋放直接內存。
-
JVM 的性能調優
調優參數
配置方式
- java [options] MainClass [arguments]
- options :JVM 啟動參數。 配置多個參數的時候,參數之間使用空格分隔。
- 參數命名: 常見為 -參數名
- 參數賦值: 常見為 -參數名=參數值 或 -參數名:參數值
內存參數:
-
-Xms(s 為 strating):初始堆大小,JVM啟動的時候,給定堆空間大小。
可以設置與-Xmx 相同,以避免每次垃圾回收完成后 JVM 重新分配內存。
示例:-Xms3550m :設置 JVM 初始內存為 3550M。
-
-Xmx(x 為 max):最大堆大小,JVM運行過程中,如果初始堆空間不足的時候,最大可以擴展到多少。
示例:-Xmx3550m :設置 JVM 最大可用內存為 3550M。
-
-Xmn(n 為 new):新生代大小
整個堆大小 = 新生代大小 + 老年代大小 + 持久代大小(jkd1.8廢棄)
持久代一般固定大小為64m,所以增大年輕代后,將會減小年老代大小。
此值對系統性能影響較大,Sun官方推薦配置為整個堆的 3/8
示例:-Xmn2g:設置年輕代大小為2G。
-
-Xss:設置每個線程的 Java 棧大小。
根據應用的線程所需內存大小進行調整。
在相同物理內存下,減小這個值能生成更多的線程。
但是操作系統對一個進程內的線程數還是有限制的,不能無限生成,經驗值在3000~5000左右。
JDK5.0 以后每個線程 Java 棧大小為1M,以前每個線程堆棧大小為 256K。
示例:-Xss128k :設置每個線程的堆棧大小為128k。
-
-XX:NewSize=n:設置年輕代大小
-
-XX:NewRatio=n:設置年輕代(包括 Eden 和兩個 Survivor 區)與年老代的比值。
示例:設置為 4 :年輕代與年老代所占比值為 1:4,年輕代占整個堆棧的 1/5
-
-XX:SurvivorRatio=n:年輕代中 Eden 區與兩個 Survivor 區的比值。
注意 Survivor 區有兩個。
示例:設置為 3 :表示 Eden:Survivor=3:2,一個 Survivor 區占整個年輕代的 1/5。
-
-XX:MaxPermSize=n:設置永久代大小
示例:-XX:MaxPermSize=16m:設置持久代大小為16m。
-
-XX:MaxTenuringThreshold=n:設置垃圾最大年齡
如果設置為 0 的話,則年輕代對象不經過 Survivor 區,直接進入年老代。對于年老代比較多的應用,可以提高效率。
如果將此值設置為一個較大值,則年輕代對象會在Survivor區進行多次復制,這樣可以增加對象再年輕代的存活時間,增加在年輕代即被回收的概率。
垃圾回收器參數
JVM給了三種選擇:串行收集器、并行收集器、并發收集器。串行收集器只適用于小數據量的情況。
-
-XX:+UseSerialGC: 設置串行收集器。
-
-XX:+UseParallelGC: 設置并行收集器,表示年輕代使用并行收集器。
-
-XX:+UseParNewGC: 設置年輕代為并行收集。
可與 CMS 收集同時使用。
JDK5.0 以上,JVM 會根據系統配置自行設置,所以無需再設置此值。
-
-XX:+UseParallelOldGC: 設置并行年老代收集器
JDK6.0 支持對年老代并行收集。
-
-XX:+UseConcMarkSweepGC: 設置年老代并發收集器 CMS。
-
-XX:+UseG1GC: 設置G1收集器
-
-XX:ParallelGCThreads=n: 設置并行收集器收集時最大線程數使用的CPU數。并行收集線程數。
-
-XX:MaxGCPauseMillis=n: 設置并行收集最大暫停時間,單位毫秒。
可以減少STW時間。
-
-XX:GCTimeRatio=n: 設置垃圾回收時間占程序運行時間的百分比。
公式為 1/(1+n) 并發收集器設置
-
-XX:+CMSIncrementalMode: 設置為增量模式。
適用于單 CPU 情況。
-
-XX:+UseAdaptiveSizePolicy: 設置此選項后,并行收集器會自動選擇年輕代區大小和相應的 Survivor 區比例,以達到目標系統規定的最低相應時間或者收集頻率等。
此值建議使用并行收集器時,一直打開。
-
-XX:CMSFullGCsBeforeCompaction=n: 此值設置運行多少次 GC 以后對內存空間進行壓縮、整理。
因為并發收集器不對內存空間進行壓縮、整理,所以運行一段時間以后會產生“碎片”,使得運行效率降低。
-
-XX:+UseCMSCompactAtFullCollection: 打開對年老代的壓縮。
可能會影響性能,但是可以消除碎片。
元空間參數:
-
-XX:MetaspaceSize:初始化的 Metaspace 大小,該值越大觸發 Metaspace GC 的時機就越晚。
隨著GC的到來,虛擬機會根據實際情況調控 Metaspace 的大小,而上下浮動主要由 -XX:MaxMetaspaceFreeRatio 和 -XX:MinMetaspaceFreeRatio 兩個參數控制。
在默認情況下,這個值大小根據不同的平臺在 12M 到 20M 浮動。
使用 java -XX:+PrintFlagsInitial 命令查看本機的初始化參數。
-
-XX:MinMetaspaceFreeRatio:
當進行過 Metaspace GC 之后,會計算當前 Metaspace 的空閑空間比,如果空閑比小于這個參數,那么虛擬機將增加 MetaspaceSize 的大小(為了避免過早引發一次垃圾回收)。
默認值為40,也就是40%。
設置該參數可以控制 Metaspace 的增長的速度,太小的值會導致 Metaspace 增長的緩慢,Metaspace的使用逐漸趨于飽和,可能會影響之后類的加載。而太大的值會導致 Metaspace 增長的過快,浪費內存。
-
-XX:MaxMetaspaceFreeRatio:當進行過 Metaspace GC 之后, 會計算當前Metaspace的空閑空間比,如果空閑比大于這個參數,那么虛擬機會減小 MetaspaceSize 的大小。
默認值為70,也就是70%。
-
-XX:MaxMetaspaceExpansion :Metaspace 增長時的最大幅度。默認值大約為5MB。
-
-XX:MinMetaspaceExpansion :Metaspace 增長時的最小幅度。默認值大約330KB。
-
-XX:MaxMetaspaceSize:最大空間。默認是沒有限制的。
指定該值可以防止因為某些情況導致Metaspace無限的使用本地內存,影響到其他程序。
輔助參數
JVM提供了大量命令行參數,打印信息,供調試使用。商業項目上線的時候,不允許使用。一定使用 loggc。主要有以下一些:
-
-XX:+PrintGC
輸出形式:
[GC 118250K->113543K(130112K), 0.0094143 secs]
[Full GC 121376K->10414K(130112K), 0.0650971 secs]
-
-XX:+PrintGCDetails
輸出形式:
[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs]
[GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs]
-
-XX:+PrintGCTimeStamps -XX:+PrintGC:可與上面兩個混合使用
輸出形式:
11.851: [GC 98328K->93620K(130112K), 0.0082960 secs]
-
-XX:+PrintGCApplicationConcurrentTime :打印每次垃圾回收前,程序未中斷的執行時間。
可與上面混合使用
輸出形式:
Application time: 0.5291524 seconds
-
-XX:+PrintGCApplicationStoppedTime: 打印垃圾回收期間程序暫停的時間。
可與上面混合使用
輸出形式:
Total time for which application threads were stopped: 0.0468229 seconds
-
-XX:PrintHeapAtGC :打印GC前后的詳細堆棧信息
-
-Xloggc:filename :與上面幾個配合使用,把相關日志信息記錄到文件以便分析。
調優建議
-
年輕代大小選擇
-
響應時間優先的應用:
盡可能設大,直到接近系統的最低響應時間限制(根據實際情況選擇)。
在此種情況下,年輕代收集發生的頻率也是最小的。同時,減少到達老年代的對象。
-
吞吐量優先的應用:
盡可能的設置大,可能到達 Gbit 的程度。
因為對響應時間沒有要求,垃圾收集可以并行進行,一般適合 8 CPU 以上的應用。
-
-
老年代大小選擇
-
響應時間優先的應用:
老年代使用并發收集器,所以其大小需要小心設置,一般要考慮并發會話率和會話持續時間等一些參數。
如果堆設置小了,可以會造成內存碎片、高回收頻率以及應用暫停而使用傳統的標記清除方式;
如果堆大了,則需要較長的收集時間。
最優化的方案,一般需要參考以下數據獲得:
- 并發垃圾收集信息
- 持久代并發收集次數
- 傳統GC信息
- 花在年輕代和年老代回收上的時間比例
- 減少年輕代和老年代花費的時間,一般會提高應用的效率
-
吞吐量優先的應用:
一般吞吐量優先的應用都有一個很大的年輕代和一個較小的老年代。
原因是,這樣可以盡可能回收掉大部分短期對象,減少中期的對象,而老年代盡存放長期存活對象。
-
-
較小堆引起的碎片問題
因為老年代的并發收集器使用標記-清除算法,所以不會對堆進行壓縮。
當收集器回收時,它會把相鄰的空間進行合并,這樣可以分配給較大的對象。但是,當堆空間較小時,運行一段時間以后,就會出現“碎片”,如果并發收集器找不到足夠的空間,那么并發收集器將會停止,然后使用傳統的標記-清除方式進行回收。
如果出現“碎片”,可能需要進行如下配置:
- -XX:+UseCMSCompactAtFullCollection:使用并發收集器時,開啟對年老代的壓縮。
- -XX:CMSFullGCsBeforeCompaction=0:上面配置開啟的情況下,這里設置多少次 Full GC 后,對老年代進行壓縮