天天看點

萬字長文,帶你深入了解Java虛拟機!前言

根據《Java虛拟機規範》的規定,Class檔案格式采用一種類似于C語言結構體的僞結構來存儲資料,這種僞結構中隻有兩種資料類型:“無符号數”和“表”。

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

javac指令會将Java源檔案編譯成位元組碼檔案,即.class檔案,其中就包含了大量的位元組碼指令。

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

存儲和加載類指令:主要包括load系列指令、store系列指令和ldc、push系列指令,主要用于在局部變量表、操作數棧和常量池三者之間進行資料排程;(關于常量池前面沒有特别講解,這個也很簡單,顧名思義,就是這個池子裡放着各種常量,好比片場的道具庫)

對象操作指令(建立與讀寫通路):比如我們剛剛的putfield和getfield就屬于讀寫通路的指令,此外還有putstatic/getstatic,還有new系列指令,以及instanceof等指令。

操作數棧管理指令:如pop和dup,他們隻對操作數棧進行操作。

類型轉換指令和運算指令:如add/div/l2i等系列指令,實際上這類指令一般也隻對操作數棧進行操作。

控制跳轉指令:這類裡包含常用的if系列指令以及goto類指令。

方法調用和傳回指令:主要包括invoke系列指令和return系列指令。這類指令也意味這一個方法空間的開辟和結束,即invoke會喚醒一個新的java方法小宇宙(新的棧和局部變量表),而return則意味着這個宇宙的結束回收。

公有設計,私有實作

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

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

精确定義的虛拟機行為和目标檔案格式,不應當對虛拟機實作者的創造性産生太多的限制,Java虛拟機是被設計成可以允許有衆多不同的實作,并且各種實作可以在保持相容性的同時提供不同的新的、有趣的解決方案。

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

Class檔案是Java虛拟機執行引擎的資料入口,也是Java技術體系的基礎支柱之一。

Java虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這個過程被稱作虛拟機的類加載機制。

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

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

3)當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

4)當虛拟機啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛拟機會先初始化這個主類。

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

加載

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

2)将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構。

3)在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口。

·從ZIP壓縮包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎。

·從網絡中擷取,這種場景最典型的應用就是Web Applet。·運作時計算生成,這種場景使用得最多的就是動态代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()來為特定接口生成形式為“*$Proxy”的代理類的二進制位元組流。

