【面試】JVM

1、JDK、JRE和JVM

JDK = JRE + 開發工具
JRE = JVM + 類庫

Java程序的開發過程爲:

  • 我們利用 JDK (調用 Java API)編寫出 Java 源代碼,存儲於 .java 文件中
  • JDK 中的編譯器 javac 將 Java 源代碼編譯成 Java 字節碼,存儲於 .class 文件中
  • JRE 加載、驗證、執行 Java 字節碼
  • JVM 將字節碼解析爲機器碼並映射到 CPU 指令集或 OS 的系統調用。

 2、JVM組成

JVM包含兩個子系統和兩個組件

兩個子系統:

  • Class loader(類裝載)
  • Execution engine(執行引擎)

兩個組件:

  • Runtime data area(運行時數據區)
  • Native Interface(本地接口)

3、Java程序運行機制步驟

  • 首先利用IDE集成開發工具編寫Java源代碼,源文件的後綴爲.java;
  • 再利用編譯器(javac命令)將源代碼編譯成字節碼文件,字節碼文件的後綴名爲.class;
  • 運行字節碼的工作是由解釋器(java命令)來完成的。

4、Java內存區域和內存模型(JMM)

JMM與Java內存區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程序中各個變量在共享數據區域和私有數據區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開的。

java內存區域是指 Jvm 運行時將數據分區域存儲,強調對內存空間的劃分。

1)java運行時數據區域

2)java內存模型(JMM)

這裏所講的主內存、工作內存與 Java 內存區域中的 Java 堆、棧、方法區等並不是同一個層次的內存劃分,這兩者基本上是沒有關係的,如果兩者一定要勉強對應起來,那如下所示:

  • 主內存==》方法區、堆
  • 工作內存==》虛擬機棧、本地方法棧、程序計數器

參考:Java內存區域(運行時數據區域)和內存模型(JMM)

5、java內存區域詳細說明

下圖是 JDK8 之後的 JVM 內存佈局

 

1)本地方法棧

作用:與虛擬機棧所發揮的作用非常相似,爲虛擬機使用到的 Native 方法服務。

線程開始調用本地方法時,會進入 個不再受 JVM 約束的世界。本地方法可以通過 JNI(Java Native Interface)來訪問虛擬機運行時的數據區,甚至可以調用寄存器,具有和 JVM 相同的能力和權限。 當大量本地方法出現時,勢必會削弱 JVM 對系統的控制力,因爲它的出錯信息都比較黑盒。對內存不足的情況,本地方法棧還是會拋出 nativeheapOutOfMemory。

JNI 類本地方法最著名的應該是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系統的特性功能,複用非 Java 代碼。 但是在項目過程中, 如果大量使用其他語言來實現 JNI , 就會喪失跨平臺特性。

異常:會拋出 StackOverflowError 和 OutOfMemoryError 異常。

2)程序計數器

作用:記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是 Native 方法,這個計數器值則爲空(Undefined)。此內存區域是唯一一個在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域。

異常:無

3)java虛擬機棧

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

在活動線程中,只有位於棧頂的幀纔是有效的,稱爲當前棧幀。正在執行的方法稱爲當前方法,棧幀是方法運行的基本結構。在執行引擎運行時,所有指令都只能針對當前棧幀進行操作。

異常:會拋出 StackOverflowError 和 OutOfMemoryError 異常。

1. 局部變量表
局部變量表是存放方法參數和局部變量的區域。 局部變量沒有準備階段, 必須顯式初始化。如果是非靜態方法,則在 index[0] 位置上存儲的是方法所屬對象的實例引用,一個引用變量佔 4 個字節,隨後存儲的是參數和局部變量。字節碼指令中的 STORE 指令就是將操作棧中計算完成的局部變呈寫回局部變量表的存儲空間內。

虛擬機棧規定了兩種異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常;如果虛擬機棧可以動態擴展(當前大部分的 Java 虛擬機都可動態擴展),如果擴展時無法申請到足夠的內存,就會拋出 OutOfMemoryError 異常。

2. 操作棧
操作棧是個初始狀態爲空的桶式結構棧。在方法執行過程中, 會有各種指令往
棧中寫入和提取信息。JVM 的執行引擎是基於棧的執行引擎, 其中的棧指的就是操
作棧。字節碼指令集的定義都是基於棧類型的,棧的深度在方法元信息的 stack 屬性中。

i++ 和 ++i 的區別:

