Java 類加載與運行

Java 類加載與運行

類從加載到內存直至被卸載,整個生命週期包括:加載、驗證、準備、解析(綁定)、初始化、使用和卸載7個階段,對於不一樣的 JVM 實現,這 7 個階段可能各有重疊,但大體過程相同java

graph TB A[Class 加載] style A fill:#f90 B[Files<br/>network] C[<b>準備</b><br/>方法區<br/>static 0值] D[<b>類首次</b>初始化<br/><b>clinit</b><br/>5個初始化時機] E[加載器名字空間] O[運行時棧] P[<b>Slots</b><br/>0 this<br/>args<br/>locals] Q[Slot 複用] R[操做數棧] S[動態連接<br/>棧幀] T[動態特性] U[invoke<br/>反射] A --> B A --> C C --> D B --> E O --> P P --> Q O --> R O --> S O --> T T --> U

[TOC]數組

從類到對象

粗略來看,類的加載通常須要通過下面三個過程tomcat

  1. 經過一個類的全限定名來獲取定義此類的二進制字節流 ,例如使用文件(Jar、war)、網絡(Applet) 等方式
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構
  3. 生成一個表明這個類的 java.lang.Class 對象(方法區或者堆),做爲方法區這個類的各類數據的訪問入口

下面介紹部分細節安全

驗證

class 文件不必定由 Java 編寫,也可能由 C 或其餘語言生成,因此爲了安全,載入 class 文件時 JVM 會對 class 文件進行驗證。驗證 階段大體上會完成下面 4 個階段的檢驗動做:網絡

  • 文件格式驗證
  • 元數據驗證
  • 字節碼驗證
  • 符號引用驗證

準備

準備階段主要初始化方法區內存數據結構

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段中有 兩個容易產生混淆的概念須要強調一下,首先,這時候進行內存分配的僅包括類變量(被 static 修飾的變量),而不包括實例變量, 實例變量將會在對象實例化時隨着對象一塊兒分配在 Java 堆中。其次,這裏所說的初始值「 一般狀況」 下是數據類型的零值,由於這時候還沒有開始執行任何 Java 方法,成員變量的賦值編譯後常置於構造器 <clinit>() 中。可是,若是類字段爲 const 類型,那麼在編譯與準備時變量的值就已經肯定了,不須要寫進構造器中多線程

public static int value       = 123; // 變量 value 在準備階段事後的初始值爲 0 而不是 123
public static final int value = 123; // 變量 value 在準備階段的初始值爲 123

解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程架構

類初始化

注意,這裏的初始化指的不是類對象的初始化,而是類或接口首次使用(建立對象)前所作的準備工做ide

類初始化階段是類加載過程的最後一步,前面的類加載過程當中,除了在加載階段用戶應用程序能夠經過自定義類加載器參與以外, 其他動做徹底由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼(或者說是字節碼)。從另一個角度來表達:初始化階段是執行類構造器 <clinit>() 方法的過程,調用<clinit>()是一個類或接口被首次使用前的最後一項工做函數

<clinit>() 方法是由編譯器自動收集類中的全部類變量的賦值動做和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊以前的變量,定義在它以後的變量, 在前面的靜態語句塊能夠賦值,可是不能訪問

public class Test{ 
    static
    { 
        i= 0;// 給變量賦值能夠正常編譯經過
    	System.out.print(i)// 這句編譯器會提示"非法向前引用" 
    } 
    static int i = 1; 
}

<clinit>()方法對於類或接口來講並不是必需的,若是一個類中沒有靜態語句塊,也沒有對變量的賦值操做,那麼編譯器能夠不爲這個類生成 <clinit>()方法

<clinit>() 方法與類的構造函數(或者說實例構造器 <init>() 方法)不一樣,它不須要顯式地調用父類構造器,虛擬機會 保證在子類的 <clinit>() 方法執行以前,父類的 <clinit>()方法已經執行完畢。虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確地加鎖、同步,若是多個線程同時去初始化一個類,那麼只會 有一個線程去執行這個類的<clinit>()

