2w 字長文爆肝 JVM 經典面試題!太頂了!

1、Java 類加載過程

Java 類加載需要經歷以下 7 個過程:

1.1 加載

加載是類加載的第一個過程,在這個階段,將完成以下三件事情:

1.2 驗證

驗證的目的是爲了確保 Class 文件的字節流中的信息不會危害到虛擬機。

在該階段主要完成以下四種驗證:

1.3 準備

準備階段是爲類的靜態變量分配內存並將其初始化爲默認值,這些內存都將在方法區中進行分配。準備階段不分配類中的實例變量的內存,實例變量將會在對象實例化時隨着對象一起分配在 Java 堆中。

public static String value = "公衆號【老周聊架構】"; // 在準備階段 value 初始值爲 null 。在初始化階段纔會變爲 "公衆號【老周聊架構】" 。

1.4 解析

該階段主要完成符號引用到直接引用的轉換動作。解析動作並不一定在初始化動作完成之前,也有可能在初始化之後。

1.5 初始化

初始化是類加載的最後一步,前面的類加載過程,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼。

1.5.1 類構造器

初始化階段是執行類構造器<client>方法的過程。<client>方法是由編譯器自動收集類中的類變量的賦值操作和靜態語句塊中的語句合併而成的。虛擬機會保證子<client>方法執行之前,父類的<client>方法已經執行完畢,如果一個類中沒有對靜態變量賦值也沒有靜態語句塊,那麼編譯器可以不爲這個類生成<client>()方法。

注意以下幾種情況不會執行類初始化:

1.6 使用

1.7 卸載

2、描述一下 JVM 加載 Class 文件的原理機制

Java 語言是一種具有動態性的解釋型語言,類 (Class) 只有被 載到 JVM 後才能運行。當運行指定程序時,JVM 會將編譯生成 的 .class 文件按照需求和一定的規則加載到內存中,並組織成爲一個完整的 Java 應用程序。這個加載過程是由類加載器完成,具體來說,就是由 ClassLoader 和它的子類來實現的。類加載器本身也是一個類,其實就是把類文件從硬盤讀取到內存中。

類的加載方式分爲隱式加載和顯示加載。隱式加載指的是程序在使 用 new 等方式創建對象時,會隱式地調用類的加載器把對應的類加載到 JVM 中。顯示加載指的是通過直接調用 class.forName() 方法來把所需的類加載到 JVM 中。

任何一個工程項目都是由許多類組成的,當程序啓動時,只把需要的類加載到 JVM 中,其他類只有被使用到的時候纔會被加載,採用這種方法一方面可以加快加載速度,另一方面可以節約程序運行時對內存的開銷。此外,在 Java 語言中,每個類或接口都對應一個 .class 文件,這些文件可以被看成是一個個可以被動態加載的單元,因此當只有部分類被修改時,只需要重新編譯變化的類即可, 而不需要重新編譯所有文件,因此加快了編譯速度。

在 Java 語言中,類的加載是動態的,它並不會一次性將所有類全部加載後再運行,而是保證程序運行的基礎類 (例如基類) 完全加載到 JVM 中,至於其他類,則在需要的時候才加載。

類加載的主要步驟:

3、什麼是類加載器,類加載器有哪些?

實現通過類的全限定名獲取該類的二進制字節流的代碼塊叫做類加載器。

主要有以下四種類加載器:

3.1 雙親委派機制

當一個類收到了類加載請求,他首先不會嘗試自己去加載這個類,而是把這個請求委派給父
類去完成,每一個層次類加載器都是如此,因此所有的加載請求都應該傳送到啓動類加載器中,只有當父類加載器反饋自己無法完成這個請求的時候 (在它的加載路徑下沒有找到所需加載的 Class),子類加載器纔會嘗試自己去加載。

採用雙親委派的一個好處是比如加載位於 rt.jar 包中的類 java.lang.Object,不管是哪個加載
器加載這個類,最終都是委託給頂層的啓動類加載器進行加載,這樣就保證了使用不同的類加載。

4、談談你對 JVM 的理解

JVM 是可運行 Java 代碼的假想計算機 ,包括一套字節碼指令集、一組寄存器、一個棧、 一個垃圾回收,堆和一個存儲方法域。JVM 是運行在操作系統之上的,它與硬件沒有直接的交互。

5、JVM 內存模型

JVM 內存區域主要分爲:

線程共享區域隨虛擬機的啓動 / 關閉而創建 / 銷燬。

線程私有數據區域生命週期與線程相同,依賴用戶線程的啓動 / 結束而創建 / 銷燬 (在 Hotspot VM 內,每個線程都與操作系統的本地線程直接映射,因此這部分內存區域的存活跟隨本地線程的生死對應)。

