Android面試:Java虛擬機JVM

一、JVM基本構成?

1)類加載器(ClassLoader):在JVM啓動時或者在類運行時需要將class文件轉換成字節碼加載到運行時數據區(Runtime Data Area)。
2)運行時數據區(Runtime Data Area):是在JVM運行的時候操作所分配的內存區。運行時內存區主要可以劃分爲6個區域,程序計數器(Program Counter Register)、Java虛擬機棧(Java Virtual Machine Stack)、本地方法棧(Native Method Stack)、Java(Java Heap)、方法區(Methed Area)、直接內存(Direct Memory)。
3)執行引擎(Execution Engine):負責將class字節碼翻譯成底層系統指令再交由CPU去執行,而這個過程中需要調用其他語言(主要是C和C++)的接口本地庫接口(Native Interface)來實現。
4)本地庫接口(Native Interface):主要是調用C或C++實現的本地方法及回調結果。

二、Java內存模型JMM?

Java內存模型即Java Memory Model,簡稱JMM。JMM定義了Java 虛擬機(JVM)在計算機內存(RAM)中的工作方式。Java虛擬機所管理的內存主要包括5個區域,線程私有的數據區包括程序計數器虛擬機棧本地方法棧線程共享的數據區包括Java方法區(JDK1.8 之後的元空間)

1)程序計數器(Program Counter Register):程序計數器是線程私有的一塊較小的內存空間,其可以看做是當前線程所執行的字節碼的行號指示器。如果線程正在執行的是一個 Java 方法,計數器記錄的是正在執行的字節碼指令的地址;如果正在執行的是 Native 方法,則計數器的值爲空。
2)虛擬機棧(Java Virtual Machine Stack):虛擬機棧描述的是Java方法執行的內存模型,是線程私有的。每個方法在執行的時候都會創建一個棧幀,用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。拋StackOverflowError和OutOfMemoryError異常。
3)本地方法棧(Native Method Stack):本地方法棧與Java虛擬機棧非常相似,也是線程私有的,區別是虛擬機棧爲虛擬機執行 Java 方法服務,而本地方法棧爲虛擬機執行 Native 方法服務。拋StackOverflowError和OutOfMemoryError異常。
4)(Java Heap):Java 堆的唯一目的就是存放對象實例,幾乎所有的對象實例(和數組)都在這裏分配內存。是垃圾收集器管理的主要區域,故也稱爲稱爲GC堆。拋OutOfMemoryError異常。
5)方法區(Methed Area):方法區與Java堆一樣,也是線程共享的並且不需要連續的內存,其用於存儲已被虛擬機加載的 類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。包含運行時常量池。拋OutOfMemoryError異常。

除了程序計數器,其他的部分都會發生 OOM。

JVM 內存模型概述

三、什麼是直接內存(Direct Memory)?

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域,但是這部分內存也被頻繁地使用,而且也可能導致OutOfMemoryError異常出現。也叫Java堆外內存

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

直接內存分配不會受到Java堆大小的限制,但是受到本機總內存大小限制,在設置虛擬機參數的時候,不能忽略直接內存,把實際內存設置爲-Xmx,使得內存區域的總和大於物理內存的限制,從而導致動態擴展時出現OutOfMemoryError異常。

四、Java類加載過程?

一個類的完整生命週期如下:

類加載過程包括五個步驟:加載驗證準備解析初始化,其中驗證準備解析統稱爲連接階段。如下:

1.加載

將class文件加載到Java虛擬機中,併爲這個類在方法區創建對應的 Class 對象加載階段和連接階段的部分內容是交叉進行的。這一步主要完成下面三件事:

1.通過全類名獲取定義此類的二進制字節流
2.將字節流所代表的靜態存儲結構轉換爲方法區的運行時數據結構
3.在內存中生成一個代表該類的 Class 對象,作爲方法區這些數據的訪問入口

2.驗證

確保加載進來的class文件包含的信息符合Java虛擬機的規範;

3.準備

爲類的static變量在方法區分配內存;將上述變量的初始值設置爲0而非開發者定義的值(特殊情況:若static變量加了final,則值被設置爲開發者定義的值); 

4.解析

將常量池內的符號引用替換爲直接引用的過程。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用限定符7類符號引用進行。

5.初始化

初始化靜態變量和靜態代碼塊。主要有下面兩件事:

1)生成類構造器<clinit>()方法。<clinit>()方法由編譯器自動收集靜態變量和靜態代碼塊合併產生。
2)執行<clinit>()方法。虛擬機會保證在子類類構造器<clinit>()執行之前,父類的類構造<clinit>()執行完畢。由於父類的構造器<clinit>()先執行,也就意味着父類中定義的靜態代碼塊/靜態變量的初始化要優先於子類的靜態代碼塊/靜態變量的初始化執行。靜態代碼塊/靜態變量的初始化順序與代碼書寫的順序一致。特別地,類構造器<clinit>()對於類或者接口來說並不是必需的,如果一個類中沒有靜態代碼塊,也沒有對類變量的賦值操作,那麼編譯器可以不爲這個類生產類構造器<clinit>()。

卸載:卸載類即該類的Class對象被GC。

JavaGuide:類加載過程

(JVM)Java虛擬機:類加載的5個過程

書呆子Rico:深入理解Java對象的創建過程:類的初始化與實例化

五、Java類的加載機制(雙親委派)?

雙親委派機制:即在類加載的時候,系統會首先判斷當前類是否被加載過。已經被加載的類會直接返回,否則纔會嘗試加載。加載時,首先將加載任務委託給父類加載器,依次遞歸 (本質上就是loadClass函數的遞歸調用),因此所有的加載請求最終都應該傳送到頂層的啓動類加載器中。如果父類加載器可以完成這個類加載請求,就成功返回;只有當父類加載器無法完成此加載請求時,子加載器纔會嘗試自己去加載(源碼查看ClassLoader.loadClass(String name, boolean resolve))。

類加載器:BootstrapClassLoader(啓動類加載器)、ExtensionClassLoader(擴展類加載器)、ApplicationClassLoader(應用程序類加載器)、自定義類加載器

public class ClassLoader {
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 檢查需要加載的類是否已經被加載過
        Class<?> c = findLoadedClass(name);
        
        if (c == null) {
            try {
                // 若沒有加載,則調用父加載器的loadClass()方法加載
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 若父類加載器爲空,則使用啓動類加載器BootstrapClassLoader加載
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 若父類加載器加載失敗會拋出ClassNotFoundException, 
                //說明父類加載器無法完成加載請求 
            }
            if (c == null) {
                // 在父類加載器無法加載時 
                // 再調用本身的findClass方法進行類加載 
                c = findClass(name);
            }
        }
        
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

雙親委派機制優點?
1)避免同一個類被重複加載(緩存);
2)避免Java 的核心 API 被篡改;

如何自定義類加載器?
繼承ClassLoader,並重寫findClass(String name)。

自定義類加載器使用場景?
1)加密:由於java代碼很容易被反編譯,如果需要對自己的代碼加密的話,可以對編譯後的代碼進行加密,然後再通過實現自己的自定義類加載器進行解密,最後再加載:
2)從非標準的來源加載代碼:如果你的字節碼是放在數據庫、甚至是在雲端,就可以自定義類加載器,從指定的來源加載類:
3)以上兩種情況在實際中的綜合運用。

如果我們不想用雙親委派模型怎麼辦?
爲了避免雙親委託機制,我們可以自己定義一個類加載器,然後重寫loadClass()即可。

雙親委派模型不是一種強制性約束,也就是你不這麼做也不會報錯怎樣的,它是一種JAVA設計者推薦使用類加載器的方式。
有些情況不得不違反這個約束,例如JDBC,它是面向擴展的(SPI)。

JavaGuide:類加載器
(JVM)Java虛擬機:(雙親委派模型)類加載器全解析
深入理解Java類加載器(一):Java類加載原理解析
面試官:說說雙親委派模型?

六、Java對象創建過程?

1)類加載檢查:檢查對應的類是否已被加載完成。虛擬機遇到一條 new 指令時,首先將去檢查這個指令的參數是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已被加載過、解析和初始化過。如果沒有,那必須先執行相應的類加載過程;
2)分配內存:把一塊確定大小的內存從 Java 堆中劃分出來。分配方式根據堆內存是否規整有指針碰撞(規整)和空閒列表(不規整)兩種,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定;
3)初始化零值:虛擬機需要將分配到的內存空間都初始化爲零值(不包括對象頭),這一步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值;
4)設置對象頭:虛擬機對對象進行必要的設置,例如這個對象是那個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息。 這些信息存放在對象頭中。 另外,根據虛擬機當前運行狀態的不同,如是否啓用偏向鎖等,對象頭會有不同的設置方式;
5)執行 init 方法:在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了,但從 Java 程序的視角來看,對象創建纔剛開始,<init> 方法還沒有執行,所有的字段都還爲零。所以一般來說,執行 new 指令之後會接着執行 <init> 方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象纔算完全產生出來。

