附java虛擬機所有知識點「無法安裝java虛擬機是什麼意思」

類文件結構

class類文件的結構

任何一個Class文件都對應着唯一的一個類或接口的定義信息[插圖],但是反過來說,類或接口並不一定都得定義在文件里(譬如類或接口也可以動態生成,直接送入類加載器中)。

《Java虛擬機規範》

根據《Java虛擬機規範》的規定,Class文件格式採用一種類似於C語言結構體的偽結構來存儲數據,這種偽結構中只有兩種數據類型:「無符號數」和「表」。

無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。

表是由多個無符號數或者其他表作為數據項構成的複合數據類型,為了便於區分,所有表的命名都習慣性地以「_info」結尾。

位元組碼由10部分組成,依次是魔數、版本號、常量池、訪問權限、類索引、父類索引、接口索引、字段表索引、方法、Attribute。

  1. 魔數 每個Class文件的頭4個位元組被稱為魔數(Magic Number),它的唯一作用是確定這個文件是否為一個能被虛擬機接受的Class文件

1個十六進制數對應 4 位 二進制數,那麼CAFEBABE 一共 8 個十六進制數,一共需要 32 位二進制數,對應就是 4 個位元組

  1. 版本號 由minor_version(次版本號)major_version(主版本號) 組成各佔兩個位元組
  2. 常量池 Class文件結構中與其他項目關聯最多的數據,通常也是佔用Class文件空間最大的數據項目之一,另外,它還是在Class文件中第一個出現的表類型數據項目。

常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。字面量比較接近於Java語言層面的常量概念,如文本字符串、被聲明為final的常量值等。而符號引用則屬於編譯原理方面的概念,主要包括下面幾類常量:

·被模塊導出或者開放的包(Package)

類和接口的全限定名(Fully Qualified Name)

·字段的名稱和描述符(Descriptor)

·方法的名稱和描述符·方法句柄和方法類型(Method Handle、Method Type、Invoke Dynamic)

·動態調用點和動態常量(Dynamically-Computed Call Site、
Dynamically-ComputedConstant)

  1. 訪問標識 緊接着的2個位元組代表訪問標誌(access_flags),這個標誌用於識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstract類型;如果是類的話,是否被聲明為final;等等
  2. 類索引(this_class)和父類索引(super_class)都是一個u2類型的數據,而接口索引集合(interfaces)是一組u2類型的數據的集合,Class文件中由這三項數據來確定該類型的繼承關係。
  3. 字段表(field_info)用於描述接口或者類中聲明的變量。Java語言中的「字段」(Field)包括類級變量以及實例級變量,但不包括在方法內部聲明的局部變量。段可以包括的修飾符有字段的作用域(public、private、protected修飾符)、是實例變量還是類變量(static修飾符)、可變性(final)、並發可見性(volatile修飾符,是否強制從主內存讀寫)、可否被序列化(transient修飾符)、字段數據類型(基本類型、對象、數組)、字段名稱。

描述符的作用是用來描述字段的數據類型、方法的參數列表(包括數量、類型以及順序)和返回值。根據描述符規則,基本數據類型(byte、char、double、float、int、long、short、boolean)以及代表無返回值的void類型都用一個大寫字符來表示,而對象類型則用字符L加對象的全限定名來表示

對於數組類型,每一維度將使用一個前置的「[」字符來描述,如一個定義為「java.lang.String[][]」類型的二維數組將被記錄成「[[Ljava/lang/String;」,一個整型數組「int[]」將被記錄成「[I」。

用描述符來描述方法時,按照先參數列表、後返回值的順序描述,參數列表按照參數的嚴格順序放在一組小括號「()」之內。如方法void inc()的描述符為「()V」,方法java.lang.StringtoString()的描述符為「()Ljava/lang/String;」,方法int indexOf(char[]source,intsourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,intfromIndex)的描述符為「([CII[CIII)I」。

7.方法表集合 方法里的Java代碼,經過Javac編譯器編譯成位元組碼指令之後,存放在方法屬性表集合中一個名為「Code」的屬性裏面,屬性表作為Class文件格式中最具擴展性的一種數據項目。

有可能會出現由編譯器自動添加的方法,最常見的便是類構造器「<clinit>()」方法和實例構造器「<init>()」方法

8.屬性表(attribute_info)在前面的講解之中已經出現過數次,Class文件、字段表、方法表都可以攜帶自己的屬性表集合,以描述某些場景專有的信息。《Java虛擬機規範》允許只要不與已有屬性名重複,任何人實現的編譯器都可以向屬性表中寫入自己定義的屬性信息,Java虛擬機運行時會忽略掉它不認識的屬性。

位元組碼指令

Java位元組碼指令就是Java虛擬機能夠聽得懂、可執行的指令,可以說是Jvm層面的彙編語言,或者說是Java代碼的最小執行單元。

javac命令會將Java源文件編譯成位元組碼文件,即.class文件,其中就包含了大量的位元組碼指令。

Java虛擬機採用面向操作數棧而不是面向寄存器的架構(這兩種架構的執行過程、區別和影響將在第8章中探討),所以大多數指令都不包含操作數,只有一個操作碼,指令參數都存放在操作數棧中。

位元組碼指令分類:

  1. 存儲和加載類指令:主要包括load系列指令、store系列指令和ldc、push系列指令,主要用於在局部變量表、操作數棧和常量池三者之間進行數據調度;(關於常量池前面沒有特別講解,這個也很簡單,顧名思義,就是這個池子里放着各種常量,好比片場的道具庫)
  2. 對象操作指令(創建與讀寫訪問):比如我們剛剛的putfield和getfield就屬於讀寫訪問的指令,此外還有putstatic/getstatic,還有new系列指令,以及instanceof等指令。
  1. 操作數棧管理指令:如pop和dup,他們只對操作數棧進行操作。
  2. 類型轉換指令和運算指令:如add/div/l2i等系列指令,實際上這類指令一般也只對操作數棧進行操作。
  1. 控制跳轉指令:這類里包含常用的if系列指令以及goto類指令。
  2. 方法調用和返回指令:主要包括invoke系列指令和return系列指令。這類指令也意味這一個方法空間的開闢和結束,即invoke會喚醒一個新的java方法小宇宙(新的棧和局部變量表),而return則意味着這個宇宙的結束回收。

公有設計,私有實現

虛擬機實現的方式主要有以下兩種:

·將輸入的Java虛擬機代碼在加載時或執行時翻譯成另一種虛擬機的指令集;

·將輸入的Java虛擬機代碼在加載時或執行時翻譯成宿主機處理程序的本地指令集(即即時編譯器代碼生成技術)。

精確定義的虛擬機行為和目標文件格式,不應當對虛擬機實現者的創造性產生太多的限制,Java虛擬機是被設計成可以允許有眾多不同的實現,並且各種實現可以在保持兼容性的同時提供不同的新的、有趣的解決方案。

class文件的變化

Class文件格式所具備的平台中立(不依賴於特定硬件及操作系統)、緊湊、穩定和可擴展的特點,是Java技術體系實現平台無關、語言無關兩項特性的重要支柱。

Class文件是Java虛擬機執行引擎的數據入口,也是Java技術體系的基礎支柱之一。

虛擬機類加載機制

Java虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這個過程被稱作虛擬機的類加載機制。

一文深入理解 Java 虛擬機

類的生命周期

java編譯時不像其他語言需要連接,類型的加載、連接和初始化過程都是在程序運行期間完成的。編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現類,用戶可以通過Java預置的或自定義類加載器,讓某個本地的應用程序在運行時從網絡或其他地方上加載一個二進制流作為其程序代碼的一部分。運行時加載廣泛應用於Java程序之中。

一文深入理解 Java 虛擬機

《Java虛擬機規範》則是嚴格規定了有且只有六種情況必須立即對類進行「初始化」(而加載、驗證、準備自然需要在此之前開始):

  1. 1)遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:·使用new關鍵字實例化對象的時候。·讀取或設置一個類型的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候。·調用一個類型的靜態方法的時候。
  2. 2)使用java.lang.reflect包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。
  1. 3)當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  2. 4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  1. 5)當使用JDK

    7新加入的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,並且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。

  2. 6)當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。

接口與類真正有所區別的是前面講述的六種「有且僅有」需要觸發初始化場景中的第三種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化。

類加載過程

加載

1)通過一個類的全限定名來獲取定義此類的二進制位元組流。