·由其他檔案生成,典型場景是JSP應用,由JSP檔案生成對應的Class檔案。·從資料庫中讀取,這種場景相對少見些,例如有些中間件伺服器(如SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式代碼在叢集間的分發。

·可以從加密檔案中擷取,這是典型的防Class檔案被反編譯的保護措施,通過加載時解密Class檔案來保障程式運作邏輯不被窺探。

驗證階段大緻上會完成下面四個階段的檢驗動作:檔案格式驗證、中繼資料驗證、位元組碼驗證和符号引用驗證。

“停機問題”(Halting Problem)插圖,即不能通過程式準确地檢查出程式是否能在有限的時間之内結束運作。

正式為類中定義的變量(即靜态變量,被static修飾的變量)配置設定記憶體并設定類變量初始值的階段。

首先是這時候進行記憶體配置設定的僅包括類變量,而不包括執行個體變量,執行個體變量将會在對象執行個體化時随着對象一起配置設定在Java堆中。其次是這裡所說的初始值“通常情況”下是資料類型的零值,假設一個類變量的定義為:

public static int value=123;

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

Java虛拟機将常量池内的符号引用替換為直接引用的過程。

符号引用與虛拟機實作的記憶體布局無關,引用的目标并不一定是已經加載到虛拟機記憶體當中的内容。直接引用是可以直接指向目标的指針、相對偏移量或者是一個能間接定位到目标的句柄。直接引用是和虛拟機實作的記憶體布局直接相關

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

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

·被通路類C是public的,并且與通路類D處于同一個子產品。

·被通路類C是public的,不與通路類D處于同一個子產品,但是被通路類C的子產品允許被通路類D的子產品進行通路。

·被通路類C不是public的,但是它與通路類D處于同一個包中。

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

先解析出方法表的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異常。

方法解析類似

JDK 9中增加了接口的靜态私有方法,也有了子產品化的通路限制,是以從JDK 9起,接口方法的通路也完全有可能因通路權限控制而出現java.lang.IllegalAccessError異常。

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

·()方法是由編譯器自動收集類中的所有類變量的指派動作和靜态語句塊(static{}塊)中的語句合并産生的,編譯器收集的順序是由語句在源檔案中出現的順序決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊可以指派,但是不能通路。

()方法與類的構造函數(即在虛拟機視角中的執行個體構造器()方法)不同,它不需要顯式地調用父類構造器,Java虛拟機會保證在子類的()方法執行前,父類的()方法已經執行完畢。是以在Java虛拟機中第一個被執行的()方法的類型肯定是java.lang.Object。·由于父類的()方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作。

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

比較兩個類是否“相等”,隻有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class檔案,被同一個Java虛拟機加載,隻要加載它們的類加載器不同,那這兩個類就必定不相等。

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

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

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

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

擴充類加載器Extension ClassLoader

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

雙親委派模型

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

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

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

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

JDK12才有雙親委派模型,面對已經存在的使用者自定義類加載器的代碼,為了相容這些已有代碼,無法再以技術手段避免loadClass()被子類覆寫的可能性,隻能在JDK 1.2之後的java.lang.ClassLoader中添加一個新的protected方法findClass(),并引導使用者編寫的類加載邏輯時盡可能去重寫這個方法,而不是在loadClass()中編寫代碼。

由這個模型自身的缺陷導緻的,如果有基礎類型又要調用回使用者的代碼。

由于使用者對程式動态性的追求而導緻的,這裡所說的“動态性”指的是一些非常“熱”門的名詞:代碼熱替換(Hot Swap)、子產品熱部署(HotDeployment)等

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

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

·JAR檔案在類路徑的通路規則:所有類路徑下的JAR檔案及其他資源檔案,都被視為自動打包在一個匿名子產品(Unnamed Module)裡,這個匿名子產品幾乎是沒有任何隔離的,它可以看到和使用類路徑上所有的包、JDK系統子產品中所有的導出包,以及子產品路徑上所有子產品中導出的包。

·子產品在子產品路徑的通路規則:子產品路徑下的具名子產品(Named Module)隻能通路到它依賴定義中列明依賴的子產品和包,匿名子產品裡所有的内容對具名子產品來說都是不可見的,即具名子產品看不見傳統JAR包的内容。

·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虛拟機的解釋執行引擎被稱為“基于棧的執行引擎”,裡面的“棧”就是操作數棧。

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

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

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

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

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

無論采用何種退出方式,在方法退出之後,都必須傳回到最初方法被調用時的位置,程式才能繼續執行,方法傳回時可能需要在棧幀中儲存一些資訊,用來幫助恢複它的上層主調方法的執行狀态。

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

一般會把動态連接配接、方法傳回位址與其他附加資訊全部歸為一類,稱為棧幀資訊。

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

所有方法調用的目标方法在Class檔案裡面都是一個常量池中的符号引用,在類加載的解析階段,會将符合“編譯期可知,運作期不可變”的方法符号引用轉化為直接引用。換句話說,調用目标在程式代碼寫好、編譯器進行編譯那一刻就已經确定下來。這類方法的調用被稱為解析(Resolution)。

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

英文一般是“Method Overload Resolution”,是以其實是個動态概念

Human hu = new Man():

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

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

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

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

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

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

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

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

根據《Java虛拟機規範》,invokevirtual指令的運作時解析過程插圖大緻分為以下幾步:

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

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

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

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

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

既然這種多态性的根源在于虛方法調用指令invokevirtual的執行邏輯,那自然我們得出的結論就隻會對方法有效,對字段是無效的,因為字段不使用這條指令。事實上,在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常量),前面已經提到過,方法的符号引用在編譯時産生,而動态類型語言隻有在運作期才能确定方法的接收者。

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

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

讀、了解,然後獲得執行能力。大部分的程式代碼轉換成實體機的目标代碼或虛拟機能執行的指令集之前,都需要下圖的步驟:

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

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

而如果用基于寄存器的指令集,那程式可能會是這個樣子:mov指令把EAX寄存器的值設為1,然後add指令再把這個值加1,結果就儲存在EAX寄存器裡面。這種二位址指令是x86指令集中的主流,每個指令都包含兩個單獨的輸入參數,依賴于寄存。

基于棧的指令集主要優點是可移植,因為寄存器由硬體直接提供插圖,程式直接依賴這些硬體寄存器則不可避免地要受到硬體的限制。

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

例如:

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

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是這類工具中的傑出代表。

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)。

标注檢查步驟要檢查的内容包括諸如變量使用前是否已被聲明、變量與指派之間的資料類型是否能夠比對。

常量折疊(Constant Folding)的代碼優化:在代碼裡面定義“a=1+2”比起直接定義“a=3”來,并不會增加程式運作期哪怕僅僅一個處理器時鐘周期的處理工作量。

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

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

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

Java語言中的泛型則不同,它隻在程式源碼中存在,在編譯後的位元組碼檔案中,全部泛型都被替換為原來的裸類型(Raw Type,稍後我們會講解裸類型具體是什麼)了,并且在相應的地方插入了強制轉型代碼,是以對于運作期的Java語言來說,ArrayList與ArrayList其實是同一個類型

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

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

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

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

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

基于這種方法實作的泛型稱為僞泛型。

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

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

2.運作期無法取到泛型類型資訊。

由于List和List擦除後是同一個類型,我們隻能添加兩個并不需要實際使用到的傳回值才能完成重載。

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

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

因為編譯器會對代碼進行優化,對于條件永遠為 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)互相之間沒有繼承關系。

數組是協變的

泛型是不變的

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

List

可以把 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赫茲左右,無法再進一步提升。摩爾定律逐漸失效。

