面試系列二:精選大資料面試真題JVM專項-附答案詳細解析

五分鐘學大資料發表於2021-03-01

公眾號(五分鐘學大資料)已推出大資料面試系列文章—五分鐘小面試,此係列文章將會深入研究各大廠筆面試真題,並根據筆面試題擴充套件相關的知識點,助力大家都能夠成功入職大廠!

大資料筆面試系列文章分為兩種型別:混合型(即一篇文章中會有多個框架的知識點—融會貫通);專項型(一篇文章針對某個框架進行深入解析—專項演練)。

此篇文章為系列文章的第二篇(JVM專項)

第一題:JVM記憶體相關(百度)

問:JVM記憶體模型瞭解嗎,簡單說下

答:

因為這塊內容太多了,許多小夥伴可能記不住這麼多,所以下面的答案分為簡答和精答

JVM 執行時記憶體共分為程式計數器,Java虛擬機器棧,本地方法棧,堆,方法區五個部分:

注:JVM調優主要就是優化 Heap 堆 和 Method Area 方法區

  1. 程式計數器(執行緒私有):

簡答: 每個執行緒都有一個程式計算器,就是一個指標,指向方法區中的方法位元組碼(下一個將要執行的指令程式碼),由執行引擎讀取下一條指令,是一個非常小的記憶體空間,幾乎可以忽略不記。

精答:佔據一塊較小的記憶體空間,可以看做當前執行緒所執行的位元組碼的行號指示器。在虛擬機器概念模型裡,位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支,迴圈,跳轉,異常處理,執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於jvm的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器都只會執行一條執行緒中的指令。因此未來執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

如果執行緒正在執行的是一個Java方法,這個計數器記錄的則是正在執行的虛擬機器位元組碼指令的地址;

如果正在執行的是Native方法,這個計數器則為空(undefined)。

此記憶體區域是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域

  1. Java虛擬機器棧(執行緒私有):

簡答:主管Java程式的執行,線上程建立時建立,它的生命期是跟隨執行緒的生命期,執行緒結束棧記憶體也就釋放,對於棧來說不存在垃圾回收問題,只要執行緒一結束該棧就Over,生命週期和執行緒一致,是執行緒私有的。基本型別的變數和物件的引用變數都是在函式的棧記憶體中分配。

精答:執行緒私有,生命週期和執行緒相同,虛擬機器棧描述的是Java方法執行的記憶體模型,每個方法在執行的同時都會建立一個棧幀用於儲存區域性變數表,運算元棧,動態連結,方法出口等資訊。每一個方法從呼叫直至完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

區域性變數表存放了編譯期可知的各種基本型別資料(boolean、byte、char、short、int、float、long、double)、物件引用、returnAddress型別(指向了一條位元組碼指令的地址)。

其中64位長度的long和double型別的資料會佔用2個區域性變數表空間(slot),其餘的資料型別只佔用1個。區域性變數表所需的記憶體空間在編譯期完成分配,當進入一個方法時,這個方法所需要在棧幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表的大小。

在Java虛擬機器規範中,對此區域規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將會丟擲Stack OverflowError異常;如果虛擬機器棧可以動態擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。

  1. 本地方法棧(執行緒私有):

簡答:本地方法棧為虛擬機器中使用到的native方法服務,native方法作用是融合不同的程式語言為Java所用,它的初衷是融合C/C++程式,Java誕生的時候C/C++橫行的時候,要想立足,必須有呼叫C/C++程式,於是就在記憶體中專門開闢了一塊區域處理標記為native的程式碼。

精答:本地方法棧與虛擬機器棧所發揮的作用非常相似,他們之間的區別不過是虛擬機器棧為虛擬機器執行Java方法(位元組碼)服務,而本地方法棧則為虛擬機器中使用到的native方法服務。在虛擬機器規範中對本地方法棧中方法使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。甚至有的虛擬機器直接把本地方法棧和虛擬機器棧合二為一,與虛擬機器棧一樣也會丟擲Stack OverflowError異常和OutOfMemoryError異常。

  1. Java堆(執行緒共享):

簡答:堆這塊區域是JVM中最大的,應用的物件和資料都是存在這個區域,這塊區域也是執行緒共享的,也是 gc 主要的回收區,一個 JVM 例項只存在一個堆類存。堆記憶體的大小是可以調節的。