2)將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構。

3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

獲取類的二進制位元組流的形式

  1. ·從ZIP壓縮包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎。
  2. ·從網絡中獲取,這種場景最典型的應用就是Web

    Applet。·運行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()來為特定接口生成形式為「*$Proxy」的代理類的二進制位元組流。

  1. ·由其他文件生成,典型場景是JSP應用,由JSP文件生成對應的Class文件。·從數據庫中讀取,這種場景相對少見些,例如有些中間件服務器(如SAP Netweaver)可以選擇把程序安裝到數據庫中來完成程序代碼在集群間的分發。
  2. ·可以從加密文件中獲取,這是典型的防Class文件被反編譯的保護措施,通過加載時解密Class文件來保障程序運行邏輯不被窺探。

驗證

驗證階段大致上會完成下面四個階段的檢驗動作:文件格式驗證、元數據驗證、位元組碼驗證和符號引用驗證。

「停機問題」(Halting Problem)[插圖],即不能通過程序準確地檢查出程序是否能在有限的時間之內結束運行。

準備

正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存並設置類變量初始值的階段。

首先是這時候進行內存分配的僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在Java堆中。其次是這裡所說的初始值「通常情況」下是數據類型的零值,假設一個類變量的定義為:

public static int value=123;

那變量value在準備階段過後的初始值為0而不是123,因為這時尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為123的動作要到類的初始化階段才會被執行。表7-1列出了Java中所有基本數據類型的零值。

一文深入理解 Java 虛擬機

解析

Java虛擬機將常量池內的符號引用替換為直接引用的過程。

符號引用與虛擬機實現的內存布局無關,引用的目標並不一定是已經加載到虛擬機內存當中的內容。直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局直接相關

1.類或接口的解析

需要判斷該類是否是數組類型

如果我們說一個D擁有C的訪問權限,那就意味着以下3條規則中至少有其中一條成立:

·被訪問類C是public的,並且與訪問類D處於同一個模塊。

·被訪問類C是public的,不與訪問類D處於同一個模塊,但是被訪問類C的模塊允許被訪問類D的模塊進行訪問。

·被訪問類C不是public的,但是它與訪問類D處於同一個包中。

2.字段解析

首先將會對字段表內class_index[插圖]項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用;

3.方法解析

先解析出方法表的class_index[插圖]項中索引的方法所屬的類或接口的符號引用,如果解析成功,那麼我們依然用C表示這個類。

1)由於Class文件格式中類的方法和接口的方法符號引用的常量類型定義是分開的,如果在類的方法表中發現class_index中索引的C是個接口的話,那就直接拋出
java.lang.IncompatibleClassChangeError異常。

2)如果通過了第一步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

3)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

4)否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時候查找結束,拋出
java.lang.AbstractMethodError異常。

5)否則,宣告方法查找失敗,拋出
java.lang.NoSuchMethodError。最後,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,將拋出java.lang.IllegalAccessError異常。

4.接口方法解析

方法解析類似

JDK 9中增加了接口的靜態私有方法,也有了模塊化的訪問約束,所以從JDK 9起,接口方法的訪問也完全有可能因訪問權限控制而出現
java.lang.IllegalAccessError異常。

初始化

初始化階段就是執行類構造器<clinit>()方法的過程。

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

<clinit>()方法與類的構造函數(即在虛擬機視角中的實例構造器<init>()方法)不同,它不需要顯式地調用父類構造器,Java虛擬機會保證在子類的<clinit>()方法執行前,父類的<clinit>()方法已經執行完畢。因此在Java虛擬機中第一個被執行的<clinit>()方法的類型肯定是java.lang.Object。·由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。

類加載器

對於任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。

比較兩個類是否「相等」,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個Class文件,被同一個Java虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

這裡所指的「相等」,包括代表類的Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括了使用instanceof關鍵字做對象所屬關係判定等各種情況。

從java虛擬機角度看有倆種不同的類加載器:

一:啟動類加載器(Bootstrap ClassLoader) C++實現

二:所有其他的類加載器(全部都繼承自抽象類java.lang.ClassLoader) java實現

從開發人員角度看:

啟動類加載器Bootstrap ClassLoader