i++:從局部變量表取出 i 並壓入操作棧(load memory),然後對局部變量表中的 i 自增 1(add&store memory),將操作棧棧頂值取出使用,如此線程從操作棧讀到的是自增之前的值。
++i:先對局部變量表的 i 自增 1(load memory&add&store memory),然後取出並壓入操作棧(load memory),再將操作棧棧頂值取出使用,線程從操作棧讀到的是自增之後的值。
之前之所以說 i++ 不是原子操作,即使使用 volatile 修飾也不是線程安全,就是因爲,可能 i 被從局部變量表(內存)取出,壓入操作棧(寄存器),操作棧中自增,使用棧頂值更新局部變量表(寄存器更新寫入內存),其中分爲 3 步,volatile 保證可見性,保證每次從局部變量表讀取的都是最新的值,但可能這 3 步可能被另一個線程的 3 步打斷,產生數據互相覆蓋問題,從而導致 i 的值比預期的小。

3. 動態鏈接
每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支持方法調用過程的動態連接。

4.方法返回地址
方法執行時有兩種退出情況:

正常退出,即正常執行到任何方法的返回字節碼指令,如 RETURN、IRETURN、ARETURN 等;
異常退出。
無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當於彈出當前棧幀,退出可能有三種方式:

返回值壓入上層調用棧幀。
異常信息拋給能夠處理的棧幀。
PC計數器指向方法調用後的下一條指令。
View Code

4)java堆

作用:Java 虛擬機所管理的內存中最大的一塊。Java 堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裏分配內存。 

劃分:①從內存回收的角度來看,由於現在收集器基本都採用分代收集算法,所以 Java 堆中還可以細分爲:新生代和老年代;再細緻一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。②從內存分配的角度來看,線程共享的 Java 堆中可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。

參數: -Xmx 和 -Xms

異常:如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,將會拋出 OutOfMemoryError 異常。

5)方法區

作用:用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

回收:垃圾收集行爲在這個區域是比較少出現的,其內存回收目標主要是針對常量池的回收和對類型的卸載。

異常:當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError 異常。

JDK8 之前,Hotspot 中方法區的實現是永久代(Perm),JDK8 開始使用元空間(Metaspace),以前永久代所有內容的字符串常量移至堆內存,其他內容移至元空間,元空間直接在本地內存分配。
問:爲什麼要使用元空間取代永久代的實現?
①字符串存在永久代中,容易出現性能問題和內存溢出。
②類及方法的信息等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢出,太大則容易導致老年代溢出。
③永久代會爲 GC 帶來不必要的複雜度,並且回收效率偏低。
④將 HotSpot 與 JRockit 合二爲一。

6)直接內存

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的內存區域。

在 JDK 1.4 中新加入了 NIO,引入了一種基於通道(Channel)與緩衝區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作爲這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因爲避免了在 Java 堆和 Native 堆中來回複製數據。

顯然,本機直接內存的分配不會受到 Java 堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存(包括 RAM 以及 SWAP 區或者分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置 -Xmx 等參數信息,但經常忽略直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操作系統級的限制),從而導致動態擴展時出現 OutOfMemoryError 異常。

6、類的加載過程

1)加載

 在加載階段,虛擬機需要完成以下3件事情:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流
  • 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  • 在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。
加載.class文件的方式
①從本地系統中直接加載
②通過網絡下載.class文件
③從zip,jar等歸檔文件中加載.class文件
④從專有數據庫中提取.class文件
⑤將Java源文件動態編譯爲.class文件    
View Code

2)連接第一步:驗證

確保被加載的類的正確性

  • 文件格式驗證:驗證字節流是否符合Class文件格式的規範,如:是否以模數0xCAFEBABE開頭、主次版本號是否在當前虛擬機處理範圍內等等。
  • 元數據驗證:對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求;如:這個類是否有父類,是否實現了父類的抽象方法,是否重寫了父類的final方法,是否繼承了被final修飾的類等等。
  • 字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的,如:操作數棧的數據類型與指令代碼序列能配合工作,保證方法中的類型轉換有效等等。
  • 符號引用驗證:確保解析動作能正確執行;如:通過符合引用能找到對應的類和方法,符號引用中類、屬性、方法的訪問性是否能被當前類訪問等等。

驗證階段是非常重要的,但不是必須的。可以採用-Xverify:none參數來關閉大部分的類驗證措施。

3)連接第二步:準備

  爲類變量分配內存並設置類變量初始值,這些內存都將在方法區中分配。對於該階段有以下幾點需要注意:

  • 只對static修飾的靜態變量進行內存分配、賦默認值(如0、0L、null、false等)。如static int a = 100;靜態變量a就會在準備階段被賦默認值0。
  • 對final的靜態字面值常量直接賦初值(賦初值不是賦默認值,如果不是字面值靜態常量,那麼會和靜態變量一樣賦默認值)。如static final int a = 666;  靜態常量a就會在準備階段被直接賦值爲666

4)連接第三步:解析

解析階段JVM將常量池中的符號引用替換爲直接引用。準備階段只是分配了內存,但是類變量並沒有指向那一塊內存,這一步就是完成實際指向的工作。

5)初始化

初始化階段爲類變量設置正確的初始值。

