類加載到卸載的全過程分析
在Java代碼中,類型的加、連接與初始化過程都是在程序運行期間完成的。其中類型指我們定義的一個class、interface、enum,此時並未包含對象。這一點提供了更大的靈活性、增加了更多的可能性。每一個類都是由類加載器class loader 加載到內存當中的。
1. Java虛擬機的生命週期
JVM虛擬機最最本質上是一個進程,所以JVM和普通的進程一樣,都是有生命週期的。Java虛擬機和程序的生命週期,在如下幾種情況下,Java虛擬機的將結束生命週期:
- 執行了System.exit()方法
- 程序正常執行結束
- 程序在執行過程中遇到了異常或錯誤而異常終止
- 由於操作系統出現錯誤而導致Java虛擬機進程終止
對於上述給出的第三點給出進一步詳細的例子描述,因爲其十分常見,比如說我們調用一個程序其會向上拋出異常,但是直至main方法我們還是採取向上拋出異常的異常處理機制,那麼此時程序就會結束運行,這是一種很常見的JVM結束生命週期的方式。
示例代碼:
System.exit()
:可以看出,在此方法之後所定義的方法並沒能夠被有效執行,因爲此時虛擬機以及被關閉了。
class GfG
{
public static void main(String[] args)
{
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8};
for (int i = 0; i < arr.length; i++)
{
if (arr[i] >= 5)
{
System.out.println("exit...");
// Terminate JVM
System.exit(0);
}
else
System.out.println("arr["+i+"] = " +
arr[i]);
}
System.out.println("End of Program");
}
}
控制檯輸出:
arr[0] = 1
arr[1] = 2
arr[2] = 3
arr[3] = 4
exit...
2. 類的加載、連接與初始化的概括摘要
2.1 類的加載、連接、初始化的流程說明
- 加載:查找並加載類的二進制數據
- 連接:連接是一個比較複雜的分步步驟,具體可以分爲以下三步:
- 驗證:確保被加載的類的正確性
- 準備:爲類的靜態變量分配內存,並將其初始化爲默認值
- 解析:把類中的符號引用轉換爲直接引用(涉及變量和方法)
- 初始化:爲類的靜態變量賦予正確的初始化值
靜態變量:就是在Java代碼中使用static修飾的值。
默認值:int類型的默認值爲0,boolean類型的默認值爲false, 引用的默認值爲null。注意,如果我們使用static int a =10;
,但是在連接階段的準備階段,a變量的值被賦值爲0;
下圖和上圖表達的意思實際上是一樣的:
2.2 符號引用與直接引用的區別
號引用/直接引用之間的區別:如果想仔細瞭解這兩個概念的區別,不妨查看R大對此的回答R大。如果簡單點說,就是JVM在加載完二進制數據之後,並未完成類的內存分配問題,這樣一來我們就不能通過內存偏移量來查找方法以及變量了。符號引用(以方法爲例)是一個包含類信息、方法名、方法參數的字符串,例如:java/io/PrintStream.println:(Ljava/lang/String;)V
,我們根據這個字符串就可以準確地找到相關類。但是,此實現方式速度上還是不夠快,所以就出現了基於內存地址偏移量的直接引用:運行一次之後,符號引用會被替換爲直接引用,下次就不用搜索了。直接引用就是偏移量,通過偏移量虛擬機可以直接在該類的內存區域中找到方法字節碼的起始位置。
3.類的加載
備註:在IDEA中查詢類有無被加載的方法,請移步第7.3小節。
類的加載指的是將類的.class文件中的二進制數據讀到內存中,將其放在運行時數據區的方法區內,然後在內存中創建一個java.lang.class對象(其即是被我們俗稱的類對象,JVM虛擬機規範並沒有說明Class對象位於哪裏,HotSpot虛擬機將其放在了方法區中),類對象用來封裝類在方法區內的數據結構。JVM規範也沒有指定從哪裏來加載Class文件。
整個過程可以使用下圖表示:
類的加載的最終產品是位於內存中的Class對象(不是我們在Java代碼中調用構造方法所產生的對象)。 Class對象封裝了類在方法區內的數據結構,並向Java程序員提供了訪問方法區內數據結構的接口。Class對象是整個反射的入口,它就好像是一面鏡子一樣,能夠洞悉出類文件中的所有信息。
3.1 加載.clss文件的的源頭
- 從本地系統中直接加載(位於本地磁盤額的類路徑中被調用,這是我們最常見的一種調用方法)
- 通過網絡下載.class文件
- 從zip,jar等歸檔文件中加載.class文件(第三包架包就是這樣被我們調用的)
- 從專有的數據庫中提取.class文件(比較少)
- 將Java源文件中動態便以爲.class文件(web開發中常用)
注意:這些都是可能的源頭,實際上在不同的開發環境下應當會有不同的源頭,而不同的源頭就要求我們使用不同的字節處理流,比如說文件流:FileInputStream
3.2 類加載器的分類以及自帶加載器的概念
有兩種類型的類加載器:
- Java虛擬機自帶的類加載器
- 根類加載器(Bootstrap)
- 擴展類加載器(Extension)
- 系統(應用)類加載器(System)
- 用戶自定義的類加載器
- 其一定是java.lang.ClassLoader抽象類(這個類本身就是提供給自定義加載器繼承的)的子類
- 用戶可以定製的加載方式
默認情況下,我們自定義的類的加載器是系統類加載器。
java虛擬機自帶的幾種加載器:
(1) 根(Bootstrap)類加載器:該類加載器沒有父加載器,他負責加載虛擬機的核心類庫,如java.lang.*等。根類加載器從系統屬性sun.boot.class.path所指定的目錄中加載類庫。根類加載器的實現依賴於底層操作系統,屬於虛擬機的實現的一部分,他並沒有繼承java.lang.ClassLoader類。比如說java.lang.Object就是由根類加載器加載的。
(2)擴展(Extension)類加載器:它的父類加載器爲根類加載器。他從java.ext.dirs系統屬性所指定的目錄中加載類庫,或者從JDK的安裝目錄的jre\lib\ext子目錄(擴展目錄)下加載類庫,如果把用戶創建的JAR文件放在這個目錄下,也會自動有擴展類加載器加載。擴展類加載器是純java類,是java.lang.ClassLoader類的子類。
(3) 系統(System)類加載器:也稱爲應用加載器,他的父類加載器爲擴展類加載器。他從環境變量classpath或者系統屬性java.class.path所指定的目錄中加載類。他是用戶自定義的類加載器的默認父加載器。系統類加載器是純java類,是java.lang.ClassLoader子類。
上圖是類加載器的層次關係圖。從表象上看這些加載器是一種繼承關係,但是實際上是一種包含關係。比如說,系統類加載器加載一個類,首先會委託給擴展類加載器,後者又委託給根類加載器,如果根類加載器加載失敗,那麼就委託回擴展類加載器,如果還不行,那麼就係統類加載器加載,最後還不行,則拋出異常。但是實際上系統類加載器包含了擴展類加載器,後者又包含了根類加載器。
上述類加載器父子(非繼承中的父子關係)結構的代碼證明:
public class MyTest13 {
public static void main(String[] args) {
/**
* 默認情況下,系統類加載器是用戶自定義加載器的雙親,典型情況下其是應用啓動的類加載器。
*
*/
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
System.out.println(classLoader);
while (null != classLoader) {
classLoader = classLoader.getParent();
System.out.println(classLoader);
}
}
}
輸出:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null
注意:應用類加載器就是系統類加載器,啓動類加載器被null表示,所以就證明了這個加載器的層次關係(但注意:這不是子類和父類關係)。
3.3 類加載和類初始化的關係
類加載完成時類初始化的必要條件,但是類被加載了不意味着其一定會被初始化。類加載器並不需要等到某個類被“首次主動使用”時再加載它。JVM規範允許類加載器再預料某個類將要被使用時就預先加載它,如果再預先加載的過程中遇到了.class文件缺失或者存在錯誤,類加載器必須再程序首次主動使用該類時才報告錯誤(LinkageError鏈接錯誤)。如果一個類一直沒有被程序主動使用,那麼類加載器就不會報告錯誤(即使我們已經將類文件預先加載了)。
3.4 獲得類加載器的方法
小總結:獲得ClassLoader的途徑,有:
-
獲得當前類的ClassLoader,clzz爲類的類對象,而不是普通對象
clazz.getClassLoader();
-
獲得當先線程上下文的ClassLoader
Thread.currentThread().getContextClassLoader();
-
獲得系統的ClassLoader
ClassLoader.getSystemClassLoader();
-
獲得調用者的ClassLoader
DriverManager.getCallerClssLoader();
兩個概念(瞭解即可,基於3.5小節中的雙親委託機制纔有這兩種叫法):
- 定義類加載器:若有一個類加載器能成功加載Test類(自己寫的),那麼這個類加載器被稱爲定義類加載器
- 初始類加載器:所有能夠成功返回Class對象引用的類加載器(包括自定義類加載器)都被稱爲初始類加載器
下面我們給出一個使用根類加載器的一段代碼:
public class MyTest7 {
public static void main(String[] args) throws ClassNotFoundException {
Class clazz;
clazz = Class.forName("java.lang.String");
System.out.println(clazz.getClassLoader());
}
}
上述方法在控制檯輸出:null
,預示着這個String類由根類加載器加載。
每個類在被加載時都會對應一個類對象,而Class.forName()方法就是返回一個類對象。
getClassLoader()
是Class對象(類對象)的方法,有以下性質:
- 返回類對象所對應的類或接口的加載器,如果加載器是根加載器可能會返回null,也可能不會,主要看相關類的實現(返回null所用的加載器就是根加載器);
- 如果類對象對應的是基本類型以及void,那麼一定返回null;
第二個代碼塊:
public class MyTest7 {
public static void main(String[] args) throws ClassNotFoundException {
Class clazz;
clazz = Class.forName("classloader.C");
System.out.println(clazz.getClassLoader());
}
}
class C{
}
控制檯輸出:
sun.misc.Launcher$AppClassLoader@18b4aac2
可見這是一個引用類加載器完成的類加載(其爲Launcher類的內部類,且Launcher類爲反編譯出來的類)。
3.5 類加載器的父親(雙親)委託機制
類加載器用來把類加載到Java虛擬機中,從JDK1.2版本開始,類的加載過程採用了父類委託機制,這種機制能夠更好第保證Java平臺的安全性。在此委託機制中,除了Java虛擬機自帶的根加載器之外,其餘的類加載器都有且只有一個父加載器。當Java程序請求加載loader1加載Sample類時,loader1首先委託父加載器區加載Sample類,若父加載器能加載,則由父加載器完成加載任務,否則才由加載器loader1本身加載Sample類。
在父親委託機制中,各個加載器按照父子關係形成了樹形結構,除了根類加載器之外,其餘的類加載器都有且只有一個父加載器。下面舉一個例子:
備註:我們自定義的加載器當然也可以有所謂的父子關係:
我們想通過loader1加載器來加載Sample類,但是實際上loader1不會直接去進行加載Sample類的相關操作,而是交給系統類加載器來加載,而後者又交給擴展類加載器來完成,接着又交給了跟類加載器來嘗試加載。但是實際上根類加載器並不能我們自定義的Sample類,於是加載失敗,就委託給擴展類加載器加載。而擴展類加載器也會失敗(這兩個加載器加載Sample類失敗的原因待會兒會說)。實際上一般最終會由系統類加載器來加載Sample類,而loader1並沒有加載Sample類。
小節:
除了根類加載器,每個加載器被委託加載任務時,總是第一時間選擇讓其父類加載器來執行相關加載操作,最終總是會讓根類加載器來嘗試加載,如果加載失敗,則再次依次返回加載,只要這個過程有一個加載器加載成功,那麼這個加載任務就會執行完成(這是Oracle公司的Hotpot虛擬機默認執行的類加載機制,並且大部分虛擬機都是如此執行的),整個過程如下圖所示:
上圖中所含的系統自帶類加載的概念可以參看3.2小節。
3.6 自定義類加載器的代碼實現
首先介紹自定義類的應用場景:
(1)加密:Java代碼可以輕易的被反編譯,如果你需要把自己的代碼進行加密以防止反編譯,可以先將編譯後的代碼用某種加密算法加密,類加密後就不能再用Java的ClassLoader去加載類了,這時就需要自定義ClassLoader在加載類的時候先解密類,然後再加載。
(2)從非標準的來源加載代碼:如果你的字節碼是放在數據庫、甚至是在雲端,就可以自定義類加載器,從指定的來源加載類。
(3)以上兩種情況在實際中的綜合運用:比如你的應用需要通過網絡來傳輸 Java 類的字節碼,爲了安全性,這些字節碼經過了加密處理。這個時候你就需要自定義類加載器來從某個網絡地址上讀取加密後的字節代碼,接着進行解密和驗證,最後定義出在Java虛擬機中運行的類。
package classloader;
import java.io.*;
/**
* @author Fisherman
* @date 2019/10/19
*/
public class MyTest16 extends ClassLoader {
private String classLoaderName; //定義一個類加載器的名字,既然這裏是類加載器類,那麼這個名字就取決於對象的設計好了
private final String fileExtension = ".class";//需要用此來進行本地文件的後綴定位
public MyTest16(String classLoaderName) {
super(); //默認無參構造器會將系統類加載器當作該類加載器的父加載器(參看此源代碼既能理解這個含義)
this.classLoaderName = classLoaderName;//得到自定義加載器的名字
}
public MyTest16(ClassLoader parent, String classLoaderName) {
super(parent);//將傳入的指定類加載器當作該類加載器的父加載器
this.classLoaderName = classLoaderName;//得到自定義加載器的名字
}
/**
* 重寫toString方法
*
* @return 標準格式的返回類加載器對象的名字字符串
*/
@Override
public String toString() {
return "[" + this.classLoaderName;
}
/**
* 如果你仔細查看類加載過程的需要完成工作的話,你會知道這個過程需要進行類.class文件的查找
* loadClassData 方法:這是自定義的方法,用於返回對應類名的字節數組
* defineClass方法:通過字節數字以及類名(與系統無關)返回一個類對象,此方法是由JDK實現的,是一個本地方法
*
* @param className
* @return
* @throws ClassNotFoundException
*/
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
byte[] data = this.loadClassData(className);
return defineClass(className, data, 0, data.length);
}
/**
*
* @param name 輸入的本地硬盤上的類名
* @return 對應類名的.class文件的字節數組
*/
private byte[] loadClassData(String name) {
byte[] data = null;
byte[] temp = new byte[128];//設置中間操作字節數組
/**
* 這裏只是相當於將類類加載器的二進制名字轉爲Windows系統文件路徑,
* 如果是MacOS系統將"\\"替換爲“.”即可,並將其賦值給className字符串變量
*/
this.classLoaderName = this.classLoaderName.replace(".", "\\");
/**
* 下面則是使用try-with-resources語句塊來進行讀取.class文件於字節數組中
* 只有讀語句塊使用了緩衝流,因爲只有其涉及了硬盤讀寫操作
*/
try (InputStream is = new FileInputStream(name + this.fileExtension);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
BufferedInputStream bis = new BufferedInputStream(is);) {
int EffectiveLength = 0;
while (-1 != (EffectiveLength = bis.read(temp,0,temp.length))) {
baos.write(temp,0, EffectiveLength);
}
data = baos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return data;
}
public static void main(String[] args) throws IllegalAccessException, InstantiationException, ClassNotFoundException {
MyTest16 loader = new MyTest16("loader1");//得到類加載器對象
test(loader);
}
/**
* 下面是一個測試方法,用於得到由類加載生成的普通對象,並在控制檯上打出其類信息
* loadClass方法會調用我們MyTest14所實現的方法findClass方法
*
* 輸出:classloader.MyTest1@1b6d3586
* 其中classloader爲我給此類的包名而已
* @param classLoader
* @throws ClassNotFoundException
* @throws IllegalAccessException
* @throws InstantiationException
*/
public static void test(ClassLoader classLoader) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
Class<?> clazz = classLoader.loadClass("classloader.MyTest1");
Object object = clazz.newInstance();
System.out.println(object);
}
}
4. 類的連接階段
類被加載後,就進入連接階段。連接就是將已經讀入到內存的類的二進制數據合併到虛擬機的運行時環境中去。
4.1 類的驗證階段
類的驗證中的重要內容:
- 類文件的結構檢查
- 語義檢查
- 字節碼驗證
- 二進制兼容性的驗證
4.2 類的準備階段
在準備階段,Java虛擬機爲類的靜態變量分配內存,並設置默認的初始值。例如以下代碼:
public class Sample{
private static int a =1;
public static long b;
public static String str;
static{
b=2;
str="hello world"
}
}
在此例的準備階段中,我們會把上述int類型的靜態變量 a 分配4個字節(32位)的內存空間,並賦值爲默認值0;爲long類的靜態變量 b 分配8個字節(64位)的內存空間,並默認賦值爲0;爲String類型的靜態變量 str 默認賦值爲null。
4.3 類的解析階段
在解析階段,java虛擬機會把類的二進制數據中的符號引用替換爲直接引用。列如:如在Worker類的gotoWork()方法鍾會引用Car類的run()方法。
public void gotoWork(){
car.run();//這段代碼在worker類的二進制數據中表示爲符號引用
}
在Worker類的二進制數據中,包含了一個父Car類的run()方法的符號引用,它由run()方法的全名和相關描述符組成。在解析階段,java虛擬機會把這個符號引用替換爲一個指針,該指針指向Car類的run()
方法在方法區內的位置,這個指針就是一個直接引用。
5. 類的初始化:給靜態變量賦予正確的值
5.1 初始化的工作以及內部執行邏輯
- 聲明類變量是指定初始值
- 使用靜態代碼塊爲類變量指定初始值
可見靜態變量的聲明語句,以及靜態代碼塊都被看作類的初始化語句,Java虛擬機會按照初始化語句在類文件中的先後順序來依次執行它們。
JVM對於類初始化的判斷執行邏輯:
- 是否完成前置步驟:假如這個類還沒有被加載和連接,則程序先加載並連接該類
- 父類是否初始化:假如該類的直接父類還沒有被初始化,則先初始化其直接父類
- 類中有無初始化語句:假如類中有初始化語句,則系統依次執行這些初始化語句
所有的Java虛擬機實現必須在每個類或接口被Java程序首次主動使用時才初始化它們(要求有倆:1.首次 2.主動使用,而被動使用不會觸發類的初始化),關於主動使用的概念請看第7小節。、
5.2 final修飾的靜態類變量的初始化(反彙編:javap -c)
final關鍵字修飾靜態變量:
public class MyTeest2 {
public static void main(String[] args) {
System.out.println(MyParent2.str);
}
}
class MyParent2{
public static final String str ="hello world";
static{
System.out.println("MyParent2 static block");
}
}
控制檯輸出:
hello world
如果將final
修飾符去掉,那麼控制檯輸出:
MyParent2 static block
hello world
可以看到,僅僅一個final修飾就可以產生如此巨大的區別,那麼究其原因是爲何呢?
final
修飾的變量在Java代碼的編譯階段就會被存入到調用這個變量方法所在的類的常量池當中,就這個例子來說,字符串"hello world"作爲一個常量就會被存入在MyTest2類所在的常量池當中,本質上,調用方法的類並沒有直接引用到定義常量的類,因此並不會觸發定義常量的類的初始化。注意:這裏指的是將常量存放到了調用類MyTest2的常量池中,之後MyTest2與MyParent2就沒有任何關係了,甚至,我們可以將MyParent2的class文件刪除!我們使用反編譯的操作來驗證此說法:
在windows系統中,在IDEA編譯器自帶的Terminal中輸入:
cd build\classes\java\main\classloader
dir
javap -c MyTest2.class
dir
:此指令只是單純輸出此文件夾下有多少文檔,這也可以給我們提供是否進入了正確的文件夾下的判斷依據。
在MacOS系統中只需要更改前兩句指令,\
換成/
以及dir
換成ls
:
cd build/classes/java/main/classloader
ls
然後Terminal會輸出:
Compiled from "MyTest2.java"
public class classloader.MyTest2 {
public classloader.MyTest2();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String hello world
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
首先,第一個被調用的方法是類:MyTest2
的構造方法,這個不是我們此次學習關注的重點。接下來就是main方法的分析:
getstatic
:其對應着main方法中調用的System.out
靜態方法,返回一個PrintStream對象(之前也說了此JVM助記符代表的就是訪問靜態的成員變量);ldc
:其後面已經跟着Sting hello world 了,代表的就是此時MyParent2.str已經成爲一個固定的hello world了;
助記符ldc的含義:表示將int,float或者是String類型的常量值從常量池中推送至棧頂,所謂棧頂就是結下的代碼馬上就要用的代碼。如果類型爲short,那麼則需要bipush了,其表示將單字節(-128-127)的常量推送至棧頂,反編譯結果如下所示:
public class classloader.MyTest2 {
public classloader.MyTest2();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: bipush 100
5: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
8: return
}
sipush
則是表示將一個短整型常量值(-32768-32767)推送至棧頂。
inconst_1
表示將int類型1推送至棧頂(-1,0,2,3,4,5都類似於此,比如inconst_3就是將整型數組3推入棧頂,但是6及以上則是使用sipush
,再大點,就使用ldc
了)
3: iconst_5
3: bipush 6
3: sipush 32767
3: ldc #4 // int 32768
再舉一個例子,代碼如下:
public class MyTest3 {
public static void main(String[] args) {
System.out.println(MyParent3.str);
}
}
class MyParent3 {
public static final String str = UUID.randomUUID().toString();
static {
System.out.println("MyParent3 static code.");
}
}
控制檯輸出:當然,每次運行的UUID都是一個不同大小的值。
MyParent3 static code.
7c380593-cfcf-466b-8aa0-2ca483b59cb3
同樣是final
修飾的靜態變量,這裏就需要MyParent3類進行初始化操作了,可見final並未有關鍵的作用,而更爲關鍵的作用是這個靜態不可變變量在編譯階段能夠確定取值,如果不能,則需要進行類的初始化,如果可以進行確定,那麼就不需要進行類的初始化。
總結一下:當一個常量的值並非編譯期間可以確定,那麼其值就不會被放到調用類的常量池中,這時在程序運行時,會導致主動使用這個常量所在的類,自然就會導致這個類會進行初始化。
5.3 只有在第一次主動使用類時才導致類的初始化
下面的代碼塊則是爲了驗證只有在第一次主動使用類的時候,纔會進行初始化操作:
public class MyTest4 {
public static void main(String[] args) {
MyParent4 myParent4 = new MyParent4();
System.out.println("第二次創建對象");
MyParent4 myParent4_2 = new MyParent4();
}
}
class MyParent4{
static{
System.out.println("MyParent4 static block");
}
}
控制檯輸出:
MyParent4 static block
第二次創建對象
雖然我們調用了MyParent4類的構造方法兩次,對應於主動調用該類兩次,但是隻有第一次主動使用類的時候才進行類的初始化操作。
5.4 原始類型以及引用類型數組的初始化
數組例子:
public class MyTest4 {
public static void main(String[] args) {
MyParent4[] myParent4s = new MyParent4[1];
System.out.println(myParent4s.getClass());
MyParent4[][] myParent4s1 = new MyParent4[1][1];
System.out.println(myParent4s1.getClass());
System.out.println(myParent4s.getClass().getSuperclass());
System.out.println(myParent4s1.getClass().getSuperclass());
}
}
class MyParent4 {
static {
System.out.println("MyParent4 static block");
}
}
控制檯輸出:
class [Lclassloader.MyParent4;
class [[Lclassloader.MyParent4;
class java.lang.Object
class java.lang.Object
對於數組實例來說,其類型是由JVM在運行期間動態生成的,表示爲:class [Lclassloader.MyParent4;
這種以方括號加大寫L形式。動態生成的類型,其父類就是Object類。正因爲這樣動態生成類型的特性所以創建數組實例並不屬於主動地使用類,所以靜態代碼塊沒有得到執行。
對於數組來說,JavaDoc經常將構成數組的元素爲Component,實際上就是將數組降低一個維度後的類型。
但是上面的數組是引用類型的數組,但是對於原生類型的數組又是如何呢?
int[] arr = new int[10];
System.out.println(arr.getClass());
System.out.println(arr.getClass().getSuperclass());
控制檯輸出:
class [I
class java.lang.Object
此時類型是[I
,父類還是Object,類似的有char類型的數組,其class類型用:[C
來表示。
我們對整個程序反編譯一下,主要還是看看助記符:
Compiled from "MyTest4.java"
public class classloader.MyTest4 {
public classloader.MyTest4();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: anewarray #2 // class classloader/MyParent4
4: astore_1
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: aload_1
9: invokevirtual #4 // Method java/lang/Object.getClass:()Ljava/lang/Class;
12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
15: iconst_1
16: iconst_1
17: multianewarray #6, 2 // class "[[Lclassloader/MyParent4;"
21: astore_2
22: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
25: aload_2
26: invokevirtual #4 // Method java/lang/Object.getClass:()Ljava/lang/Class;
29: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
32: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
35: aload_1
36: invokevirtual #4 // Method java/lang/Object.getClass:()Ljava/lang/Class;
39: invokevirtual #7 // Method java/lang/Class.getSuperclass:()Ljava/lang/Class;
42: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
45: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
48: aload_2
49: invokevirtual #4 // Method java/lang/Object.getClass:()Ljava/lang/Class;
52: invokevirtual #7 // Method java/lang/Class.getSuperclass:()Ljava/lang/Class;
55: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
58: bipush 10
60: newarray int
62: astore_3
63: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
66: aload_3
67: invokevirtual #4 // Method java/lang/Object.getClass:()Ljava/lang/Class;
70: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
73: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
76: aload_3
77: invokevirtual #4 // Method java/lang/Object.getClass:()Ljava/lang/Class;
80: invokevirtual #7 // Method java/lang/Class.getSuperclass:()Ljava/lang/Class;
83: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
86: return
}
助記符:
anewarray
:表示創建一個引用類型的(如類、接口、數組)數組,
newarray
:表示創建一個原始類型(int、float、char等)的數組,並將其壓入棧頂
6. 類的卸載
JVM中的Class只有滿足以下三個條件,才能被GC回收,也就是該Class被卸載(unload):
- 該類所有的實例都已經被GC,也就是JVM中不存在該Class的任何實例。
- 加載該類的ClassLoader已經被GC。
- 該類的java.lang.Class 對象沒有在任何地方被引用,如不能在任何地方通過反射訪問該類的方法
7. 類的主動/被動使用
7.1 主/被動使用的概念
Java程序對類的使用方式可以分爲兩種:
- 主動使用
- 被動使用
主動使用類的七種方式,即類的初始化時機:
- 創建類的實例;
- 訪問某個類或接口的靜態變量(無重寫的變量繼承,變量其屬於父類,而不屬於子類),或者對該靜態變量賦值(靜態的read/write操作);
- 調用類的靜態方法;
- 反射(如:
Class.forName("com.test.Test")
); - 初始化一個類的子類(Chlidren 繼承了Parent類,如果僅僅初始化一個Children類,那麼Parent類也是被主動使用了);
- Java虛擬機啓動時被標明爲啓動類的類(換句話說就是包含main方法的那個類,而且本身main方法就是static的);
- JDK1.7開始提供的動態語言的支持:java.lang.invoke.MethodHandle實例的解析結果REF_getStatic,REF_public,REF_invokeStatic句柄對應的類沒有初始化,則初始化;
除了上述所講七種情況,其他使用Java類的方式都被看作是對類的被動使用,都不會導致類的初始化,比如:調用ClassLoader類的loadClass()
方法加載一個類,並不是對類的主動使用,不會導致類的初始化。
注意:初始化單單是上述類加載、連接、初始化過程中的第三步,被動使用並不會規定前面兩個步驟被使用與否,也就是說即使被動使用只是不會引起類的初始化,但是完全可以進行類的加載以及連接。例如:調用ClassLoader類的loadClass方法加載一個類,這並不是對類的主動使用,不會導致類的初始化。
需要銘記於心的一點:只有當程序訪問的靜態變量或靜態變量確實在當前類或當前接口中定義時,纔可以認爲是對類或接口的主動使用,通過子類調用繼承過來的靜態變量算作父類的主動使用。
class文件的助記符(此處先列具一些簡單且常見的助記符):
getstatic
:讀取類的靜態變量putstatic
:寫類的靜態變量invokestatic
:調用類的靜態方法
7.2 主/被動使用的案例分析
類的主動使用和被動使用的區別案例:
public class MyTest1 {
public static void main(String[] args) {
System.out.println(MyChild1.str);
}
}
class MyParent1 {
public static String str = "hello world";
static {
System.out.println("MyParent static block.");
}
}
class MyChild1 extends MyParent1 {
static {
System.out.println("MyChild1 static block.");
}
}
控制檯輸出:
MyParent static block.
hello world
我們可以發現,子類MyChild1中的靜態代碼塊並沒有得到執行,這是爲什麼呢?
我相信看本文的童鞋即時沒學過JVM,也知道JAVA中靜態域中若涉及繼承,那麼父類靜態域加載早於子類靜態域的加載,那麼這也至少說明了一點,子類的靜態域也是會得到執行的,那麼爲何在此例中,子類的靜態域沒有得到執行呢(況且是在mian方法中通過子類來進行靜態域的調用的)?實際上回答很簡單,就是我們是被動地使用了子類,對於靜態字段來說只有直接定義了該字段的類纔會被初始化,當一個類在初始化時,要求其父類全部都已經初始化完畢了。
如果在子類中重寫了父類的str變量,將上述代碼中的相關語句修改爲以下語句:
public static String str = "hello world---------by child";
那麼此時就會初始化子類,控制檯則會輸出
MyParent static block.
MyChild1 static block.
hello world-----------by child
當然還有很多方法使子類主動地被使用,比如構造子類對象。
7.3 在IDEA中查詢類有無被加載的方法
現在我們知道了在這個例子中如果被動地使用子類,那麼子類就不會進行初始化,那麼問題來了:子類有被加載和連接嗎?實際上這個JVM規範對此事沒有相關規定的,所以我們需要使用虛擬機的運行選項來顯示是否有此類的加載:
-XX:+TraceClassLoading
:用於追蹤類的加載信息並打印出來,在IntelliJ IDEA中使用此虛擬機參數,那麼只需修改運行時參數即可,軟件點擊的順序大概如此:
注意,如果打錯了VM options,那麼虛擬機則會運行失敗,程序自然也無法運行,並且,編譯器會告訴你:你可能所需的VM optional參數值。
再次運行程序,可以控制檯輸出了許許多多的關於類加載的信息,如下所示,其順序就是JVM真實加載這些類的順序:其他的你可能不知道,至少我們發現JVM加載的第一個類爲java.lang.Object
,因爲其是所有類的父類。
我們在控制檯輸出行向上找找,可以發現我們感興趣的類加載過程:
可以看到實際上雖然MyChlid1作爲一個子類是被動使用,沒有參與初始化,但還是有被加載。特別提一點,有main方法的類都是主動使用,所以可以看到第一個紅框中顯示了有main方法的MyTest1類的加載。
下面簡單提一下JVM參數設置的三種方式(實際運行時無須加入<>
,這裏只是爲了顯示方便):
-XX:+<option>
:表示開啓option選項
-XX:-<option>
:表示關閉option選項
-XX:<option>=<value>
:表示將option選項的值設置爲value
反射是對類的主動使用的例子:
public class TempTest {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> testReflection = Class.forName("classloader.TestReflection");
}
}
class TestReflection {
static {
System.out.println("類:" + TestReflection.class + "被初始化了!");
}
}
控制檯輸出:
類:class classloader.TestReflection被初始化了!
10. 類的初始化過程中順序問題
直接上代碼吧:
public class MyTest6 {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println(Singleton.counter1);
System.out.println(Singleton.counter2);
}
}
class Singleton {
public static int counter1;
public static int counter2 = 0;
private static Singleton singleton = new Singleton();
private Singleton() {
counter1++;
counter2++;
}
public static Singleton getInstance() {
return singleton;
}
}
這就是一個單例模式,由於變量都被修飾爲static
靜態,爲類變量,所以再調用了構造方法後,兩個數都被加1了,所以控制檯輸出值爲:
1
1
你自然會認爲這是理所當然的,的確也是,畢竟代碼是按照我們平時寫的順序來的,那麼如果做出以下改變呢?即把public static int counter2 = 0;
移動到私有的構造方法以下呢?即如下代碼,結果還是這般嗎?
public class MyTest6 {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println(Singleton.counter1);
System.out.println(Singleton.counter2);
}
}
class Singleton {
public static int counter1;
private static Singleton singleton = new Singleton();
private Singleton() {
counter1++;
counter2++;
}
public static int counter2 = 0;
public static Singleton getInstance() {
return singleton;
}
}
這裏不賣關子了,直接說出答案。控制檯輸出:
1
0
從表面上看,counter1的值成功加1,counter2的值卻沒有加1,保持了不變,但是事實真的如此嗎?爲了檢驗這個說明的正確性,我們在構造方法中加入:
System.out.println("爲了驗證counter2有無成功加1:"+counter2);
我們發現控制檯輸出了:
爲了驗證counter2有無成功加1:1
1
0
現在可以說了,counter2是曾被成功加1的,但是後面被以某種方式修該爲1,那麼究竟是如何做到的呢?
實際上,這個問題用本文中JVM類對加載以及初始化整個過程掌握的好的話就能夠輕鬆回答了,以下說一下JVM的類加載順序和緣由:
- main方法中調用了Singleton類的靜態方法:
getInstance()
,這屬於類的主動使用之一,所以觸發了JVM對Singleton類的初始化操作; - 初始化的前提是類有被加載,而加載過程中有準備步驟,即:爲類的靜態變量分配內存,並將其初始化爲默認值,在這一步中我們按照順序有:counter1被賦值爲0,私有引用變量singleton被賦值爲null,counter2被賦值爲0;
- 接着開始類的加載操作,類的加載操作目的就是給類的靜態變量賦予正確的值,這裏還是按JVM執行順序說:
- counter1因爲並沒有指定值,所以值保持不變,還是爲默認值0;
- 私有引用變量singleton通過私有構造器指定了值,所以調用私有構造器,在這裏我們執行了:
counter1++;
以及counter2++;
操作,所以在這counter1值更新爲1,counter2的值被更新爲1; - counter2因爲語句
public static int counter2 = 0;
指定了初始值0,所以這裏counter2又被更新爲0;
所以類就這樣被加載完成了,可以發現正是由於類靜態變量準備階段賦予默認值以及類加載過程中賦予正確初始值這兩個過程的按代碼編寫的順序執行這一特性,這纔有上述所表現的反常理。
11. 類的實例化
類的實例化按序完成的作用:
- 爲新的對象分配內存
- 爲實例變量賦予默認值
- 爲實例變量賦予正確的初始值
Java編譯器在爲它編譯的每一個類都至少生成一個實例初始化方法,在Java的class文件中,這個實例初始化方法被稱爲<init>
。針對源代碼中每一個類的構造方法,Java編譯器都產生一個<init>
方法。
12. javap-c 反編譯中的JVM助記符
根據棧的使用原則,我們可以知道:一個元素或者其值被推向棧頂,意味着其即將被使用,或者如果新加入棧的元素,其應當放在棧頂,這是遵循了後進先出的內存分配原則。
助記符 | 含義 |
---|---|
ldc | 將int/float/String類型的常量值從常量池中推送至棧頂 |
bipush | 將單字節(-128 ~ 127)的常量值從常量池中推至棧頂 |
sipush | 將一個短整型(-32768 ~ 32767)的常量值從常量池中推至棧頂 |
inconst_1 | 將int型的常量值1從常量池中推至棧頂(jvm專門爲0/1/2/3/4/5這5個數字開的助記符),iconst_m1則表示的是-1 |
anwearray | 創建一個引用類型(如類、接口、數組)的數組,並將其引用值推至棧頂 |
newarray | 創建一個指定的原始類型(如int/float)的數組,並將其引用值推至棧頂 |
上述說法中所涉及的基本數據類型的內存模型如下所示:
數據類型 | 表示範圍 | 2進製表示範圍 |
---|---|---|
byte | -128 ~ 127 | -2^7 ~ 2^7-1 |
short | -32768 ~ 32767 | -2^15 ~ 2^15 -1 |
int | -2,147,483,648 ~ 2,147,483,647 | -2^31 ~ 2^31 -1 |
實際上就是8位計數、16位計數、32位計數方法分爲三種不同的操作方法:bipush
和sipush
以及ldc
,實際上用大小爲100的整型,用byte/short/int修飾最終都是使用bipush
助記符,可見最終決定助記符的不是Java代碼中對變量的修飾是用int/short…,而是實際需要多少位來進行數據的存儲。