作用:負責將存放在<JAVA_HOME>lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的(僅按照文件名識別,如rt.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機中。啟動類加載器無法被java程序直接引用,用戶在編寫自定義類加載時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可,

擴展類加載器Extension ClassLoader

這個加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載<JAVA_HOME>libext目錄中的,或者被java.ext.dirs系統變量所指定的路徑的所有類庫,開發者可以直接使用擴展類加載器

應用程序加載器Application ClassLoader

這個類加載器是由sun.misc.Launcher$AppClassLoader實現。由於這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱它為系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器。如果應用程序中沒有自定義自己的類加載器,一般情況下這個就是程序中默認的類加載器。

雙親委派模型

雙親委派模型的工作流程:

當類加載器接收到類加載的請求時,它不會自己去嘗試加載這個類,而是把這個請求委派給父加載器去完成,每一個層次的類加載器都是如此,因此所有的請求最終都應該傳送到啟動類加載器中,只有當父類加載器反饋自己無法完成這個加載請求(它的搜索範圍內沒有找到所需的類)時,子加載器才會嘗試自己去加載。

優點:java類隨着它的類加載器一起具備了一種帶有優先級的層次關係。

舉例:比如我們要加載java.lang.Object,它存放在rt.jar中,無論哪個類加載器要加載換個類,都會委派給處於模型最頂端的啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類(上面提到了如何比較倆個類是否’相等’)。相反,如果沒有雙親委派模型,那麼各個類加載器都去自行加載的話,那麼在程序中就會出現多個Object類,導致應用程序一片混亂。

雙親委派模型的實現

protected synchronized Class<?> loadClass(String name, Boolean resolve) throws ClassNotFoundException{
    //首先檢查請求的類是否已經被加載過
    Class c = findLoadedClass(name);
         if(c == null){
         try{
             if(parent != null){
             //委派父類加載器加載
             c = parent.loadClass(name, false);
         }
         else{
              //委派啟動類加載器加載
             c = findBootstrapClassOrNull(name);
         }
         }catch(ClassNotFoundException e){
             //父類加載器無法完成類加載請求
         }
         if(c == null){
              //本身類加載器進行類加載
             c = findClass(name);
         }
     }
     if(resolve){
         resolveClass(c);
     }
     return c;
}

雙親委派模型要求除了頂層的啟動類加載器外,其餘的類加載器都應有自己的父類加載器。不過這裡類加載器之間的父子關係一般不是以繼承(Inheritance)的關係來實現的,而是通常使用組合(Composition)關係來複用父加載器的代碼。

雙親委派模型的破壞

  1. JDK12才有雙親委派模型,面對已經存在的用戶自定義類加載器的代碼,為了兼容這些已有代碼,無法再以技術手段避免loadClass()被子類覆蓋的可能性,只能在JDK

    1.2之後的java.lang.ClassLoader中添加一個新的protected方法findClass(),並引導用戶編寫的類加載邏輯時儘可能去重寫這個方法,而不是在loadClass()中編寫代碼。

  2. 由這個模型自身的缺陷導致的,如果有基礎類型又要調用回用戶的代碼。
  3. 由於用戶對程序動態性的追求而導致的,這裡所說的「動態性」指的是一些非常「熱」門的名詞:代碼熱替換(Hot Swap)、模塊熱部署(HotDeployment)等

OSGi實現模塊化熱部署的關鍵是它自定義的類加載器機制的實現,每一個程序模塊(OSGi中稱為Bundle)都有一個自己的類加載器,當需要更換一個Bundle時,就把Bundle連同類加載器一起換掉以實現代碼的熱替換。在OSGi環境下,類加載器不再雙親委派模型推薦的樹狀結構,而是進一步發展為更加複雜的網狀結構

Java模塊化系統

在JDK 9中引入的Java模塊化系統(Java Platform Module System,JPMS)是對Java技術的一次重要升級,為了能夠實現模塊化的關鍵目標——可配置的封裝隔離機制,Java虛擬機對類加載架構也做出了相應的變動調整,才使模塊化系統得以順利地運作。

  1. ·JAR文件在類路徑的訪問規則:所有類路徑下的JAR文件及其他資源文件,都被視為自動打包在一個匿名模塊(Unnamed

    Module)里,這個匿名模塊幾乎是沒有任何隔離的,它可以看到和使用類路徑上所有的包、JDK系統模塊中所有的導出包,以及模塊路徑上所有模塊中導出的包。

  2. ·模塊在模塊路徑的訪問規則:模塊路徑下的具名模塊(Named Module)只能訪問到它依賴定義中列明依賴的模塊和包,匿名模塊里所有的內容對具名模塊來說都是不可見的,即具名模塊看不見傳統JAR包的內容。
  3. ·JAR文件在模塊路徑的訪問規則:如果把一個傳統的、不包含模塊定義的JAR文件放置到模塊路徑中,它就會變成一個自動模塊(Automatic

    Module)。儘管不包含module-info.class,但自動模塊將默認依賴於整個模塊路徑中的所有模塊,因此可以訪問到所有模塊導出的包,自動模塊也默認導出自己所有的包。

JDK9以後,擴展類加載器(Extension Class Loader)被平台類加載器(Platform ClassLoader)取代。

當平台及應用程序類加載器收到類加載請求,在委派給父加載器加載前,要先判斷該類是否能夠歸屬到某一個系統模塊中,如果可以找到這樣的歸屬關係,就要優先委派給負責那個模塊的加載器完成加載,也許這可以算是對雙親委派的第四次破壞。

虛擬機執行引擎

Java虛擬機以方法作為最基本的執行單元,「棧幀」(Stack Frame)則是用於支持虛擬機進行方法調用和方法執行背後的數據結構,它也是虛擬機運行時數據區中的虛擬機棧(VirtualMachine Stack)[插圖]的棧元素。

棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。

每一個方法從調用開始至執行結束的過程,都對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。

局部變量表

局部變量表(Local Variables Table)是一組變量值的存儲空間,用於存放方法參數和方法內部定義的局部變量。在Java程序被編譯為Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需分配的局部變量表的最大容量。

一個變量槽可以存放一個32位以內的數據類型,Java中佔用不超過32位存儲空間的數據類型有boolean、byte、char、short、int、float、reference[插圖]和returnAddress這8種類型。

第7種reference類型表示對一個對象實例的引用,虛擬機實現至少都應當能通過這個引用做到兩件事情,一是從根據引用直接或間接地查找到對象在Java堆中的數據存放的起始地址或索引,二是根據引用直接或間接地查找到對象所屬數據類型在方法區中的存儲的類型信息,否則將無法實現《Java語言規範》中定義的語法約定。

當一個方法被調用時,Java虛擬機會使用局部變量表來完成參數值到參數變量列表的傳遞過程,即實參到形參的傳遞。如果執行的是實例方法(沒有被static修飾的方法),那局部變量表中第0位索引的變量槽默認是用於傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字「this」來訪問到這個隱含的參數。

操作數棧

操作數棧(Operand Stack)也常被稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候被寫入到Code屬性的max_stacks數據項之中。

Java虛擬機的解釋執行引擎被稱為「基於棧的執行引擎」,裏面的「棧」就是操作數棧。

一文深入理解 Java 虛擬機

動態連接

每個棧幀都包含一個指向運行時常量池[插圖]中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接(Dynamic Linking)。

Class文件的常量池中存有大量的符號引用,位元組碼中的方法調用指令就以常量池裡指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就被轉化為直接引用,這種轉化被稱為靜態解析。另外一部分將在每一次運行期間都轉化為直接引用,這部分就稱為動態連接。

方法返回地址

當一個方法開始執行後,只有兩種方式退出這個方法。

第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者或者主調方法),方法是否有返回值以及返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為「正常調用完成」(Normal Method InvocationCompletion)。

另外一種退出方式是在方法執行的過程中遇到了異常,並且這個異常沒有在方法體內得到妥善處理。這種退出方法的方式稱為「異常調用完成(Abrupt Method Invocation Completion)」。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者提供任何返回值的。

無論採用何種退出方式,在方法退出之後,都必須返回到最初方法被調用時的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層主調方法的執行狀態。

方法正常退出時,主調方法的PC計數器的值就可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中就一般不會保存這部分信息。

一般會把動態連接、方法返回地址與其他附加信息全部歸為一類,稱為棧幀信息。

方法調用

方法調用並不等同於方法中的代碼被執行,方法調用階段唯一的任務就是確定被調用方法的版本(即調用哪一個方法),暫時還未涉及方法內部的具體運行過程。

解析

所有方法調用的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將符合「編譯期可知,運行期不可變」的方法符號引用轉化為直接引用。換句話說,調用目標在程序代碼寫好、編譯器進行編譯那一刻就已經確定下來。這類方法的調用被稱為解析(Resolution)。

靜態方法、私有方法、實例構造器、父類方法4種,再加上被final修飾的方法(儘管它使用invokevirtual指令調用),這5種方法調用會在類加載的時候就可以把符號引用解析為該方法的直接引用。這些方法統稱為「非虛方法」(Non-VirtualMethod),與之相反,其他方法就被稱為「虛方法」(Virtual Method)。

分派Dispatch

1.靜態分派

英文一般是「Method Overload Resolution」,所以其實是個動態概念

public class StaticDispatch {
    static abstract class Human{

    }
    static class Man extends Human{

    }
    static class Woman extends Human{

    }
    public void sayHello(Human guy){
        System.out.println("Hello guy");
    }
    public void sayHello(Man guy){
        System.out.println("Hello gentleman");
    }
    public void sayHello(Woman guy){
        System.out.println("Hello lady");
    }
    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch staticDispatch = new StaticDispatch();
        staticDispatch.sayHello(man);
        staticDispatch.sayHello(woman);
    }
}
運行結果
Hello guy
Hello guy