在Java中對類變量進行初始值設定有兩種方式:

(1)聲明類變量時指定初始值;

(2)使用靜態代碼塊爲類變量指定初始值。

JVM初始化步驟:

(1)假如這個類還沒有被加載和連接,則程序先加載並連接該類;

(2)假如該類的直接父類還沒有被初始化,則先初始化其直接父類;

(3)假如類中有初始化語句,則系統依次執行這些初始化語句。

類初始化時機:只有當對類主動使用的時候纔會導致類的初始化,類的主動使用包括以下6種:

  • 創建類的實例,也就是new的方式;
  • 訪問某個類或接口的靜態變量,或者對該靜態變量賦值;
  • 調用類的靜態方法;
  • 反射(如Class.forName("…"));
  • 初始化某個類的子類,則其父類也會被初始化;
  • Java虛擬機啓動時被標明爲啓動類的類,直接使用java.exe命令來運行某個主類。
被動引用不會創建不會觸發初始化的情況:
  • 通過子類引用父類的靜態字段,只會觸發父類的初始化,而不會觸發子類的初始化。
  • 定義對象數組和集合,不會觸發該類的初始化
  • 類A引用類B的static final常量不會導致類B初始化(注意靜態常量必須是字面值常量,否則還是會觸發B的初始化)
  • 通過類名獲取Class對象,不會觸發類的初始化。如System.out.println(Person.class);
  • 通過Class.forName加載指定類時,如果指定參數initialize爲false時,也不會觸發類初始化。
  • 通過ClassLoader默認的loadClass方法,也不會觸發初始化動作

 注意:被動引用不會導致類初始化,但不代表類不會經歷加載、驗證、準備階段。

參考:https://blog.csdn.net/zhaocuit/article/details/93038538

7、三種類加載器

  • 啓動類加載器(Bootstrap ClassLoader)
  • 擴展類加載器(Extension ClassLoader)
  • 應用程序類加載器(Application ClassLoader)

1)啓動類加載器:用來加載Java的核心庫,主要加載的是JVM自身所需要的類,使用C++實現,並非繼承於java.lang.ClassLoader,是JVM的一部分。負責加載JAVA_HOME\lib目錄中的,或者-Xbootclasspath參數指定的路徑中的,且被虛擬機認可[注1]的類。開發者無法直接獲取到其引用。

注1:JVM是按文件名識別的,如rt.jar,如果文件名不被虛擬機識別,即使把jar包放在lib目錄下也沒有作用,同時啓動加載器只加載包名爲java,javax,sun等開頭的類。且java是特殊包名,開發者的包名不能以java開頭,如果自定義了一個java.***包來讓類加載器加載,那麼就會拋出異常java.lang.SecurityException: Prohibited package name: java.***

2)擴展類加載器: 用來加載Java的擴展庫。負責加載JAVA_HOME\lib\ext目錄中的,或通過系統變量java.ext.dirs指定路徑中的類庫。由java語言實現。開發者可以直接使用。

3)應用程序類加載器:負責加載用戶路徑(classpath)上的類庫。開發者可以直接使用。可以通過ClassLoader.getSystemClassLoader()獲得。一般情況下程序的默認類加載器就是該加載器。

4)除了提供的加載器外,開發者可以通過繼承ClassLoader類的方式實現自己的類加載器。

8、雙親委派機制

 解釋:當某個類加載器需要加載某個.class文件時,它首先把這個任務委託給他的上級類加載器,遞歸這個操作,如果上級的類加載器沒有加載,自己纔會去加載這個類。

 作用:

  • 防止重複加載同一個.class。通過委託去向上面問一問,加載過了,就不用再加載一遍。保證數據安全。
  • 保證核心.class不能被篡改。通過委託方式,不會去篡改核心.clas,即使篡改也不會去加載,即使加載也不會是同一個.class對象了。不同的加載器加載同一個.class也不是同一個Class對象。這樣保證了Class執行安全。

9、對象創建過程

 參考:https://zhuanlan.zhihu.com/p/142614439

10、對象的訪問定位

取決於虛擬機的實現而決定,目前主流的訪問方式有兩種:

  • 使用句柄池間接訪問實例數據
  • 指針直接訪問實例數據

1)句柄訪問

解釋:JVM會在堆中劃分一塊內存來作爲句柄池,JVM棧中的棧幀中的本地變量表中所存儲的引用地址是這個對象所對應的句柄地址,而非對象本身的地址。句柄池中的一個個對象地址有兩部分組成,一部分就是對象數據在堆內存中實例池中的地址,另一部分就是對象類型在方法區中的地址。

好處:訪問對象通過一個句柄指針一次間接索引之後,當對象實例數據被移動的時候(垃圾回收的時候有些對象會被移動),只需要改變句柄池中該對象實例的地址即可,無需改變引用和句柄池的對應關係,所以引用中存儲的是穩定的句柄地址。