直接內存並不是 JVM 運行時數據區的一部分,但也會被頻繁的使用。在 JDK 1.4 引入的 NIO 提供了基於 Channel 與 Buffer 的 IO 方式,它可以使用 Native 函數庫直接分配堆外內存,然後使用 DirectByteBuffer 對象作爲這塊內存的引用進行操作,這樣就避免了在 Java 堆和 Native 堆中來回複製數據,因此在一些場景中可以顯著提高性能。

5.1 程序計數器 (線程私有)

一塊較小的內存空間,是當前線程所執行的字節碼的行號指示器,每條線程都要有一個獨立的程序計數器,這類內存也稱爲 “線程私有” 的內存。

正在執行 java 方法的話,計數器記錄的是虛擬機字節碼指令的地址 (當前指令的地址)。如果還是 Native 方法,則爲空。

這個內存區域是唯一一個在虛擬機中沒有規定任何 OutOfMemoryError 情況的區域。

5.2 虛擬機棧 (線程私有)

是描述 java 方法執行的內存模型,每個方法在執行的同時都會創建一個棧幀 (Stack Frame) 用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。

棧幀 (Frame) 是用來存儲數據和部分過程結果的數據結構,同時也被用來處理動態鏈接 (Dynamic Linking)、 方法返回值和異常分派 (Dispatch Exception)。棧幀隨着方法調用而創建,隨着方法結束而銷燬——無論方法是正常完成還是異常完成(拋出了在方法內未被捕獲的異常) 都算作方法結束。

5.3 本地方法區 (線程私有)

本地方法區和 Java Stack 作用類似,區別是虛擬機棧爲執行 Java 方法服務,而本地方法棧則爲 Native 方法服務,如果一個 VM 實現使用 C-linkage 模型來支持 Native 調用,那麼該棧將會是一個 C 棧,但 HotSpot VM 直接就把本地方法棧和虛擬機棧合二爲一。

5.4 堆 (Heap)- 運行時數據區 (線程共享)

是被線程共享的一塊內存區域,創建的對象和數組都保存在 Java 堆內存中,也是垃圾收集器進行垃圾收集的最重要的內存區域。由於現代 VM 採用分代收集算法。因此 Java 堆從 GC 的角度還可以細分爲: 新生代 (Eden 區、From Survivor 區和 To Survivor 區) 和老年代。

5.5 方法區 / 永久代 (線程共享)

即我們常說的永久代 (Permanent Generation),用於存儲被 JVM 加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。HotSpot VM 把 GC 分代收集擴展至方法區,即使用 Java 堆的永久代來實現方法區,這樣 HotSpot 的垃圾收集器就可以像管理 Java 堆一樣管理這部分內存,而不必爲方法區開發專門的內存管理器 (永久代的內存回收的主要目標是針對常量池的回收和類型的卸載,因此收益一般很小)。