Human hu = new Man():

面代碼中的「Human」稱為變量的靜態類型(Static Type)或者外觀類型(Apparent Type),後面的「Man」則稱為變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,並且最終的靜態類型是編譯期可知的;而實際類型變化的結果在運行期才可確定,編譯期在編譯程序的時候並不知道一個對象的實際類型是什麼?如下面的代碼:

//實際類型變化
Human man = new Man();
man = new Woman();
//靜態類型變化
sd.sayHello((Man)man);
sd.sayHello((Woman)man);

所有依賴靜態類型來決定方法執行版本的分派動作,都稱為靜態分派。靜態分派的最典型應用表現就是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的,這點也是為何一些資料選擇把它歸入「解析」而不是「分派」的原因。

public class StaticPaiDemo {
    public static void sayHello(int i){
        System.out.println("int 類型");
    }

    public static void sayHello(Object obj){
        System.out.println("obj 類型");
    }

    public static void sayHello(long i){
        System.out.println("long 類型");
    }

    public static void sayHello(char i){
        System.out.println("char 類型");
    }

    public static void main(String[] args) {
        sayHello(1);
        sayHello(1L);
        sayHello('a');
    }
}

筆者講述的解析與分派這兩者之間的關係並不是二選一的排他關係,它們是不同層次上去篩選、確定目標方法的過程。例如,前面說過,靜態方法會在類加載期就進行解析,而靜態方法顯然也是可以擁有重載版本的,選擇重載版本的過程也是通過靜態分派完成的。

自動轉型還能繼續發生多次,按照char>int>long>float>double的順序轉型進行匹配,但不會匹配到byte和short類型的重載,因為char到byte或short的轉型是不安全的。

自動裝箱

裝箱後轉型為父類了,如果有多個父類,那將在繼承關係中從下往上開始搜索,越接上層的優先級越低。

可見變長參數的重載優先級是最低的,這時候字符’a’被當作了一個char[]數組的元素。

有一些在單個參數中能成立的自動轉型,如char轉型為int,在變長參數中是不成立的

動態分派

Java語言多態性的另外一個重要體現——重寫(Override)。

public class DynamicDispatch {  
    static abstract class Human{  
        protected abstract void sayHello();  
    }  
    static class Man extends Human{   
        @Override  
        protected void sayHello() {   
            System.out.println("man say hello!");  
        }  
    }  
    static class Woman extends Human{   
        @Override  
        protected void sayHello() {   
            System.out.println("woman say hello!");  
        }  
    }   
    public static void main(String[] args) {  

        Human man=new Man();  
        Human woman=new Woman();  
        man.sayHello();  
        woman.sayHello();  
        man=new Woman();  
        man.sayHello();   
    }  
}

輸出結果:
man say hello!
woman say hello!
woman say hello!

根據《Java虛擬機規範》,invokevirtual指令的運行時解析過程[插圖]大致分為以下幾步:

1)找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C。

2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;不通過則返回
java.lang.IllegalAccessError異常。

3)否則,按照繼承關係從下往上依次對C的各個父類進行第二步的搜索和驗證過程。

4)如果始終沒有找到合適的方法,則拋出
java.lang.AbstractMethodError異常。

正是因為invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令並不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據方法接收者的實際類型來選擇方法版本,這個過程就是Java語言中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。

既然這種多態性的根源在於虛方法調用指令invokevirtual的執行邏輯,那自然我們得出的結論就只會對方法有效,對字段是無效的,因為字段不使用這條指令。事實上,在Java裏面只有虛方法存在,字段永遠不可能是虛的,換句話說,字段永遠不參與多態,哪個類的方法訪問某個名字的字段時,該名字指的就是這個類能看到的那個字段。當子類聲明了與父類同名的字段時,雖然在子類的內存中兩個字段都會存在,但是子類的字段會遮蔽父類的同名字段。

一文深入理解 Java 虛擬機
一文深入理解 Java 虛擬機

輸出兩句都是「I am Son」,這是因為Son類在創建的時候,首先隱式調用了Father的構造函數,而Father構造函數中對showMeTheMoney()的調用是一次虛方法調用,實際執行的版本是Son::showMeTheMoney()方法,所以輸出的是「I am Son」,這點經過前面的分析相信讀者是沒有疑問的了。而這時候雖然父類的money字段已經被初始化成2了,但Son::showMeTheMoney()方法中訪問的卻是子類的money字段,這時候結果自然還是0,因為它要到子類的構造函數執行時才會被初始化。main()的最後一句通過靜態類型訪問到了父類中的money,輸出了2。

方法的多分派和單分派

方法的接收者與方法的參數統稱為方法的宗量,這個定義最早應該來源於著名的《Java與模式》一書。根據分派基於多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。

根據上述論證的結果,我們可以總結一句:如今(直至本書編寫的Java 12和預覽版的Java 13)的Java語言是一門靜態多分派、動態單分派的語言。

動態語言支持

JDK 7的發佈的位元組碼首位新成員——invokedynamic指令。

動態類型語言的關鍵特徵是它的類型檢查的主體過程是在運行期而不是編譯期進行的,滿足這個特徵的語言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相對地,在編譯期就進行類型檢查過程的語言,譬如C++和Java等就是最常用的靜態類型語言。

Java虛擬機層面對動態類型語言的支持一直都還有所欠缺,主要表現在方法調用方面:JDK 7以前的位元組碼指令集中,4條方法調用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一個參數都是被調用的方法的符號引用(CONSTANT_Methodref_info或者
CONSTANT_InterfaceMethodref_info常量),前面已經提到過,方法的符號引用在編譯時產生,而動態類型語言只有在運行期才能確定方法的接收者。

java.lang.invoke包[插圖]是JSR 292的一個重要組成部分,這個包的主要目的是在之前單純依靠符號引用來確定調用的目標方法這條路之外,提供一種新的動態確定目標方法的機制,稱為「方法句柄」(Method Handle)。

invokedynamic指令與MethodHandle機制的作用是一樣的,都是為了解決原有4條「invoke*」指令方法分派規則完全固化在虛擬機之中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼之中,讓用戶(廣義的用戶,包含其他程序語言的設計者)有更高的自由度。

基於棧的位元組碼解釋執行引擎

讀、理解,然後獲得執行能力。大部分的程序代碼轉換成物理機的目標代碼或虛擬機能執行的指令集之前,都需要下圖的步驟:

一文深入理解 Java 虛擬機

基於棧的指令集與基於寄存器的指令集這兩者之間有什麼不同呢?舉個最簡單的例子,分別使用這兩種指令集去計算「1+1」的結果,基於棧的指令集會是這樣子的:

一文深入理解 Java 虛擬機

兩條iconst_1指令連續把兩個常量1壓入棧後,iadd指令把棧頂的兩個值出棧、相加,然後把結果放回棧頂,最後istore_0把棧頂的值放到局部變量表的第0個變量槽中。這種指令流中的指令通常都是不帶參數的,使用操作數棧中的數據作為指令的運算輸入,指令的運算結果也存儲在操作數棧之中。

而如果用基於寄存器的指令集,那程序可能會是這個樣子:

一文深入理解 Java 虛擬機

mov指令把EAX寄存器的值設為1,然後add指令再把這個值加1,結果就保存在EAX寄存器裏面。這種二地址指令是x86指令集中的主流,每個指令都包含兩個單獨的輸入參數,依賴於寄存。