精答:對於大多數應用來說,堆空間是jvm記憶體中最大的一塊。Java堆是被所有執行緒共享,虛擬機器啟動時建立,此記憶體區域唯一的目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。這一點在Java虛擬機器規範中的描述是:所有的物件例項以及陣列都要在堆上分配,但是隨著JIT編譯器的發展和逃逸分析技術逐漸成熟,棧上分配,標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也就變得不那麼絕對了。

Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為“GC堆”。從記憶體回收角度看,由於現在收集器基本都採用分代收集演算法,所以Java堆還可以細分為:新生代和老年代;再細緻一點的有Eden空間,From Survivor空間,To Survivor空間等。從記憶體分配的角度來看,執行緒共享的Java堆中可能劃分出多個執行緒私有的分配緩衝區。不過無論如何劃分,都與存放內容無關,無論哪個區域,儲存的都仍然是物件例項,進一步劃分的目的是為了更好的回收記憶體,或者更快的分配記憶體。(如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError異常。)

  1. 方法區(執行緒共享)

簡答:和堆一樣所有執行緒共享,主要用於儲存已被jvm載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

精答:方法區是被所有執行緒共享,所有欄位和方法位元組碼,以及一些特殊方法如建構函式,介面程式碼也在此定義。簡單說,所有定義的方法的資訊都儲存在該區域,此區域屬於共享區間。

靜態變數,常量,類資訊(構造方法/介面定義),執行時常量池存在方法區中;但是例項變數存在堆記憶體中,和方法區無關。

在JDK1.7釋出的HotSpot中,已經把字串常量池移除方法區了

  1. 常量池(執行緒共享):

簡答:執行時常量池是方法區的一部分。用於存放編譯期生成的各種字面量和符號引用,它的重要特性是動態性,即Java語言並不要求常量一定只能在編譯期產生,執行期間也可能產生新的常量,這些常量被放在執行時常量池中。

精答:執行時常量池是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。

Java虛擬機器對class檔案每一部分的格式都有嚴格規定,每一個位元組用於儲存哪種資料都必須符合規範才會被jvm認可。但對於執行時常量池,Java虛擬機器規範沒做任何細節要求。

執行時常量池有個重要特性是動態性,Java語言不要求常量一定只在編譯期才能產生,也就是並非預置入class檔案中常量池的內容才能進入方法區的執行時常量池,執行期間也有可能將新的常量放入池中,這種特性使用最多的是String類的intern()方法。

既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制。當常量池無法再申請到記憶體時會丟擲outOfMemeryError異常

jdk 1.8 同 jdk 1.7 比,最大的差別就是:後設資料區取代了永久代。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:後設資料空間並不在虛擬機器中,而是使用本地記憶體

第二題:類載入相關(新浪微博)

問:jvm載入類的過程主要有哪些,具體怎麼載入?

答:

簡答:類載入過程即是指JVM虛擬機器把.class檔案中類資訊載入進記憶體,並進行解析生成對應的class物件的過程。分為五個步驟:載入 -> 驗證 -> 準備 -> 解析 -> 初始化。載入:將外部的 .class 檔案載入到Java虛擬機器中;驗證:確保載入進來的 calss 檔案包含的額資訊符合 Java 虛擬機器的要求;準備:為類變數分配記憶體,設定類變數的初始值;解析:將常量池內的符號引用 轉為 直接引用;初始化:初始化類變數和靜態程式碼塊。

精答前方預警,內容較長,做好準備

一個Java檔案從編碼完成到最終執行,一般主要包括兩個過程:編譯、執行

  • 編譯:即把我們寫好的java檔案,通過javac命令編譯成位元組碼,也就是我們常說的.class檔案。

  • 執行:則是把編譯生成的.class檔案交給Java虛擬機器(JVM)執行。

而我們所說的類載入過程即是指JVM虛擬機器把.class檔案中類資訊載入進記憶體,並進行解析生成對應的class物件的過程。

  • 類載入過程

舉個簡單的例子來說,JVM在執行某段程式碼時,遇到了class A, 然而此時記憶體中並沒有class A的相關資訊,於是JVM就會到相應的class檔案中去尋找class A的類資訊,並載入進記憶體中,這就是我們所說的類載入過程。
由此可見,JVM不是一開始就把所有的類都載入進記憶體中,而是隻有第一次遇到某個需要執行的類時才會載入,且只載入一次。

  • 類載入

