面試題:類加載機制的原理
面試官考察點
考察目標: 了解面試者對JVM的理解,屬于面試八股文系列,
考察范圍: 作業3年以上,
技術背景知識
在回答這個問題之前,我們需要先了解一下什么是類加載機制?
類加載機制簡述
什么是類加載機制?
簡單來說:類的加載指的是將類的.class檔案中的二進制資料讀入到記憶體中,將其放在運行時資料區的方法區內,然后在堆區創建一個java.lang.Class物件,用來封裝類在方法區內的資料結構,
經過類加載這個程序后,我們才能在程式中構建這個類的實體物件,并完成物件的方法呼叫和操作,
基本的作業原理下圖所示,

我們撰寫的.java后綴的原始代碼,通過JVM編譯之后得到.class檔案,
類加載機制,就是把.class檔案加載到JVM中,我們知道JVM的運行時資料區又分為堆記憶體、虛擬機堆疊、元空間、本地方法堆疊、程式計數器等空間,當類被加載后,會根據JVM記憶體規則,把資料保存到對應區域內,
了解類加載器
大家想想,在實際開發中,運行一個程式,有哪些地方的類需要被加載?
-
從本地系統直接加載,如JRE、CLASSPATH,
-
通過網路下載.class檔案
-
從zip,jar等歸檔檔案中加載.class檔案
-
從專有
資料庫中提取.class檔案 -
將Java源檔案動態編譯為.class檔案(服務器)
由于類加載器是負責這些和系統運行有關的所有類的加載行為,而針對不同位置的類,JVM提供了三種類加載器:
- 啟動類加載器,BootStrapClassLoader,最頂層的加載類,主要加載核心類別庫,也就是我們環境變數下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等,還可以通過啟動jvm時指定-Xbootclasspath和路徑來改變Bootstrap ClassLoader的加載目錄,
- 擴展類加載器,ExtClassLoader,加載目錄%JRE_HOME%\lib\ext目錄下的jar包和class檔案,還可以加載-D java.ext.dirs選項指定的目錄
- 應用類加載器,AppClassLoader,也稱為SystemAppClass, 加載當前應用的classpath的所有類和jar包
從上述三個類加載器的描述來看,不同的加載器代表了不同的加載職能,當我們自己定義的一個類,要被加載到記憶體中時,類加載器的作業原理如下圖所示,

從Java2開始,類加載程序采取了雙親委派模型(Parents Delegation Model【PDM】),PDM 更好的保證了 Java 平臺的安全性,在該機制中,JVM 自帶的 BootStrapClassLoader 是根加載器,其他的加載器都有且僅有一個父類加載器,類的加載首先請求父類加載器加載,父類加載器無能為力時才由其子類加載器自行加載,
PDM 只是 Java 推薦的機制,并不是強制的,可以繼承java.lang.ClassLoader類,實作自己的類加載器,如果想保持 PDM,就重寫 findClass(name);如果想破壞 PDM,就重寫 loadClass(name),JDBC使用執行緒背景關系加載器打破了 PDM,原因是 JDBC 只提供了介面,并沒有提供實作,
類加載器的演示
通過下面這段代碼演示一下類所使用的加載器,
public class ClassLoaderExample {
public static void main(String[] args) {
ClassLoader loader=ClassLoaderExample.class.getClassLoader();
System.out.println(loader); //case1
System.out.println(loader.getParent()); //case2
System.out.println(loader.getParent().getParent()); //case3
}
}
- Case1 所示的代碼,表示
ClassLoaderExample這個類是被那個類加載器加載的, - Case2 所示的代碼,表示
ClassLoaderExample的父加載器 - Case2 所示的代碼,表示
ClassLoaderExample的祖父加載器
運行結果如下:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@29453f44
null
證明了,ClassLoaderExample是被AppClassLoader加載,
最后一個應該是Bootstrap類加載器,但是這里輸出為null,原因是BootStrapClassLoader是一個使用 C/C++ 撰寫的類加載器,它已經嵌入到了 JVM 的內核之中,當 JVM 啟動時,BootStrapClassLoader 也會隨之啟動并加載核心類別庫,當核心類別庫加載完成后,BootStrapClassLoader 會創建 ExtClassLoader 和 AppClassLoader 的實體,兩個 Java 實作的類加載器將會加載自己負責路徑下的類別庫,這個程序可以在sun.misc.Launcher中看到,
為什么要設計PDM
Java中為什么要采用PDM方式來實作類加載呢?有幾個目的
- 防止記憶體中出現多份同樣的位元組碼,如果沒有 PDM 而是由各個類加載器自行加載的話,用戶撰寫了一個
java.lang.Object的同名類并放在ClassPath中,多個類加載器都能加載這個類到記憶體中,系統中將會出現多個不同的Object類,那么類之間的比較結果及類的唯一性將無法保證,同時,也會給虛擬機的安全帶來隱患, - 雙親委派機制能夠保證多加載器加載某個類時,最終都是由一個加載器加載,確保最終加載結果相同,
- 這樣可以保證系統庫優先加載,即便是自己重寫,也總是使用Java系統提供的System,自己寫的System類根本沒有機會得到加載,從而保證安全性,
類的加載原理
一個類在加載程序中,到底做了什么?它的實作原理是什么呢?
類從被加載到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:加載、驗證、準備、決議、初始化、使用和卸載七個階段,它們的順序如下圖所示:

其中類加載的程序包括了加載、驗證、準備、決議、初始化五個階段,在這五個階段中,加載、驗證、準備和初始化這四個階段發生的順序是確定的,而決議階段則不一定,它在某些情況下可以在初始化階段之后開始,另外注意這里的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的程序中呼叫或激活另一個階段,
每個階段的所執行的作業,如下圖所示,

下面詳細分析一下類加載器在每個階段的詳細作業流程,
加載
”加載“是”類加機制”的第一個程序,在加載階段,虛擬機主要完成三件事:
(1)通過一個類的全限定名來獲取其定義的二進制位元組流
(2)將這個位元組流所代表的的靜態存盤結構轉化為方法區的運行時資料結構
(3)在堆中生成一個代表這個類的Class物件,作為方法區中這些資料的訪問入口,
驗證
驗證的主要作用就是確保被加載的類的正確性,也是連接階段的第一步,說白了也就是我們加載好的.class檔案不能對我們的虛擬機有危害,所以先檢測驗證一下,他主要是完成四個階段的驗證:
(1)檔案格式的驗證:驗證.class檔案位元組流是否符合class檔案的格式的規范,并且能夠被當前版本的虛擬機處理,這里面主要對魔數、主版本號、常量池等等的校驗(魔數、主版本號都是.class檔案里面包含的資料資訊、在這里可以不用理解),
(2)元資料驗證:主要是對位元組碼描述的資訊進行語意分析,以保證其描述的資訊符合java語言規范的要求,比如說驗證這個類是不是有父類,類中的欄位方法是不是和父類沖突等等,
(3)位元組碼驗證:這是整個驗證程序最復雜的階段,主要是通過資料流和控制流分析,確定程式語意是合法的、符合邏輯的,在元資料驗證階段對資料型別做出驗證后,這個階段主要對類的方法做出分析,保證類的方法在運行時不會做出危害虛擬機安全的事,
(4)符號參考驗證:它是驗證的最后一個階段,發生在虛擬機將符號參考轉化為直接參考的時候,主要是對類自身以外的資訊進行校驗,目的是確保決議動作能夠完成,
對整個類加載機制而言,驗證階段是一個很重要但是非必需的階段,如果我們的代碼能夠確保沒有問題,那么我們就沒有必要去驗證,畢竟驗證需要花費一定的的時間,當然我們可以使用-Xverfity:none來關閉大部分的驗證,
準備
準備階段主要為類變數分配記憶體并設定初始值,這些記憶體都在方法區分配,在這個階段我們只需要注意兩點就好了,也就是類變數和初始值兩個關鍵詞:
(1)類變數(static)會分配記憶體,但是實體變數不會,實體變數主要隨著物件的實體化一塊分配到java堆中,
(2)這里的初始值指的是資料型別默認值,而不是代碼中被顯示賦予的值,比如public static int value = https://www.cnblogs.com/mic112/p/1;,在這里準備階段過后的value值為0,而不是1,賦值為1的動作在初始化階段,
在上面value是被static所修飾的準備階段之后是0,但是如果同時被final和static修飾準備階段之后就是1了,我們可以理解為static final在編譯器就將結果放入呼叫它的類的常量池中了,
決議
決議階段主要是虛擬機將常量池中的符號參考轉化為直接參考的程序,什么是符號應用和直接參考呢?
符號參考:以一組符號來描述所參考的目標,可以是任何形式的字面量,只要是能無歧義的定位到目標就好,就好比在班級中,老師可以用張三來代表你,也可以用你的學號來代表你,但無論任何方式這些都只是一個代號(符號),這個代號指向你(符號參考)直接參考:直接參考是可以指向目標的指標、相對偏移量或者是一個能直接或間接定位到目標的句柄,和虛擬機實作的記憶體有關,不同的虛擬機直接參考一般不同,決議動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法句柄和呼叫點限定符7類符號參考進行,
初始化
一個類在以下情況下,會被初始化,
-
創建類的實體,也就是new一個物件
-
訪問某個類或介面的靜態變數,或者對該靜態變數賦值
-
呼叫類的靜態方法
-
反射(Class.forName("com.gupao.Example"))
-
初始化一個類的子類(會首先初始化子類的父類)
-
JVM啟動時標明的啟動類,即檔案名和類名相同的那個類
類的初始化步驟:
-
如果這個類還沒有被加載和鏈接,那先進行加載和鏈接
-
假如這個類存在直接父類,并且這個類還沒有被初始化(注意:在一個類加載器中,類只能初始化一次),那就初始化直接的父類(不適用于介面)
-
加入類中存在初始化陳述句(如static變數和static塊),那就依次執行這些初始化陳述句,
類加載的擴展知識點
在類加載機制中,還有很多可以擴展的知識,我們通過三個擴展變體來進行鞏固分析
- 為什么靜態方法不能呼叫非靜態方法和變數
- 靜態類和非靜態類程式的初始化順序
為什么靜態方法不能呼叫非靜態方法和變數
我想大家應該都知道,在靜態方法中時無法直接呼叫非靜態方法和變數的,為什么呢?
理解了類類的加載原理之后,不難發現,靜態方法的記憶體分配時間與實體方法不同,
- 靜態方法屬于類,在類加載的時候就會分配記憶體,有了入口地址,可以通過“類名.方法名”直接呼叫,
- 非靜態成員(變數和方法)屬于類的物件,所以只有該物件初始化之后才會分配記憶體,然后通過類的物件去訪問,
意味著,也就是說在靜態方法中呼叫非靜態成員變數,該變數可能還未初始化,因此編譯器會報錯,
另外,除此之外,還有其他的變體,比如靜態塊.
public class ClassLoaderExample {
static {
//dosomething()
}
}
靜態塊是在什么時候執行呢?
類中的靜態塊會在整個類加載程序中的初始化階段執行,而不是在類加載程序中的加載階段執行,
初始化階段是類加載程序中的最后一個階段,該階段就是執行類構造器
clinit是類構造器方法,也就是在jvm進行類加載—–驗證—-決議—–初始化,中的初始化階段jvm會呼叫clinit方法,
clinit是class類構造器對靜態變數,靜態代碼塊進行初始化
class Example {
static Log log = LogFactory.getLog(); // <clinit>
private int x = 1; // <init>
Example(){
// <init>
}
static {
// <clinit>
}
}
Java程式的初始化順序
有以下代碼,請說出它們的加載順序.
class Base {
public Base() {
System.out.println("父類構造方法");
}
String b = "父類非靜態變數";
{
System.out.println(b);
System.out.println("父類非靜態代碼塊");
}
static String a = "父類靜態變數";
static {
System.out.println(a);
System.out.println("父類靜態代碼塊");
}
public static void A() {
System.out.println("父類普通靜態方法");
}
}
class Derived extends Base {
public Derived() {
System.out.println("子類構造器");
}
String b = "子類非靜態變數";
{
System.out.println(b);
System.out.println("子類非靜態代碼塊");
}
static String a = "子類靜態變數";
static {
System.out.println(a);
System.out.println("子類靜態塊");
}
public static void A() {
System.out.println("子類普通靜態方法");
}
public static void main(String[] args) {
Base.A();
Derived.A();
new Derived();
}
}
這個問題,需要理解類的加載順序,初始化規則如下,
-
父類靜態變數
-
父類靜態代碼塊
-
子類靜態變數
-
子類靜態代碼塊
-
父類非靜態變數
-
父類非靜態代碼塊
-
父類建構式
-
子類非靜態變數
-
子類非靜態代碼塊
-
子類建構式
總的來說,父類需要優先加載,然后在是子類,接著是父類的靜態方法加載優先,其次是子類,
自定義類加載器
除了系統自帶的三種類加載器以外,我們還可以定義自己的類加載器,
需要繼承java.lang.ClassLoader這個類來實作自定義類加載器,并且重寫findClass方法或者loadClass方法,
1、如果不想打破雙親委派模型,那么只需要重寫findClass方法,
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
這個方法并沒有實作,它直接回傳ClassNotFoundException,因此,自定義類加載器必須重寫findClass方法,
2、如果想打破雙親委派模型,那么就重寫loadClass方法,
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
ClassLoader中的loadClass方法,大致流程如下:
- 檢查類是否已加載,如果是則不用再重新加載了;
- 如果未加載,則通過父類加載(依次遞回)或者啟動類加載器(bootstrap)加載;
- 如果還未找到,則呼叫本加載器的findClass方法;
不破壞雙親委派自定義類加載器實戰
實作自定義類加載器的實作,主要分三個步驟
-
創建一個類繼承ClassLoader抽象類
-
重寫findClass()方法
-
在findClass()方法中呼叫defineClass()
在/tmp目錄下創建一個PrintClass.java類,代碼如下,
public class PrintClass {
public PrintClass(){
System.out.println("PrintClass:"+getClass().getClassLoader());
System.out.println("PrintClass Parent:"+getClass().getClassLoader().getParent());
}
public String print(){
System.out.println("PrintClass method for print");
return "PrintClass.print()";
}
}
使用javac PrintClass對源檔案進行編譯,得到PrintClass.class檔案
接在,下Java專案中創建一個自定義類加載器,代碼如下,
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = getClassBytes(name);
Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
return c;
} catch (Exception e) {
e.printStackTrace();
}
return super.findClass(name);
}
private byte[] getClassBytes(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
int len = fis.available();
byte[] data = https://www.cnblogs.com/mic112/p/new byte[len];
fis.read(data);
fis.close();
return data;
}
}
MyClassLoader繼承了ClassLoader并且重寫了findClass方法,該方法中是從指定路徑下加載.class檔案,
撰寫測驗代碼.
public class ClassLoaderMain {
public static void main(String[] args) throws Exception {
MyClassLoader mc=new MyClassLoader("/tmp");
Class clazz=mc.loadClass("PrintClass");
Object o=clazz.newInstance();
Method print=clazz.getDeclaredMethod("print",null);
print.invoke(o,null);
}
}
運行結果如下:
PrintClass:org.example.cl.MyClassLoader@5cad8086
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print
可以看到,PrintClass.class這個類,它的類加載器是MyClassLoader,
破壞雙親委派自定義類加載器實戰
原本ClassLoader類中的loadClass方法,是基于雙親委派機制來實作,破壞雙親委派,只需要重寫loadClass方法即可,
在MyClassLoader類中,重寫loadClass方法,代碼如下,
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//非自定義的類還是走雙親委派加載
if (!name.equals("PrintClass")) {
c = this.getParent().loadClass(name);
} else { //自己寫的類,走自己的類加載器,
c = findClass(name);
}
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
把PrintClass.java復制到/tmp/cl目錄下,并修改print方法,
public class PrintClass {
public PrintClass(){
System.out.println("PrintClass:"+getClass().getClassLoader());
System.out.println("PrintClass Parent:"+getClass().getClassLoader().getParent());
}
public String print(){
System.out.println("PrintClass method for print NEW"); //修改了列印陳述句,用來區分被加載的類
return "PrintClass.print()";
}
}
撰寫測驗代碼
public class ClassLoaderMain {
public static void main(String[] args) throws Exception {
MyClassLoader mc=new MyClassLoader("/tmp");
Class clazz=mc.loadClass("PrintClass");
System.out.println(clazz.getClassLoader());
System.out.println();
//在另外一個目錄下創建相同的PrintClass.class檔案
MyClassLoader mc1=new MyClassLoader("/tmp/cl");
Class clazz1=mc1.loadClass("PrintClass");
System.out.println(clazz1.getClassLoader());
System.out.println();
}
}
上述代碼中,分別加載tmp和tmp/cl目錄下的PrintClass.class檔案,列印結果如下,
PrintClass:org.example.cl.MyClassLoader@5cad8086
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print
PrintClass:org.example.cl.MyClassLoader@610455d6
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print NEW
結論:通過重寫loadClass方法,使得自己創建的類,讓第一個加載器直接加載,不委托父加載器尋找,從而實作雙親委派的破壞
Tomcat是如何實作應用jar包的隔離的?
相信不少小伙伴在面試的時候遇到過這個問題,
在思考這個問題之前,我們先來想想Tomcat作為一個JSP/Servlet容器,它應該要解決什么問題?
- 一個web容器可能需要部署兩個應用程式,不同的應用程式可能會依賴同一個第三方類別庫的不同版本,不能要求同一個類別庫在同一個服務器只有一份,因此要保證每個應用程式的類別庫都是獨立的,保證相互隔離,
- 部署在同一個web容器中相同的類別庫相同的版本可以共享,否則,如果服務器有10個應用程式,那么要有10份相同的類別庫加載進虛擬機,必然會帶來記憶體消耗過高的問題,
- web容器也有自己依賴的類別庫,不能與應用程式的類別庫混淆,基于安全考慮,應該讓容器的類別庫和程式的類別庫隔離開來,
為了達到這些目的,Tomcat一定不能使用默認的類加載機制,
原因:如果使用默認的類加載器機制,那么是無法加載兩個相同類別庫的不同版本的,默認的類加載器是不管你是什么版本的,只在乎你的全限定類名,并且只有一份
所以Tomcat實作了自己的類加載器,同樣也打破了雙親委派這一機制,下圖表示Tomcat的類加載機制,