運行時常量池 (Runtime Constant Pool) 是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述等信息外,還有一項信息是常量池 (Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後存放到方法區的運行時常量池中。Java 虛擬機對 Class 文件的每一部分(自然也包括常量池) 的格式都有嚴格的規定,每一個字節用於存儲哪種數據都必須符合規範上的要求,這樣纔會被虛擬機認可、裝載和執行。

這裏提一下 JDK 8 永久代被元空間 (Metaspace) 替換。

6、JVM 運行時內存

Java 堆從 GC 的角度還可以細分爲: 新生代 (Eden 區、Survivor From 區和 Survivor To 區) 和老年代。

6.1 新生代

是用來存放新生的對象。一般佔據堆的 1/3 空間。由於頻繁創建對象,所以新生代會頻繁觸發 MinorGC 進行垃圾回收。新生代又分爲 Eden 區、Survivor From、Survivor To 三個區。

6.1.1 Eden 區

Java 新對象的出生地 (如果新創建的對象佔用內存很大,則直接分配到老年代)。當 Eden 區內存不夠的時候就會觸發 MinorGC,對新生代區進行一次垃圾回收。

6.1.2 Survivor From

上一次 GC 的倖存者,作爲這一次 GC 的被掃描者。

6.1.3 Survivor To

保留了一次 MinorGC 過程中的倖存者。

6.1.4 MinorGC 的過程 (複製 -> 清空 ->互換)

6.2 老年代

主要存放應用程序中生命週期長的內存對象。

老年代的對象比較穩定,所以 MajorGC 不會頻繁執行。在進行 MajorGC 前一般都先進行了一次 MinorGC,使得有新生代的對象晉身入老年代,導致空間不夠用時才觸發。當無法找到足夠大的連續空間分配給新創建的較大對象時也會提前觸發一次 MajorGC 進行垃圾回收騰出空間。

MajorGC 採用標記清除算法:首先掃描一次所有老年代,標記出存活的對象,然後回收沒有標記的對象。MajorGC 的耗時比較長,因爲要掃描再回收。MajorGC 會產生內存碎片,爲了減少內存損耗,我們一般需要進行合併或者標記出來方便下次直接分配。當老年代也滿了裝不下的時候,就會拋出 OOM(Out of Memory) 異常。

6.3 永久代

指內存的永久保存區域,主要存放 Class 和 Meta(元數據) 的信息,Class 在被加載的時候被放入永久區域,它和存放實例的區域不同,GC 不會在主程序運行期對永久區域進行清理。所以這也導致了永久代的區域會隨着加載的 Class 的增多而脹滿,最終拋出 OOM 異常。

6.3.1 JDK 8 與元數據

在 JDK 8 中,永久代已經被移除,被一個稱爲 “元數據區”(元空間) 的區域所取代。元空間的本質和永久代類似,元空間與永久代之間最大的區別在:元空間並不在虛擬機中,而是使用本地內存。因此,默認情況下,元空間的大小僅受本地內存限制。類的元數據放入 native memory,字符串池和類的靜態變量放入 java 堆中,這樣可以加載多少類的元數據就不再由 MaxPermSize 控制,而由系統的實際可用空間來控制。

這裏老周要提兩點注意的地方:

7、垃圾回收與算法

7.1 如何確定垃圾

7.1.1 引用計數法

在 Java 中,引用和對象是有關聯的。如果要操作對象則必須用引用進行。因此,很顯然一個簡單 的辦法是通過引用計數來判斷一個對象是否可以回收。簡單說,即一個對象如果沒有任何與之關聯的引用,即他們的引用計數都不爲 0,則說明對象不太可能再被用到,那麼這個對象就是可回收對象。

7.1.2 可達性分析

爲了解決引用計數法的循環引用問題,Java 使用了可達性分析的方法。通過一系列的 “GC roots” 對象作爲起點搜索。如果在 “GC roots” 和一個對象之間沒有可達路徑,則稱該對象是不可達的。要注意的是,不可達對象不等價於可回收對象,不可達對象變爲可回收對象至少要經過兩次標記過程。兩次標記後仍然是可回收對象,則將面臨回收。

7.2 垃圾回收算法

7.2.1 標記清除算法 (Mark-Sweep)

最基礎的垃圾回收算法,分爲兩個階段,標記和清除。標記階段標記出所有需要回收的對象,清除階段回收被標記的對象所佔用的空間。

從圖中我們就可以發現,該算法最大的問題是內存碎片化嚴重,後續可能發生大對象不能找到可利用空間的問題。

7.2.2 複製算法 (copying)

爲了解決 Mark-Sweep 算法內存碎片化的缺陷而被提出的算法。按內存容量將內存劃分爲等大小的兩塊。每次只使用其中一塊,當這一塊內存滿後將尚存活的對象複製到另一塊上去,把已使用的內存清掉。

這種算法雖然實現簡單,內存效率高,不易產生碎片,但是最大的問題是可用內存被壓縮到了原本的一半。且存活對象增多的話,Copying 算法的效率會大大降低。

7.2.3 標記整理算法 (Mark-Compact)

結合了以上兩個算法,爲了避免缺陷而提出。標記階段和 Mark-Sweep 算法相同,標記後不是清理對象,而是將存活對象移向內存的一端。然後清除端邊界外的對象。

7.2.4 分代收集算法

分代收集法是目前大部分 JVM 所採用的方法,其核心思想是根據對象存活的不同生命週期將內存劃分爲不同的域,一般情況下將 GC 堆劃分爲老年代 (Tenured/Old Generation) 和新生代(Young Generation)。老年代的特點是每次垃圾回收時只有少量對象需要被回收,新生代的特點是每次垃圾回收時都有大量垃圾需要被回收,因此可以根據不同區域選擇不同的算法。

7.2.4.1 新生代與複製算法

目前大部分 JVM 的 GC 對於新生代都採取 Copying 算法,因爲新生代中每次垃圾回收都要 回收大部分對象,即要複製的操作比較少,但通常並不是按照 1:1 來劃分新生代。一般將新生代劃分爲一塊較大的 Eden 空間和兩個較小的 Survivor 空間 (From Space, To Space),每次使用 Eden 空間和其中的一塊 Survivor 空間,當進行回收時,將該兩塊空間中還存活的對象複製到另一塊 Survivor 空間中。

7.2.4.2 老年代與標記整理算法

而老年代因爲每次只回收少量對象,因而採用 Mark-Compact 算法。

8、JAVA 四中引用類型

8.1 強引用

在 Java 中最常見的就是強引用,把一個對象賦給一個引用變量,這個引用變量就是一個強引用。當一個對象被強引用變量引用時,它處於可達狀態,它是不可能被垃圾回收機制回收的,即使該對象以後永遠都不會被用到 JVM 也不會回收。因此強引用是造成 Java 內存泄漏的主要原因之一。

8.2 軟引用

軟引用需要用 SoftReference 類來實現,對於只有軟引用的對象來說,當系統內存足夠時它
不會被回收,當系統內存空間不足時它會被回收。軟引用通常用在對內存敏感的程序中。

8.3 弱引用

弱引用需要用 WeakReference 類來實現,它比軟引用的生存期更短,對於只有弱引用的對象來說,只要垃圾回收機制一運行,不管 JVM 的內存空間是否足夠,總會回收該對象佔用的內存。

8.4 虛引用

虛引用需要 PhantomReference 類來實現,它不能單獨使用,必須和引用隊列聯合使用。虛 引用的主要作用是跟蹤對象被垃圾回收的狀態。

9、GC 垃圾收集器

Java 堆內存被劃分爲新生代和老年代兩部分,新生代主要使用複製和標記 - 清除垃圾回收,
老年代主要使用標記 - 整理垃圾回收算法,因此 Java 虛擬中針對新生代和年老代分別提供了多種不
同的垃圾收集器,Sun HotSpot 虛擬機的垃圾收集器如下:

9.1 Serial 垃圾收集器 (單線程、複製算法)

Serial(連續) 是最基本垃圾收集器,使用複製算法,曾經是 JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一個單線程的收集器,它不但只會使用一個 CPU 或一條線程去完成垃圾收集工作,並且在進行垃圾收集的同時,必須暫停其他所有的工作線程,直到垃圾收集結束。

Serial 垃圾收集器雖然在收集垃圾過程中需要暫停所有其他的工作線程,但是它簡單高效,對於限定單個 CPU 環境來說,沒有線程交互的開銷,可以獲得最高的單線程垃圾收集效率,因此 Serial 垃圾收集器依然是 Java 虛擬機運行在 Client 模式下默認的新生代垃圾收集器。

9.2 ParNew 垃圾收集器 (Serial + 多線程)

ParNew(平行的) 垃圾收集器其實是 Serial 收集器的多線程版本,也使用複製算法,除了使用多線程進行垃圾收集之外,其餘的行爲和 Serial 收集器完全一樣,ParNew 垃圾收集器在垃圾收集過程中同樣也要暫停所有其他的工作線程。

ParNew 收集器默認開啓和 CPU 數目相同的線程數,可以通過 -XX:ParallelGCThreads 參數來限制垃圾收集器的線程數。

ParNew 雖然是除了多線程外和 Serial 收集器幾乎完全一樣,但是 ParNew 垃圾收集器是很多 Java 虛擬機運行在 Server 模式下新生代的默認垃圾收集器。

9.3 Parallel Scavenge 收集器 (多線程複製算法、高效)

Parallel Scavenge 收集器也是一個新生代垃圾收集器,同樣使用複製算法,也是一個多線程的垃圾收集器,它重點關注的是程序達到一個可控制的吞吐量 (Thoughput,CPU 用於運行用戶代碼的時間 / CPU 總消耗時間,即吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 + 垃圾收集時間)), 高吞吐量可以最高效率地利用 CPU 時間,儘快地完成程序的運算任務,主要適用於在後臺運算而不需要太多交互的任務。自適應調節策略也是 ParallelScavenge 收集器與 ParNew 收集器的一個重要區別。

