Java剛誕生的宣傳口號:一次編寫,處處運行(Write Once, Run Anywhere),其中字節碼是構成平臺無關的基石,也是語言無關性的基礎。html
Java虛擬機不和包括Java在內的任何語言綁定,它只與Class文件這種特定的二進制文件格式所關聯,這使得任何語言的均可以使用特定的編譯器將其源碼編譯成Class文件,從而在虛擬機上運行。java
任何一個Class文件都對應着惟一一個類或接口的定義信息,但反過來講,Class文件實際上它並不必定以磁盤文件的形式存在。mysql
Class文件是一組以8位字節爲基礎單位的二進制流。c++
各個數據項目嚴格按照順序緊湊地排列在Class文件之中,中間沒有添加任何分隔符,這使得整個Class文件中存儲的內容幾乎所有是程序運行的必要數據,沒有空隙存在。
Class文件格式採用一種相似於C語言結構體的僞結構來存儲數據,這種僞結構中只有兩種數據類型:無符號數和表。git
無符號數屬於基本的數據類型,以u一、u二、u四、u8來分別表明1個字節、2個字節、4個字節和8個字節的無符號數,無符號數能夠用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。github
表是由多個無符號數或者其餘表做爲數據項構成的複合數據類型,全部表都習慣性地以「_info」結尾。表用於描述有層次關係的複合結構的數據,整個Class文件本質上就是一張表。web
整個class類的文件結構以下表所示:面試
佔用大小 | 字段描述 | 數量 |
---|---|---|
佔用大小 | 字段描述 | 數量 |
u4 | magic:魔數,用於標識文件類型,對於java來講是0xCAFEBABE | 1 |
u2 | minor_version:次版本號 | 1 |
u2 | major_version:主版本號 | 1 |
u2 | constant_pool_count:常量池大小,從1開始而不是0。當這個值爲0時,表示後面沒有常量 | 1 |
cp_info | constant_pool:#常量池 | constant_pool_count-1 |
u2 | access_flags:訪問標誌,標識這個class是類仍是接口、public、abstract、final等 | 1 |
u2 | this_class:類索引 #類索引查找全限定名的過程 | 1 |
u2 | super_class:父類索引 | 1 |
u2 | interfaces_count:接口計數器 | 1 |
u2 | interfaces:接口索引集合 | interfaces_count |
u2 | fields_count:字段的數量 | 1 |
field_info | fields:#字段表 | fields_count |
u2 | methods_count:方法數量 | 1 |
method_info | methods:#方法表 | methods_count |
u2 | attributes_count:屬性數量 | 1 |
attribute_info | attrbutes:#屬性表 | attributes_count |
可使用javap -verbose輸出class文件的字節碼內容。sql
下面按順序對這些字段進行介紹。數據庫
每一個Class文件的頭4個字節稱爲魔數(Magic Number),它的惟一做用是肯定這個文件是否爲一個能被虛擬機接受的Class文件。使用魔數而不是擴展名來進行識別主要是基於安全方面的考慮,由於文件擴展名能夠隨意地改動。文件格式的制定者能夠自由地選擇魔數值,只要這個魔數值尚未被普遍採用過同時又不會引發混淆便可。
緊接着魔數的4個字節存儲的是Class文件的版本號:第5和第6個字節是次版本號(MinorVersion),第7和第8個字節是主版本號(Major Version)。Java的版本號是從45開始的,JDK 1.1以後的每一個JDK大版本發佈主版本號向上加1高版本的JDK能向下兼容之前版本的Class文件,但不能運行之後版本的Class文件,即便文件格式並未發生任何變化,虛擬機也必須拒絕執行超過其版本號的Class文件。
常量池中常量的數量是不固定的,因此在常量池的入口須要放置一項u2類型的數據,表明常量池容量計數值(constant_pool_count)。與Java中語言習慣不同的是,這個容量計數是從1而不是0開始的。
常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。
用於識別一些類或者接口層次的訪問信息,包括:
這三項數據來肯定這個類的繼承關係。
描述接口或者類中聲明的變量。字段(field)包括類級變量以及實例級變量。
而字段叫什麼名字、字段被定義爲何數據類型,這些都是沒法固定的,只能引用常量池中的常量來描述。
字段表集合中不會列出從超類或者父接口中繼承而來的字段,但有可能列出本來Java代碼之中不存在的字段,譬如在內部類中爲了保持對外部類的訪問性,會自動添加指向外部類實例的字段。
描述了方法的定義,可是方法裏的Java代碼,通過編譯器編譯成字節碼指令後,存放在屬性表集合中的方法屬性表集合中一個名爲「Code」的屬性裏面。
與字段表集合相相似的,若是父類方法在子類中沒有被重寫(Override),方法表集合中就不會出現來自父類的方法信息。但一樣的,有可能會出現由編譯器自動添加的方法,最典型的即是類構造器「<clinit>」方法和實例構造器「<init>」
存儲Class文件、字段表、方法表都本身的屬性表集合,以用於描述某些場景專有的信息。如方法的代碼就存儲在Code屬性表中。
Java虛擬機的指令由一個字節長度的、表明着某種特定操做含義的數字(稱爲操做碼,Opcode)以及跟隨其後的零至多個表明此操做所需參數(稱爲操做數,Operands)而構成。
因爲限制了Java虛擬機操做碼的長度爲一個字節(即0~255),這意味着指令集的操做碼總數不可能超過256條。
大多數的指令都包含了其操做所對應的數據類型信息。例如:
iload指令用於從局部變量表中加載int型的數據到操做數棧中,而fload指令加載的則是float類型的數據。
大部分的指令都沒有支持整數類型byte、char和short,甚至沒有任何指令支持boolean類型。不是每種數據類型和每一種操做都有對應的指令,有一些單獨的指令能夠在必要的時候用在將一些不支持的類型轉換爲可被支持的類型。大多數對於boolean、byte、short和char類型數據的操做,實際上都是使用相應的int類型做爲運算類型。
加載和存儲指令用於將數據在幀棧中的局部變量表和操做數棧之間來回傳遞。
上面帶尖括號的指令其實是表明的一組指令,如iload_0、iload_一、iload_2和iload_3。這些指令把操做數隱含在名稱內,不須要進行取操做數的動做。
運算或算術指令用於對兩個操做數棧上的值進行某種特定運算,並把結果從新存入到操做棧頂,可分爲整型數據和浮點型數據指令。byte、short、char和boolean類型的算術指令使用int類型的指令代替。
能夠將兩種不一樣的數值類型進行相互轉換,
控制轉移指令可讓Java虛擬機有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程序,從概念模型上理解,能夠認爲控制轉移指令就是在有條件或無條件地修改PC寄存器的值。控制轉移指令以下。
是根據返回值的類型區分的,包括ireturn(當返回值是boolean、byte、char、short和int類型時使用)、lreturn、freturn、dreturn和areturn,另外還有一條return指令供聲明爲void的方法、實例初始化方法以及類和接口的類初始化方法使用。
在java程序中,顯式拋出異常的操做都由athrow指令來實現。而在java虛擬機中,處理異常不是由字節碼指令來實現的,而是採用異常表來完成的
java虛擬機能夠支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor)來支持的。方法級的同步是隱式的,利用方法表結構中的ACC_SYNCHRONIZED訪問標誌得知。指令序列的同步是由monitorenter和monitorexit兩條指令支持。
這是一個很是典型的面試題,標準回答以下:
通常來講,咱們把 Java 的類加載過程分爲三個主要步驟:加載、連接、初始化。
1. 加載(Loading)
此階段中Java 將字節碼數據從不一樣的數據源讀取到 JVM 中,並映射爲 JVM 承認的數據結構(Class 對象),這裏的數據源多是各類各樣的形態,如 jar 文件、class 文件,甚至是網絡數據源等;若是輸入數據不是 ClassFile 的結構,則會拋出 ClassFormatError。 加載階段是用戶參與的階段,咱們能夠自定義類加載器,去實現本身的類加載過程。
2. 連接(Linking)
這是核心的步驟,簡單說是把原始的類定義信息平滑地轉化入 JVM 運行的過程當中。這裏可進一步細分爲三個步驟:
3. 初始化(initialization)
這一步真正去執行類初始化的代碼邏輯,包括靜態字段賦值的動做,以及執行類定義中的靜態初始化塊內的邏輯,編譯器在編譯階段就會把這部分邏輯整理好,父類型的初始化邏輯優先於當前類型的邏輯。
雙親委派模型:
簡單說就是當類加載器(Class-Loader)試圖加載某個類型的時候,除非父加載器找不到相應類型,不然儘可能將這個任務代理給當前加載器的父加載器去作。使用委派模型的目的是避免重複加載 Java 類型。
類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verificatio)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱爲鏈接(Linking)。
於初始化階段,虛擬機規範則是嚴格規定了有且只有5種狀況必須當即對類進行「初始化」(而加載、驗證、準備天然須要在此以前開始):
關於靜態變量的初始化,必需要注意如下三種狀況下是不會觸發類的初始化的:
下面是測試程序:
public class SuperClass { static { System.out.println("SuperClass init!"); } public static int value = 123; } public class SubClass extends SuperClass { static { System.out.println("Subclass init!"); } } public class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD_STRING = "hello world"; }
如下是對三種狀況的測試程序:
public class NotInitialization { public static void main(String[] args) { // 1. 只有直接定義這個字段的類纔會被初始化,所以經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。 // Result: SuperClass init! 123 System.out.println(SubClass.value); // 2. 經過數組定義來引用類,不會觸發此類的初始化 SuperClass[] superClasses = new SubClass[10]; // 3. 常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化 // Result: hello world System.out.println(ConstClass.HELLOWORLD_STRING); } }
在加載階段,虛擬機須要完成下列3件事:
驗證是鏈接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。驗證階段大體上會完成下面4個階段的檢驗動做:
是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。
這個階段中有兩個容易產生混淆的概念須要強調一下,首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在Java堆中。
其次,這裏所說的初始值「一般狀況」下是數據類型的零值,假設一個類變量的定義爲:
public static int value=123;
那變量value在準備階段事後的初始值爲0而不是123,由於這時候還沒有開始執行任何Java方法,而把value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,因此把value賦值爲123的動做將在初始化階段纔會執行。
表7-1列出了Java中全部基本數據類型的零值:
假設上面類變量value的定義變爲:public static final int value=123;
編譯時Javac將會爲value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值爲123。
是虛擬機將常量池內的符號引用替換爲直接引用的過程。
符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用是能無歧義地定位到目標便可。符號引用與虛擬機實現的內存佈局無關,引用的目標不必定已經加載到內存中。各類虛擬機實現的內存佈局能夠各不相同,可是它們能接受地符號引用必須是一致的,由於符號引用地字面量形式明肯定義在java虛擬機規範地Class文件格式中。
直接引用(Direct References):直接引用能夠是直接指向目標的指針、相對偏移量或是一個能直接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不一樣虛擬機實例上翻譯出來的直接引用通常不會相同。若是有了直接引用,那引用的目標一定已經在內存中存在。
類初始化是類加載過程的最後一步,在這個階段才真正開始執行類中的字節碼。初始化階段是執行類構造器<clinit>()
方法的過程。
<clinit>()
方法與類的構造函數(<init>()
方法)不一樣,它不須要顯式調用父類構造器,虛擬機會保證在子類的<clinit>()
方法執行以前,父類的<clinit>()
方法已經執行完畢。<clinit>()
方法先執行,所以父類中定義的靜態語句塊要先於子類執行。<clinit>()
方法對於類或接口來講不是必需的,若是一個類中沒有靜態語句塊,也沒有對變量賦值操做,那麼編譯器能夠不爲這個類生成<clinit>()
方法。<clinit>()
方法,但與類不一樣的是,執行接口的<clinit>()
方法不須要先執行父接口的<clinit>()
方法,只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也同樣不會執行接口的<clinit>()
方法。類加載器雖然只用於實現類的加載動做,但在java程序中起到的做用卻遠不止類加載階段。
對於任意一個類,都須要由加載它的類加載器和這個類自己一同確立其在java虛擬機中的惟一性,每一個類加載器,都擁有一個獨立的類命名空間。當一個Class文件被不一樣的類加載器加載時,加載生成的兩個類一定不相等(equals()、isAssignableFrom()、isInstance()、instanceof關鍵字的結果爲false)。
從java虛擬機的角度來看,只存在兩種不一樣的類加載器:一種是啓動類加載器(Bootstrap ClassLoader),這個類加載器使用c++實現,是虛擬機的一部分;另外一種是全部其餘的類加載器,這些類加載器都由java實現,獨立於虛擬機外部,而且所有繼承自抽象類java.lang.ClassLoader。java提供的類加載器主要分如下三種:
雙親委派模型的工做過程是:若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋本身沒法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試本身去加載。
使用雙親委派模型來組織類加載器之間的關係,有一個顯而易見的好處就是Java類隨着它的類加載器一塊兒具有了一種帶有優先級的層次關係。例如類java.lang.Object,它存放在rt.jar之中,不管哪個類加載器要加載這個類,最終都是委派給處於模型最頂端的啓動類加載器進行加載,所以Object類在程序的各類類加載器環境中都是同一個類。相反,若是沒有使用雙親委派模型,由各個類加載器自行去加載的話,若是用戶本身編寫了一個稱爲java.lang.Object的類,並放在程序的ClassPath中,那系統中將會出現多個不一樣的Object類,Java類型體系中最基礎的行爲也就沒法保證,應用程序也將會變得一片混亂。
首先看一下實現雙親委派模型的代碼,邏輯就是先檢查類是否已經被加載,若是沒有則調用父加載器的loadClass()方法,若是父加載器爲空則默認使用啓動類加載器做爲父加載器。若是父類加載失敗,拋出ClassNotFoundException異常後,再調用本身的findClass()方法進行加載。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 先從緩存查找該class對象,找到就不用從新加載 Class<?> c = findLoadedClass(name); if (c == null) { 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 // 若是都沒有找到,則經過自定義實現的findClass去查找並加載 c = findClass(name); } } if (resolve) {//是否須要在加載時進行解析 resolveClass(c); } return c; } }
在實現本身的類加載器時,一般有兩種作法,一種是重寫loadClass方法,另外一種是重寫findClass方法。其實這兩種方法本質上差很少,畢竟loadClass也會調用findClass,可是最好不要直接修改loadClass的內部邏輯,以避免破壞雙親委派的邏輯。推薦的作法是隻在findClass裏重寫自定義類的加載方法。
下面例子實現了文件系統類加載器,
public class FileSystemClassLoader extends ClassLoader { private String rootDir; public FileSystemClassLoader(String rootDir) { this.rootDir = rootDir; } protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] classData = getClassData(name); if (classData == null) { throw new ClassNotFoundException(); } else { return defineClass(name, classData, 0, classData.length); } } private byte[] getClassData(String className) { String path = classNameToPath(className); try { InputStream ins = new FileInputStream(path); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int bufferSize = 4096; byte[] buffer = new byte[bufferSize]; int bytesNumRead = 0; while ((bytesNumRead = ins.read(buffer)) != -1) { baos.write(buffer, 0, bytesNumRead); } return baos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return null; } private String classNameToPath(String className) { return rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; } }
Class.forName是Class類的方法public static Class<?> forName(String className) throws ClassNotFoundException
ClassLoader.loadClass是ClassLoader類的方法public Class<?> loadClass(String name) throws ClassNotFoundException
Class.forName和ClassLoader.loadClass均可以用來進行類型加載,而在Java進行類型加載的時刻,通常會有多個ClassLoader可使用,並可使用多種方式進行類型加載。
class A { public void m() { A.class.getClassLoader().loadClass(「B」); } }
在A.class.getClassLoader().loadClass(「B」)
;代碼執行B的加載過程時,通常會有三個概念上的ClassLoader提供使用。
SCL和TCCL能夠理解爲在代碼中使用ClassLoader的引用進行類加載,而CCL卻沒法獲取到其引用,雖然在代碼中CCL == A.class.getClassLoader() == SCL。CCL的加載過程是由JVM運行時來控制的,是沒法經過Java編程來更改的。
爲何須要破壞雙親委派?
由於在某些狀況下父類加載器須要委託子類加載器去加載class文件。受到加載範圍的限制,父類加載器沒法加載到須要的文件,以Driver接口爲例,因爲Driver接口定義在jdk當中的,而其實現由各個數據庫的服務商來提供,好比mysql的就寫了MySQL Connector,那麼問題就來了,DriverManager(也由jdk提供)要加載各個實現了Driver接口的實現類,而後進行管理,可是DriverManager由啓動類加載器加載,只能記載JAVA_HOME的lib下文件,而其實現是由服務商提供的,由系統類加載器加載,這個時候就須要啓動類加載器來委託子類來加載Driver實現,從而破壞了雙親委派,這裏僅僅是舉了破壞雙親委派的其中一個狀況。
Tomcat的類加載機制是違反了雙親委託原則的,對於一些未加載的非基礎類(Object,String等),各個web應用本身的類加載器(WebAppClassLoader)會優先加載,加載不到時再交給commonClassLoader走雙親委託。
如何破壞?
Tomcat的類加載機制是違反了雙親委託原則的,對於一些未加載的非基礎類(Object,String等),各個web應用本身的類加載器(WebAppClassLoader)會優先加載,加載不到時再交給commonClassLoader走雙親委託。
Tomcat是個web容器, 那麼它要解決什麼問題:
Tomcat 若是使用默認的類加載機制行不行 ?
答案是不行的。爲何?
第一個問題,若是使用默認的類加載器機制,那麼是沒法加載兩個相同類庫的不一樣版本的,默認的累加器是無論你是什麼版本的,只在意你的全限定類名,而且只有一份。
第二個問題,默認的類加載器是可以實現的,由於他的職責就是保證惟一性。
第三個問題和第一個問題同樣。
第四個問題,咱們要怎麼實現jsp文件的熱修改(樓主起的名字),jsp 文件其實也就是class文件,那麼若是修改了,但類名仍是同樣,類加載器會直接取方法區中已經存在的,修改後的jsp是不會從新加載的。那麼怎麼辦呢?咱們能夠直接卸載掉這jsp文件的類加載器,因此你應該想到了,每一個jsp文件對應一個惟一的類加載器,當一個jsp文件修改了,就直接卸載這個jsp類加載器。從新建立類加載器,從新加載jsp文件。
Tomcat 如何實現本身獨特的類加載機制?
前面3個類加載和默認的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是Tomcat本身定義的類加載器,它們分別加載/common/*
、/server/*
、/shared/*
(在tomcat 6以後已經合併到根目錄下的lib目錄下)和/WebApp/WEB-INF/*
中的Java類庫。其中WebApp類加載器和Jsp類加載器一般會存在多個實例,每個Web應用程序對應一個WebApp類加載器,每個JSP文件對應一個Jsp類加載器。
從圖中的委派關係中能夠看出:
CommonClassLoader能加載的類均可以被Catalina ClassLoader和SharedClassLoader使用,從而實現了公有類庫的共用,而CatalinaClassLoader和Shared ClassLoader本身能加載的類則與對方相互隔離。
WebAppClassLoader可使用SharedClassLoader加載到的類,但各個WebAppClassLoader實例之間相互隔離。
而JasperLoader的加載範圍僅僅是這個JSP文件所編譯出來的那一個.Class文件,它出現的目的就是爲了被丟棄:當Web容器檢測到JSP文件被修改時,會替換掉目前的JasperLoader的實例,並經過再創建一個新的Jsp類加載器來實現JSP文件的HotSwap功能。
下圖展現了Tomcat的類加載流程:
當tomcat啓動時,會建立幾種類加載器:
1. Bootstrap 引導類加載器
加載JVM啓動所需的類,以及標準擴展類(位於jre/lib/ext下)
2. System 系統類加載器
加載tomcat啓動的類,好比bootstrap.jar,一般在catalina.bat或者catalina.sh中指定。位於CATALINA_HOME/bin下。
3. Common 通用類加載器
加載tomcat使用以及應用通用的一些類,位於CATALINA_HOME/lib下,好比servlet-api.jar
4. webapp 應用類加載器
每一個應用在部署後,都會建立一個惟一的類加載器。該類加載器會加載位於 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。
典型面試題
tomcat 違背了java 推薦的雙親委派模型了嗎?
違背了,雙親委派模型要求除了頂層的啓動類加載器以外,其他的類加載器都應當由本身的父類加載器加載。tomcat 不是這樣實現,tomcat 爲了實現隔離性,沒有遵照這個約定,每一個webappClassLoader加載本身的目錄下的class文件,不會傳遞給父類加載器。
若是tomcat 的 Common ClassLoader 想加載 WebApp ClassLoader 中的類,該怎麼辦?
可使用線程上下文類加載器實現,使用線程上下文加載器,可讓父類加載器請求子類加載器去完成類加載的動做。
參考:
棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧的棧元素。典型棧幀結構:
下面對各個部分進行仔細介紹:
局部變量表(Local Variable Table)是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。局部變量表的容量以變量槽(Variable Slot)爲最小單位,虛擬機規範中並無明確指定一個Slot應占用的內存空間大小,只是規定每一個Slot都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的數據,這樣能夠屏蔽32位跟64位虛擬機在內存空間上的差別。
虛擬機經過索引定位的方式使用局部變量表,索引值的範圍從0到最大Slot數量,索引n對應第n個Slot。局部變量表中第0位索引的Slot默認是用於傳遞方法所屬對象實例的引用,即this。
爲了儘量的節省棧幀空間,局部變量表中的Slot是能夠重用的,同時這也影響了垃圾收集行爲。即對已使用完畢的變量,局部變量表仍持有該對象的引用,致使對象沒法被GC回收,佔用大量內存。這也是「不使用的對象應手動賦值爲null」這條推薦編碼規則的緣由。不過從執行角度使用賦null值的操做來優化內存回收是創建在對字節碼執行引擎概念模型的理解之上,代碼在通過編譯器優化後纔是虛擬機真正須要執行的代碼,這時賦null值會被消除掉,所以更優雅的解決辦法是以恰當的變量做用域來控制變量回收時間。
操做數棧(Operand Stack)也常稱操做棧,它是一個後入先出(Last In First Out,LIFO)棧。方法在執行過程當中,經過各類字節碼指令對棧進行操做,出棧/入棧。java虛擬機的解釋執行引擎稱爲「基於棧的執行引擎」,其中所指的「棧」就是操做數棧。
每一個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用時爲了執行方法調用過程當中的動態鏈接(Dynamic Linking)。
當一個方法開始執行後,只有兩種方式能夠退出這個方法:
執行引擎遇到任意一個方法返回的字節碼指令,這個時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱爲調用者),這種退出方式稱爲正常完成出口(Normal Method Invocation Completion)。
方法執行過程當中遇到了異常,而且這個異常沒有在方法體內獲得處理,不管是java虛擬機內部產生的異常,仍是代碼使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會致使方法退出,這種退出方式稱爲異常完成出口(Abrupt Method Invocation Completion),這時不會給它的上層調用者產生任何返回值。
方法退出的過程實際上就等同於把當前棧幀出棧,所以退出時可能執行的操做有:
虛擬機規範容許具體的虛擬機實現增長一些規範裏沒有描述的信息到棧幀中,稱之爲棧幀信息。
方法調用並不等同於方法執行,方法調用階段的惟一任務就是肯定被調用方法的版本,即調用哪個方法,暫時還不涉及方法內部的具體運行過程,就是類加載過程當中的類方法解析。
解析就是將Class的常量池中的符號引用轉化爲直接引用(內存佈局中的入口地址)。
在java虛擬機中提供了5條方法調用字節碼指令:
System.exit(1); ==>編譯 iconst_1 ;將1放入棧內 ;執行System.exit() invokestatic java/lang/System/exit(I)V
//<init>方法 new StringBuffer() ==>編譯 new java/lang/StringBuffer ;建立一個StringBuffer對象 dup ;將對象彈出棧頂 ;執行<init>()來初始化對象 invokespecial java/lang/StringBuffer/<init>()V //父類方法 super.equals(x); ==>編譯 aload_0 ;將this入棧 aload_1 ;將第一個參數入棧 ;執行Object的equals()方法 invokespecial java/lang/Object/equals(Ljava/lang/Object;)Z //私有方法 與父類方法相似
X x; ... x.equals("abc"); ==>編譯 aload_1 ;將x入棧 ldc "abc" ;將「abc」入棧 ;執行equals()方法 invokevirtual X/equals(Ljava/lang/Object;)Z
List x; ... x.toString(); ==>編譯 aload_1 ;將x入棧 ;執行toString()方法 invokeinterface java/util/List/toString()Z
在編譯階段就能夠肯定惟一調用版本的方法有:靜態方法(類名)、私有方法、實例構造器(
指在運行時對類內相同名稱的方法根據描述符來肯定執行版本的分派,多見於方法的重載。
下面的例子中,輸出結果均爲hello guy
。
「Human」稱爲變量的靜態類型(Static Type),或者叫作的外觀類型(Apparent Type),後面的「Man」則稱爲變量的實際類型(Actual Type),靜態類型和實際類型在程序中均可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量自己的靜態類型不會被改變,而且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期纔可肯定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什麼。
代碼中定義了兩個靜態類型相同但實際類型不一樣的變量,但虛擬機(準確地說是編譯器)在重載時是經過參數的靜態類型而不是實際類型做爲斷定依據的。而且靜態類型是編譯期可知的,所以,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪一個重載版本,因此選擇了sayHello(Human)做爲調用目標。全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,所以肯定靜態分派的動做實際上不是由虛擬機來執行的。
指對於相同方法簽名的方法根據實際執行對象來肯定執行版本的分派。編譯器是根據引用類型來判斷方法是否可執行,真正執行的是實際對象方法。多見於類多態的實現。
動態分配的實現,最經常使用的手段就是爲類在方法區中創建一個虛方法表。虛方法表中存放着各個方法的實際入口地址。若是某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。若是子類中重寫了這個方法,子類方法表中的地址將會替換爲指向子類實現版本的入口地址。PPT圖中,Son重寫了來自Father的所有方法,所以Son的方法表沒有指向Father類型數據的箭頭。可是Son和Father都沒有重寫來自Object的方法,因此它們的方法表中全部從Object繼承來的方法都指向了Object的數據類型。
Java語言常常被人們定位爲「解釋執行」語言,在Java初生的JDK1.0時代,這種定義還比較準確的,但當主流的虛擬機中都包含了即時編譯後,Class文件中的代碼到底會被解釋執行仍是編譯執行,就成了只有虛擬機本身才能準確判斷的事情。再後來,Java也發展出來了直接生成本地代碼的編譯器[如何GCJ(GNU Compiler for the Java)],而C/C++也出現了經過解釋器執行的版本(如CINT),這時候再籠統的說「解釋執行」,對於整個Java語言來講就成了幾乎沒有任何意義的概念。
基於棧的指令集:指令流中的指令大部分都是零地址指令,它們依賴操做數棧進行工做。
基於寄存器的指令集:最典型的就是X86的地址指令集,通俗一點,就是如今咱們主流的PC機中直接支持的指令集架構,這些指令集依賴寄存器工做。
舉個簡單例子,分別使用這兩種指令計算1+1的結果,基於棧的指令集會是這個樣子:
iconst_1 iconst_1 iadd istore_0
兩條iconst_1指令連續把兩個常量1壓入棧後,iadd指令把棧頂的兩個值出棧、相加,而後將結果放回棧頂,最後istore_0把棧頂的值放到局部變量表中的第0個Slot中。
若是基於寄存器的指令集,那程序可能會是這個樣子:
mov eax, 1 add eax, 1
mov指令把EAX寄存器的值設置爲1,而後add指令再把這個值加1,將結果就保存在EAX寄存器裏面。
基於棧的指令集:
優勢:可移植、代碼相對更緊湊、編譯器實現更簡單等
缺點:執行速度慢、完成相同功能的指令數量更多、棧位於內存中基於寄存器的指令集:
優勢:速度快
缺點:與硬件結合緊密
參考連接:
本文由『後端精進之路』原創,首發於博客 http://teckee.github.io/ , 轉載請註明出處
搜索『後端精進之路』關注公衆號,馬上獲取最新文章和價值2000元的BATJ精品面試課程。