引言
執(zhí)行引擎子系統(tǒng)是JVM的重要組成部分之一,在JVM系列的開篇曾提到:JVM是一個(gè)架構(gòu)在平臺(tái)上的平臺(tái),虛擬機(jī)是一個(gè)相似于“物理機(jī)”的概念,與物理機(jī)一樣,都具備代碼執(zhí)行的能力。但虛擬機(jī)與物理機(jī)最大的不同在于:物理機(jī)的執(zhí)行引擎是直接建立在處理器、高速緩存、平臺(tái)指令集與操作系統(tǒng)層面上的,物理機(jī)的執(zhí)行引擎可以直接調(diào)用各處資源對(duì)代碼進(jìn)行直接執(zhí)行,而虛擬機(jī)則是建立在軟件層面上的平臺(tái),它的執(zhí)行引擎則是負(fù)責(zé)解釋編譯執(zhí)行自身定義的指令集代碼。同時(shí),也正因Java設(shè)計(jì)出了JVM虛擬機(jī)的結(jié)構(gòu),從而才使得Java可以不受物理平臺(tái)限制,能夠真正實(shí)現(xiàn)“一次編譯,到處執(zhí)行”的理念。
對(duì)于執(zhí)行引擎這塊的知識(shí),對(duì)于理解JVM是有很大幫助的,但JVM相關(guān)現(xiàn)有的文章/書籍資料對(duì)這塊卻少有提及或者泛泛而談,本篇文章則是準(zhǔn)備對(duì)JVM的執(zhí)行引擎子系統(tǒng)進(jìn)行全面的闡述。
一、機(jī)器碼、指令集與匯編語(yǔ)言、高級(jí)語(yǔ)言的關(guān)系
在準(zhǔn)備對(duì)JVM的執(zhí)行引擎進(jìn)行分析之前,首先得搞明白機(jī)器碼、指令集、匯編語(yǔ)言以及高級(jí)語(yǔ)言之間的關(guān)系,只有當(dāng)搞清楚這幾者之間的關(guān)系后才能更好的弄懂JVM的執(zhí)行引擎原理。
1.1、機(jī)器碼
機(jī)器碼也被稱為機(jī)器指令碼,也就是指各種由二進(jìn)制編碼方式表示的指令(011101、11110等),最開始的程序員就是通過(guò)這種方式編寫程序,用這種方式編寫出的代碼可以直接被CPU讀取執(zhí)行,因?yàn)樽钯N近硬件機(jī)器,所以也是執(zhí)行速度最快的指令。但因?yàn)檫@種指令和CPU之間是緊緊相關(guān)的,所以不同種類的CPU對(duì)應(yīng)的機(jī)械指令也不同。同時(shí),機(jī)械指令都是由二進(jìn)制數(shù)字組成的指令,對(duì)于人來(lái)說(shuō),實(shí)在太過(guò)繁雜、難以理解且不容易記憶,容易出錯(cuò),最終指令的方式代替了這種編碼方式。
1.2、指令與指令集
由于機(jī)器碼都是由0和1組成的指令代碼,可讀性實(shí)在太差,所以慢慢的推出了指令,用于替代機(jī)器碼的編碼方式。指令是指將機(jī)械碼中特定的0和1組成的序列,簡(jiǎn)化為對(duì)應(yīng)的指令,如INC、DEC、MOV等,從可讀性上來(lái)說(shuō),對(duì)比之前的二進(jìn)制序列組成的機(jī)器碼要好上許多。但由于不同的硬件平臺(tái)的組成架構(gòu)也不同,所以往往在執(zhí)行一個(gè)指令操作時(shí),對(duì)應(yīng)的機(jī)器碼也不同,所以不同的硬件平臺(tái)就算是同一個(gè)指令(如INC),對(duì)應(yīng)的機(jī)器碼也不同。
同時(shí),正是因?yàn)椴煌挠布脚_(tái)支持的指令是有些稍許不同的,所以每個(gè)平臺(tái)所支持的指令則被稱為對(duì)應(yīng)平臺(tái)的指令集。比如X86架構(gòu)平臺(tái)對(duì)應(yīng)的X86指令集、ARM架構(gòu)平臺(tái)對(duì)應(yīng)的ARM指令集等。
1.3、匯編語(yǔ)言
前面雖然通過(guò)了指令和指令集的方式替代了之前由0和1序列組成的機(jī)器碼,但指令的可讀性相對(duì)來(lái)說(shuō)還是比較差的,所以人們又發(fā)明了匯編語(yǔ)言。在匯編語(yǔ)言中,用助記符(Mnemonics)代替機(jī)器指令的操作碼,用地址符號(hào)(Symbol)以及標(biāo)號(hào)(Label)代替指令或操作數(shù)的地址。在不同的平臺(tái),匯編代碼對(duì)應(yīng)不同的指令集,但由于計(jì)算機(jī)只認(rèn)機(jī)器碼,所以通過(guò)匯編語(yǔ)言編寫的程序必須還要經(jīng)過(guò)匯編階段,變?yōu)橛?jì)算機(jī)可識(shí)別的機(jī)器指令碼才可執(zhí)行。
1.4、高級(jí)語(yǔ)言
為了使得開發(fā)人員編寫程序更為簡(jiǎn)易一些,后面就涌現(xiàn)了各種高級(jí)語(yǔ)言,如Java、Python、Go、Rust等。高級(jí)語(yǔ)言對(duì)比之前的機(jī)器碼、指令、匯編等方式,可讀性更高,代碼編寫的難度更低。但通過(guò)高級(jí)語(yǔ)言編寫出的程序,則需要先經(jīng)過(guò)解釋或編譯過(guò)程,先翻譯成匯編指令,然后再經(jīng)過(guò)匯編過(guò)程,轉(zhuǎn)換為計(jì)算機(jī)可識(shí)別的機(jī)器指令碼才能執(zhí)行。
OK~,簡(jiǎn)單的敘述了一下機(jī)器碼、指令集與匯編語(yǔ)言、高級(jí)語(yǔ)言的關(guān)系,從這段闡述中可以得知,Java屬于一門高級(jí)語(yǔ)言,在執(zhí)行的時(shí)候需要將它編寫的代碼先編譯成匯編指令,再轉(zhuǎn)換為機(jī)械指令才能被計(jì)算機(jī)識(shí)別。但似乎我們?cè)谑褂肑ava過(guò)程中,好像沒有這個(gè)過(guò)程呀?這樣因?yàn)槭裁丛蚰兀?/p>
這是因?yàn)镴ava存在JVM這個(gè)虛擬平臺(tái),JVM的主要任務(wù)是負(fù)責(zé)將javac編譯后生成的字節(jié)碼文件裝載到其內(nèi)部,但字節(jié)碼并不能夠直接運(yùn)行在操作系統(tǒng)之上,因?yàn)樽止?jié)碼指令并非等價(jià)于本地機(jī)器指令,它內(nèi)部包含的僅僅只是一些能夠被JVM所識(shí)別的字節(jié)碼指令、符號(hào)表和其他輔助信息,這些Java字節(jié)碼指令是無(wú)法直接被OS識(shí)別的。那么一個(gè)Java程序可以在操作系統(tǒng)上跑起來(lái)的根本原因在于什么呢?答案是:依靠于JVM的執(zhí)行引擎子系統(tǒng)。
二、初窺JVM執(zhí)行引擎與源碼編譯原理
Java的執(zhí)行引擎子系統(tǒng)的主要任務(wù)是將字節(jié)碼指令解釋/編譯成對(duì)應(yīng)平臺(tái)上的本地機(jī)器指令,簡(jiǎn)單來(lái)說(shuō),JVM執(zhí)行引擎是充當(dāng)Java虛擬機(jī)與操作系統(tǒng)平臺(tái)之間的“翻譯官”的角色。
而目前主要的執(zhí)行技術(shù)有:解釋執(zhí)行、靜態(tài)編譯、即時(shí)編譯、自適應(yīng)優(yōu)化、芯片級(jí)直接執(zhí)行,釋義如下:
- 解釋執(zhí)行:程序在運(yùn)行過(guò)程中,只有當(dāng)每次用到某處代碼時(shí),才會(huì)將某處代碼轉(zhuǎn)換為機(jī)器碼交給計(jì)算機(jī)執(zhí)行。
- 靜態(tài)編譯:所謂的靜態(tài)編譯是指程序在啟動(dòng)前,先根據(jù)對(duì)應(yīng)的硬件/平臺(tái),將所有代碼全部編譯成對(duì)應(yīng)平臺(tái)的機(jī)器碼。
- 即時(shí)編譯:程序運(yùn)行過(guò)程中,通過(guò)相關(guān)技術(shù)(如HotSpot中的熱點(diǎn)探測(cè))動(dòng)態(tài)的探測(cè)出運(yùn)行比較頻繁的代碼,然后在運(yùn)行過(guò)程中,將這些執(zhí)行比較頻繁的代碼轉(zhuǎn)換機(jī)械碼并存儲(chǔ)下來(lái),下次執(zhí)行時(shí)則直接執(zhí)行機(jī)器碼。
- 自適應(yīng)優(yōu)化:開始對(duì)所有的代碼都采取解釋執(zhí)行的方式,并監(jiān)視代碼執(zhí)行情況,然后對(duì)那些經(jīng)常調(diào)用的方法啟動(dòng)一個(gè)后臺(tái)線程,將其編譯為本地代碼,并進(jìn)行仔細(xì)優(yōu)化。若方法不再頻繁使用,則取消編譯過(guò)的代碼,仍對(duì)其進(jìn)行解釋執(zhí)行。
- 芯片級(jí)直接執(zhí)行:也就是直接編寫機(jī)器碼的方式,編寫出的代碼可以直接被CPU識(shí)別,讀取后可以直接執(zhí)行。
如上便是現(xiàn)有的一些執(zhí)行技術(shù),在其中解釋執(zhí)行屬于第一代JVM,即時(shí)編譯JIT屬于第二代JVM,自適應(yīng)優(yōu)化(目前Sun的Hotspot采用這種技術(shù))則吸取第一代JVM和第二代JVM的經(jīng)驗(yàn),采用兩者結(jié)合的方式。而靜態(tài)編譯的技術(shù)在BEA公司的JRockit虛擬機(jī)以及JDK9的AOT編譯器中都實(shí)現(xiàn)了,這樣做的好處在于:執(zhí)行性能堪稱最佳,但缺點(diǎn)在于:?jiǎn)?dòng)的時(shí)間會(huì)很長(zhǎng),同時(shí)也打破了Java“一次編譯,到處運(yùn)行”的原則。
其實(shí)在Java剛誕生時(shí),JDK1.0的時(shí)候,Java的定位是一門解釋型語(yǔ)言,也就是將Java程序編寫好之后,先通過(guò)javac將源碼編譯為字節(jié)碼,再對(duì)生成的字節(jié)碼進(jìn)行逐行 解釋執(zhí)行 。但這樣就導(dǎo)致了程序執(zhí)行速度比較緩慢,啟動(dòng)速度也并不樂(lè)觀,因?yàn)閱?dòng)時(shí)需對(duì)于未編譯的.java文件進(jìn)行編譯,而且編譯之后生成的字節(jié)碼指令也不能被計(jì)算機(jī)識(shí)別,還需要在執(zhí)行時(shí)再經(jīng)過(guò)一次 解釋 后,才能變?yōu)橛?jì)算機(jī)可識(shí)別的機(jī)器碼指令,從而才能使得代碼被機(jī)器執(zhí)行。
經(jīng)過(guò)如上分析,JDK1.0時(shí)的這種解釋執(zhí)行的缺點(diǎn)非常明顯,Java為了做到“一次編譯,到處運(yùn)行”這個(gè)準(zhǔn)則,將程序的綜合性能大大拉低了,為什么呢?因?yàn)閷?duì)比其他語(yǔ)言多了一個(gè)步驟。一般來(lái)說(shuō),一個(gè)Java程序想要運(yùn)行,必須要經(jīng)過(guò) 先編譯,再解釋 的過(guò)程才可以真正的執(zhí)行。而我們此時(shí)再來(lái)看看其他語(yǔ)言的執(zhí)行。純編譯型語(yǔ)言:在程序啟動(dòng)時(shí),將編寫好的源碼全部編譯為所處平臺(tái)的機(jī)械碼指令。
特點(diǎn):執(zhí)行性能最佳,啟動(dòng)時(shí)間較長(zhǎng),移植性差,不同平臺(tái)需要重新發(fā)包。
純解釋型語(yǔ)言:在程序運(yùn)行過(guò)程中,需要執(zhí)行某處代碼時(shí),再將該代碼解釋為平臺(tái)對(duì)應(yīng)的機(jī)械碼指令,然后交由計(jì)算機(jī)執(zhí)行。
特點(diǎn):?jiǎn)?dòng)速度快,執(zhí)行性能較差,移植性較好。
OK~,簡(jiǎn)單的看了一下解釋型和編譯型的語(yǔ)言特點(diǎn)之后,再回過(guò)頭來(lái)想想1.0版本的Java,是不是發(fā)現(xiàn)Java因?yàn)樘摂M機(jī)的存在,搞的不上不下的,卡在了中間。因?yàn)樵贘ava程序運(yùn)行時(shí),既要編譯源碼,又要解釋執(zhí)行,所以最終導(dǎo)致執(zhí)行性能一般,啟動(dòng)速度也一般。
再到后來(lái),Java為了解決這個(gè)問(wèn)題,在1.2的時(shí)候推出了一款后端編譯器,也就是JIT即時(shí)編譯器(后面分析),它可以支持在Java在執(zhí)行過(guò)程中動(dòng)態(tài)生成本地的機(jī)械碼。現(xiàn)代的高性能JVM都是采用解釋器與即使編譯器共存的模式工作,所以Java也被稱為“半解釋半編譯型語(yǔ)言”。
而本篇?jiǎng)t會(huì)基于目前的HotSpot虛擬機(jī)對(duì)JVM的執(zhí)行引擎進(jìn)行分析,它的執(zhí)行引擎中也采用解釋器與即使編譯器共存的模型工作,但這款虛擬機(jī)的執(zhí)行模式采用的是 自適應(yīng)優(yōu)化 方案執(zhí)行。
2.1、執(zhí)行引擎工作過(guò)程
對(duì)于執(zhí)行引擎而言,在《虛擬機(jī)規(guī)范》中曾提到了,要求所有廠商在實(shí)現(xiàn)時(shí),輸入輸出都必須一致,也就是執(zhí)行引擎接受的輸入內(nèi)容必須為字節(jié)碼的二進(jìn)制流數(shù)據(jù),而輸出的則必須為程序的執(zhí)行結(jié)果。而執(zhí)行引擎到底需要執(zhí)行什么操作,完全是依賴與PC寄存器(程序計(jì)數(shù)器)的,每當(dāng)執(zhí)行引擎處理完一項(xiàng)指令操作后,程序計(jì)數(shù)器就需要更新下一條需要被執(zhí)行的指令地址。
在執(zhí)行Java方法過(guò)程中,執(zhí)行引擎也有可能會(huì)根據(jù)棧幀中操作數(shù)棧的引用信息,直接去訪問(wèn)存儲(chǔ)在堆中的Java對(duì)象實(shí)例數(shù)據(jù),也有可能會(huì)通過(guò)實(shí)例對(duì)象的對(duì)象頭中記錄的元數(shù)據(jù)指針(KlassWord)去定位對(duì)象的類型信息,也就是會(huì)通過(guò)元數(shù)據(jù)指針去訪問(wèn)元數(shù)據(jù)空間(方法區(qū))中的數(shù)據(jù)。如下圖:
2.1.1、Java源碼編譯過(guò)程
在之前提及過(guò),JVM只識(shí)別字節(jié)碼文件,所以當(dāng)編寫好.java
后綴的Java源碼時(shí),我們往往還需要通過(guò)javac
這樣的源碼編譯器(前端編譯器),對(duì)Java代碼進(jìn)行編譯生成.class
后才能被JVM裝載進(jìn)內(nèi)存,源碼編譯過(guò)程如下:
編譯是指將一種語(yǔ)言規(guī)范轉(zhuǎn)化成另外一種語(yǔ)言規(guī)范,通常編譯器都是將便于人理解的語(yǔ)言規(guī)范(編程語(yǔ)言)轉(zhuǎn)化成機(jī)器容易理解的語(yǔ)言規(guī)范(由二進(jìn)制序列組成的機(jī)械碼)。比如C/C++或匯編語(yǔ)言都是將源代碼直接編譯成目標(biāo)機(jī)器碼。
javac作為Java語(yǔ)言的源碼編譯器,它編譯的目的卻不是為了針對(duì)于某個(gè)硬件平臺(tái)進(jìn)行編譯的,而是為JVM進(jìn)行編譯,javac的任務(wù)就是將Java源代碼轉(zhuǎn)換為JVM可識(shí)別的字節(jié)碼,也就是.java
文件到.class
文件的過(guò)程。對(duì)于怎么消除不同種類,不同平臺(tái)之間的差異這個(gè)任務(wù)就交由JVM來(lái)處理,由JVM中的執(zhí)行引擎來(lái)負(fù)責(zé)將字節(jié)碼指令翻譯成當(dāng)前程序所在平臺(tái)可識(shí)別的機(jī)械碼指令。
javac編譯過(guò)程具體釋義如下:
- ①詞法分析:先讀取源代碼的字節(jié)流數(shù)據(jù),然后根據(jù)源碼語(yǔ)言的語(yǔ)法規(guī)則找出源代碼中的定義的語(yǔ)言關(guān)鍵字,如
if、else、while、for
等,然后判斷這些關(guān)鍵字的定義是否合法,對(duì)于合法的關(guān)鍵字生成用于語(yǔ)法分析的記號(hào)序列,同時(shí)創(chuàng)建符號(hào)表,將
所有的標(biāo)識(shí)符記錄在符號(hào)表中,這個(gè)過(guò)程就被稱為詞法分析。- 符號(hào)表的作用:記錄源代碼中使用的標(biāo)識(shí)符,收集每個(gè)表示符的各種屬性信息。
- 詞法分析的結(jié)果:從源代碼中找出一些合法的
Token
流,生成記號(hào)序列。
- ②語(yǔ)法分析:對(duì)詞法分析后得到的
Token
流進(jìn)行語(yǔ)法分析,就是依據(jù)源程序的語(yǔ)法規(guī)則,檢查這些關(guān)鍵詞組合在一起是否符合Java語(yǔ)言規(guī)范,比如if的后面是不是緊跟著一個(gè)布爾型判斷表達(dá)式、else是否寫在if后面等。對(duì)于符合規(guī)范的,組織上一步產(chǎn)生的記號(hào)序列生成語(yǔ)法樹。- 語(yǔ)法分析的結(jié)果:形成一顆符合Java語(yǔ)言規(guī)定的抽象語(yǔ)法樹。抽象語(yǔ)法樹是一個(gè)結(jié)構(gòu)化的語(yǔ)法表達(dá)形式,它的作用是把語(yǔ)言的主要詞法用一個(gè)結(jié)構(gòu)化的形式組織在一起,這棵語(yǔ)法樹可以被后面按照新的規(guī)則再重新組織。
- ③語(yǔ)義分析:經(jīng)過(guò)語(yǔ)法分析后就不存在語(yǔ)法錯(cuò)誤這些問(wèn)題了,語(yǔ)義分析主要任務(wù)有兩個(gè),一個(gè)是對(duì)上步產(chǎn)生的語(yǔ)法樹進(jìn)行檢查,其中包括類型檢查、控制流檢查、唯一性檢查等,第二個(gè)則是將一些復(fù)雜的語(yǔ)法轉(zhuǎn)換為更簡(jiǎn)單的語(yǔ)法,相當(dāng)于把一些文言文、古詩(shī)、成語(yǔ)翻譯成大白話的意思。比如將
foreach
轉(zhuǎn)化為for
循環(huán)、循環(huán)標(biāo)志位替換為break
等。- 語(yǔ)義分析的結(jié)果:簡(jiǎn)化語(yǔ)法后會(huì)生成一棵語(yǔ)法樹,這棵語(yǔ)法樹也就更接近目標(biāo)語(yǔ)言的語(yǔ)法規(guī)則。
- ④字節(jié)碼生成:將簡(jiǎn)化后的語(yǔ)法樹轉(zhuǎn)換為
Class
文件的格式,也就是在該階段會(huì)根據(jù)簡(jiǎn)化后的語(yǔ)法樹生成字節(jié)碼。- 字節(jié)碼生成的結(jié)果:生成符合虛擬機(jī)規(guī)范的字節(jié)碼數(shù)據(jù)。
經(jīng)過(guò)如上過(guò)程后,編寫程序時(shí)的.java
源代碼文件會(huì)被轉(zhuǎn)換.class
字節(jié)碼文件,然后這些字節(jié)碼會(huì)在啟動(dòng)時(shí),被虛擬機(jī)的類加載機(jī)制裝載進(jìn)內(nèi)存,當(dāng)程序運(yùn)行過(guò)程中,調(diào)用某個(gè)方法時(shí),就會(huì)將對(duì)應(yīng)的字節(jié)碼指令交由執(zhí)行引擎處理。
總的來(lái)說(shuō),Java代碼執(zhí)行的過(guò)程會(huì)主要分為三個(gè)階段,分別為:源碼編譯階段、類加載階段以及類代碼(字節(jié)碼)執(zhí)行階段,接著我們?cè)賮?lái)分析一下執(zhí)行階段的過(guò)程。
2.1.2、執(zhí)行引擎執(zhí)行過(guò)程
被加載進(jìn)內(nèi)存的字節(jié)碼最終執(zhí)行是由執(zhí)行引擎來(lái)負(fù)責(zé)的,但JVM的執(zhí)行引擎并不能真正的執(zhí)行字節(jié)碼指令,而是將字節(jié)碼指令翻譯成本地機(jī)械指令交由物理機(jī)的執(zhí)行引擎來(lái)真正的執(zhí)行的。整體流程如下:
一般而言,在字節(jié)碼被加載進(jìn)內(nèi)存之后,都會(huì)經(jīng)過(guò)如上幾個(gè)步驟才會(huì)被翻譯成本地的機(jī)械指令執(zhí)行,但這幾個(gè)優(yōu)化步驟卻并不是必須的,如果不需要也可以在程序啟動(dòng)時(shí)通過(guò)JVM參數(shù)關(guān)閉。但綜合而言,雖然優(yōu)化的過(guò)程會(huì)耗費(fèi)一些時(shí)間,但這樣卻能夠大大的提升程序在執(zhí)行時(shí)的速度,所以總歸而言利大于弊。
OK~,從上圖中可以看出,執(zhí)行引擎的入口的數(shù)據(jù)是字節(jié)碼文件,而在HotSpot虛擬機(jī)中對(duì)于Class文件結(jié)構(gòu)的定義如下:
struct ClassFile {
u4 magic; // 識(shí)別Class文件格式,具體值為0xCAFEBABE
u2 minor_version; // Class文件格式副版本號(hào)
u2 major_version; // Class文件格式主版本號(hào)
u2 constant_pool_count; // 常量表項(xiàng)個(gè)數(shù)
cp_info **constant_pool; // 常量表,又稱變長(zhǎng)符號(hào)表
u2 access_flags; // Class的聲明中使用的修飾符掩碼
u2 this_class; // 常數(shù)表索引,索引內(nèi)保存類名或接口名
u2 super_class; // 常數(shù)表索引,索引內(nèi)保存父類名
u2 interfaces_count; // 超接口個(gè)數(shù)
u2 *interfaces; // 常數(shù)表索引,各超接口名稱
u2 fields_count; // 類的域個(gè)數(shù)
field_info **fields; // 域數(shù)據(jù),包括屬性名稱索引
u2 methods_count; // 方法個(gè)數(shù)
method_info **methods; // 方法表:包括方法名稱索引/方法修飾符掩碼等
u2 attributes_count; // 類附加屬性個(gè)數(shù)
attribute_info **attributes; // 類附加屬性數(shù)據(jù),包括源文件名等
};
任何.java
后綴的Java源碼經(jīng)過(guò)編譯后都會(huì)生成為符合如上格式的class
字節(jié)碼文件。執(zhí)行引擎接收的輸入格式也為如上格式的class
文件,不過(guò)值得注意一提的是:JVM不僅僅只接收.java
文件編譯成的.class
文件,對(duì)于所有符合如上格式規(guī)范的字節(jié)碼文件都可以被JVM接收?qǐng)?zhí)行。
HotSpot虛擬機(jī)是基于棧式的,也就代表著執(zhí)行引擎在執(zhí)行方法時(shí),執(zhí)行的是一個(gè)個(gè)的棧幀,棧幀中包含局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接以及方法返回地址等描述方法的相關(guān)信息。但執(zhí)行引擎在虛擬機(jī)運(yùn)行時(shí),只會(huì)執(zhí)行最頂層的棧幀,因?yàn)樽铐攲拥臈钱?dāng)前需要執(zhí)行的方法,執(zhí)行完當(dāng)前方法后會(huì)彈出頂部的棧幀,然后將下一個(gè)棧幀(新的頂部棧幀)拿出繼續(xù)執(zhí)行。
剛剛提到了方法的相關(guān)信息被存儲(chǔ)在棧幀中,而棧幀的方法信息是從class
字節(jié)碼文件中讀出來(lái)的,每個(gè)方法通過(guò)結(jié)構(gòu)體method_info
來(lái)描述,如下:
struct method_info
{
u2 access_flags; //方法修飾符掩碼
u2 name_index; //方法名在常數(shù)表內(nèi)的索引
u2 descriptor_index; //方法描述符,其值是常數(shù)表內(nèi)的索引
u2 attributes_count; //方法的屬性個(gè)數(shù)
attribute_info **attributes; //方法的屬性表(局部變量表)
};
在method_info
中存在一個(gè)attribute_info
類型的成員attributes
,該成員就是平時(shí)所說(shuō)的局部變量表,其內(nèi)也存放著方法參數(shù)和方法內(nèi)的局部變量,當(dāng)方法是實(shí)例方法時(shí),局部變量表的第0位會(huì)被用來(lái)傳遞方法所屬對(duì)象的引用,即this
。Java虛擬機(jī)執(zhí)行引擎是基于棧式的,棧就是操作數(shù)棧,操作數(shù)棧的深度也是記錄在方法屬性集合的Code
屬性中,同時(shí)attributes
成員中也記錄著局部變量表所需的空間大小。
下面來(lái)個(gè)簡(jiǎn)單的例子感受一下執(zhí)行引擎執(zhí)行的過(guò)程:
/* ------Java代碼------ */
public int add(){
int a = 3;
int b = 2;
int c = a + b;
return c;
}
/* ------javap -c -v -p 查看到的字節(jié)碼(省略描述方法的字節(jié)碼)------ */
0: iconst_3 // 將3放入操作數(shù)棧頂
1: istore_1 // 寫出操作數(shù)棧頂部元素,并將其放在局部變量表中索引為1的位置
2: iconst_2 // 將2放入操作數(shù)棧頂
3: istore_2 // 寫出操作數(shù)棧頂部元素,并將其放在局部變量表中索引為2的位置
4: iload_1 // 從局部變量表中加載索引位置=1的數(shù)據(jù)值
5: iload_2 // 從局部變量表中加載索引位置=2的數(shù)據(jù)值
6: iadd // 彈出操作棧頂?shù)膬蓚€(gè)元素并進(jìn)行 加 操作(3 + 2)
7: istore_3 // 將加之后的結(jié)果刷寫到局部變量表中索引為3的位置
8:iload_3 // 從局部變量表中加載索引位置=3的數(shù)據(jù)值
8: ireturn // 將加載的c返回
對(duì)于如上過(guò)程中,前四條分配指令就不分析了,重點(diǎn)分析一下后面的運(yùn)算過(guò)程,也就是c=a+b
這個(gè)過(guò)程,具體執(zhí)行如下:
- ①數(shù)據(jù)
a
從局部變量表經(jīng)過(guò)總線傳輸?shù)讲僮鲾?shù)棧 - ②數(shù)據(jù)
b
從局部變量表經(jīng)過(guò)總線傳輸?shù)讲僮鲾?shù)棧 - ③數(shù)據(jù)
a
從操作數(shù)棧經(jīng)過(guò)總線傳輸給CPU
- ④數(shù)據(jù)
b
從操作數(shù)棧經(jīng)過(guò)總線傳輸給CPU
- ⑤
CPU
計(jì)算完成后,將結(jié)果通過(guò)數(shù)據(jù)總線傳輸?shù)讲僮鲾?shù)棧 - ⑥運(yùn)算結(jié)果從操作數(shù)棧經(jīng)過(guò)總線傳輸?shù)?code>CPU
- ⑦
CPU
將數(shù)據(jù)經(jīng)過(guò)總線傳輸?shù)骄植孔兞勘碣x值給c
- ⑧將計(jì)算后的結(jié)果從局部變量表索引為3的位置加載到操作數(shù)棧
- ⑨最后使用
ireturn
指令將計(jì)算后的結(jié)果c
返回給方法的調(diào)用者
如上便是棧式虛擬機(jī)的執(zhí)行過(guò)程,其中所提到的局部變量表會(huì)在編譯器確定長(zhǎng)度,也就是等于一個(gè)
this
加上三個(gè)局部變量,長(zhǎng)度最終為4。當(dāng)程序執(zhí)行到方法定義的那行代碼時(shí),局部變量表中會(huì)被依次填入數(shù)據(jù):this、3、2
,同時(shí)程序計(jì)數(shù)器會(huì)跟著代碼的執(zhí)行位置不斷更新,當(dāng)執(zhí)行完add
操作后,會(huì)將數(shù)據(jù)a+b
的結(jié)果5
再填入局部變量表。
三、詳解JVM執(zhí)行引擎子系統(tǒng)
在第二階段,咱們簡(jiǎn)單的分析了一下Java代碼的編譯過(guò)程以及執(zhí)行過(guò)程,同時(shí)在前面也提到了,Java是使用解釋器+編譯器共存的模式工作的,也就代表著JVM執(zhí)行引擎子系統(tǒng)中,是包含了解釋器和編譯器的,如下圖:
Java虛擬機(jī)的執(zhí)行引擎子系統(tǒng)中包含兩種執(zhí)行器,分別為解釋器和即時(shí)編譯器。當(dāng)執(zhí)行引擎獲取到由javac編譯后的
.class
字節(jié)碼文件后,在運(yùn)行時(shí)是通過(guò)解釋器(Interpreter)轉(zhuǎn)換成最終的機(jī)械碼執(zhí)行。另外為了提升效率,JVM加入了一種名為 JIT即時(shí)編譯 的技術(shù),即時(shí)編譯器的目的是為了避免一些經(jīng)常執(zhí)行的代碼被解釋執(zhí)行,JIT會(huì)將整個(gè)函數(shù)編譯為平臺(tái)本地的機(jī)械碼,從而在很大程度上提升了執(zhí)行的效率。
3.1、解釋器(Interpreter)
當(dāng)Java程序運(yùn)行時(shí),在執(zhí)行一個(gè)方法或某處代碼時(shí),會(huì)找到.class
文件中對(duì)應(yīng)的字節(jié)碼,然后會(huì)根據(jù)定義的規(guī)范,對(duì)每條需執(zhí)行的字節(jié)碼指令逐行解釋,將其翻譯成平臺(tái)對(duì)應(yīng)對(duì)應(yīng)的本地機(jī)械碼執(zhí)行。當(dāng)一條字節(jié)碼指令被解釋執(zhí)行完成后,緊接著會(huì)再根據(jù)PC寄存器(程序計(jì)數(shù)器)中記錄的下一條需被執(zhí)行指令,讀取并再次進(jìn)行解釋執(zhí)行操作。
在HotSpot虛擬機(jī)中,解釋器主要由Interpreter模塊和Code模塊構(gòu)成,Interpreter模塊實(shí)現(xiàn)了解釋執(zhí)行的核心功能,Code模塊主要用于管理解釋器運(yùn)行時(shí)生成的本地機(jī)械指令。
3.2、JIT即時(shí)編譯器(Just In Time Compiler)
由于解釋器實(shí)現(xiàn)簡(jiǎn)單,并且具備非常優(yōu)異的跨平臺(tái)性,所以現(xiàn)在的很多高級(jí)語(yǔ)言都采用解釋器的方式執(zhí)行,比如Python、Rust、JavaScript
等,但對(duì)于編譯型語(yǔ)言,如C/C++、Go
等語(yǔ)言來(lái)說(shuō),執(zhí)行的性能肯定是差一籌的,而前面不止一次提到過(guò):Java為了解決性能問(wèn)題,所以采用了一種叫做JIT即時(shí)編譯的技術(shù),也就是直接將執(zhí)行比較頻繁的整個(gè)方法或代碼塊直接編譯成本地機(jī)器碼,然后以后執(zhí)行這些方法或代碼時(shí),直接執(zhí)行生成的機(jī)器碼即可。
OK~,那么對(duì)于上述中 執(zhí)行次數(shù)比較頻繁的代碼 判斷基準(zhǔn)又是什么呢?答案是:熱點(diǎn)探測(cè)技術(shù)。
3.3、熱點(diǎn)代碼探測(cè)技術(shù)
HotSpot VM的名字就可以看出這是一款具備熱點(diǎn)代碼探測(cè)能力的虛擬機(jī),所謂的熱點(diǎn)代碼也就是指調(diào)用次數(shù)比較多、執(zhí)行比較頻繁的代碼,當(dāng)某個(gè)方法的執(zhí)行次數(shù)在一定時(shí)間內(nèi)達(dá)到了規(guī)定的閾值,那么JIT則會(huì)對(duì)于該代碼進(jìn)行深度優(yōu)化并將該方法直接編譯成當(dāng)前平臺(tái)對(duì)應(yīng)的機(jī)器碼,以此提升Java程序執(zhí)行時(shí)的性能。
一個(gè)被多次調(diào)用執(zhí)行的方法或一處代碼中循環(huán)次數(shù)比較多的循環(huán)體都可以被稱為 熱點(diǎn)代碼 ,因此都可以通過(guò)JIT編譯為本地機(jī)器指令。
3.3.1、棧上替換
縱觀所有編程語(yǔ)言,類似于C/C++、GO
等編譯型語(yǔ)言,都屬于靜態(tài)編譯型,也就是指在程序啟動(dòng)時(shí)就會(huì)將所有源代碼編譯為平臺(tái)對(duì)應(yīng)的機(jī)器碼,但JVM中的JIT卻屬于動(dòng)態(tài)編譯器,因?yàn)閷?duì)于熱點(diǎn)代碼的編譯是發(fā)生在運(yùn)行過(guò)程中的,所以這種方式也被稱之為 棧上替換(On Stack Replacement),在有的地方也被稱為OSR替換。
3.3.2、方法調(diào)用計(jì)數(shù)器與回邊計(jì)數(shù)器
前面提到過(guò):“一個(gè)被多次調(diào)用執(zhí)行的方法或一處代碼中循環(huán)次數(shù)比較多的循環(huán)體都可以被稱為 熱點(diǎn)代碼”,那么一個(gè)方法究竟要被調(diào)用多少次或一個(gè)循環(huán)體到底要循環(huán)多少遍才可被稱為熱點(diǎn)代碼呢?必然會(huì)存在一個(gè)閾值,而JIT又是如何判斷一段代碼的執(zhí)行次數(shù)是否達(dá)到了這個(gè)閾值的呢?主要依賴于熱點(diǎn)代碼探測(cè)技術(shù)。
在HotSpotVM中,熱點(diǎn)代碼探測(cè)技術(shù)主要是基于計(jì)數(shù)器實(shí)現(xiàn)的。HotSpot中會(huì)為每個(gè)方法創(chuàng)建兩個(gè)不同類型的計(jì)數(shù)器,分別為方法調(diào)用計(jì)數(shù)器(Invocation Counter)和回邊計(jì)數(shù)器(BackEdge Counter),方法調(diào)用計(jì)數(shù)器主要用于統(tǒng)計(jì)方法被調(diào)用的次數(shù),回邊計(jì)數(shù)器主要用于統(tǒng)計(jì)一個(gè)方法體中循環(huán)體的循環(huán)次數(shù)。
方法調(diào)用計(jì)數(shù)器
方法調(diào)用計(jì)數(shù)器的閾值在Client
模式下默認(rèn)是1500次,在Server
模式下默認(rèn)是10000次,當(dāng)一段代碼的執(zhí)行次數(shù)達(dá)到這個(gè)閾值則會(huì)觸發(fā)JIT即時(shí)編譯。當(dāng)然,如果你對(duì)這些缺省(默認(rèn))的數(shù)值不滿意,也可以通過(guò)JVM參數(shù)-XX :CompileThreshold
來(lái)自己指定。
如上,當(dāng)一個(gè)方法被調(diào)用執(zhí)行時(shí),會(huì)首先檢查該方法是否已經(jīng)被JIT編譯過(guò)了,如果是的話,則直接執(zhí)行上次編譯后生成的本地機(jī)器碼。反之,如果還沒有編譯,則先對(duì)方法調(diào)用計(jì)數(shù)器+1,然后判斷計(jì)數(shù)器是否達(dá)到了規(guī)定的閾值,如果還未達(dá)到閾值標(biāo)準(zhǔn)則采用解釋器的模式執(zhí)行代碼。如果達(dá)到了規(guī)定閾值則提交編譯請(qǐng)求,由JIT負(fù)責(zé)后臺(tái)編譯,后臺(tái)線程編譯完成后會(huì)生成本地的機(jī)器碼指令,這些指令會(huì)被放入
Code Cache
中緩存起來(lái)(熱點(diǎn)代碼緩存,存放在方法區(qū)/元數(shù)據(jù)空間中),當(dāng)下次執(zhí)行該方法時(shí),直接從緩存中讀取對(duì)應(yīng)的機(jī)械碼執(zhí)行即可。
回邊計(jì)數(shù)器
回邊計(jì)數(shù)器的作用是統(tǒng)計(jì)一個(gè)方法中循環(huán)體的執(zhí)行次數(shù),在字節(jié)碼中遇到控制流向后跳轉(zhuǎn)的指令稱為“回邊” (Back Edge)。與方法調(diào)用計(jì)數(shù)器一樣,當(dāng)執(zhí)行次數(shù)達(dá)到某個(gè)閾值后,也會(huì)觸發(fā)OSR編譯。如下圖:
OK~,回邊計(jì)數(shù)器的編譯過(guò)程和方法調(diào)用計(jì)數(shù)器的相差無(wú)幾,唯一值得一提的就是:不管是方法調(diào)用計(jì)數(shù)器還是回邊計(jì)數(shù)器,在提交OSR編譯請(qǐng)求的那次執(zhí)行操作,還是依舊會(huì)采用解釋器執(zhí)行,而不會(huì)等到編譯操作完成后去執(zhí)行機(jī)器碼,因?yàn)檫@樣耗費(fèi)的時(shí)間比較長(zhǎng),只有下次再執(zhí)行該代碼時(shí)才會(huì)執(zhí)行編譯后的機(jī)器碼。
3.3.3、熱度衰減
一般而言,如果以缺省參數(shù)啟動(dòng)Java程序,那么方法調(diào)用計(jì)數(shù)器統(tǒng)計(jì)的執(zhí)行次數(shù)并不是絕對(duì)次數(shù),而是一個(gè)相對(duì)的執(zhí)行頻率,也代表是指方法在一段時(shí)間內(nèi)被執(zhí)行的次數(shù)。當(dāng)超過(guò)一定的時(shí)間,但計(jì)數(shù)器還是未達(dá)到編譯閾值無(wú)法提交給JIT即時(shí)編譯器編譯時(shí),那此時(shí)就會(huì)對(duì)計(jì)數(shù)器進(jìn)行減半,這個(gè)過(guò)程被稱為方法調(diào)用計(jì)數(shù)器的熱度衰減(Counter Decay),而這段時(shí)間則被稱為方法調(diào)用計(jì)數(shù)器的半衰周期(Counter Half Life Time)。
而發(fā)生熱度衰減的動(dòng)作是在虛擬機(jī)GC進(jìn)行垃圾回收時(shí)順帶進(jìn)行的,可以通過(guò)參數(shù)-XX:-UseCounterDecay
關(guān)閉熱度衰減,這樣可以使得方法調(diào)用計(jì)數(shù)器的判斷基準(zhǔn)變?yōu)榻^對(duì)調(diào)用次數(shù),而不是以相對(duì)執(zhí)行頻率作為閾值判斷的標(biāo)準(zhǔn)。不過(guò)如果關(guān)閉了熱度衰減,就會(huì)導(dǎo)致一個(gè)Java程序只要在線上運(yùn)行的時(shí)間足夠長(zhǎng),程序中的方法必然絕大部分都會(huì)被編譯為本地機(jī)器碼。
同時(shí)也可以通過(guò)
-XX:CounterHalfLifeTime
參數(shù)調(diào)整半衰周期的時(shí)間,單位為秒。
一般而言,如果項(xiàng)目規(guī)模不大,并且上線后很長(zhǎng)一段時(shí)間不需要進(jìn)行版本迭代的產(chǎn)品,都可以嘗試把熱度衰減關(guān)閉掉,這樣可以使得Java程序在線上運(yùn)行的時(shí)間越久,執(zhí)行性能會(huì)更佳。只要線上運(yùn)行的時(shí)間足夠長(zhǎng),到后面可以與C編寫的程序性能相差無(wú)幾甚至超越(因?yàn)镃/C++需要手動(dòng)管理內(nèi)存,管理內(nèi)存是需要耗費(fèi)時(shí)間的,但Java程序在執(zhí)行程序時(shí)卻不需要擔(dān)心內(nèi)存方面的問(wèn)題,會(huì)有GC機(jī)制負(fù)責(zé))。
3.3.4、其他的熱點(diǎn)探測(cè)技術(shù)
在前面分析中,我們得知了,在HotSpot中的熱點(diǎn)代碼探測(cè)是基于計(jì)數(shù)器模式實(shí)現(xiàn)的,但是除開計(jì)數(shù)器的方式探測(cè)之外,還可以基于采樣(sampling)以及蹤跡(Trace)模式對(duì)代碼進(jìn)行熱點(diǎn)探測(cè)。
- 采樣探測(cè):采用這種探測(cè)技術(shù)的虛擬機(jī)會(huì)周期性的檢查每個(gè)線程的虛擬機(jī)棧棧頂,如果一些在檢查時(shí)經(jīng)常出現(xiàn)在棧頂?shù)姆椒ǎ敲淳痛磉@個(gè)方法經(jīng)常被調(diào)用執(zhí)行,對(duì)于這類方法可以判定為熱點(diǎn)方法。
- 優(yōu)點(diǎn):實(shí)現(xiàn)簡(jiǎn)單,可以很輕松的判定出熱度很高(調(diào)用次數(shù)頻繁)的方法。
- 缺點(diǎn):無(wú)法實(shí)現(xiàn)精準(zhǔn)探測(cè),因?yàn)闄z查是周期性的,并且有些方法中存在線程阻塞、休眠等因素,會(huì)導(dǎo)致有些方法無(wú)法被精準(zhǔn)檢測(cè)。
- 蹤跡探測(cè):采用這種方式的虛擬機(jī)是將一段頻繁執(zhí)行的代碼作為一個(gè)編譯單元,并僅對(duì)該單元進(jìn)行編譯,該單元由一個(gè)線性且連續(xù)的指令集組成,僅有一個(gè)入口,但有多個(gè)出口。也就代表著:基于蹤跡而編譯的熱點(diǎn)代碼不僅僅局限在一個(gè)單獨(dú)的方法或者代碼塊中,一條蹤跡可能對(duì)應(yīng)多個(gè)方法,代碼中頻繁執(zhí)行的路徑就可能被識(shí)別成不同的蹤跡。
- 優(yōu)點(diǎn):這種方式實(shí)現(xiàn)可以使得熱點(diǎn)探測(cè)擁有更高精度,可以避免將一塊代碼塊中所有的代碼都進(jìn)行編譯的情況出現(xiàn),能夠在很大程序上減少不必要的編譯開銷。因?yàn)闊o(wú)論是采樣探測(cè)還是計(jì)數(shù)器探測(cè)的方式,都是以方法體或循環(huán)體作為編譯的基本單元的。
- 缺點(diǎn):蹤跡探測(cè)的實(shí)現(xiàn)過(guò)程非常復(fù)雜,難度非常高。
而HotSpot虛擬機(jī)采用的計(jì)數(shù)探測(cè)的方式,實(shí)現(xiàn)難度、編譯開銷與探測(cè)精準(zhǔn)三者之間會(huì)有一個(gè)很好的權(quán)衡。三種探測(cè)技術(shù)比較如下:
- 實(shí)現(xiàn)難度:采樣探測(cè) < 計(jì)數(shù)探測(cè) < 蹤跡探測(cè)
- 探測(cè)精度:采樣探測(cè) < 計(jì)數(shù)探測(cè) < 蹤跡探測(cè)
- 編譯開銷:蹤跡探測(cè) < 計(jì)數(shù)探測(cè) < 采樣探測(cè)
3.4、JVM為何不移除解釋器?
在前面分析了JIT即時(shí)編譯器,可以很直觀的感受到,如果程序以純JIT編譯器的方式執(zhí)行,性能方面絕對(duì)會(huì)超出解釋器+編譯器混合的模式,但為何虛擬機(jī)中至今也不移除解釋器,還要用解釋器來(lái)拖累Java程序的性能呢?就如在開篇中提到的JRockit虛擬機(jī)中,就移除了解釋器模塊,字節(jié)碼文件全部依靠即時(shí)編譯器執(zhí)行。
主要有兩個(gè)原因,一個(gè)是為了保證Java的絕對(duì)跨平臺(tái)性,另一個(gè)則是為了保證啟動(dòng)速度,考慮綜合性能。
①保證絕對(duì)的跨平臺(tái)性:如果將解釋器從虛擬機(jī)中移除就代表著:每到一個(gè)不同的平臺(tái),比如從Windows遷移到Linux環(huán)境,那么JIT又要重新編譯,生成對(duì)應(yīng)平臺(tái)的機(jī)器碼指令才能讓Java程序執(zhí)行。但如果是解釋器+JIT編譯器混合的模式工作就不需要擔(dān)心這個(gè)問(wèn)題,因?yàn)榍捌诳梢灾苯佑山忉屍鲗⒆止?jié)碼指令翻譯成當(dāng)前所在的機(jī)械碼執(zhí)行,解釋器會(huì)根據(jù)所在平臺(tái)的不同,翻譯出平臺(tái)對(duì)應(yīng)的機(jī)器碼指令。這樣從而使得Java具備更強(qiáng)的跨平臺(tái)性。
②保證Java啟動(dòng)速度,考慮綜合性能:因?yàn)槿绻瞥私忉屍髂K,那么就代表著所有的字節(jié)碼指令需要在啟動(dòng)時(shí)全部先編譯為本地的機(jī)械碼,這樣才能使得Java程序能夠正常執(zhí)行。不過(guò)如果想在啟動(dòng)時(shí)將整個(gè)程序中所有的字節(jié)碼指令全部編譯為機(jī)器碼指令,需要的時(shí)間開銷是非常巨大的,如果把解釋器從JVM中移除,那么會(huì)導(dǎo)致一些需要緊急上線的項(xiàng)目可能編譯都需要等半天的時(shí)間。
綜上所述,虛擬機(jī)移除解釋器有移除后的隱患,當(dāng)然,如果移除了也有移除之后的好處,比如前面提到的JRockitVM中,就移除了解釋器模塊,從而使它獲取了一個(gè)“史上最快”虛擬機(jī)的稱號(hào)。
而HotSpot中采用的是解釋器+JIT即時(shí)編譯器混合的模式,這種模式的好處在于:在Java程序運(yùn)行時(shí),JVM可以快速啟動(dòng),前期先由解釋器發(fā)揮作用,不需要等到編譯器把所有字節(jié)碼指令編譯完之后才執(zhí)行,這樣可以省去很大一部分的編譯時(shí)間。后續(xù)隨著程序在線上運(yùn)行的時(shí)間越來(lái)越久,JIT發(fā)揮作用,慢慢的將一些程序中的熱點(diǎn)代碼替換為本地機(jī)器碼運(yùn)行,這樣可以讓程序的執(zhí)行效率更高。同時(shí),因?yàn)镠otSpotVM中存在熱度衰減的概念,所以當(dāng)一段代碼的熱度下降時(shí),JIT會(huì)取消對(duì)它的編譯,重新更換為解釋器執(zhí)行的模式工作,所以HotSpot的這種執(zhí)行模式也被成為“自適應(yīng)優(yōu)化”執(zhí)行。
當(dāng)然,我們?cè)诔绦騿?dòng)時(shí)也可以通過(guò)JVM參數(shù)自己指定執(zhí)行模式:
①-Xint:完全采用解釋器模式執(zhí)行程序。
②-Xcomp:完全采用即時(shí)編譯器模式執(zhí)行程序。如果即時(shí)編譯器出現(xiàn)問(wèn)題,解釋器會(huì)介入執(zhí)行。
③-Xmixed:采用解釋器+JIT即時(shí)編譯器的混合模式共同執(zhí)行(默認(rèn)的執(zhí)行方式)。
3.5、熱冷機(jī)流量遷移注意事項(xiàng)
通過(guò)上述的分析之后,我們可以得到一個(gè)結(jié)論:
編譯執(zhí)行的方式性能會(huì)遠(yuǎn)遠(yuǎn)超出解釋執(zhí)行。
這句話聽起來(lái)好像是廢話,因?yàn)槭莻€(gè)明眼人就能看出這個(gè)結(jié)論,但實(shí)則不然。此時(shí)我們可以從系統(tǒng)架構(gòu)的角度思考一下這個(gè)結(jié)論,對(duì)于系統(tǒng)整體而言,這個(gè)結(jié)論有什么不同嗎?是有的,如下:
既然編譯執(zhí)行比解釋執(zhí)行的效率要高,那么就代表著程序如果處于編譯執(zhí)行的周期內(nèi),系統(tǒng)的吞吐量要比解釋執(zhí)行期間高很多。而Java現(xiàn)在默認(rèn)的虛擬機(jī)HotSpot并不是一開始就是編譯執(zhí)行的,而是在運(yùn)行過(guò)程中通過(guò)JIT即時(shí)編譯器進(jìn)行動(dòng)態(tài)編譯的。
所以現(xiàn)在又可以得到一個(gè)簡(jiǎn)單的結(jié)論,Java程序的機(jī)器可以簡(jiǎn)單分為兩種狀態(tài):
- 熱機(jī):長(zhǎng)時(shí)間在線上運(yùn)行Java程序的機(jī)器,程序中很多代碼都已經(jīng)被JIT編譯為了本地機(jī)器碼指令。
- 冷機(jī):剛剛啟動(dòng)的Java程序的機(jī)器,所有代碼還是處于解釋執(zhí)行的階段。
從上面的分析中可以得知:機(jī)器在熱機(jī)狀態(tài)可以承受的流量負(fù)載會(huì)遠(yuǎn)遠(yuǎn)超出冷機(jī)狀態(tài)。如果程序以熱機(jī)狀態(tài)切換流量到冷機(jī)狀態(tài)的機(jī)器時(shí),可能會(huì)導(dǎo)致冷機(jī)狀態(tài)的服務(wù)器因無(wú)法承載流量而假死。
之前我在開發(fā)過(guò)程中也曾遇到過(guò)這樣的問(wèn)題,某個(gè)服務(wù)因?yàn)橐獢U(kuò)容,原本按照之前的集群規(guī)模計(jì)算,再擴(kuò)容1/4之一左右的機(jī)器是可以承載新的流量的,但后面啟動(dòng)之后出現(xiàn)了問(wèn)題,新啟動(dòng)的機(jī)器網(wǎng)關(guān)那邊分配轉(zhuǎn)發(fā)流量之后,立馬就宕機(jī)了,最開始因?yàn)槭堑谝淮闻龅竭@樣的問(wèn)題,以為是機(jī)器或者程序中代碼的問(wèn)題,最后排查發(fā)現(xiàn)都沒問(wèn)題,后來(lái)嘗試將擴(kuò)容的機(jī)器數(shù)量從原本計(jì)劃的1/4增加到1/3之后,流量平滑的被遷移到了新的機(jī)器,沒有再出現(xiàn)宕機(jī)的故障。
從上述這個(gè)案例中可以得知,如果直接將熱機(jī)狀態(tài)的流量遷移到冷機(jī)狀態(tài)的機(jī)器是不可行的,所以一般在計(jì)劃擴(kuò)容時(shí),想要流量平滑的切換到新的機(jī)器,一般有軟硬件兩種層面的解決方案,如下:
第一種方案是和上述案例中一樣,采用更多的機(jī)器承載熱機(jī)狀態(tài)過(guò)來(lái)的流量,等后續(xù)這些剛啟動(dòng)的冷機(jī)變成熱機(jī)狀態(tài)了,可以再把多余的機(jī)器停掉。
第二種方案則是網(wǎng)關(guān)這邊控制流量,先將一部分流量轉(zhuǎn)發(fā)給剛啟動(dòng)的冷機(jī),讓剛啟動(dòng)的冷機(jī)先做預(yù)熱,等運(yùn)行一段時(shí)間之后再將原本計(jì)劃的所有流量遷移到這些機(jī)器。
四、全面剖析JIT即時(shí)編譯器
在Java的編譯器中,大體可以分為三類:
- ①前端編譯器:類似于javac、JDT中的ECJ增量編譯器等。就是指將
.java
的源代碼編譯成.class
字節(jié)碼指令的編譯器。 - ②后端編譯器:也就是指JIT即時(shí)編譯器,指把字節(jié)碼指令編譯成機(jī)器碼指令的編譯器。
- 靜態(tài)編譯器:類似于Java9中的AOT編譯器,是指把
.java
源代碼直接編譯為機(jī)器碼指令的編譯器。
在JVM運(yùn)行過(guò)程中采用的解釋器+編譯器混合執(zhí)行的模式,一般是指JIT編譯器,在Java中對(duì)于靜態(tài)編譯器的應(yīng)用還是比較少的。在HotSpot虛擬機(jī)中內(nèi)嵌著兩個(gè)JIT即時(shí)編譯器,分別為Client Compiler
與Server Compiler
,也就是通常所說(shuō)的C1和C2編譯器,JVM在64位的系統(tǒng)中默認(rèn)采用的C2編譯器,也就是Server Compiler
編譯器。不過(guò)同樣的,在程序啟動(dòng)的時(shí)候也可以通過(guò)參數(shù)顯式指定運(yùn)行時(shí)到底采用哪種編譯器,如下:
- -client:指定JVM運(yùn)行時(shí)采用C1編譯器。
- C1編譯器會(huì)對(duì)字節(jié)碼進(jìn)行簡(jiǎn)單和可靠的優(yōu)化,耗時(shí)比較短,追求編譯速度。
- -server:指定JVM運(yùn)行時(shí)采用C2編譯器。
- C2編譯器會(huì)對(duì)字節(jié)碼進(jìn)行激進(jìn)優(yōu)化,耗時(shí)比較長(zhǎng),追求編譯后的執(zhí)行性能。
兩種編譯器因?yàn)樽非蟮姆较虿煌栽趦?yōu)化時(shí)的過(guò)程也存在差異,下面來(lái)簡(jiǎn)單分析一下C1和C2編譯器。
4.1、C1編譯器(Client Compiler)
C1編譯器主要追求穩(wěn)定和編譯速度,屬于保守派,C1中常見的優(yōu)化方案有幾種:公共子表達(dá)式消除、方法內(nèi)聯(lián)、去虛擬化以及冗余消除等。
- 公共子表達(dá)式消除:如果一個(gè)表達(dá)式E已經(jīng)計(jì)算過(guò)了,并且從先前的計(jì)算到現(xiàn)在E中所有變量的值都沒有發(fā)生變化,那E的這次出現(xiàn)就成公共子表達(dá)式,可以用原先的表達(dá)式進(jìn)行消除,直接使用上次的計(jì)算結(jié)果,無(wú)需再次計(jì)算。
- 方法內(nèi)聯(lián):將引用的方法代碼編譯到引用點(diǎn)處,這樣可以減少棧幀的生成,減少參數(shù)傳遞以及跳轉(zhuǎn)過(guò)程。
- 去虛擬化:對(duì)唯一的實(shí)現(xiàn)類進(jìn)行內(nèi)聯(lián)。
- 冗余消除:通過(guò)對(duì)字節(jié)碼指令進(jìn)行流分析,將一些運(yùn)行過(guò)程中不會(huì)執(zhí)行的代碼消除。
- 空檢測(cè)消除:將顯式調(diào)用的NullCheck(空指針判斷)擦除,改成ImplicitNullCheck異常信號(hào)機(jī)制處理。
- 自動(dòng)裝箱消除:對(duì)于一些不必要的裝箱操作會(huì)被消除,比如剛裝箱的數(shù)據(jù)又在后面立馬被拆箱,這種無(wú)用操作就會(huì)被消除。
- 安全點(diǎn)消除:對(duì)于線程無(wú)法抵達(dá)或不會(huì)停留的安全點(diǎn)會(huì)進(jìn)行消除。
- 反射消除:對(duì)于一些可以正常訪問(wèn)無(wú)需通過(guò)反射機(jī)制獲取的數(shù)據(jù),會(huì)被改為直接訪問(wèn),消除反射操作。
4.2、C2編譯器(Server Compiler)
C2編譯器則主要是追求編譯后的執(zhí)行性能,屬于激進(jìn)派,C2編譯器建立在C1編譯器的基礎(chǔ)優(yōu)化之上,除開使用了C1中的優(yōu)化手段之外,還有幾種基于逃逸分析的激進(jìn)優(yōu)化手段:標(biāo)量替換、棧上分配以及同步消除等。
- 逃逸分析:逃逸分析是建立在方法為單位之上的,判斷變量作用域是否存在于其他棧幀或者線程中,如果一個(gè)成員在方法體中產(chǎn)生,但是直至方法結(jié)束也沒有走出方法體的作用域,那么該成員就可以被理解為未逃逸。反之,如果一個(gè)成員在方法最后被
return
出去了或在方法體的邏輯中被賦值給了外部成員,那么則代表著該成員逃逸了,判斷逃逸的方法被稱為逃逸分析。- 也可以換個(gè)說(shuō)法,建立在線程的角度來(lái)看:如果一條線程中的對(duì)象無(wú)法被另一條線程訪問(wèn)到,就代表該對(duì)象未逃逸。
- 逃逸的作用域:
- ①棧幀逃逸:當(dāng)前方法內(nèi)定義了一個(gè)局部變量逃出了當(dāng)前方法/棧幀。
- ②線程逃逸:當(dāng)前方法內(nèi)定義了一個(gè)局部變量逃出了當(dāng)前線程能夠被其他線程訪問(wèn)。
- 逃逸類型:
- 全局變量賦值逃逸:當(dāng)前對(duì)象被賦值給類屬性、靜態(tài)屬性
- 參數(shù)賦值逃逸:當(dāng)前對(duì)象被當(dāng)作參數(shù)傳遞給另一個(gè)方法
- 方法返回值逃逸:當(dāng)前對(duì)象被當(dāng)做返回值return
- 標(biāo)量替換:建立在逃逸分析的基礎(chǔ)上使用基本量標(biāo)量代替對(duì)象這種聚合量。
- 標(biāo)量:reference與八大基本數(shù)據(jù)類型就是典型的標(biāo)量,泛指不可再拆解的數(shù)據(jù)。
- 好處:
- ①能夠節(jié)省堆內(nèi)存,因?yàn)檫M(jìn)行標(biāo)量替換之后的對(duì)象可以在棧上進(jìn)行內(nèi)存分配。
- ②相對(duì)運(yùn)行而言省去了去堆中查找對(duì)象引用的過(guò)程,速度會(huì)更快一些。
- ③因?yàn)槭欠峙湓跅I希詴?huì)隨著方法結(jié)束和線程棧的彈出自動(dòng)銷毀,不需要GC的介入。
- 棧上分配:對(duì)于未逃逸的對(duì)象使用標(biāo)量替換進(jìn)行拆解,然后將拆解后的標(biāo)量分配在局部變量表中,從而減少實(shí)例對(duì)象的產(chǎn)生,減少堆內(nèi)存的使用以及GC次數(shù)。
- 決定一個(gè)對(duì)象能否在棧上分配的因素(兩個(gè)都必須滿足):
- ①對(duì)象能夠通過(guò)標(biāo)量替換分解成一個(gè)個(gè)標(biāo)量。
- ②對(duì)象在棧幀級(jí)作用域不可逃逸。
- 決定一個(gè)對(duì)象能否在棧上分配的因素(兩個(gè)都必須滿足):
- 同步消除:在出現(xiàn)
synchronized
嵌套的情況下,如一個(gè)同步方法中調(diào)用另一個(gè)同步方法,那么第二個(gè)同步方法的synchronized
鎖會(huì)被消除,因?yàn)榈诙€(gè)方法只有獲取到了第一個(gè)鎖的線程才能訪問(wèn),不存在線程并發(fā)安全問(wèn)題。- 決定能否同步消除(滿足一個(gè)即可):
- ①當(dāng)前對(duì)象被分配在棧上。
- ②當(dāng)前對(duì)象的無(wú)法逃出線程作用域。
- 決定能否同步消除(滿足一個(gè)即可):
- 空檢查剪支:經(jīng)過(guò)流分析后,對(duì)于一些不會(huì)執(zhí)行的Null分支判斷會(huì)直接剪掉
- 如一個(gè)參數(shù)在外部方法傳遞前已經(jīng)做了非空檢測(cè)了,但在內(nèi)部方法中依舊又做了一次非空判斷,那么對(duì)于內(nèi)部的這個(gè)非空判斷會(huì)被直接剪除掉。
逃逸的作用域:①棧幀逃逸:當(dāng)前方法內(nèi)定義了一個(gè)局部變量逃出了當(dāng)前方法/棧幀。 ②線程逃逸:當(dāng)前方法內(nèi)定義了一個(gè)局部變量逃出了當(dāng)前線程能夠被其他線程訪問(wèn)。全局變量賦值逃逸:當(dāng)前對(duì)象被賦值給類屬性、靜態(tài)屬性參數(shù)賦值逃逸:當(dāng)前對(duì)象被當(dāng)作參數(shù)傳遞給另一個(gè)方法方法返回值逃逸:當(dāng)前對(duì)象被當(dāng)做返回值return
前面提到了,64位的JVM中都是默認(rèn)使用C2編譯器的,但實(shí)際上JDK1.6之后如果是64位的機(jī)器,默認(rèn)情況下或顯式指定了-server模式運(yùn)行時(shí),JVM會(huì)開啟分層編譯策略,也就是通過(guò)C1+C2相互協(xié)作共同處理編譯任務(wù)。而分層編譯大體的邏輯為:Java程序剛啟動(dòng)還處于冷機(jī)狀態(tài)時(shí),采用C1編譯器進(jìn)行簡(jiǎn)單優(yōu)化,追求編譯速度和穩(wěn)定性,當(dāng)JVM達(dá)到熱機(jī)狀態(tài)時(shí),后面的編譯請(qǐng)求則通過(guò)C2編譯器進(jìn)行全面激進(jìn)優(yōu)化,追求編譯后執(zhí)行時(shí)的性能和效率。
PS:兩種不同的模式運(yùn)行,熱點(diǎn)代碼緩存區(qū)大小也會(huì)不一樣,Server模式下CodeCache的初始大小為2496KB,Client模式下CodeCache的初始大小為160KB,可以通過(guò)
-XX:ReservedCacheSize
參數(shù)指定CodeCache的最大大小。
4.3、其他的編譯器
在JDK10的時(shí),HotSpot加入了一種新的編譯器:Graal
編譯器,該編譯器的性能經(jīng)過(guò)幾代的更新后很快就追上了老牌的C2編譯器,在JDK10中可以通過(guò)-XX: +UnlockExperimentalVMOptions -XX: +UseJVMCICompiler
參數(shù)使用它。
五、分派(Dispatch)調(diào)用
在學(xué)習(xí)JavaSE的時(shí)候大家應(yīng)該都學(xué)到了OOP的基本特征,也就是封裝、繼承與多態(tài)。而關(guān)于多態(tài)性在運(yùn)行時(shí)到底是如何找到具體方法的,如重寫和重載方法到底在運(yùn)行時(shí)是如何確定具體調(diào)用那個(gè)方法的呢?也就是通過(guò)分派技術(shù)進(jìn)行調(diào)用的。
5.1、方法調(diào)用
先來(lái)說(shuō)說(shuō)方法調(diào)用,方法調(diào)用和方法執(zhí)行是不同的,方法調(diào)用階段的主要任務(wù)是確定被調(diào)用方法的版本,這個(gè)版本是指要具體調(diào)用重載、重寫情況下的哪一個(gè)方法,方法調(diào)用階段并不會(huì)涉及到方法體中邏輯的執(zhí)行。一般來(lái)說(shuō),.java
文件經(jīng)過(guò)前端編譯器編譯成.class
文件后,所有的方法調(diào)用存儲(chǔ)在class
文件都是 符號(hào)引用 ,而并不是 直接引用(運(yùn)行時(shí)方法在內(nèi)存中的入口) 。
一般而言,方法的直接引用需要等到類加載中的解析階段甚至運(yùn)行時(shí)才可以確定,在類加載中的解析階段能夠被確認(rèn)直接引用方法只有靜態(tài)方法、final方法以及私有方法,因?yàn)檫@幾類都是屬于“編譯器可知、運(yùn)行期不可變”的方法,因?yàn)檫@幾種方式定義的方法要么與類直接關(guān)聯(lián),要么外部不能訪問(wèn)以及不可修改,這就決定了他們不能通過(guò)重寫的方法更改其方法版本,因此都可以直接在解析階段確認(rèn)直接引用,可以在類加載階段進(jìn)行解析。
在JVM虛擬機(jī)中提供了五條方法調(diào)用的指令:
-
invokestatic
:調(diào)用靜態(tài)方法 -
invokespecial
:調(diào)用構(gòu)造<init>
構(gòu)造方法、私有方法以及super()、super.xxx()
父類方法 -
invokevirtual
:調(diào)用所有的虛方法(靜態(tài)、私有、構(gòu)造、父類、final方法都屬于非虛方法) -
invokeinterface
:調(diào)用接口方法,會(huì)在運(yùn)行期間才能確定具體的實(shí)現(xiàn)類方法 -
invokedynamic
:現(xiàn)在運(yùn)行時(shí)期動(dòng)態(tài)解析出調(diào)用點(diǎn)限定符所引用的方法,然后再執(zhí)行該方法,在此之前的4條指令,分派邏輯都是固化在JVM中的,而invokedynamic
指令的分派邏輯是由用戶所設(shè)定的引導(dǎo)方法決定的
一般而言,能夠被invokestatic
和invokespecial
指令調(diào)用的方法都可以在解析階段確定調(diào)用的具體版本信息,像靜態(tài)、私有、構(gòu)造、父類、final方法都符合調(diào)用條件,所以這些方法在類加載階段就會(huì)把符號(hào)引用替換成直接引用。因?yàn)檫@些方法是一個(gè)靜態(tài)的過(guò)程,在編譯期間就能完全確定版本,無(wú)需將這些工作延遲到運(yùn)行期間再去處理,而這類調(diào)用方式就被稱為靜態(tài)分派。但對(duì)于公開實(shí)例方法、非私有成員方法這些就無(wú)法在編譯期確定版本,所以這些方法的調(diào)用方式被稱為動(dòng)態(tài)分派。同時(shí)根據(jù)方法的宗量數(shù)也可分為單分派和多分派。
方法宗量是指方法的所有者和參數(shù),根據(jù)分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。所以稍微總結(jié)一下,如下:
- 非虛方法:指在類加載階段可確定版本(可將符號(hào)引用轉(zhuǎn)換為直接引用)的方法
- 虛方法:指在類加載階段無(wú)法確定版本(無(wú)法確定符號(hào)引用可以解析為哪個(gè)直接引用)的方法
- 靜態(tài)分派:編譯期可以確定方法的版本,類加載階段可以通過(guò)解析階段完成版本判定
- 動(dòng)態(tài)分派:運(yùn)行期才可確定方法版本,由JVM來(lái)確定方法的具體版本
- 單分派:根據(jù)單個(gè)方法宗量進(jìn)行方法版本選擇
- 動(dòng)態(tài)分派的選擇是依據(jù)方法接收者來(lái)選擇版本的,所以動(dòng)態(tài)分派屬于單分派
- 多分派:根據(jù)多個(gè)方法宗量進(jìn)行方法版本選擇
- 靜態(tài)分派是依據(jù)方法的接收者和參數(shù)兩個(gè)宗量進(jìn)行版本選擇,因此靜態(tài)分派屬于多分派
5.2、靜態(tài)分派
靜態(tài)分派是指所有依賴于靜態(tài)類型來(lái)定位方法執(zhí)行版本的分派動(dòng)作,靜態(tài)分派發(fā)生在編譯期,由編譯器執(zhí)行分派動(dòng)作,所以靜態(tài)分派并不是虛擬機(jī)來(lái)執(zhí)行的。
靜態(tài)類型是指什么?
User u = new Admin();
如上代碼,User
是變量u
的靜態(tài)類型(外觀類型),而Admin
是變量的實(shí)際類型。
靜態(tài)分派的典型體現(xiàn)是方法重載(Overload),重載方法的特性是方法簽名不同(方法名相同、參數(shù)列表不同。下面上個(gè)案例理解一下:
public class User{
public void identity(VipUser vip){
System.out.println("我是VIP會(huì)員用戶....");
}
public void identity(AdminUser admin){
System.out.println("我是管理員....");
}
public static void main(String[] args) {
User user = new User();
VipUser vip = new VipUser();
user.identity(vip);
}
}
class VipUser{}
class AdminUser{}
如上源碼,User
中存在兩個(gè)方法,identity(VipUser)
重載了方法identity(AdminUser)
,編譯器在解析名為identity
的方法時(shí),會(huì)根據(jù)其重載參數(shù)的靜態(tài)類型(即外觀類型或直接類型)來(lái)選擇方法版本。
輸出結(jié)果:我是VIP會(huì)員用戶....
這個(gè)結(jié)果不難理解,因?yàn)樵谡{(diào)用方法的時(shí)候:user.identity(vip)
傳入的參數(shù)靜態(tài)類型為VipUser
,所以編譯器最終會(huì)找到identity(VipUser vip)
方法。
如果參數(shù)為無(wú)類型的字面量(基本數(shù)據(jù)類型),那么編譯器會(huì)在最大程度上去推導(dǎo)出字面量上最貼合的方法版本,如下:
public class User{
public void print(char arg){
System.out.println("char....");
}
public void print(long arg){
System.out.println("long....");
}
public void print(int arg){
System.out.println("int....");
}
// 省略其他方法.......
public void print(char... arg){
System.out.println("char... ....");
}
public static void main(String[] args) {
User user = new User();
user.print('a');
}
}
// 輸出結(jié)果:char....
觀察如上代碼,輸出結(jié)果為char....
,這很正常,但如果我們把print(char arg)
方法注釋掉,再次執(zhí)行會(huì)發(fā)生什么情況?報(bào)錯(cuò)?并不是,注釋掉后再執(zhí)行,如下:
輸出結(jié)果:int....
從上面的執(zhí)行結(jié)果中可以得知:雖然User
類中沒有了char
類型參數(shù)的方法,但實(shí)際上編譯器會(huì)通過(guò)參數(shù)自動(dòng)轉(zhuǎn)型幫你找到了一個(gè)“合適”的方法調(diào)用,轉(zhuǎn)換路徑如下:
char → int → long → float → double
,經(jīng)過(guò)如上過(guò)程還未找到符合要求的方法時(shí),會(huì)自動(dòng)將調(diào)用方法時(shí)傳遞的參數(shù)裝箱為Character
對(duì)象,如果還是未找到,會(huì)進(jìn)一步查找Character
類實(shí)現(xiàn)的接口Serializable
,如果還未找到,會(huì)進(jìn)一步查找Serializable
的父類Object
,如果還未找到則會(huì)再找到char...
,所以總體查找路徑如下:
char → int → long → float → double → Character → Serializable → Object → char...
實(shí)際上關(guān)于編譯器的類型轉(zhuǎn)換,推導(dǎo)最合適的方法調(diào)用這個(gè)點(diǎn),大家了解一下有這個(gè)概念存在即可,實(shí)際開發(fā)過(guò)程中,代碼不會(huì)寫這么苛刻。
5.3、動(dòng)態(tài)分派
動(dòng)態(tài)分派是指在編譯期無(wú)法通過(guò)靜態(tài)類型判定出方法版本,需要在運(yùn)行期間由虛擬機(jī)來(lái)判定方法調(diào)用的具體版本的方式。動(dòng)態(tài)分派的典型體現(xiàn)是方法重寫(Override),重寫的概念是方法簽名相同(方法名相同、參數(shù)列表相同),上個(gè)案例理解。如下:
public class User{
public void identity(){
System.out.println("我是用戶....");
}
public static void main(String []args) {
User user = new VipUser();
user.identity();
}
}
class VipUser extends User {
public void identity(){
System.out.println("我是VIP會(huì)員用戶....");
}
}
class AdminUser extends User{
public void identity(){
System.out.println("我是管理員....");
}
}
// 輸出結(jié)果:我是VIP會(huì)員用戶....
對(duì)于這個(gè)結(jié)果相信不會(huì)出乎大家的意料,那虛擬機(jī)在運(yùn)行時(shí)又是如何定位到VipUser.identity()
方法的呢?這里顯然不是通過(guò)變量的靜態(tài)類型進(jìn)行的版本判定,因?yàn)殪o態(tài)類型為User
的變量user
調(diào)用identity
執(zhí)行后,最終執(zhí)行的卻是VipUser.identity()
方法,這是什么原因呢?其實(shí)道理也非常簡(jiǎn)單,就是因?yàn)?code>user變量的實(shí)際類型不同,那Java又是如何通過(guò)變量的實(shí)際類型來(lái)判定方法版本的?接下來(lái)進(jìn)行逐步分析。
對(duì)于如上源碼使用javap
進(jìn)行反編譯后,現(xiàn)在來(lái)觀察一下User.main()
的字節(jié)碼信息,如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class User$VipUser
3: dup
4: invokespecial #3 // Method User$VipUser."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method identity:()V
12: return
LineNumberTable:
line 7: 0
line 8: 8
line 9: 12
在字節(jié)碼指令的第七行,通過(guò)了invokevirtual
指令調(diào)用了VipUser.identity
虛方法,運(yùn)行時(shí)JVM的執(zhí)行引擎對(duì)于invokevirtual
指令進(jìn)行解析,解析操作會(huì)分為如下幾個(gè)步驟:
- 找到操作數(shù)棧頂部的第一個(gè)元素,也就是指向變量
user
的實(shí)際類型,即VipUser
- 在
VipUser
的方法表中查找名稱和參數(shù)類型與invokevirtual
指令調(diào)用的方法符號(hào)引用相同的方法- 找到了:代表
VipUser
類中存在方法identity()
方法,判斷是否具備方法的訪問(wèn)權(quán)限- 具備:將調(diào)用方法處的符號(hào)引用替換為該方法的直接引用
- 不具備:拋出
java.lang.IllegalAccessError
錯(cuò)誤
- 沒找到:
- 繼續(xù)自下向上的方式查找
VipUser
父類的方法表- 找到了:代表父類中有
identity()
方法,,判斷是否具備方法的訪問(wèn)權(quán)限- 具備:將調(diào)用方法處的符號(hào)引用替換為該方法的直接引用
- 不具備:拋出
java.lang.IllegalAccessError
錯(cuò)誤
- 還是沒找到:代表調(diào)用的方法根本不存在,拋出
java.lang.AbstractMethodError
錯(cuò)誤
- 找到了:代表父類中有
- 繼續(xù)自下向上的方式查找
- 找到了:代表
由于invokevirtual
指令執(zhí)行的第一步就是在運(yùn)行期確定接收者的實(shí)際類型,所以調(diào)用中的invokevirtual
指令把常量池中的類方法符號(hào)引用解析到了不同的直接引用上,這個(gè)過(guò)程就是Java語(yǔ)言中方法重寫的本質(zhì)。同時(shí),這種在運(yùn)行期根據(jù)實(shí)際類型確定方法執(zhí)行版本的分派過(guò)程稱為動(dòng)態(tài)分派。
5.4、虛擬機(jī)中動(dòng)態(tài)分派的實(shí)現(xiàn)
由于動(dòng)態(tài)分派在運(yùn)行時(shí)是頻繁執(zhí)行的動(dòng)作。而且相當(dāng)來(lái)說(shuō),動(dòng)態(tài)分派的方法版本判定需要在類的元數(shù)據(jù)中搜索出符合要求的合適版本,性能開銷也比較大,因此在虛擬機(jī)的實(shí)際實(shí)現(xiàn)中基于性能的考慮,大部分實(shí)現(xiàn)都不會(huì)真正的進(jìn)行如此頻繁的搜索。
綜合考慮,一般的JVM實(shí)現(xiàn)中,都會(huì)為每個(gè)類在元數(shù)據(jù)空間(原方法區(qū))中建立一個(gè)虛方法表,在解析
invokevirtual
指令時(shí),使用方法表索引來(lái)代替查找元數(shù)據(jù)的開銷,以此提高性能。
虛方法表中存放著各個(gè)類方法的實(shí)際入口地址,如果某個(gè)方法在子類中沒有被重寫,那子類的虛方法表中該方法的地址入口和父類中相同的方法入口地址是一致的,都指向父類的實(shí)現(xiàn)入口。如果子類重寫了這個(gè)方法,子類方法表中的地址將會(huì)替換為指向子類實(shí)現(xiàn)版本的入口地址。比如xxx
類沒有重寫Object
類的toString()
方法,那么xxx
類的虛方法表中toString()
的入口地址則指向Object.toString()
方法。
在虛擬機(jī)中,具有相同簽名的方法,在父類、子類的虛方法表中都具有一樣的索引號(hào),這樣當(dāng)類型變換時(shí),僅需要變更查找的方法表,就可以從不同的虛方法表中按照索引轉(zhuǎn)換出所需要的方法入口地址。
方法表一般在類加載中的連接階段進(jìn)行初始化,準(zhǔn)備了類變量初始值之后,虛擬機(jī)會(huì)把該類的方法表也初始化完成。
當(dāng)然,在C2編譯器的執(zhí)行模式下,也會(huì)存在一些不穩(wěn)定的激進(jìn)優(yōu)化策略,比如內(nèi)聯(lián)緩存,基于“類型繼承關(guān)系分析”技術(shù)的守護(hù)內(nèi)聯(lián)。
對(duì)于方法分派調(diào)用這塊有些小伙伴看了可能會(huì)有些不理解,那么你只需要記住分派調(diào)用的目的是為了確定方法執(zhí)行時(shí)的具體版本即可。同時(shí),分派調(diào)用的過(guò)程實(shí)際上就是符號(hào)引用替換為直接飲用的過(guò)程,在有些地方也被稱為方法綁定的過(guò)程。靜態(tài)分派調(diào)用的方法也被稱為早期綁定,因?yàn)樵诰幾g期間被調(diào)用的目標(biāo)方法就已經(jīng)知曉。動(dòng)態(tài)分派調(diào)用的方法則被稱為晚期綁定,被調(diào)用的在編譯期是不可知的,必須要等到運(yùn)行時(shí)才能與根據(jù)實(shí)際的類型進(jìn)行綁定。