9.4 Serial Old 收集器 (單線程標記整理算法)

Serial Old 是 Serial 垃圾收集器年老代版本,它同樣是個單線程的收集器,使用標記 - 整理算法, 這個收集器也主要是運行在 Client 默認的 Java 虛擬機默認的年老代垃圾收集器。
在 Server 模式下,主要有兩個用途:

9.5 Parallel Old 收集器 (多線程標記整理算法)

Parallel Old 收集器是 Parallel Scavenge 的年老代版本,使用多線程的標記 - 整理算法,在 JDK1.6 纔開始提供。

在 JDK1.6 之前,新生代使用 Parallel Scavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保證新生代的吞吐量優先,無法保證整體的吞吐量,Parallel Old 正是爲了在年老代同樣提供吞吐量優先的垃圾收集器,如果系統對吞吐量要求比較高,可以優先考慮新生代 Parallel Scavenge 和年老代 Parallel Old 收集器的搭配策略。

9.6 CMS 收集器 (多線程標記清除算法)

Concurrent Mark Sweep(CMS) 收集器是一種年老代垃圾收集器,其最主要目標是獲取最短垃圾回收停頓時間,和其他年老代使用標記 - 整理算法不同,它使用多線程的標記 - 清除算法。最短的垃圾收集停頓時間可以爲交互比較高的程序提高用戶體驗。

CMS 工作機制相比其他的垃圾收集器來說更復雜,整個過程分爲以下 4 個階段:

9.6.1 初始標記

只是標記一下 GC Roots 能直接關聯的對象,速度很快,仍然需要暫停所有的工作線程。

9.6.2 併發標記

進行 GC Roots 跟蹤的過程,和用戶線程一起工作,不需要暫停工作線程。

9.6.3 重新標記

爲了修正在併發標記期間,因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記 記錄,仍然需要暫停所有的工作線程。