JavaGuide:Java內存區域
JVM:Java對象的創建、內存佈局 & 訪問定位 全過程解析

七、對象的訪問定位有哪兩種方式?

創建對象就是爲了使用對象,我們的 Java 程序通過棧上的 reference 數據來操作堆上的具體對象。對象的訪問方式由虛擬機實現而定,目前主流的訪問方式有使用句柄直接指針兩種:

1)句柄:如果使用句柄的話,那麼 Java 堆中將會劃分出一塊內存來作爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息;

2)直接指針:如果使用直接指針訪問,那麼 Java 堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,而 reference 中存儲的直接就是對象的地址。

這兩種對象訪問方式各有優勢。使用句柄來訪問的最大好處是 reference 中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。使用直接指針訪問方式最大的好處就是速度快,它節省了一次指針定位的時間開銷。

八、Java堆內存中對象分配的基本策略?

堆空間基本結構:

目前主流的垃圾收集器都會採用分代回收算法,因此需要將堆內存分爲新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集算法。

1)新生代(Young Generation)
新生代內存按照 8:1:1 的比例分爲一個eden區和兩個survivor(survivor0survivor1)區。大部分情況,對象都會首先在eden區域分配。當eden區沒有足夠空間進行分配時,虛擬機將發起一次新生代GC。在進行新生代GC時,先將eden區存活對象複製到survivor0區,然後清空eden區,當這個survivor0區也滿了時,則將eden區和survivor0區存活對象複製到survivor1區,然後清空eden和這個survivor0區,此時survivor0區是空的,然後交換survivor0區和survivor1區的角色(即下次垃圾回收時會掃描Eden區和survivor1區),即保持survivor0區爲空,如此往復。每經過一次立即回收,對象的年齡加1。特別地,當survivor1區也不足以存放eden區和survivor0區的存活對象時,或者存活的對象的年齡超過閾值(可以通過參數 -XX:MaxTenuringThreshold 來設置,默認15)時, 就將存活對象直接存放到老年代。

2)老年代(Old Generation)
老年代的內存也比新生代大很多(大概比例是1:2)。老年代存放的大部分是一些生命週期較長的對象。另外,爲了避免爲大對象分配內存時由於分配機制帶來的複製而降低效率,大對象直接進入老年代。當老年代滿時會觸發老年代GC。

九、Minor GC 和 Full GC

1)新生代 GC(Minor GC):指發生新生代的的垃圾收集動作,Minor GC 非常頻繁耗時較短
2)老年代 GC(Major GC/Full GC):指發生在老年代的 GC,發生頻率較低,出現了 Major GC 經常會伴隨至少一次的 Minor GC(並非絕對),Major GC 耗時較長,一般會比Minor GC慢10倍以上。

Full GC與Major GC的爭議?

Major GC通常是跟full GC是等價的。
知乎:Major GC和Full GC的區別是什麼?

十、如何判斷對象需回收?

1)引用計數法:通過判斷對象的引用數量來決定對象是否可以被回收,任何引用計數爲0的對象實例可以被當作垃圾收集。引用計數法效率高,但很難解決對象之間相互循環引用的問題,因此目前主流的虛擬機中並沒有選擇這個算法來管理內存。

2)可達性分析算法:可達性分析算法是以“GC Roots”對象作爲起點判斷對象的引用鏈是否可達來決定對象是否可以被回收。可達性分析算法流程:
第一次標記:對象在經過可達性分析後發現沒有與GC Roots有引用鏈,則進行第一次標記並進行一次篩選,篩選條件是:該對象是否有必要執行finalize()方法。沒有覆蓋finalize()方法或者finalize()方法已經被執行過都會被認爲沒有必要執行。 如果有必要執行:則該對象會被放在一個F-Queue隊列,並稍後在由虛擬機建立的低優先級Finalizer線程中觸發該對象的finalize()方法,但不保證一定等待它執行結束,因爲如果這個對象的finalize()方法發生了死循環或者執行時間較長的情況,會阻塞F-Queue隊列裏的其他對象,影響GC。
第二次標記:GC對F-Queue隊列裏的對象進行第二次標記,如果在第二次標記時該對象又成功被引用,則會被移除即將回收的集合,否則會被回收。