計算機的運算速度與它的存儲和通信子系統的速度差距太大,大量的時間都花費在磁盤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動作相關聯的,必須連續且一起出現。

基本資料類型的通路、讀寫都是具備原子性的(例外就是long和double的非原子性協定

普通變量與volatile變量的差別是,volatile的特殊規則保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理

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

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

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

·管程鎖定規則(Monitor Lock Rule):在一個線程内,按照控制流順序,書寫在前面的操作先行發生于書寫在後面的操作。注意,這裡說的是控制流順序而不是程式代碼順序,因為要考慮分支、循環等結構。

·管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生于後面對同一個鎖的lock操作。這裡必須強調的是“同一個鎖”,而“後面”是指時間上的先後。

·volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生于後面對這個變量的讀操作,這裡的“後面”同樣是指時間上的先後。

·線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生于此線程的每一個動作。

·線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生于對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的傳回值等手段檢測線程是否已經終止執行。

·線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷發生。

·對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。

·傳遞性(Transitivity):如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論。

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

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

系統調用的代價相對較高,需要在使用者态(User Mode)和核心态(Kernel Mode)中來回切換。其次,每個輕量級程序都需要有一個核心線程的支援,是以輕量級程序要消耗一定的核心資源(如核心線程的棧空間),是以一個系統支援輕量級程序的數量是有限的。

使用使用者線程實作(1:N實作)——一個線程隻要不是核心線程,都可以認為是使用者線程(User Thread,UT)的一種

使用使用者線程加輕量級程序混合實作(N:M實作)。

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

線程排程是指系統為線程配置設定處理器使用權的過程,排程主要方式有兩種,分别是協同式(Cooperative Threads-Scheduling)線程排程和搶占式(Preemptive Threads-Scheduling)線程排程。

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

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

·建立(New):建立後尚未啟動的線程處于這種狀态。

·運作(Runnable):包括作業系統線程狀态中的Running和Ready,也就是處于此狀态的線程有可能正在執行,也有可能正在等待着作業系統為它配置設定執行時間。

·無限期等待(Waiting):處于這種狀态的線程不會被配置設定處理器執行時間,它們要等待被其他線程顯式喚醒。以下方法會讓線程陷入無限期的等待狀态:■沒有設定Timeout參數的Object::wait()方法;■沒有設定Timeout參數的Thread::join()方法;■LockSupport::park()方法。

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

·阻塞(Blocked):線程被阻塞了,“阻塞狀态”與“等待狀态”的差別是“阻塞狀态”在等待着擷取到一個排它鎖,這個事件将在另外一個線程放棄這個鎖的時候發生;而“等待狀态”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,線程将進入這種狀态。

·結束(Terminated):已終止線程的線程狀态,線程已經結束執行。

Java目前的并發程式設計機制就與上述架構趨勢産生了一些沖突,1:1的核心線程模型是如今Java虛拟機線程實作的主流選擇,但是這種映射到作業系統上的線程天然的缺陷是切換、排程成本高昂,系統能容納的線程數量也很有限。

核心線程的排程成本主要來自于使用者态與核心态之間的狀态轉換,而這兩種狀态轉換的開銷主要來自于響應中斷、保護和恢複執行現場的成本。

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

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

按照線程安全的“安全程度”由強至弱來排序,可以将Java語言中各種操作共享的資料分為以下五類:

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

絕對線程安全

相對線程安全

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

線程相容

線程對立

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

是一種最常見也是最主要的并發正确性保障手段。也被稱為阻塞同步(Blocking Synchronization)。

同步是指在多個線程并發通路共享資料時,保證共享資料在同一個時刻隻被一條(或者是一些,當使用信号量的時候)線程使用。而互斥是實作同步的一種手段,臨界區(CriticalSection)、互斥量(Mutex)和信号量(Semaphore)都是常見的互斥實作方式。是以在“互斥同步”這四個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。

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

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

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

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

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

基于沖突檢測的樂觀并發政策,通俗地說就是不管風險,先進行操作,如果沒有其他線程争用共享資料,那操作就直接成功了;如果共享的資料的确被争用,産生了沖突,那再進行其他的補償措施,最常用的補償措施是不斷地重試,直到出現沒有競争的共享資料為止。這種樂觀并發政策的實作不再需要把線程阻塞挂起,是以這種同步操作被稱為非阻塞同步(Non-Blocking Synchronization),使用這種措施的代碼也常被稱為無鎖(Lock-Free)程式設計。

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

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

解決方案是使用版本号

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

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

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

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

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

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

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

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

“輕量級”是相對于使用作業系統互斥量來實作的傳統鎖而言的,是以傳統的鎖機制就被稱為“重量級”鎖。

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

代碼進入同步塊前,若同步對象标志位為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替換回來。假如能夠成功替換,那整個同步過程就順利完成了;如果替換失敗,則說明有其他線程嘗試過擷取該鎖,就要在釋放鎖的同時,喚醒被挂起的線程。

JVM大廠面試真題:JVM虛拟機面試題&附答案解析

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

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

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

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