基於棧的指令集主要優點是可移植,因為寄存器由硬件直接提供[插圖],程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束。

棧架構指令集的主要缺點是理論上執行速度相對來說會稍慢一些,所有主流物理機的指令集都是寄存器架構。

例如:

a=100;
b=200;
c=300;
return (a+b)*c

javap提示這段代碼需要深度為2的操作數棧和4個變量槽的局部變量空間

一文深入理解 Java 虛擬機

代碼編譯和優化

Tomcat的類加載

一文深入理解 Java 虛擬機

OSGi(Open Service Gateway Initiative)是OSGi聯盟(OSGi Alliance)制訂的一個基於Java語言的動態模塊化規範(在JDK 9引入的JPMS是靜態的模塊系統)

位元組碼生成技術與動態代理的實現

位元組碼生成技術應用於:javac,Web服務器中的JSP編譯器,編譯時織入的AOP框架,還有很常用的動態代理技術,甚至在使用反射的時候虛擬機都有可能會在運行時生成位元組碼來提高執行速度。

動態代理中所說的「動態」,是針對使用Java代碼實際編寫了代理類的「靜態」代理而言的,它的優勢不在於省去了編寫代理類那一點編碼工作量,而是實現了可以在原始類和接口還未知的時候,就確定代理類的代理行為,當代理類與原始類脫離直接聯繫後,就可以很靈活地重用於不同的應用場景之中。

跨越JDK版本之間的溝壑,把高版本JDK中編寫的代碼放到低版本JDK環境中去部署使用。為了解決這個問題,一種名為「Java逆向移植」的工具(Java Backporting Tools)應運而生,Retrotranslator[插圖]和Retrolambda是這類工具中的傑出代表。

JDK的每次升級新增的功能大致可以分為以下五類:

1)對Java類庫API的代碼增強。譬如JDK 1.2時代引入的java.util.Collections等一系列集合類,在JDK 5時代引入的java.util.concurrent並發包、在JDK 7時引入的java.lang.invoke包,等等。

2)在前端編譯器層面做的改進。這種改進被稱作語法糖,如自動裝箱拆箱,實際上就是Javac編譯器在程序中使用到包裝對象的地方自動插入了很多Integer.valueOf()、Float.valueOf()之類的代碼;變長參數在編譯之後就被自動轉化成了一個數組來完成參數傳遞;泛型的信息則在編譯階段就已經被擦除掉了(但是在元數據中還保留着),相應的地方被編譯器自動插入了類型轉換代碼[插圖]。

3)需要在位元組碼中進行支持的改動。如JDK 7裏面新加入的語法特性——動態語言支持,就需要在虛擬機中新增一條invokedynamic位元組碼指令來實現相關的調用功能。不過位元組碼指令集一直處於相對穩定的狀態,這種要在位元組碼層面直接進行的改動是比較少見的。

4)需要在JDK整體結構層面進行支持的改進,典型的如JDK 9時引入的Java模塊化系統,它就涉及了JDK結構、Java語法、類加載和連接過程、Java虛擬機等多個層面。

5)集中在虛擬機內部的改進。如JDK 5中實現的JSR-133[插圖]規範重新定義的Java內存模型(Java Memory Model,JMM),以及在JDK 7、JDK 11、JDK 12中新增的G1、ZGC和Shenandoah收集器之類的改動,這種改動對於程序員編寫代碼基本是透明的,只會在程序運行時產生影響。

編譯的概念

前端編譯器(叫「編譯器的前端」更準確一些)把*.java文件轉變成*.class文件的過程;

Java虛擬機的即時編譯器(常稱JIT編譯器,Just In Time Compiler)運行期把位元組碼轉變成本地機器碼的過程;

指使用靜態的提前編譯器(常稱AOT編譯器,Ahead Of Time Compiler)。

Java中即時編譯器在運行期的優化過程,支撐了程序執行效率的不斷提升;而前端編譯器在編譯期的優化過程,則是支撐着程序員的編碼效率和語言使用者的幸福感的提高。

編譯——1個準備3個處理過程

1)準備過程:初始化插入式註解處理器。

2)解析與填充符號表過程,包括:·詞法、語法分析。將源代碼的字符流轉變為標記集合,構造出抽象語法樹。·填充符號表。產生符號地址和符號信息。

3)插入式註解處理器的註解處理過程:插入式註解處理器的執行階段,本章的實戰部分會設計一個插入式註解處理器來影響Javac的編譯行為。

4)分析與位元組碼生成過程,包括:·標註檢查。對語法的靜態信息進行檢查。·數據流及控制流分析。對程序動態運行過程進行檢查。·解語法糖。將簡化代碼編寫的語法糖還原為原有的形式。·位元組碼生成。將前面各個步驟所生成的信息轉化成位元組碼。

執行插入式註解時又可能會產生新的符號,如果有新的符號產生,就必須轉回到之前的解析、填充符號表的過程中重新處理這些新符號

插入式註解處理器看作是一組編譯器的插件,當這些插件工作時,允許讀取、修改、添加抽象語法樹中的任意元素。如果這些插件在處理註解期間對語法樹進行過修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式註解處理器都沒有再對語法樹進行修改為止,每一次循環過程稱為一個輪次(Round)。

語義分析與位元組碼生成

1.標註檢查

標註檢查步驟要檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否能夠匹配。

常量摺疊(Constant Folding)的代碼優化:在代碼裏面定義「a=1+2」比起直接定義「a=3」來,並不會增加程序運行期哪怕僅僅一個處理器時鐘周期的處理工作量。

2.數據及控制流分析

數據流分析和控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。

語法糖

泛型

泛型的本質是參數化類型(Parameterized Type)或者參數化多態(ParametricPolymorphism)的應用,即可以將操作的數據類型指定為方法簽名中的一種特殊參數,這種參數類型能夠用在類、接口和方法的創建中,分別構成泛型類、泛型接口和泛型方法。

Java選擇的泛型實現方式叫作「類型擦除式泛型」(Type Erasure Generics),而C#選擇的泛型實現方式是「具現化式泛型」(Reified Generics)。

Java語言中的泛型則不同,它只在程序源碼中存在,在編譯後的位元組碼文件中,全部泛型都被替換為原來的裸類型(Raw Type,稍後我們會講解裸類型具體是什麼)了,並且在相應的地方插入了強制轉型代碼,因此對於運行期的Java語言來說,ArrayList<int>與ArrayList<String>其實是同一個類型

在沒有泛型的時代,由於Java中的數組是支持協變(Covariant)的,引入泛型後可以選擇:

1)需要泛型化的類型(主要是容器類型),以前有的就保持不變,然後平行地加一套泛型化版本的新類型。

2)直接把已有的類型泛型化,即讓所有需要泛型化的已有類型都原地泛型化,不添加任何平行於已有類型的泛型版。

我們繼續以ArrayList為例來介紹Java泛型的類型擦除具體是如何實現的。由於Java選擇了第二條路,直接把已有的類型泛型化。要讓所有需要泛型化的已有類型,譬如ArrayList,原地泛型化後變成了ArrayList<T>,而且保證以前直接用ArrayList的代碼在泛型新版本里必須還能繼續用這同一個容器,這就必須讓所有泛型化的實例類型,譬如ArrayList<Integer>、ArrayList<String>這些全部自動成為ArrayList的子類型才能可以,否則類型轉換就是不安全的。由此就引出了「裸類型」(Raw Type)的概念,裸類型應被視為所有該類型泛型化實例的共同父類型(Super Type)。

