一、JVM內存結構
1.1 內存結構---概略圖
1.2 內存結構--詳細圖
二、類加載器子系統的作用
- 類加載器子系統負責從文件系統或網絡中加載
.Class
文件,文件需要有特定的標識(cafe babe
)。 ClassLoader
只負責.Class
文件的加載,至於它是否可以運行,由執行引擎決定。- 加載的類信息存放於一塊被稱為“方法區”的內存空間。除了類信息外,方法區還會存放運行時常量池信息,可能還包括字符串字面量(字面量指的是固定值,初始值)和數字常量(這部分常量信息是
.Class
文件中常量池部分的內存映射) .Class
文件被解析加載到 JVM,類的對象加載到堆區,類信息被加載到方法區(java8 中方法區的實現是“元空間”)。這部分工作是類加載子系統完成的
三、類加載的過程
假設定義了一個類,名為HelloWorld
,運行其 Main 方法,流程如圖:
3.1 加載(Loading)
加載(Loading)是狹義上的加載,“類加載”中的加載是廣義上的。
- 通過一個類的全限定名獲取定義此類的二進制字節流;
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據機構;
- 在內存中生成一個代表這個類的
java.lang.Class
對象,作為方法區這個類的各種數據的訪問入口。
加載.class文件的方式:
- 從本地系統中直接加載。
- 通過網絡獲取,典型場景:Web Applet。
- 從zip壓縮包中讀取,成為日后Jar、war格式的基礎。
- 運行時計算生成,使用最多的是動態代理技術。
- 有其他文件生成,典型場景是JSP應用。
- 從專有數據庫中提取.class文件,比較少見。
- 從加密文件中獲取,典型的防Class文件被反編譯的保護措施。
3.2 鏈接(Linking)
鏈接可細分為三步:驗證(verify)准備(prepare)、解析(resolve)
3.2.1 驗證
- 目的在於確保class文件的字節流中包含信息符合當前虛擬機要求,保證被加載類的正確性,不會危害虛擬機自身安全。
- 主要包括四種驗證:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。
3.2.2 准備
- 為類的變量分配內存並設置默認初始值,即零值(不同數據類型的零值不同,布爾值為false,引用類型為null)。下面這個語句在這個階段,a將被初始化為0。
static int a = 2;
- 這里不包含
final
修飾的static
,因為final
修飾的變量不再是變量而是不可更改的常量,在編譯期就已經分配內存,准備階段會將其顯示初始化。如上面的賦值語句,加上final
后,a的值就被初始化為2。
final static int a = 2;
- 這里不會為實例變量分配內存和初始化,類變量會分配在方法區中,而實例變量是會隨着對象一起分配到Java堆中。
3.2.3 解析
- 將常量池內的符號引用轉換為直接引用的過程。
- 事實上,解析動作往往伴隨着 JVM 在執行完初始化之后再執行。
3.3 初始化(Initialization)
- 初始化階段就是執行類構造方法
<clinit>()
的過程。 - 此方法不需要定義,是javac編譯器自動收集類中所有類靜態變量的賦值動作和靜態代碼塊的語句合並而來。
- 只有當有靜態變量
static int a = 1;
或者靜態代碼塊static {}
時才會創建並執行該方法。 - 靜態方法並不會使得虛擬機創建執行該方法。
- 只有當有靜態變量
<clinit>()
中指令按語句在源文件中出現的順序執行。具體表現就是一個靜態變量的最后的取值決定於最后一行它的賦值語句。
public class ClassInitTest {
private static int num = 1;
static{
num = 2;
number = 20;
System.out.println(num);
//System.out.println(number);//報錯:非法的前向引用。
}
private static int number = 10; //linking之prepare: number = 0 --> initial: 20 --> 10
public static void main(String[] args) {
System.out.println(ClassInitTest.num);//2
System.out.println(ClassInitTest.number);//10
}
}
在以上代碼中,num和number的最終值分別為2和10。
4. <clinit>()
不同於我們常說的類的構造函數。類的構造函數在虛擬機中是<init>()
,在任何時候都是會創建的,因為所有的類都至少有一個默認無參構造函數。
5. 若該類具有父類,JVM會保證子類的 <clinit>()
執行前,父類的 <clinit>()
已經執行完畢。
6. 虛擬機必須保證一個類的 <clinit>()
方法在多線程下被同步加鎖。保證同一個類只被虛擬機加載一次(只調用一次 <clinit>()
),后續其它線程再使用類,只需要在虛擬機的緩存中獲取即可。
四、類加載器的分類
類加載分為啟動類加載器、擴展類加載器、應用程序類加載器(系統類加載器)、自定義加載器。如下圖:
需要注意的是,它們四者並非子父類的繼承關系。以下展示了如何獲取類加載器:
public class ClassLoaderTest {
public static void main(String[] args) {
//獲取系統類加載器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//獲取其上層:擴展類加載器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//獲取其上層:獲取不到引導類加載器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//對於用戶自定義類來說:默認使用系統類加載器進行加載
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String類使用引導類加載器進行加載的。---> Java的核心類庫都是使用引導類加載器進行加載的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null
}
}
關於幾種類加載器具體的加載對象和雙親委派機制可以參考:玩命學JVM-類加載機制
4.1 用戶自定義類加載器
4.1.1 為什么要自定義類加載器
- 隔離加載類。
- 修改類加載的方式。除了bootstrap classloader是必須要用到的,其它的類加載器都不必須。
- 擴展加載源。
- 防止源碼泄露。java代碼很容易被反編譯和篡改。因此有對源碼進行加密的需求,在這個過程中就會用到自己定義的類加載器去完成解密和類加載。
4.1.2 如何自定義類加載器
步驟
- 繼承
java.lang.ClassLoade
r的方式,實現自己的類加載器。 - 建議不要去覆蓋
loadClass()
,而是重寫findClass()
。 - 如果沒有太復雜的需求(解密、從不同的路徑下加載),那么可直接繼承
URLClassLoade
r,這樣可以避免自己去編寫findClass()方法以及其獲取字節碼流的方式,使其自定義類加載器編寫更加簡潔。
樣例代碼
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] result = getClassFromCustomPath(name);
if(result == null){
throw new FileNotFoundException();
}else{
return defineClass(name,result,0,result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
throw new ClassNotFoundException(name);
}
private byte[] getClassFromCustomPath(String name){
//從自定義路徑中加載指定類:細節略
//如果指定路徑的字節碼文件進行了加密,則需要在此方法中進行解密操作。
return null;
}
public static void main(String[] args) {
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
Class<?> clazz = Class.forName("One",true,customClassLoader);
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}
4.2 關於ClassLoader
它是一個抽象類,其后所有的類加載器都繼承自ClassLoader
(不包括啟動類加載器)。
API
getParent()
返回該類加載器的超類加載器。loadClass(String name)
加載名稱為name的類,返回結果為java.lang.Class類的實例。findClass(String name)
查找名稱為name的類,返回結果為java.lang.Class類的實例。findLoaderClass(String name)
查找名稱為name的已經被加載過的類,返回結果為java.lang.Class類的實例。defineClass(String name, byte[] b, int off, int len)
把字節數組b中的內容轉換為一個Java類,返回結果為java.lang.Class類的實例。與 findClass(String name) 搭配使用resolveClass(Class<?>c)
連接一個指定的Java類
4.3 雙親委派機制
詳見 https://www.cnblogs.com/cleverziv/p/13759175.html
自定義的一個java.lang.String不能加載到的JVM中,原因:
使用自定義的java.lang.String時,首先是應用類加載器向上委托到擴展類加載器,然后擴展類加載器向上委托給引導類加載器,引導類加載接收到類的信息,發現該類的路徑時“java.lang.String”,這在引導類加載器的加載范圍內,因此引導類加載器開始加載“java.lang.String”,只不過此時它加載的是jdk核心類庫里的“java.lang.String”。這就是雙親委派機制中的向上委托。在完成向上委托之后,如到了引導類加載器,引導類加載器發現待加載的類不屬於自己加載的類范圍,就會再向下委托給擴展類加載器,讓下面的加載器進行類的加載。
優勢
- 避免類的重復加載。類加載器+類本身決定了 JVM 中的類加載,雙親委派機制保證了只會有一個類加載器去加載類。
- 保護程序安全,防止核心api被篡改
沙箱安全機制
上文中提到的java.lang.String就是沙箱安全機制的表現,保證了對java核心源代碼的保護。
五、幾個JVM常出現的術語解析
5.1 字面量
首先來看一下百度百科的定義:
在計算機科學中, 字面量(literal)是用於表達源代碼中一個固定值的表示法(notation)。幾乎所有計算機編程語言都具有對基本值的字面量表示, 諸如: 整數, 浮點數以及字符串; 而有很多也對布爾類型和字符類型的值也支持字面量表示; 還有一些甚至對枚舉類型的元素以及像數組, 記錄和對象等復合類型的值也支持字面量表示法.
這段話不太好理解,我們來拆解下(注意下面這段話純屬個人理解):
“字面量(literal)是用於表達源代碼中一個固定值的表示法(notation)”,這里說明了兩點:第一,字面量是體現在源碼中的;第二,字面量是對一個固定值的表示。接下來它提到了“幾乎所有計算機編程語言都具有對基本值的字面量表示”,並以一些基本數據類型、枚舉、數組等數據類型舉例。它們都有一個特點,就是它們的賦值是可以做到“代碼可視化的”。你可以在代碼中給以上提到的類型進行賦值。而我們賦值時所給出的“值”,更准確來說是一種“表示”(比如給數組賦值時,約定了需要用大括號括起來)就是字面量的含義。
舉個例子:
int i = 1;
String s = "abs";
int[] a = {1, 3, 4};
// 以上 1,“abc”,{1,3,4}均是字面量
5.2 符號引用、直接引用
同樣,先來看一下書面定義:
符號引用:符號引用以一組符號來描述所引用的目標,符號引用可以是任何形式的字面量,只要使用時能夠無歧義的定位到目標即可。比如org.simple.People類引用了org.simple.Language類,在編譯時People類並不知道Language類的實際內存地址,因此只能使用符號org.simple.Language(假設是這個,當然實際中是由類似於CONSTANT_Class_info的常量來表示的)來表示Language類的地址。各種虛擬機實現的內存布局可能有所不同,但是它們能接受的符號引用都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。
直接引用: 直接引用可以是
(1)直接指向目標的指針(比如,指向“類型”【Class對象】、類變量、類方法的直接引用可能是指向方法區的指針)
(2)相對偏移量(比如,指向實例變量、實例方法的直接引用都是偏移量)
(3)一個能間接定位到目標的句柄
直接引用是和虛擬機的布局相關的,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經被加載入內存中了。
說實話看這種書面化語言抽象晦澀,下面給出一些自己的理解吧。首先找到一個.class文件(來源:玩命學JVM(一))反編譯后的結果中常量池的部分:
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // Hello World
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // Main
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 Main.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 Hello World
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 Main
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
為什么只看常量池呢,因為在“解析”的定義中提到了:解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。我們來看看常量池中有什么:
常量池:常量池指的是字節碼文件中的Constant pool部分。它是靜態的,當編譯生成字節碼文件直接就不變了。常量池中包括各種字面量和對類型、域和方法的符號引用。幾種在常量池內存儲的數據類型包括:數量值、字符串值、類引用、字段引用、方法引用。
由此我們可以看出,上面我們給出的常量池中都屬於“符號引用”(符號引用本身就是一種字面量)或字面量。我們不禁要問了了,那直接引用在哪呢?
我找到了《深入理解Java虛擬機》中的一句話:
對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令外,虛擬機實現可以對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用,並把常量標識為已解析狀態)從而避免解析動作重復進行。
關鍵是括號中話給了啟發,說明直接引用是放在運行時常量池中的,接下來我們看看運行時常量池的一些定義或特性。
運行時常量池是方法區的一部分。常量池用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載后存放到方法區的運行時常量池中。而加載類和接口到虛擬機后,就會創建對應的運行時常量池。
運行時常量池與常量池的不同點在於:
1. 運行時常量池中包括了編譯期就已經明確的數值字面量,也包括在運行期解析后才能獲得的方法或字段引用。但,請注意,此時的方法或字段引用已經不再是常量池中的“符號引用”,而是“直接引用”。
2. 運行時常量池具備“動態性”。
至此,關於“符號引用”和“直接引用”的解釋就差不多了。最后我再多說一句,再分析的時候,我一直在疑惑“直接引用”到底是什么,我能不能像看到常量池中的內容一樣看到“直接引用”。實際上,我們並不能拿到這樣一個文件,里面整齊地寫了直接引用的具體內容,因為直接引用不是所謂的“字面量”。但我們可以回到“直接引用”的最初定義:直接引用可以是指向目標的指針、相對偏移量或是能間接定位到目標的句柄,可以想象一下在運行時,在內存中存放的直接引用大概是什么內容。
六、其它
JVM 中兩個Class對象是否為同一個類的必要條件
- 類的完整類名必須一致,包括包名。
- 加載這個類的ClassLoader(指ClassLoader實例對象)必須相同。
對類加載器的引用
JVM 必須知道一個類型是由啟動類加載器加載的還是由用戶類加載器加載的,如果一個類型是由用戶類加載器加載的,那么 JVM 會將這個類加載器的一個引用作為類型信息的一部分保存在方法區中,當解析一個類型到另一個類型的引用的時候, JVM 需要保證這兩個類型的類加載器是相同的。
類的主動使用和被動使用
主動使用和被動使用的區別是,主動使用會導致類的初始化。
主動使用有以下七種情況:
- 創建類的實例。
- 訪問某個類或接口的靜態變量,或者對該靜態變量賦值。
- 調用類的靜態方法。
- 反射(比如:Class.forName("com,atguigu.Test"))。
- 初始化一個類的子類。
- Java虛擬機啟動時被標明為啟動的類。
- JDK 7開始提供的動態語言支持:java.lang.invoke.MethodHandle 實例的解析結果 REF_getStatic、REF_putStatic、 REF_invokeStatic句柄對應的類沒有初始化則初始化。
參考文獻:
https://blog.csdn.net/u011069294/article/details/107489721
https://www.cnblogs.com/cleverziv/p/13751488.html