類載入的過程主要分為三個部分:載入、連結、初始化

而連結又可以細分為三個小部分:驗證、準備、解析

  • 載入

簡單來說,載入指的是把class位元組碼檔案從各個來源通過類載入器裝載入記憶體中。

這裡有兩個重點:

位元組碼來源:一般的載入來源包括從本地路徑下編譯生成的.class檔案,從jar包中的.class檔案,從遠端網路,以及動態代理實時編譯

類載入器:一般包括啟動類載入器,擴充套件類載入器,應用類載入器,以及使用者的自定義類載入器。

注:為什麼會有自定義類載入器?
一方面是由於java程式碼很容易被反編譯,如果需要對自己的程式碼加密的話,可以對編譯後的程式碼進行加密,然後再通過實現自己的自定義類載入器進行解密,最後再載入。
另一方面也有可能從非標準的來源載入程式碼,比如從網路來源,那就需要自己實現一個類載入器,從指定源進行載入。

  • 驗證

主要是為了保證載入進來的位元組流符合虛擬機器規範,不會造成安全錯誤。

包括對於檔案格式的驗證,比如常量中是否有不被支援的常量?檔案中是否有不規範的或者附加的其他資訊?

對於後設資料的驗證,比如該類是否繼承了被final修飾的類?類中的欄位,方法是否與父類衝突?是否出現了不合理的過載?

對於位元組碼的驗證,保證程式語義的合理性,比如要保證型別轉換的合理性。

對於符號引用的驗證,比如校驗符號引用中通過全限定名是否能夠找到對應的類?校驗符號引用中的訪問性(private,public等)是否可被當前類訪問?

  • 準備

主要是為類變數(注意,不是例項變數)分配記憶體,並且賦予初值。

特別需要注意,初值,不是程式碼中具體寫的初始化的值,而是Java虛擬機器根據不同變數型別的預設初始值。

比如8種基本型別的初值,預設為0;引用型別的初值則為null;常量的初值即為程式碼中設定的值,final
static tmp = 456, 那麼該階段tmp的初值就是456。

  • 解析

將常量池內的符號引用替換為直接引用的過程。

兩個重點:

符號引用:即一個字串,但是這個字串給出了一些能夠唯一性識別一個方法,一個變數,一個類的相關資訊。

直接引用:可以理解為一個記憶體地址,或者一個偏移量。比如類方法,類變數的直接引用是指向方法區的指標;而例項方法,例項變數的直接引用則是從例項的頭指標開始算起到這個例項變數位置的偏移量。

舉個例子來說,現在呼叫方法hello(),這個方法的地址是1234567,那麼hello就是符號引用,1234567就是直接引用。

在解析階段,虛擬機器會把所有的類名,方法名,欄位名這些符號引用替換為具體的記憶體地址或偏移量,也就是直接引用。

  • 初始化

這個階段主要是對類變數初始化,是執行類構造器的過程。
換句話說,只對static修飾的變數或語句進行初始化。
如果初始化一個類的時候,其父類尚未初始化,則優先初始化其父類。
如果同時包含多個靜態變數和靜態程式碼塊,則按照自上而下的順序依次執行。

  • 總結

類載入過程只是一個類生命週期的一部分,在其前,有編譯的過程,只有對原始碼編譯之後,才能獲得能夠被虛擬機器載入的位元組碼檔案;在其後還有具體的類使用過程,當使用完成之後,還會在方法區垃圾回收的過程中進行解除安裝。如果想要了解Java類整個生命週期的話,可以自行上網查閱相關資料,這裡不再多做贅述。

第三題:JVM記憶體相關(雲從科技)

問:Java 中會存在記憶體洩漏嗎,請簡單描述

答:

理論上Java因為有垃圾回收機制(GC)不會存在記憶體洩露問題(這也是Java被廣泛使用於伺服器端程式設計的一個重要原因);然而在實際開發中,可能會存在無用但可達的物件,這些物件不能被GC回收也會發生記憶體洩露

一個例子就是Hibernate的Session(一級快取)中的物件屬於持久態,垃圾回收器是不會回收這些物件的,然而這些物件中可能存在無用的垃圾物件。

下面的例子也展示了Java中發生記憶體洩露的情況:

package com.yuan_more;