如何實現裸類型。這裡又有了兩種選擇:一種是在運行期由Java虛擬機來自動地、真實地構造出ArrayList這樣的類型,並且自動實現從ArrayList派生自ArrayList的繼承關係來滿足裸類型的定義;另外一種是索性簡單粗暴地直接在編譯時把ArrayList還原回ArrayList,只在元素訪問、修改時自動插入一些強制類型轉換和檢查指令。

基於這種方法實現的泛型稱為偽泛型。

public static void main(String[] args){
   Map<String,String> map=new HashMap<String,String>();
   map.put("hello","nihao");
   map.put("how are you","chifan");
   System.out.println(map.get("hello"));
   System.out.println(map.get("how are you"));
}

這段代碼編譯成Class文件,然後用位元組碼反編譯工具進行反編譯後,泛型類型都變回了原生類型

public static void main(String[] args){
   Map map=new HashMap();
   map.put("hello","nihao");
   map.put("how are you","chifan");
   System.out.println((String)map.get("hello"));
   System.out.println((String)map.get("how are you"));
}

java泛型擦除式實現的缺陷:

1.對原始類型(Primitive Types)數據的支持又成了新的麻煩,既然沒法轉換那就索性別支持原生類型的泛型了吧,你們都用ArrayList<Integer>、ArrayList<Long>,反正都做了自動的強制類型轉換,遇到原生類型時把裝箱、拆箱也自動做了得了。這個決定後面導致了無數構造包裝類和裝箱、拆箱的開銷,成為Java泛型慢的重要原因,也成為今天Valhalla項目要重點解決的問題之一。

2.運行期無法取到泛型類型信息。

一文深入理解 Java 虛擬機

由於List<String>和List<Integer>擦除後是同一個類型,我們只能添加兩個並不需要實際使用到的返回值才能完成重載。

一文深入理解 Java 虛擬機

另外,從Signature屬性的出現我們還可以得出結論,擦除法所謂的擦除,僅僅是對方法的Code屬性中的位元組碼進行擦除,實際上元數據中還是保留了泛型信息,這也是我們在編碼時能通過反射手段取得參數化類型的根本依據。

條件編譯

定義一個 final 的變量,然後在 if 語句用中它隔開代碼。

public class Hello {  
   public static void main(String[] args) {  
       final boolean DEBUG = true;  
       if (DEBUG) {  
           System.out.println("Hello, world!");  
       }  else {
           // some code
       }
   }  
}

因為編譯器會對代碼進行優化,對於條件永遠為 false 的語句,Java 編譯器將不會對其生成位元組碼。

應用場景:實現一個區分DEBUG和RELEASE模式的程序。

協變與逆變

逆變與協變用來描述類型轉換(type transformation)後的繼承關係,其定義:如果A、B表示類型,f(⋅)表示類型轉換,≤表示繼承關係(比如,A≤B表示A是由B派生出來的子類);

f(⋅)是逆變(contravariant)的,當A≤B時有f(B)≤f(A)成立;

f(⋅)是協變(covariant)的,當A≤B時有f(A)≤f(B)成立;

f(⋅)是不變(invariant)的,當A≤B時上述兩個式子均不成立,即f(A)與f(B)相互之間沒有繼承關係。

數組是協變的

Food food = new Fruit();  
// or
food = new Meat(); // 即 把子類賦值給父類引用

Fruit [] arrFruit = new Fruit[3];
Food [] arrFood = arrFruit; // 數組協變的

泛型是不變的

List<Beef> beefList = new ArrayList<>();
List<Food> foodList = beefList; //錯誤:不可協變  
beefList = foodList; // 錯誤 :不可逆變

eat(beefList);// 錯誤::不可協變  

public void addFood(List<Food> list){
    list.add(new Apple());
}

泛型使用通配符實現協變與逆變。 PECS: producer-extends, consumer-super.

<? extends>實現了泛型的協變,比如:

List<? extends Number> list = new ArrayList<Integer>();

<? super>實現了泛型的逆變,比如:

List<? super Number> list = new ArrayList<Object>();
List<? extends Food> foodList = new ArrayList<>();
List<Apple> appleList = new ArrayList<>();
foodList = appleList; // ok 協變

foodList.add(new Beef()); // 錯誤 不能執行添加null 以外的操作
foodList.add(new Food());// 錯誤,同上,
foodlist.add(new Apple()); // 錯誤,同上

Food food = foodList.get(index); //ok, 把子類引用賦值給父類顯然是可以的

可以把 appleList 賦值給 foodList,但是不能對foodList 添加除null 以外的任何對象。

方法的形參是協變的、返回值是逆變的:

通過與網友iamzhoug37的討論,更新如下。

調用方法result = method(n);根據Liskov替換原則,傳入形參n的類型應為method形參的子類型,即typeof(n)≤typeof(method’s parameter);result應為method返回值的基類型,即typeof(methods’s return)≤typeof(result)

後端編譯

位元組碼看作是程序語言的一種中間表示形式(Intermediate Representation,IR)的話,那編譯器無論在何時、在何種狀態下把Class文件轉換成與本地基礎設施(硬件指令集、操作系統)相關的二進制機器碼,它都可以視為整個編譯過程的後端。

高效並發

當價格不變時,集成電路上可容納的元器件的數目,約每隔18-24個月便會增加一倍,性能也將提升一倍,

CPU長期都是以指數型快速提高,但是近年來,CPU主頻始終保持在4G赫茲左右,無法再進一步提升。摩爾定律逐漸失效。

一文深入理解 Java 虛擬機

處理器數量 並行比例

計算機的運算速度與它的存儲和通信子系統的速度差距太大,大量的時間都花費在磁盤I/O、網絡通信或者數據庫訪問上。

衡量一個服務性能的高低好壞,每秒事務處理數(Transactions PerSecond,TPS)是重要的指標之一,它代表着一秒內服務端平均能響應的請求總數,而TPS值與程序的並發能力又有非常密切的關係

java內存模型

主內存和工作內存

Java內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。此處的變量(Variables)與Java編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,因為後者是線程私有的[插圖],不會被共享,自然就不會存在競爭問題。為了獲得更好的執行效能,Java內存模型並沒有限制執行引擎使用

處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器是否要進行調整代碼執行順序這類優化措施。

Java內存模型規定了所有的變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理硬件時提到的主內存名字一樣,兩者也可以類比,但物理上它僅是虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory,可與前面講的處理器高速緩存類比),線程的工作內存中保存了被該線程使用的變量的主內存副本[插圖],線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據[插圖]。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成。

關於主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存這一類的實現細節。

·lock(鎖定):作用於主內存的變量,它把一個變量標識為一條線程獨佔的狀態。

·unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才可以被其他線程鎖定。

·read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用。

·load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。·use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的位元組碼指令時將會執行這個操作。

·assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的位元組碼指令時執行這個操作。

·store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。

·write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

如果要把一個變量從主內存拷貝到工作內存,那就要按順序執行read和load操作,如果要把變量從工作內存同步回主內存,就要按順序執行store和write操作。注意,Java內存模型只要求上述兩個操作必須按順序執行,但不要求是連續執行。也就是說read與load之間、store與write之間是可插入其他指令的,如對主內存中的變量a、b進行訪問時,一種可能出現的順序是reada、read b、load b、load a。除此之外,Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:·不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者工作內存發起回寫了但主內存不接受的情況出現。·不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。·不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。