初始化時機

JVM 規範對類初始化的時機作了明確的規定,有且僅有下面 5 種狀況必須對類進行初始化,且下面 5 種方式被稱爲主動引用

  1. 碰見 new、getstatic、putstatic 和 invokestatic 這 4 條字節碼命令時,若是類未初始化過,則須要初始化
    • new,調用構造函數初始化對象堆內存,建立對象須要知道對象的類型,而 java.lang.Class是在類初始化時載入的
    • 類中靜態初始化塊只會在類第一次初始化時調用,後三個靜態成員訪問指令須要觸發首次類初始化
  2. 使用 java.lang.reflect 包的方法對類進行反射調用時,若是類未進行過初始化,則需先觸發初始化
    • 參考這裏,反射須要維護對象的一個 Class 對象,而 Class 對象由類初始化載入
  3. 初始化一些類時,若是發現其父類未被初始化,則需先觸發父類的初始化
    • 對於接口而言,只有用到的時候纔會初始化父接口
  4. 程序的入口類(包含 main 函數的類),必定會被初始化
  5. JDK 7 中若是一個 java.lang.invoke.MethodHandle 最後解析結果 REF_getstatic 等方法句柄,且這個方法句柄對應的類沒有進行過初始化,則須要先觸發初始化

除上述 5 種方式(主動引用)外其餘對類的應用方式均不會觸發類的初始化,這些引用被稱爲被動引用。下面有幾個被動引用的示例

  1. 經過子類引用父類的靜態字段,不會致使子類的初始化
  2. 經過數組定義引用類,不會觸發此類的初始化:classA a = new classA[10],不會觸發 classA的初始化
  3. 常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化

類加載器

虛擬機設計團隊把類加載階段中的「經過一個類的全限定名來獲取描述此類的二進制字節流」 這個動做放到 Java 虛擬機外部去實現,以便讓應用程序本身決定如何去獲取所須要的類

類加載器在類層次劃分、OSGi、熱部署、代碼加密等領域十分重要,是 Java 技術體系中一塊重要的基石

若是兩個類由不一樣的類加載器加載,那麼即便類源自同一個 class 文件,這兩個類也是不一樣的(包括 equals、isAssignableFrom、isInstance ),由於每個類加載器都有本身的名字空間

雙親委派模型

開發中經常使用的 Java 類加載器有三類,這三類加載器分層,能夠由底層加載器委派上層加載器去載入數據,後續再詳細看

Tomcat & OSGi

若想詳細學習類加載技術,能夠看一看 tomcat 和 OSGi(Open Service Gateway Initiative) 的實現

運行時棧

一個棧幀中包含的內容以下:局部變量表、操做棧、動態鏈接、返回地址等數據,下面一一介紹

C/C++ 的棧是動態變化的,變量只有執行到指定位置纔會在棧中爲之分配內存,且內存大小和變量類型息息相關

局部變量表(Slots)

存儲方法內部定義的局部變量和方法參數

JVM 規定局部變量的容量以變量槽(Variable Slot)做爲最小單位,一個槽能夠存放一個 32 位之內的數據類型。JVM 使用索引定位的方式使用局部變量表,若是是 64 位數據就同時使用相鄰的兩個 Slot,JVM 不容許訪問 64 位數據其中一個 Slot

局部變量槽第 0 個Slot 保存 this 指針,其他變量從 1 開始依次佔用槽位,先是函數的參數,再是方法內部的局部變量

JVM 並不會像初始化類變量那樣初始化局部變量,因此須要手動初始化

爲失效變量賦 null

JVM 棧幀中的變量有時是複用的,下面兩段代碼,前者觸發了垃圾回收,後者未觸發,就是由於 Slot 的複用

這條原則不必體如今代碼中,JIT 會自動優化這類問題,此處提出這個概念是爲了加深對 Slot 的理解