9.6.4 併發清除

清除 GC Roots 不可達對象,和用戶線程一起工作,不需要暫停工作線程。由於耗時最長的併發標記和併發清除過程中,垃圾收集線程可以和用戶現在一起併發工作,所以總體上來看 CMS 收集器的內存回收和用戶線程是一起併發地執行。

9.7 G1 收集器

Garbage First 垃圾收集器是目前垃圾收集器理論發展的最前沿成果,相比與 CMS 收集器,G1 收集器兩個最突出的改進是:

G1 收集器避免全區域垃圾收集,它把堆內存劃分爲大小固定的幾個獨立區域,並且跟蹤這些區域的垃圾收集進度,同時在後臺維護一個優先級列表,每次根據所允許的收集時間,優先回收垃圾最多的區域。區域劃分和優先級區域回收機制,確保 G1 收集器可以在有限時間獲得最高的垃圾收集效率。

10、簡述 Java 垃圾回收機制

在 Java 中,程序員是不需要顯式的去釋放一個對象的內存的,而是由虛擬機自行執行。在 JVM 中,有一個垃圾回收線程,它是低優先級的,在正常情況下是不會執行的,只有在虛擬機空閒或者當前堆內存不足時,纔會觸發執行,掃面那些沒有被任何引用的對象,並將它們添加到要回收的集合中,進行回收。

11、如何判斷一個對象是否存活?(或者 GC 對象的判定方法)

其實第 7 點回答了哈,這裏再詳細說一下。

判斷一個對象是否存活有兩種方法:

11.1 引用計數法

所謂引用計數法就是給每一個對象設置一個引用計數器,每當有一個地方引用這個對象時,就將計數器加一,引用失效時,計數器就減一。當一個對象的引用計數器爲零時,說明此對象沒有被引用,也就是 “死對象”,將會被垃圾回收。

引用計數法有一個缺陷就是無法解決循環引用問題,也就是說當對象 A 引用對 象 B,對象 B 又引用者對象 A,那麼此時 A、B 對象的引用計數器都不爲零, 也就造成無法完成垃圾回收,所以主流的虛擬機都沒有采用這種算法。

11.2 可達性算法 (引用鏈法)

該算法的思想是:從一個被稱爲 GC Roots 的對象開始向下搜索,如果一個對象到 GC Roots 沒有任何引用鏈相連時,則說明此對象不可用。

在 Java 中可以作爲 GC Roots 的對象有以下幾種:

雖然這些算法可以判定一個對象是否能被回收,但是當滿足上述條件時,一個對象不一定會被回收。當一個對象不可達 GC Root 時,這個對象並不會立馬被回收,而是處於一個死緩的階段,若要被真正的回收需要經歷兩次標記。

如果對象在可達性分析中沒有與 GC Root 的引用鏈,那麼此時就會被第一次標記並且進行一次篩選,篩選的條件是是否有必要執行 finalize() 方法。當對象沒有覆蓋 finalize() 方法或者已被虛擬機調用過,那麼就認爲是沒必要的。如果該對象有必要執行 finalize() 方法,那麼這個對象將會放在一個稱爲 F-Queue 的對隊列中,虛擬機會觸發一個 Finalize() 線程去執行,此線程是低優先級的, 並且虛擬機不會承諾一直等待它運行完,這是因爲如果 finalize() 執行緩慢或者發生了死鎖,那麼就會造成 F-Queue 隊列一直等待,造成了內存回收系統的崩潰。GC 對處於 F-Queue 中的對象進行第二次被標記,這時,該對象將被移除 ” 即將回收” 集合,等待回收。

12、垃圾回收的優點和原理

Java 語言中一個顯著的特點就是引入了垃圾回收機制,使 C++ 程序員最頭疼的內存管理的問題迎刃而解,它使得 Java 程序員在編寫程序的時候不再需要考慮內存管理。由於有個垃圾回收機制,Java 中的對象不再有 “作用域” 的概念,只有對象的引用纔有 "作用域"。垃圾回收可以有效的防止內存泄露,有效的使用可以使用的內存。垃圾回收器通常是作爲一個單獨的低級別的線程運行,不可預知的情況下對內存堆中已經死亡的或者長時間沒有使用的對象進行清除和回收,程序員不能實時的調用垃圾回收器對某個對象或所有對象進行垃圾回收。

13、垃圾回收器可以馬上回收內存嗎? 有什麼辦法主動通知虛擬機進行垃圾回收?

對於 GC 來說,當程序員創建對象時,GC 就開始監控這個對象的地址、大小以及使用情況。通常,GC 採用有向圖的方式記錄和管理堆 (heap) 中的所有對象。通過這種方式確定哪些對象是”可達的”,哪些對象是”不可達的”。當 GC 確定一些對象爲 “不可達” 時,GC 就有責任回收這些內存空間。