volatile 將具備兩項特性:第一項是保證此變量對所有線程的可見性,這裡的「可見性」是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。比如,線程A修改一個普通變量的值,然後向主內存進行回寫,另外一條線程B在線程A回寫完成了之後再對主內存進行讀取操作,新變量值才會對線程B可見。

第二個語義是禁止指令重排序優化,普通的變量僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。

Java內存模型中對volatile變量定義的特殊規則的定義。假定T表示一個線程,V和W分別表示兩個volatile型變量,那麼在進行read、load、use、assign、store和write操作時需要滿足如下規則:·只有當線程T對變量V執行的前一個動作是load的時候,線程T才能對變量V執行use動作;並且,只有當線程T對變量V執行的後一個動作是use的時候,線程T才能對變量V執行load動作。線程T對變量V的use動作可以認為是和線程T對變量V的load、read動作相關聯的,必須連續且一起出現。

原子性(Atomicity)

基本數據類型的訪問、讀寫都是具備原子性的(例外就是long和double的非原子性協定

可見性(Visibility)

普通變量與volatile變量的區別是,volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新

Java還有兩個關鍵字能實現可見性,它們是synchronized和final。同步塊的可見性是由「對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)」這條規則獲得的。而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把「this」的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到「初始化了一半」的對象),那麼在其他線程中就能看見final字段的值。

有序性(Ordering)

Java程序中天然的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指「線程內似表現為串行的語義」(Within-ThreadAs-If-Serial Semantics),後半句是指「指令重排序」現象和「工作內存與主內存同步延遲」現象。

先行發生原則

Java內存模型下一些「天然的」先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來,則它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。

  1. ·管程鎖定規則(Monitor Lock Rule):在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。注意,這裡說的是控制流順序而不是程序代碼順序,因為要考慮分支、循環等結構。
  2. ·管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是「同一個鎖」,而「後面」是指時間上的先後。
  1. ·volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作,這裡的「後面」同樣是指時間上的先後。
  2. ·線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。
  1. ·線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。
  2. ·線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷髮生。
  1. ·對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  2. ·傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

時間先後順序與先行發生原則之間基本沒有因果關係,所以我們衡量並發安全問題的時候不要受時間順序的干擾,一切必須以先行發生原則為準。

線程

線程的三種實現

使用內核線程實現(1:1實現)——內核線程(Kernel-Level Thread,KLT)就是直接由操作系統內核(Kernel,下稱內核)支持的線程,內核線程的一種高級接口——輕量級進程(LightWeight Process,LWP),輕量級進程就是我們通常意義上所講的線程。

系統調用的代價相對較高,需要在用戶態(User Mode)和內核態(Kernel Mode)中來回切換。其次,每個輕量級進程都需要有一個內核線程的支持,因此輕量級進程要消耗一定的內核資源(如內核線程的棧空間),因此一個系統支持輕量級進程的數量是有限的。

一文深入理解 Java 虛擬機

使用用戶線程實現(1:N實現)——一個線程只要不是內核線程,都可以認為是用戶線程(User Thread,UT)的一種

一文深入理解 Java 虛擬機

使用用戶線程加輕量級進程混合實現(N:M實現)。

用戶線程還是完全建立在用戶空間中,因此用戶線程的創建、切換、析構等操作依然廉價,並且可以支持大規模的用戶線程並發。而操作系統支持的輕量級進程則作為用戶線程和內核線程之間的橋樑,這樣可以使用內核提供的線程調度功能及處理器映射,並且用戶線程的系統調用要通過輕量級進程來完成,這大大降低了整個進程被完全阻塞的風險。

一文深入理解 Java 虛擬機

主流java虛擬機是內核線程實現

線程調度是指系統為線程分配處理器使用權的過程,調度主要方式有兩種,分別是協同式(Cooperative Threads-Scheduling)線程調度和搶佔式(Preemptive Threads-Scheduling)線程調度。

協同式調度——如果使用協同式調度的多線程系統,線程的執行時間由線程本身來控制,線程把自己的工作執行完了之後,要主動通知系統切換到另外一個線程上去。