我們看到,前面3個類加載和默認的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是Tomcat自己定義的類加載器,它們分別加載${TOMCAT_HOME}/lib和/WebApp/WEB-INF/*中的Java類別庫,
其中WebApp類加載器和Jsp類加載器通常會存在多個實體,每一個Web應用程式對應一個WebApp類加載器,每一個JSP檔案對應一個Jsp類加載器,
- commonLoader:Tomcat最基本的類加載器,加載路徑中的class可以被Tomcat容器本身以及各個Webapp(web應用)訪問;
- catalinaLoader:Tomcat容器私有的類加載器,加載路徑中的class對于Webapp不可見;
- sharedLoader:各個Webapp共享的類加載器,加載路徑中的class對于所有Webapp可見,但是對于Tomcat容器不可見;
- WebappClassLoader:各個Webapp私有的類加載器,加載路徑中的class只對當前Webapp可見;
從圖中的委派關系中可以看出:
CommonClassLoader能加載的類都可以被Catalina ClassLoader和SharedClassLoader使用,從而實作了公有類別庫的共用,而CatalinaClassLoader和Shared ClassLoader自己能加載的類則與對方相互隔離,
WebAppClassLoader可以使用SharedClassLoader加載到的類,但各個WebAppClassLoader實體之間相互隔離,
而JasperLoader的加載范圍僅僅是這個JSP檔案所編譯出來的那一個.Class檔案,它出現的目的就是為了實作JSP的HotSwap功能,
很顯然,Tomcat為了實作隔離性,打破了雙親委派,每個webappClassLoader加載自己的目錄下的class檔案,
問題解答
面試題:類加載機制的原理
回答: 類的加載指的是將類的.class檔案中的二進制資料讀入到記憶體中,將其放在運行時資料區的方法區內,然后在堆區創建一個java.lang.Class物件,用來封裝類在方法區內的資料結構,
類的加載機制包括加載、驗證、準備、決議、初始化這5個程序,其中
- 加載:將.class檔案加載到記憶體中
- 驗證:確保加載的類符合JVM規范
- 準備:正式為類變數分配記憶體并設定初始值
- 決議:JVM常量池的符號參考轉換為直接參考
- 初始化:執行類的構造方法,
問題總結
一個小小的面試題,涉及到背后的技術知識非常龐大,
在面試的時候,遇到這類問題,如果自己不具備體系化的知識,那么回答時很容易找不到切入點,特別是這種比較泛的問題,切入點太多時,回答起來會比較混亂,
關注[跟著Mic學架構]公眾號,獲取更多精品原創

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/342997.html
標籤:Java