可以。程序員可以手動執行 System.gc(),通知 GC 運行,但是 Java 語言規範並不保證 GC 一定會執行。

14、Java 中會存在內存泄漏嗎,請簡單描述。

所謂內存泄露就是指一個不再被程序使用的對象或變量一直被佔據在內存中。Java 中有垃圾回收機制,它可以保證一對象不再被引用的時候,即對象變成了孤兒的時候,對象將自動被垃圾回收器從內存中清除掉。由於 Java 使用有向圖的方式進行垃圾回收管理,可以消除引用循環的問題,例如有兩個對象,相互引用,只要它們和根進程不可達的,那麼 GC 也是可以回收它們的,例如下面的代碼可以看到這種情況的內存回收:

public class GarbageTest {

    public static void main(String[] args) throws IOException {
        try {
            gcTest();
        } catch (IOException e) {
        }

        System.out.println("has exited gcTest!");
        System.in.read();
        System.in.read();
        System.out.println("out begin gc!");

        for (int i = 0; i < 100; i++) {
            System.gc();
            System.in.read();
            System.in.read();
        }
    }

    private static void gcTest() throws IOException {
        System.in.read();
        System.in.read();

        Person p1 = new Person();
        System.in.read();
        System.in.read();

        Person p2 = new Person();
        p1.setMate(p2);
        p2.setMate(p1);
        System.out.println("before exit gctest!");
        System.in.read();
        System.in.read();
        System.gc();
        System.out.println("exit gctest!");
    }

    private static class Person {
        byte[] data = new byte[20000000];
        Person mate = null;

        public void setMate(Person other) {
            mate = other;
        }
    }
}

Java 中的內存泄露的情況:長生命週期的對象持有短生命週期對象的引用就很可能發生內存泄露,儘管短生命週期對象已經不再需要,但是因爲長生命週期對象持有它的引用而導致不能被回收,這就是 Java 中內存泄露的發生場景,通俗地說,就是程序員可能創建了一個對象,以後一直不再使用這個對象,這個對象卻一直被引用,即這個對象無用但是卻無法被垃圾回收器回收的,這就是 Java 中可能出現內存泄露的情況,例如,緩存系統,我們加載了一個對象放在緩存中 (例如放在一個全局 map 對象中),然後一直不再使用它,這個對象一直被緩存引用,但卻不再被使用。

檢查 Java 中的內存泄露,一定要讓程序將各種分支情況都完整執行到程序結束,然後看某個對象是否被使用過,如果沒有,則才能判定這個對象屬於內存泄露。

如果一個外部類的實例對象的方法返回了一個內部類的實例對象,這個內部類對象被長期引用了,即使那個外部類實例對象不再被使用,但由於內部類持久外部類的實例對象,這個外部類對象將不會被垃圾回收,這也會造成內存泄露。

我們來看個堆棧經典的例子,主要特點就是清空堆棧中的某個元素,並不是徹底把它從數組中拿掉,而是把存儲的總數減少。

public class Stack {
    private Object[] elements = new Object[10];
    private int size = 0;

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        return elements[--size];
    }

    private void ensureCapacity() {
        if (elements.length == size) {
            Object[] oldElements = elements;
            elements = new Object[(2 * elements.length) + 1];
            System.arraycopy(oldElements, 0, elements, 0, size);
        }
    }
}

上面的原理應該很簡單,假如堆棧加了 10 個元素,然後全部彈出來,雖然堆棧是空的,沒有我們要的東西,但是這是個對象是無法回收的,這個才符合了內存泄露的兩個條件:無用,無法回收。但是就是存在這樣的東西也不一定會導致什麼樣的後果,如果這個堆棧用的比較少,也就浪費了幾個 K 內存而已,反正我們的內存都上 G 了,哪裏會有什麼影響,再說這個東西很快就會被回收的,有什麼關係。下面再看個例子。

public class Bad {
    public static Stack s = Stack();

    static {
        s.push(new Object());
        s.pop(); //這裏有一個對象發生內存泄露
        s.push(new Object()); //上面的對象可以被回收了,等於是自愈了
    }
}

因爲是 static,就一直存在到程序退出,但是我們也可以看到它有自愈功能,就是說如果你的 Stack 最多有 100 個對象,那麼最多也就只有 100 個對象無法被回收,其實這個應該很容易理解,Stack 內部持有 100 個引用,最壞的情況就是他們都是無用的,因爲我們一旦放新的進去,以前的引用自然消失!

內存泄露的另外一種情況:當一個對象被存儲進 HashSet 集合中以後,就不能修改這個對象中的那些參與計算哈希值的字段了,否則,對象修改後的哈希值與最初存儲進 HashSet 集合中時的哈希值就不同了,在這種情況下,即使在 contains 方法使用該對象的當前引用作爲的參數去 HashSet 集合中檢索對 象,也將返回找不到對象的結果,這也會導致無法從 HashSet 集合中單獨刪除當前對象,造成內存泄露。