Java語言一共設置了10個級別的線程優先級(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。Windows系統線程優先級有7個

java定義的線程狀態:

6種狀態分別是:

  1. ·新建(New):創建後尚未啟動的線程處於這種狀態。
  2. ·運行(Runnable):包括操作系統線程狀態中的Running和Ready,也就是處於此狀態的線程有可能正在執行,也有可能正在等待着操作系統為它分配執行時間。
  1. ·無限期等待(Waiting):處於這種狀態的線程不會被分配處理器執行時間,它們要等待被其他線程顯式喚醒。以下方法會讓線程陷入無限期的等待狀態:■沒有設置Timeout參數的Object::wait()方法;■沒有設置Timeout參數的Thread::join()方法;■LockSupport::park()方法。
  2. ·限期等待(Timed

    Waiting):處於這種狀態的線程也不會被分配處理器執行時間,不過無須等待被其他線程顯式喚醒,在一定時間之後它們會由系統自動喚醒。以下方法會讓線程進入限期等待狀態:■Thread::sleep()方法;■設置了Timeout參數的Object::wait()方法;■設置了Timeout參數的Thread::join()方法;■LockSupport::parkNanos()方法;■LockSupport::parkUntil()方法。

  1. ·阻塞(Blocked):線程被阻塞了,「阻塞狀態」與「等待狀態」的區別是「阻塞狀態」在等待着獲取到一個排它鎖,這個事件將在另外一個線程放棄這個鎖的時候發生;而「等待狀態」則是在等待一段時間,或者喚醒動作的發生。在程序等待進入同步區域的時候,線程將進入這種狀態。
  2. ·結束(Terminated):已終止線程的線程狀態,線程已經結束執行。

協程

Java目前的並發編程機制就與上述架構趨勢產生了一些矛盾,1:1的內核線程模型是如今Java虛擬機線程實現的主流選擇,但是這種映射到操作系統上的線程天然的缺陷是切換、調度成本高昂,系統能容納的線程數量也很有限。

內核線程的調度成本主要來自於用戶態與核心態之間的狀態轉換,而這兩種狀態轉換的開銷主要來自於響應中斷、保護和恢復執行現場的成本。

協程是怎麼來處理的呢,就是對於一個阻塞的業務操作,我們不是用線程來處理,而是用用協程,這樣當出現IO阻塞的時候,並且你還沒運行完時間片,你不會讓CPU跑掉,而是調起你的另一個協程任務,讓他繼續進行計算。而通常我們知道,代碼純計算執行是非常快的,5ms可能跑了N個方法了,因此這樣充分的利用時間片,並且減少CPU切換的時間。

線程安全

當多個線程同時訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那就稱這個對象是線程安全的。

按照線程安全的「安全程度」由強至弱來排序,可以將Java語言中各種操作共享的數據分為以下五類:

  1. 不可變

String之外,常用的還有枚舉類型及java.lang.Number的部分子類,如Long和Double等數值包裝類型、BigInteger和BigDecimal等大數據類型。但同為Number子類型的原子類AtomicInteger和AtomicLong則是可變的

  1. 絕對線程安全
  2. 相對線程安全

相對線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單次的操作是線程安全的,我們在調用的時候不需要進行額外的保障措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。

  1. 線程兼容
  2. 線程對立

一個線程對立的例子是Thread類的suspend()和resume()方法。如果有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,一個嘗試去恢複線程,在並發進行的情況下,無論調用時是否進行了同步,目標線程都存在死鎖風險——假如suspend()中斷的線程就是即將要執行resume()的那個線程,那就肯定要產生死鎖了。也正是這個原因,suspend()和resume()方法都已經被聲明廢棄了。

線程安全的實現

1、互斥同步(Mutual Exclusion & Synchronization)是一種最常見也是最主要的並發正確性保障手段。也被稱為阻塞同步(Blocking Synchronization)。

同步是指在多個線程並發訪問共享數據時,保證共享數據在同一個時刻只被一條(或者是一些,當使用信號量的時候)線程使用。而互斥是實現同步的一種手段,臨界區(CriticalSection)、互斥量(Mutex)和信號量(Semaphore)都是常見的互斥實現方式。因此在「互斥同步」這四個字裏面,互斥是因,同步是果;互斥是方法,同步是目的。

在Java裏面,最基本的互斥同步手段就是synchronized關鍵字,這是一種塊結構(BlockStructured)的同步語法。synchronized關鍵字經過Javac編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令。這兩個位元組碼指令都需要一個reference類型的參數來指明要鎖定和解鎖的對象。

·被synchronized修飾的同步塊對同一條線程來說是可重入的。這意味着同一線程反覆進入同步塊也不會出現自己把自己鎖死的情況。

·被synchronized修飾的同步塊在持有鎖的線程執行完畢並釋放鎖之前,會無條件地阻塞後面其他線程的進入。這意味着無法像處理某些數據庫中的鎖那樣,強制已獲取鎖的線程釋放鎖;也無法強制正在等待鎖的線程中斷等待或超時退出。

ReentrantLock一樣是可重入的,在功能上是synchronized的超集:等待可中斷、可實現公平鎖及鎖可以綁定多個條件。

jdk6以後兩者性能差不多,synchronized可自動釋放鎖,lock需要在finally中手動釋放。

2、非阻塞同步

基於衝突檢測的樂觀並發策略,通俗地說就是不管風險,先進行操作,如果沒有其他線程爭用共享數據,那操作就直接成功了;如果共享的數據的確被爭用,產生了衝突,那再進行其他的補償措施,最常用的補償措施是不斷地重試,直到出現沒有競爭的共享數據為止。這種樂觀並發策略的實現不再需要把線程阻塞掛起,因此這種同步操作被稱為非阻塞同步(Non-Blocking Synchronization),使用這種措施的代碼也常被稱為無鎖(Lock-Free)編程。

·比較並交換(Compare-and-Swap,下文稱CAS)

如果一個變量V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然為A值,那就能說明它的值沒有被其他線程改變過了嗎?這是不能的,因為如果在這段期間它的值曾經被改成B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。這個漏洞稱為CAS操作的「ABA問題」。

解決方案是使用版本號

3、無同步方案

同步只是保障存在共享數據爭用時正確性的手段,如果能讓一個方法本來就不涉及共享數據,那它自然就不需要任何同步措施去保證其正確性

可重入代碼(Reentrant Code):這種代碼又稱純代碼(Pure Code),是指可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤,也不會對結果有所影響。

線程本地存儲(Thread Local Storage)

java.lang.ThreadLocal類來實現線程本地存儲的功能。每一個線程的Thread對象中都有一個ThreadLocalMap對象,這個對象存儲了一組以
ThreadLocal.threadLocalHashCode為鍵,以本地線程變量為值的K-V值對,ThreadLocal對象就是當前線程的ThreadLocalMap的訪問入口,每一個ThreadLocal對象都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以在線程K-V值對中找回對應的本地線程變量。

鎖優化

鎖自旋

如果物理機器有一個以上的處理器或者處理器核心,能讓兩個或以上的線程同時並行執行,我們就可以讓後面請求鎖的那個線程「稍等一會」,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們只須讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。

間必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起線程。

JDK6引入自適應的自旋。自適應意味着自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認為這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間,比如持續100次忙循環。另一方面,如果對於某個鎖,自旋很少成功獲得過鎖,那在以後要獲取這個鎖時將有可能直接省略掉自旋過程,以避免浪費處理器資源。

鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上數據對待,認為它們是線程私有的,同步加鎖自然就無須再進行。

鎖粗化

如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體之中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。如果虛擬機探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的範圍擴展(粗化)到整個操作序列的外部。

輕量級鎖

「輕量級」是相對於使用操作系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就被稱為「重量級」鎖。

HotSpot虛擬機的對象頭(Object Header)

一文深入理解 Java 虛擬機

代碼進入同步塊前,若同步對象標誌位為01,沒有被鎖定->則在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的MarkWord的拷貝。

虛擬機將使用CAS操作嘗試把對象的Mark Word更新為指向Lock Record的指針。如果這個更新動作成功了,即代表該線程擁有了這個對象的鎖,並且對象Mark Word的鎖標誌位(Mark Word的最後兩個比特)將轉變為「00」,表示此對象處於輕量級鎖定狀態。如果這個更新操作失敗了,那就意味着至少存在一條線程與當前線程競爭獲取該對象的鎖。

虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個鎖對象已經被其他線程搶佔了。如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖,鎖標誌的狀態值變為「10」,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也必須進入阻塞狀態。

解鎖過程也同樣是通過CAS操作來進行的,如果對象的Mark Word仍然指向線程的鎖記錄,那就用CAS操作把對象當前的Mark Word和線程中複製的Displaced Mark Word替換回來。假如能夠成功替換,那整個同步過程就順利完成了;如果替換失敗,則說明有其他線程嘗試過獲取該鎖,就要在釋放鎖的同時,喚醒被掛起的線程。

偏向鎖

如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不去做了。

這個鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖一直沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。

假設當前虛擬機啟用了偏向鎖(啟用參數-XX:+UseBiased Locking,這是自JDK 6起HotSpot虛擬機的默認值),那麼當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設置為「01」、把偏向模式設置為「1」,表示進入偏向模式。同時使用CAS操作把獲取到這個鎖的線程的ID記錄在對象的Mark Word之中。如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作(例如加鎖、解鎖及對Mark Word的更新操作等)。

一文深入理解 Java 虛擬機

當一個對象已經計算過一致性哈希碼後,它就再也無法進入偏向鎖狀態了;而當一個對象當前正處於偏向鎖狀態,又收到需要計算其一致性哈希碼請求[插圖]時,它的偏向狀態會被立即撤銷,並且鎖會膨脹為重量級鎖。在重量級鎖的實現中,對象頭指向了重量級鎖的位置,代表重量級鎖的ObjectMonitor類里有字段可以記錄非加鎖狀態(標誌位為「01」)下的Mark Word,其中自然可以存儲原來的哈希碼。

啟用參數-XX:+UseBiased Locking,這是自JDK 6起HotSpot虛擬機的默認值

參數-XX:-UseBiasedLocking來禁止偏向鎖優化

原創文章,作者:投稿專員,如若轉載,請註明出處:https://www.506064.com/zh-hk/n/281164.html

(0)
打賞 微信掃一掃 微信掃一掃 支付寶掃一掃 支付寶掃一掃
投稿專員的頭像投稿專員
上一篇 2024-12-21 13:16
下一篇 2024-12-21 13:16

相關推薦

發表回復

登錄後才能評論