import java.util.Arrays;
import java.util.EmptyStackException;

public class MyStack<T> {
    private  T[] elements;
    private int size = 0;

    private static final int INIT_CAPACITY = 16;

    public MyStack(){
        elements = (T[]) new Object[INIT_CAPACITY];
    }

    public void push(T elem){
        ensureCapacity();
    }

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

    private void ensureCapacity() {
        if(elements.length == size){
            elements = Arrays.copyOf(elements,2 * size +1);
        }
    }
}

上面的程式碼實現了一個棧(先進後出(FILO))結構,乍看之下似乎沒有什麼明顯的問題,它甚至可以通過你編寫的各種單元測試。

然而其中的pop方法卻存在記憶體洩露的問題,當我們用pop方法彈出棧中的物件時,該物件不會被當作垃圾回收,即使使用棧的程式不再引用這些物件,因為棧內部維護著對這些物件的過期引用(obsolete reference)。

在支援垃圾回收的語言中,記憶體洩露是很隱蔽的,這種記憶體洩露其實就是無意識的物件保持。

如果一個物件引用被無意識的保留起來了,那麼垃圾回收器不會處理這個物件,也不會處理該物件引用的其他物件,即使這樣的物件只有少數幾個,也可能會導致很多的物件被排除在垃圾回收之外,從而對效能造成重大影響,極端情況下會引發Disk Paging(實體記憶體與硬碟的虛擬記憶體交換資料),甚至造成OutOfMemoryError。

第四題:垃圾回收相關(滴滴出行)

問:知道 GC 嗎?為什麼要有 GC?

答:

GC是垃圾收集的意思,記憶體處理是程式設計人員容易出現問題的地方,忘記或者錯誤的記憶體回收會導致程式或系統的不穩定甚至崩潰。

Java提供的 GC 功能可以自動監測物件是否超過作用域從而達到自動回收記憶體的目的,Java語言沒有提供釋放已分配記憶體的顯示操作方法。Java程式設計師不用擔心記憶體管理,因為垃圾收集器會自動進行管理

要請求垃圾收集,可以呼叫下面的方法之一:System.gc() 或Runtime.getRuntime().gc() ,注意,只是請求,JVM何時進行垃圾回收具有不可預知性

垃圾回收可以有效的防止記憶體洩露,有效的使用可以使用的記憶體。垃圾回收器通常是作為一個單獨的低優先順序的執行緒執行,不可預知的情況下對記憶體堆中已經死亡的或者長時間沒有使用的物件進行清除和回收,程式設計師不能實時的呼叫垃圾回收器對某個物件或所有物件進行垃圾回收。

在Java誕生初期,垃圾回收是Java最大的亮點之一,因為伺服器端的程式設計需要有效的防止記憶體洩露問題,然而時過境遷,如今Java的垃圾回收機制已經成為被詬病的東西。移動智慧終端使用者通常覺得iOS的系統比Android系統有更好的使用者體驗,其中一個深層次的原因就在於Android系統中垃圾回收的不可預知性。

第五題:JVM記憶體相關(阿里)

問:Hotspot虛擬機器中的堆為什麼要有新生代和老年代?

答:

因為有的物件壽命長,有的物件壽命短。應該將壽命長的物件放在一個區,壽命短的物件放在一個區。不同的區採用不同的垃圾收集演算法。壽命短的區清理頻次高一點,壽命長的區清理頻次低一點,提高效率。

所謂的新生代和老年代是針對於分代收集演算法來定義的,新生代又分為Eden和Survivor兩個區。加上老年代就這三個區

資料會首先分配到Eden區當中,當然也有特殊情況,如果是大物件那麼會直接放入到老年代(大物件是指需要大量連續記憶體空間的java物件)。當Eden沒有足夠空間的時候就會觸發jvm發起一次Minor GC。新生代垃圾回收採用的是複製演算法

如果物件經過一次Minor GC還存活,並且又能被Survivor空間接受,那麼將被移動到Survivor空間當中。並將其年齡設為1,物件在Survivor每熬過一次Minor GC,年齡就加1,當年齡達到一定的程度(預設為15)時,就會被晉升到老年代中了,當然晉升老年代的年齡是可以設定的。如果老年代滿了就執行:Full GC, 因為不經常執行,因此老年代垃圾回收採用了標記-整理(Mark-Compact)演算法

相關文章