運行時常量池主要回收的是廢棄的常量,如何判斷一個常量是廢棄常量?

假如在常量池中存在字符串 "abc",如果當前沒有任何 String 對象引用該字符串常量的話,就說明常量 "abc" 就是廢棄常量,如果這時發生內存回收的話而且有必要的話,"abc" 就會被系統清理出常量池。

方法區主要回收的是無用的類,如何判斷一個類是無用的類?

類需要同時滿足下面 3 個條件才能算是“無用的類”:
1)該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例;
2)加載該類的ClassLoader已經被回收;
3)該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

十一、垃圾回收算法有哪些?

1)標記-清除算法(Mark-Sweep):分爲標記和清除兩個階段,首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。它是最基礎的收集算法,後續的算法都是對其不足進行改進得到。

標記-清除算法主要有兩個缺點:
效率問題:標記和清除的效率都不高;
空間問題:容易產生大量內存碎片,大致後續沒有足夠的連續內存分配給較大對象,從而提前觸發GC。

2)複製算法(Copying):將內存分爲大小相同的兩塊,每次使用其中的一塊。當這一塊的內存使用完後,就將還存活的對象複製到另一塊去,然後再把使用的空間一次清理掉。這種算法每次都是對整個半區進行內存回收,內存分配時也不用考慮內存碎片問題,代價是使用內存爲原來的一般。這種算法適用於對象存活率低的場景,比如新生代。事實上,現在商用的虛擬機都採用這種算法來回收新生代,在實踐中會把新生代內存劃分爲塊較大的Eden空間和兩塊較小的Survivor空間。

複製收集算法在對象存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,還會浪費50%空間。

3)標記-整理算法(Mark-Compact):這種算法的標記過程類似標記-清除算法,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存,類似於磁盤整理的過程,適用於對象存活率高的場景,比如老年代。

4)分代收集算法:根據對象的存活週期將它們放到不同的區域,並在不同的區域採用不同的收集算法。目前主流的Java虛擬機的垃圾收集器都採用分代收集算法,新生代對象存活率低,就採用複製算法;老年代存活率高,就用標記-清除算法或者標記-整理算法。

十二、垃圾收集器?

1)Serial 收集器:Serial(串行)收集器收集器是最基本、歷史最悠久的垃圾收集器,是一個新生代收集器。它是一個單線程收集器,簡單而高效(與其他收集器的單線程相比)。它的 “單線程” 的意義不僅僅意味着它只會使用一條垃圾收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程( "Stop The World" ),直到它收集結束。Serial 收集器對於運行在 Client 模式下的虛擬機來說是個不錯的選擇。採用“複製”算法。

2)ParNew 收集器:ParNew 收集器其實就是 Serial 收集器的多線程版本,也是一個新生代收集器。它是許多運行在 Server 模式下的虛擬機的首要選擇。採用“複製”算法。

3)Parallel Scavenge 收集器:Parallel Scavenge 收集器幾乎和ParNew一樣,也是一個新生代收集器。不過它的關注點是吞吐量(高效率的利用 CPU)。採用“複製”算法。

4)Serial Old 收集器:Serial 收集器的老年代版本,是一個老年代收集器。採用“標記-整理”算法。

5)Parallel Old 收集器:Parallel Scavenge 收集器的老年代版本,是一個老年代收集器。採用“標記-整理”算法。

6)CMS 收集器:CMS(Concurrent Mark Sweep)收集器是一個老年代收集器,是一種以獲取最短回收停頓時間爲目標的收集器,特點是“併發”與“低停頓”。是 HotSpot 虛擬機第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。採用“標記-清除”算法。有“標記-清除”算法相應缺點。

7)G1 收集器:G1 收集器是一個新生代&老年代收集器,它採用“化整爲零”的思想,將整個Java堆劃分爲多個大小相等的獨立區域(Region),有計劃地避免在整個Java堆中進行全區域的垃圾收集,它在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的Region。主要特點也是“併發”與“低停頓”,另外與CMS 收集器相比,還不會產生內存碎片和每次停頓時間更加短。是一款面向服務端應用的收集器。從整體來看是基於“標記-整理”算法,從局部(兩個Region之間)來看是基於“複製”算法。

JavaGuide:JVM垃圾回收
《深入理解java虛擬機》

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