// 即便調用強制系統進行 GC,p 對應的內存也沒有被回收
// 由於在調用 gc 時 p 依舊保持在棧中(Slots),做爲 GC Root 指向堆內存
public static void main(String[] args)
{
    {
		byte[] p = new byte[64*1024*1024];
        // 部分書籍推薦對不使用的引用賦 null 值,避免回收失效
        // 部分 JIT 會優化掉下面的語句,因此具體狀況具體對待
        // p = null; 
    }
	System.gc();
}

// 不少實現複用了不用的 Slot,因此下面這段代碼能夠觸發垃圾回收
public static void main(String[] args)
{
    {
		byte[] p = new byte[64*1024*1024];
    }
    int a = 0; // a 複用了 p 的 Slot,p 對應的內存失去了引用,可被回收
	System.gc();
}

操做數棧

Java 中的指令面向操做數棧而非寄存器,故 Java 指令要操做的數據都須要保存在操做數棧中,與之相對應的是 x86 等 CPU 架構,指令的操做數通常都保存在 CPU 寄存器中

使用操做數棧的架構便於移植,不一樣 CPU 有不一樣的寄存器結構,移植時須要從新編譯代碼;固然使用操做數棧結構會下降性能

操做數棧的最大深度在編譯時已寫入 Code 屬性的 max_stacks

動態連接

和 C/C++ 這類語言生成的可執行文件概念相似,有些代碼在編譯時即已寫入可執行文件中(如靜態連接),有些代碼須要在運行時動態決斷(如系統庫的調用)

運行時棧都包含一個指向運行時常量池中當前棧幀所屬方法的引用,這個引用的存在是爲了支持方法調用過程的動態連接

返回地址

Java 中從一個函數返回有兩種狀況:正常 return 和異常返回

附加信息

例如調試信息等

方法調用

分派

  • 靜態分派(相似 C++ 中的靜態綁定)

    全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用是方法重載

  • 動態分派(相似 C++ 的動態綁定)

    • C++ 的動態綁定常見的實現方式是虛函數表和虛函數指針,虛函數指針保存在對象中,C++ 想實現多態,只能用指針(或引用)。Java 和 C++ 實現相似,不過 Java 的「虛函數指針」,在棧幀中有明確的存儲位置
    • Java 在解析虛函數時會使用 Slots 槽中第 0 個位置的 this 指針獲取對象的類型信息,而後查找對應的方法地址

字節碼指令

  • invokestatic
  • invokespecial,調用示例構造<init>方法、私有方法和父類方法
  • invokevirtual
  • invokeinterface
  • invokedynamic,前 4 條語句函數的解析由 JVM 控制,當前指令函數解析由用戶決定

Java 的動態語言特性

動態語言的特色是它的類型檢測主體過程在運行期而不是編譯期。以 C++ 和 Java 爲例,假設 obj 爲一個對象實例,則在 C++ 和 Java 中使用 obj.method() 的前提是 obj 的靜態類型中聲明的有 method 這個方法,不然沒法經過編譯。考慮語言的多態性,非動態語言的特色是:能夠修改對象方法的行爲,但不能調用對象中不存在的方法

動態語言就不同了,例如 JavaScript,你甚至能夠在運行時爲一個對象賦予全新的方法,簡單來講,動態語言在運行時查詢對象信息,若是有對應方法就調用,沒有就報運行時錯誤

Java 可使用反射和 invoke 包實現動態方法調用,示例以下

import java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

// 假設 ClassA 中實現了 println 方法
Object obj = System.currentTimeMillis()%2 == 0?System.out:new ClassA(); 
// 在 Java 中,下面用法是不合法的,編譯器會提示找不到 println
// 編譯器只能查詢 obj 自己所包含的信息,不能查詢 obj 所引用的對象的類型
// obj 指向的對象是包含 println 方法的,但 obj 的靜態類型種沒有 println 方法
// obj.println("Hello"); 