2)直接指針

解釋:通過直接指針訪問的方式中,reference中存儲的就是對象在堆中的實際地址,在堆中存儲的對象信息中包含了在方法區中的相應類型數據。

好處:這種方式最大的好處就是訪問對象的速度很快,比通過句柄訪問對象節約了一半的尋址時間,由於Java中對象的訪問非常頻繁,所以這種方式能節約很多尋址時間。

11、java中的引用類型

  • 強引用:發生 gc 的時候不會被回收。
  • 軟引用:有用但不是必須的對象,在發生內存溢出之前會被回收。
  • 弱引用:有用但不是必須的對象,在下一次GC時會被回收。
  • 虛引用(幽靈引用/幻影引用):無法通過虛引用獲得對象,用 PhantomReference 實現虛引用,虛引用的用途是在 gc 時返回一個通知。

12、內存分配策略

三大原則和擔保機制:

  • 優先分配到eden區
  • 大對象,直接進入到老年代
  • 長期存活的對象分配到老年代
  • 空間分配擔保

如圖所示,想要了解jvm的內存分配就要熟悉堆空間,在堆中,我給大家劃分出了兩個大的部分:
1)新生代和老年代
①.新生代主要是剛出生的對象,比如你代碼中經常用的new ,以及Method.invoke()等操作,這些操作的對象都是在Eden去先分配出內存,然後等待gc的回收,那麼在這個區域裏面我也提交,jvm主要做的回收就是MinorGC,jvm默認的是假如一個對象在新生代的年齡到達15歲之後,將其晉升到老年代存儲。意思就是這個對象要在新生代中經歷15次MinorGC之後不被回收,那麼將進入老年代。當然不排除你自己的設定,可以利用參數配置使大對象在Eden出生後直接進入老年代。
大對象直接進入老年代:-XX:PretenureSizeThreshold=n(n代表你要限制的對象的字節數BIT)
②老年代主要存儲的都是一些老的油條對象,在此內存區域,是不可能採用標記複製算法的,因爲那樣會減少一半的空間存儲量,降低程序的效率。
2.新生代中又劃分出了三個區域
①Eden主要接受剛新生的對象
②Survivor0
③Survivor1
這兩個內存區域主要是用於gc做垃圾回收算法時用到的,也就是MinorGC發生的主要內存區域。

參考:https://www.jianshu.com/p/846c49ed9f24

13、判斷對象是否存活(怎樣判斷對象是否可以被回收?)

 

14、垃圾回收算法

  • 標記-清除算法:標記無用對象,然後進行清除回收。缺點:效率不高,無法清除垃圾碎片。
  • 複製算法:按照容量劃分二個大小相等的內存區域,當一塊用完的時候將活着的對象複製到另一塊上,然後再把已使用的內存空間一次清理掉。缺點:內存使用率不高,只有原來的一半。
  • 標記-整理算法:標記無用對象,讓所有存活的對象都向一端移動,然後直接清除掉端邊界以外的內存。
  • 分代算法:根據對象存活週期的不同將內存劃分爲幾塊,一般是新生代和老年代,新生代基本採用複製算法,老年代採用標記整理算法。

分別詳細說明:

7、JVM有哪些垃圾回收器?

如果說垃圾收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。下圖展示了7種作用於不同分代的收集器,其中用於回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,還有用於回收整個Java堆的G1收集器。不同收集器之間的連線表示它們可以搭配使用。

  • Serial收集器(複製算法): 新生代單線程收集器,標記和清理都是單線程,優點是簡單高效;
  • ParNew收集器 (複製算法): 新生代收並行集器,實際上是Serial收集器的多線程版本,在多核CPU環境下有着比Serial更好的表現;
  • Parallel Scavenge收集器 (複製算法): 新生代並行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用戶線程時間/(用戶線程時間+GC線程時間),高吞吐量可以高效率的利用CPU時間,儘快完成程序的運算任務,適合後臺應用等對交互相應要求不高的場景;
  • Serial Old收集器 (標記-整理算法): 老年代單線程收集器,Serial收集器的老年代版本;
  • Parallel Old收集器 (標記-整理算法): 老年代並行收集器,吞吐量優先,Parallel Scavenge收集器的老年代版本;
  • CMS(Concurrent Mark Sweep)收集器(標記-清除算法): 老年代並行收集器,以獲取最短回收停頓時間爲目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。
  • G1(Garbage First)收集器 (標記-整理算法): Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記-整理”算法實現,也就是說不會產生內存碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。

新生代垃圾回收器一般採用的是複製算法,複製算法的優點是效率高,缺點是內存利用率低;老年代回收器一般採用的是標記-整理的算法進行垃圾回收。

 

 

內存泄露

 

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