15、簡述 Java 內存分配與回收策略以及 Minor GC 和 Major GC。

當 Eden 區沒有足夠的空間進行分配時,虛擬機會執行一次 Minor GC。Minor GC 通常發生在新生代的 Eden 區,在這個區的對象生存期短,往往發生 GC 的 頻率較高,回收速度比較快;Full GC/Major GC 發生在老年代,一般情況下, 觸發老年代 GC 的時候不會觸發 Minor GC,但是通過配置,可以在 Full GC 之前進行一次 Minor GC 這樣可以加快老年代的回收速度。

16、JVM 內存爲什麼要分成新生代,老年代,持久代。新生代中爲什麼要分爲 Eden 和 Survivor。

第一個問題我覺得是通過分新生代,老年代,持久代而更好的利用有限的內存空間。

第二個問題:

17、 Minor GC ,Full GC 觸發條件

Minor GC 觸發條件:當 Eden 區滿時,觸發 Minor GC。

Full GC 觸發條件:

18、當出現了內存溢出,你怎麼排錯?

19、你們線上應用的 JVM 參數有哪些?

這裏老周給我們服務的 JVM 參數給大家參考下哈,按照自己線上應用來答就好了。

20、什麼是內存泄漏,它與內存溢出的關係?

20.1 內存泄漏 memory leak

是指程序在申請內存後,無法釋放已申請的內存空間,一次內存泄漏似乎不會有大的影響,但內存泄漏堆積後的後果就是內存溢出。

20.2 內存溢出 out of memory

指程序申請內存時,沒有足夠的內存供申請者使用,或者說,給了你一塊存儲 int 類型數據的存儲空間,但是你卻存儲 long 類型的數據,那麼結果就是內存不夠用,此時就會報錯 OOM,即所謂的內存溢出。

20.3 二者的關係

20.4 內存泄漏的分類(按發生方式來分類)

20.5 內存溢出的原因及解決方法

20.5.1 內存溢出原因

20.5.2 內存溢出的解決方案

20.5.3 重點排查以下幾點

21、Full GC 問題的排查和解決經歷說一下

我們可以從以下幾個方面來進行排查

21.1 碎片化

對於 CMS,由於老年代的碎片化問題,在 YGC 時可能碰到晉升失敗(promotion failures,即使老年代還有足夠多有效的空間,但是仍然可能導致分配失敗,因爲沒有足夠連續的空間),從而觸發 Concurrent Mode Failure,發生會完全 STW 的 Full GC。Full GC 相比 CMS 這種併發模式的 GC 需要更長的停頓時間才能完成垃圾回收工作,這絕對是 Java 應用最大的災難之一。

爲什麼 CMS 場景下會有碎片化問題?由於 CMS 在老年代回收時,採用的是標記清理(Mark-Sweep)算法,它在垃圾回收時並不會壓縮堆,日積月累,導致老年代的碎片化問題會越來越嚴重,直到發生單線程的 Mark-Sweep-Compact GC,即 FullGC,會完全 STW。如果堆比較大的話,STW 的時間可能需要好幾秒,甚至十多秒,幾十秒都有可能。

21.2 GC 時操作系統的活動

當發生 GC 時,一些操作系統的活動,比如 swap,可能導致 GC 停頓時間更長,這些停頓可能是幾秒,甚至幾十秒級別。

如果你的系統配置了允許使用 swap 空間,操作系統可能把 JVM 進程的非活動內存頁移到 swap 空間,從而釋放內存給當前活動進程(可能是操作系統上其他進程,取決於系統調度)。Swapping 由於需要訪問磁盤,所以相比物理內存,它的速度慢的令人髮指。所以,如果在 GC 的時候,系統正好需要執行 Swapping,那麼 GC 停頓的時間一定會非常非常恐怖。

除了 swapping 以外,我們也需要監控瞭解長 GC 暫停時的任何 IO 或者網絡活動情況等, 可以通過 iostat 和 netstat 兩個工具來實現。我們還能通過 mpstat 查看 CPU 統計信息,從而弄清楚在 GC 的時候是否有足夠的 CPU 資源。

21.3 堆空間不夠

如果應用程序需要的內存比我們執行的 Xmx 還要大,也會導致頻繁的垃圾回收,甚至 OOM。由於堆空間不足,對象分配失敗,JVM 就需要調用 GC 嘗試回收已經分配的空間,但是 GC 並不能釋放更多的空間,從而又回導致 GC,進入惡性循環。

同樣的,如果在老年代的空間不夠的話,也會導致頻繁 Full GC,這類問題比較好辦,給足老年代和永久代。

21.4 JVM Bug

什麼軟件都有 BUG,JVM 也不例外。有時候,GC 的長時間停頓就有可能是 BUG 引起的。例如,下面列舉的這些 JVM 的 BUG,就可能導致 Java 應用在 GC 時長時間停頓。