// JDK 7以後可使用 invoke 類庫實現方法的動態調用,方法以下
getPrintlnMH(obj).invokeExact("Hello");

// 使用下面的方法得到方法並和指定的對象綁定
MethodHandle getPrintlnMH(Object receiver)
{
    // 得到方法類型,第一個參數爲方法的返回值類型,後面爲方法的參數類型
    MethodType mt = MethodType.methodType(void.class, String.class);
    // 從 receiver 中查找方面名爲 println,形參類型爲 mt 的方法,並與 receiver 綁定
    // 執行下面語句前,JVM 是不知道 receiver 有 println 這個成員函數的
    return lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}

和反射相比,反射比 invoke 中的方法更重量級。反射模擬的是代碼層次的調用,MethodHandle 模擬字節碼層次的調用;反射比 MethodHandle 包含了更多額外的信息,例如方法簽名、屬性等,後者僅僅包含方法調用信息

MethodHandle 面向全部語言,是 JVM 的特性;反射通常只用於 Java

invokedynamic

JVM 的 invokedynamic 指令和上面的 getPrintlnMH 函數所展現的功能相似,具體實現請參看其餘資料

字節碼執行引擎

JVM 在執行 Java 代碼時有兩種選擇:解釋執行編譯執行,有時候這兩種方法是同時存在的,例如包含JIT 的 JVM

先舉個字節碼執行過程的例子。使用 javac 命令編譯下面代碼,並使用 javap 查看 cal 函數的字節碼:

class TestClass {
    public int cal()
    {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a+b)*c;
    }
}

上面代碼對應的字節碼以下,編譯器可能會對字節碼進行優化,因此下面過程只用於說明字節碼執行過程

public int cal();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100 // 將 100 放入操做數棧
         2: istore_1          // 將棧頂數據寫入局部變量第一個 Slot 中
         3: sipush        200 // 後面 4 條語句與前兩條功能相同,即初始化變量 a,b,c 
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1       // 這兩行把 Slot 中的值寫進操做數棧
        12: iload_2
        13: iadd          // 彈出操做數棧棧頂的兩個值求和並將結果入棧,此時棧頂爲求和結果
        14: iload_3       // 將第三個 Slot 中的值,即 c 壓入操做數棧
        15: imul          // 彈出操做數棧頂兩個值求積並將結果入棧,此時棧頂爲最終結果
        16: ireturn
      LineNumberTable:
        line 8: 0
        line 9: 3
        line 10: 7
        line 12: 11
}

字節碼生成與動態代理

動態代理的簡單示例, 動態代理能夠實現適配器(Adapter)或修飾器(Decorator) 等模式

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class TestClass {
    interface IHello {
        void sayHello();
        void sayGoodbye();
    }

    static class Hello implements IHello {
        @Override
        public void sayHello() { System.out.println(" hello world"); }
        @Override
        public void sayGoodbye() { System.out.println(" bye bye!!!"); }
    }

    static class DynamicProxy implements InvocationHandler {
        Object originalObj;

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            // 三個參數,Class Loader;須要實現的接口數組;this
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),\ 
                                          originalObj.getClass().getInterfaces(), \
                                          this);
        }

        // 全部原始成員函數都如下面的方式調用
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println(" welcome");
            return method.invoke(originalObj, args);
        }
    }

    public static void main(String[] args) {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());
        hello.sayHello();
        hello.sayGoodbye();
        /* // 上面兩行代碼編譯後的結果相似於下面代碼
        public final void sayHello() throws { 
            try { // m3 是綁定在 hello 上的實際方法,即原始的 sayHello 或 sayGoodbye
        		this.h.invoke(this, m3, null); return; 
        	} 
        	catch...
        	}
        */
    }
}

/* 執行結果
welcome
hello world
welcome
bye bye!!!
*/

Retrotranslator

同時執行不一樣版本的 Java 代碼,Retrotranslator 能夠將 jdk5 的代碼編譯爲 jdk 5之前的 class 文件

相關文章
相關標籤/搜索