6459113: CMS+ParNew: wildly different ParNew pause times depending on heap shape caused by allocation spread

fixed in JDK 6u1 and 7

6572569: CMS: consistently skewed work distribution indicated in (long) re-mark pauses

fixed in JDK 6u4 and 7

6631166: CMS: better heuristics when combatting fragmentation

fixed in JDK 6u21 and 7

6999988: CMS: Increased fragmentation leading to promotion failure after CR#6631166 got implemented

fixed in JDK 6u25 and 7

6683623: G1: use logarithmic BOT code such as used by other collectors

fixed in JDK 6u14 and 7

6976350: G1: deal with fragmentation while copying objects during GC

fixed in JDK 8

如果你的 JDK 正好是上面這些版本,強烈建議升級到更新 BUG 已經修復的版本。

21.5 顯式 System.gc 調用

檢查是否有顯示的 System.gc 調用,應用中的一些類裏,或者第三方模塊中調用 System.gc 調用從而觸發 STW 的 Full GC,也可能會引起非常長時間的停頓。如下 GC 日誌所示,Full GC 後面的(System)表示它是由調用 System.GC 觸發的 FullGC,並且耗時 5.75 秒:

164638.058: [Full GC (System) [PSYoungGen: 22789K->0K(992448K)]

[PSOldGen: 1645508K->1666990K(2097152K)] 1668298K->1666990K(3089600K)

[PSPermGen: 164914K->164914K(166720K)], 5.7499132 secs] [Times: user=5.69, sys=0.06, real=5.75 secs]

如果你使用了 RMI,能觀察到固定時間間隔的 Full GC,也是由於 RMI 的實現調用了 System.gc。這個時間間隔可以通過系統屬性配置:

-Dsun.rmi.dgc.server.gcInterval=7200000

-Dsun.rmi.dgc.client.gcInterval=7200000

JDK 1.4.2 和 5.0 的默認值是 60000 毫秒,即 1 分鐘;JDK6 以及以後的版本,默認值是 3600000 毫秒,即 1 個小時。

如果你要關閉通過調用 System.gc() 觸發 Full GC,配置 JVM 參數 -XX:+DisableExplicitGC 即可。

21.6 那麼如何定位並解決這類問題問題呢?

22、GC 中的三色標記你瞭解嗎?

Java 垃圾回收目前採用的算法是可達性標記算法,即基於 GC Roots 進行可達性分析。分析標記過程採用三色標記法。

三色標記按照垃圾回收器 ” 是否訪問過 “ 爲條件將對象標爲三種顏色:

其實灰色就是一個過渡狀態,在垃圾回收器標記完成結束後,對象只有白色或者黑色其中一種狀態,當爲白色時,說明該對象在可達性分析後沒有引用,也就是之後被銷燬的對象。當爲黑色時,說明當前對象爲此次垃圾回收存活對象。

當垃圾回收開始時,GC Roots 對象是黑色對象。沿着他找到的對象 A 首先是灰色對象,當對象 A 所有引用都掃描後,對象 A 爲黑色對象,以此類推繼續往下掃描。

這是垃圾回收標記基本操作。

但目前的垃圾回收是併發操作的,就是在你進行標記的時候,程序線程也是繼續運行的,那原有的對象引用就有可能發生變化。

比如已經標記爲黑色(存活對象)對象,程序運行將其所有引用取消,那麼這個對象應該是白色的(垃圾對象)。這種情況相對好一些,在下一次垃圾回收時候,我們還是可以把他回收,只是讓他多活了一會兒,系統也不會出現什麼問題,可以不解決。

當已經標記爲白色對象(垃圾對象)時,此時程序運行又讓他和其他黑色(存活)對象產生引用,那麼該對象最終也應該是黑色(存活)對象,如果此時垃圾回收器標記完回收後,會出現對象丟失,這樣就引起程序問題。

出現對象丟失的必要條件是(在垃圾回收器標記進行時出現的改變):

因爲已經標記黑色的對象說明此輪垃圾回收中垃圾回收器對其的掃描已經完成,不會再掃描,如果他又引用了一個白色對象,而且這個白色對象在垃圾掃描完後還是白色,那麼這個白色對象最終會被誤回收。

爲了防止這種情況的出現,上邊說的必要條件中的一個處理掉即可避免對象誤刪除;

當黑色對象直接引用了一個白色對象後,我們就將這個黑色對象記錄下來,在掃描完成後,重新對這個黑色對象掃描,這個就是增量更新(Incremental Update)。

當刪除了灰色對象到白色對象的直接或間接引用後,就將這個灰色對象記錄下來,再以此灰色對象爲根,重新掃描一次。這個就是原始快照(Snapshot At TheBeginning,SATB)。

自此,對象可達標記完成。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/u3MH5WiUGWH3Xt79IcyNIQ