Java代碼是如何被CPU狂飆起來的?
無論是剛剛?cè)腴TJava的新手還是已經(jīng)工作了的老司機(jī),恐怕都不容易把Java代碼如何一步步被CPU執(zhí)行起來這個(gè)問題完全講清楚。但是對(duì)于一個(gè)Java程序員來說寫了那么久的代碼,我們總要搞清楚自己寫的Java代碼到底是怎么運(yùn)行起來的。另外在求職面試的時(shí)候這個(gè)問題也常常會(huì)聊到,面試官主要想通過它考察求職同學(xué)對(duì)于Java以及計(jì)算機(jī)基礎(chǔ)技術(shù)體系的理解程度,看似簡單的問題實(shí)際上囊括了JVM運(yùn)行原理、操作系統(tǒng)以及CPU運(yùn)行原理等多方面的技術(shù)知識(shí)點(diǎn)。我們一起來看看Java代碼到底是怎么被運(yùn)行起來的。通過這種雙親委派模型,可以保證同一個(gè)類在不同的類加載器中只會(huì)被加載一次,從而避免了類的重復(fù)加載,也保證了類的唯一性。同時(shí),由于每個(gè)類加載器只會(huì)加載自己所負(fù)責(zé)的類,因此可以防止惡意代碼的注入和類的篡改,提高了Java程序的安全性。
Java如何實(shí)現(xiàn)跨平臺(tái)
在介紹Java如何一步步被執(zhí)行起來之前,我們需要先弄明白為什么Java可以實(shí)現(xiàn)跨平臺(tái)運(yùn)行,因?yàn)楦闱宄诉@個(gè)問題之后,對(duì)于我們理解Java程序如何被CPU執(zhí)行起來非常有幫助。
為什么需要JVM
write once run anywhere曾經(jīng)是Java響徹編程語言圈的slogan,也就是所謂的程序員開發(fā)完java應(yīng)用程序后,可以在不需要做任何調(diào)整的情況下,無差別的在任何支持Java的平臺(tái)上運(yùn)行,并獲得相同的運(yùn)行結(jié)果從而實(shí)現(xiàn)跨平臺(tái)運(yùn)行,那么Java到底是如何做到這一點(diǎn)的呢?
其實(shí)對(duì)于大多數(shù)的編程語言來說,都需要將程序轉(zhuǎn)換為機(jī)器語言才能最終被CPU執(zhí)行起來。因?yàn)闊o論是如Java這種高級(jí)語言還是像匯編這種低級(jí)語言實(shí)際上都是給人看的,但是計(jì)算機(jī)無法直接進(jìn)行識(shí)別運(yùn)行。因此想要CPU執(zhí)行程序就必須要進(jìn)行語言轉(zhuǎn)換,將程序語言轉(zhuǎn)化為CPU可以識(shí)別的機(jī)器語言。
學(xué)過計(jì)算機(jī)組成原理的同學(xué)肯定都知道,CPU內(nèi)部都是用大規(guī)模晶體管組合而成的,而晶體管只有高電位以及低點(diǎn)位兩種狀態(tài),正好對(duì)應(yīng)二進(jìn)制的0和1,因此機(jī)器碼實(shí)際就是由0和1組成的二進(jìn)制編碼集合,它可以被CPU直接識(shí)別和執(zhí)行。
但是像X86架構(gòu)或者ARM架構(gòu),不同類型的平臺(tái)對(duì)應(yīng)的機(jī)器語言是不一樣的,這里的機(jī)器語言指的是用二進(jìn)制表示的計(jì)算機(jī)可以直接識(shí)別和執(zhí)行的指令集集合。不同平臺(tái)使用的CPU不同,那么對(duì)應(yīng)的指令集也就有所差異,比如說X86使用的是CISC復(fù)雜指令集而ARM使用的是RISC精簡指令集。所以Java要想實(shí)現(xiàn)跨平臺(tái)運(yùn)行就必須要屏蔽不同架構(gòu)下的計(jì)算機(jī)底層細(xì)節(jié)差異。因此,如何解決不同平臺(tái)下機(jī)器語言的適配問題是Java實(shí)現(xiàn)一次編寫,到處運(yùn)行的關(guān)鍵所在。
那么Java到底是如何解決這個(gè)問題的呢?怎么才能讓CPU可以看懂程序員寫的Java代碼呢?其實(shí)這就像在我們的日常生活中,如果雙方語言不通,要想進(jìn)行交流的話就必須中間得有一個(gè)翻譯,這樣通過翻譯的語言轉(zhuǎn)換就可以實(shí)現(xiàn)雙方暢通無阻的交流了。打個(gè)比方,一個(gè)中國廚師要教法國廚師和阿拉伯廚師做菜,中國廚師不懂法語和阿拉伯語,法國廚師和阿拉伯廚師不懂中文,要想順利把菜做好就需要有翻譯來幫忙。中國廚師把做菜的菜譜告訴翻譯者,翻譯者將中文菜譜轉(zhuǎn)換為法文菜譜以及阿拉伯語菜譜,這樣法國廚師和阿拉伯廚師就知道怎么做菜了。
因此Java的設(shè)計(jì)者借助了這樣的思想,通過JVM(Java Virtual Machine,Java虛擬機(jī))這個(gè)中間翻譯來實(shí)現(xiàn)語言轉(zhuǎn)換。程序員編寫以.java為結(jié)尾的程序之后通過javac編譯器把.java為結(jié)尾的程序文件編譯成.class結(jié)尾的字節(jié)碼文件,這個(gè)字節(jié)碼文件需要JVM這個(gè)中間翻譯進(jìn)行識(shí)別解析,它由一組如下圖這樣的16進(jìn)制數(shù)組成。JVM將字節(jié)碼文件轉(zhuǎn)化為匯編語言后再由硬件解析為機(jī)器語言最終最終交給CPU執(zhí)行。
所以說通過JVM實(shí)現(xiàn)了計(jì)算機(jī)底層細(xì)節(jié)的屏蔽,因此windows平臺(tái)有windows平臺(tái)的JVM,Linux平臺(tái)有Linux平臺(tái)的JVM,這樣在不同平臺(tái)上存在對(duì)應(yīng)的JVM充當(dāng)中間翻譯的作用。因此只要編譯一次,不同平臺(tái)的JVM都可以將對(duì)應(yīng)的字節(jié)碼文件進(jìn)行解析后運(yùn)行,從而實(shí)現(xiàn)在不同平臺(tái)下運(yùn)行的效果。
那么問題又來了,JVM是怎么解析運(yùn)行.class文件的呢?要想搞清楚這個(gè)問題,我們得先看看JVM的內(nèi)存結(jié)構(gòu)到底是怎樣的,了解JVM結(jié)構(gòu)之后這個(gè)問題就迎刃而解了。
JVM結(jié)構(gòu)
JVM(Java Virtual Machine)即Java虛擬機(jī),它的核心作用主要有兩個(gè),一個(gè)是運(yùn)行Java應(yīng)用程序,另一個(gè)是管理Java應(yīng)用程序的內(nèi)存。它主要由三部分組成,類加載器、運(yùn)行時(shí)數(shù)據(jù)區(qū)以及字節(jié)碼執(zhí)行引擎。
類加載器
類加載器負(fù)責(zé)將字節(jié)碼文件加載到內(nèi)存中,主要經(jīng)歷加載-》連接-》實(shí)例化三個(gè)階段完成類加載操作。
另外需要注意的是.class并不是一次性全部加載到內(nèi)存中,而是在Java應(yīng)用程序需要的時(shí)候才會(huì)加載。也就是說當(dāng)JVM請(qǐng)求一個(gè)類進(jìn)行加載的時(shí)候,類加載器就會(huì)嘗試查找定位這個(gè)類,當(dāng)查找對(duì)應(yīng)的類之后將他的完全限定類定義加載到運(yùn)行時(shí)數(shù)據(jù)區(qū)中。
運(yùn)行時(shí)數(shù)據(jù)區(qū)
JVM定義了在Java程序運(yùn)行期間需要使用到的內(nèi)存區(qū)域,簡單來說這塊內(nèi)存區(qū)域存放了字節(jié)碼信息以及程序執(zhí)行過程數(shù)據(jù)。運(yùn)行時(shí)數(shù)據(jù)區(qū)主要?jiǎng)澐至硕?、程序?jì)數(shù)器虛擬機(jī)棧、本地方法棧以及元空間數(shù)據(jù)區(qū)。其中堆數(shù)據(jù)區(qū)域在JVM啟動(dòng)后便會(huì)進(jìn)行分配,而虛擬機(jī)棧、程序計(jì)數(shù)器本地方法棧都是在常見線程后進(jìn)行分配。
不過需要說明的是在JDK 1.8及以后的版本中,方法區(qū)被移除了,取而代之的是元空間(Metaspace)。元空間與方法區(qū)的作用相似,都是存儲(chǔ)類的結(jié)構(gòu)信息,包括類的定義、方法的定義、字段的定義以及字節(jié)碼指令。不同的是,元空間不再是JVM內(nèi)存的一部分,而是通過本地內(nèi)存(Native Memory)來實(shí)現(xiàn)的。在JVM啟動(dòng)時(shí),元空間的大小由MaxMetaspaceSize參數(shù)指定,JVM在運(yùn)行時(shí)會(huì)自動(dòng)調(diào)整元空間的大小,以適應(yīng)不同的程序需求。
字節(jié)碼執(zhí)行引擎
字節(jié)碼執(zhí)行引擎最核心的作用就是將字節(jié)碼文件解釋為可執(zhí)行程序,主要包含了解釋器、即使編譯以及垃圾回收器。字節(jié)碼執(zhí)行引擎從元空間獲取字節(jié)碼指令進(jìn)行執(zhí)行。當(dāng)Java程序調(diào)用一個(gè)方法時(shí),JVM會(huì)根據(jù)方法的描述符和方法所在的類在元空間中查找對(duì)應(yīng)的字節(jié)碼指令。字節(jié)碼執(zhí)行引擎從元空間獲取字節(jié)碼指令,然后執(zhí)行這些指令。
JVM如何運(yùn)行Java程序
在搞清楚了JVM的結(jié)構(gòu)之后,接下來我們一起來看看天天寫的Java代碼是如何被CPU飆起來的。一般公司的研發(fā)流程都是產(chǎn)品經(jīng)理提需求然后程序員來實(shí)現(xiàn)。所以當(dāng)產(chǎn)品經(jīng)理把需求提過來之后,程序員就需要分析需求進(jìn)行設(shè)計(jì)然后編碼實(shí)現(xiàn),比如我們通過Idea來完成編碼工作,這個(gè)時(shí)候工程中就會(huì)有一堆的以.java結(jié)尾的Java代碼文件,實(shí)際上就是程序員將產(chǎn)品需求轉(zhuǎn)化為對(duì)應(yīng)的Java程序。但是這個(gè).java結(jié)尾的Java代碼文件是給程序員看的,計(jì)算機(jī)無法識(shí)別,所以需要進(jìn)行轉(zhuǎn)換,轉(zhuǎn)換為計(jì)算機(jī)可以識(shí)別的機(jī)器語言。
通過上文我們知道,Java為了實(shí)現(xiàn)write once,run anywhere的宏偉目標(biāo)設(shè)計(jì)了JVM來充當(dāng)轉(zhuǎn)換翻譯的工作。因此我們編寫好的.java文件需要通過javac編譯成.class文件,這個(gè)class文件就是傳說中的字節(jié)碼文件,而字節(jié)碼文件就是JVM的輸入。
當(dāng)我們有了.class文件也就是字節(jié)碼文件之后,就需要啟動(dòng)一個(gè)JVM實(shí)例來進(jìn)一步加載解析.class字節(jié)碼。實(shí)際上JVM本質(zhì)其實(shí)就是操作系統(tǒng)中的一個(gè)進(jìn)程,因此要想通過JVM加載解析.class文件,必須先啟動(dòng)一個(gè)JVM進(jìn)程。JVM進(jìn)程啟動(dòng)之后通過類加載器加載.class文件,將字節(jié)碼加載到JVM對(duì)應(yīng)的內(nèi)存空間。
當(dāng).class文件對(duì)應(yīng)的字節(jié)碼信息被加載到中之后,操作系統(tǒng)會(huì)調(diào)度CPU資源來按照對(duì)應(yīng)的指令執(zhí)行java程序。
以上是CPU執(zhí)行Java代碼的大致步驟,看到這里我相信很多同學(xué)都有疑問這個(gè)執(zhí)行步驟也太大致了吧。哈哈,別著急,有了基本的解析流程之后我們?cè)賹?duì)其中的細(xì)節(jié)進(jìn)行分析,首先我們就需要弄清楚JVM是如何加載編譯后的.class文件的。
字節(jié)碼文件結(jié)構(gòu)
要想搞清楚JVM如何加載解析字節(jié)碼文件,我們就先得弄明白字節(jié)碼文件的格式,因?yàn)槿魏挝募慕馕龆际歉鶕?jù)該文件的格式來進(jìn)行。就像CPU有自己的指令集一樣,JVM也有自己一套指令集也就是Java字節(jié)碼,從根上來說Java字節(jié)碼是機(jī)器語言的.class文件表現(xiàn)形式。字節(jié)碼文件結(jié)構(gòu)是一組以 8 位為最小單元的十六進(jìn)制數(shù)據(jù)流,具體的結(jié)構(gòu)如下圖所示,主要包含了魔數(shù)、class文件版本、常量池、訪問標(biāo)志、索引、字段表集合、方法表集合以及屬性表集合描述數(shù)據(jù)信息。
這里簡單說明下各個(gè)部分的作用,后面會(huì)有專門的文章再詳細(xì)進(jìn)行闡述。
魔數(shù)與文件版本
魔數(shù)的作用就是告訴JVM自己是一個(gè)字節(jié)碼文件,你JVM快來加載我吧,對(duì)于Java字節(jié)碼文件來說,其魔數(shù)為0xCAFEBABE,現(xiàn)在知道為什么Java的標(biāo)志是咖啡了吧。而緊隨魔數(shù)之后的兩個(gè)字節(jié)是文件版本號(hào),Java的版本號(hào)通常是以52.0的形式表示,其中高16位表示主版本號(hào),低16位表示次版本號(hào)。。
常量池
在常量池中說明常量個(gè)數(shù)以及具體的常量信息,常量池中主要存放了字面量以及符號(hào)引用這兩類常量數(shù)據(jù),所謂字面量就是代碼中聲明為final的常量值,而符號(hào)引用主要為類和接口的完全限定名、字段的名稱和描述符以及方法的名稱以及描述符。這些信息在加載到JVM之后在運(yùn)行期間將符號(hào)引用轉(zhuǎn)化為直接引用才能被真正使用。常量池的第一個(gè)元素是常量池大小,占據(jù)兩個(gè)字節(jié)。常量池表的索引從1開始,而不是從0開始,這是因?yàn)槌A砍氐牡?個(gè)位置是用于特殊用途的。
訪問標(biāo)志
類或者接口的訪問標(biāo)記,說明類是public還是abstract,用于描述該類的訪問級(jí)別和屬性。訪問標(biāo)志的取值范圍是一個(gè)16位的二進(jìn)制數(shù)。
索引
包含了類索引、父類索引、接口索引數(shù)據(jù),主要說明類的繼承關(guān)系。
字段表集合
主要是類級(jí)變量而不是方法內(nèi)部的局部變量。
方法表集合
主要用來描述類中有幾個(gè)方法,每個(gè)方法的具體信息,包含了方法訪問標(biāo)識(shí)、方法名稱索引、方法描述符索引、屬性計(jì)數(shù)器、屬性表等信息,總之就是描述方法的基礎(chǔ)信息。
屬性表集合
方法表集合之后是屬性表集合,用于描述該類的所有屬性。屬性表集合包含了所有該類的屬性的描述信息,包括屬性名稱、屬性類型、屬性值等等。
解析字節(jié)碼文件
知道了字節(jié)碼文件的結(jié)構(gòu)之后,JVM就需要對(duì)字節(jié)碼文件進(jìn)行解析,將字節(jié)碼結(jié)構(gòu)解析為JVM內(nèi)部流轉(zhuǎn)的數(shù)據(jù)結(jié)構(gòu)。大致的過程如下:
1、讀取字節(jié)碼文件
JVM首先需要讀取字節(jié)碼文件的二進(jìn)制數(shù)據(jù),這通常是通過文件輸入流來完成的。
2、解析字節(jié)碼
JVM解析字節(jié)碼的過程是將字節(jié)碼文件中的二進(jìn)制數(shù)據(jù)解析為Java虛擬機(jī)中的數(shù)據(jù)結(jié)構(gòu)。首先JVM首先會(huì)讀取字節(jié)碼文件的前四個(gè)字節(jié),判斷魔數(shù)是否為0xCAFEBABE,以此來確認(rèn)該文件是否是一個(gè)有效的Java字節(jié)碼文件。JVM接著會(huì)解析常量池表,將其中的常量轉(zhuǎn)換為Java虛擬機(jī)中的數(shù)據(jù)結(jié)構(gòu),例如將字符串常量轉(zhuǎn)換為Java字符串對(duì)象。解析類、接口、字段、方法等信息:JVM會(huì)依次解析類索引、父類索引、接口索引集合、字段表集合、方法表集合等信息,將這些信息轉(zhuǎn)換為Java虛擬機(jī)中的數(shù)據(jù)結(jié)構(gòu)。最后,JVM將解析得到的數(shù)據(jù)結(jié)構(gòu)組裝成一個(gè)Java類的結(jié)構(gòu),并將其放入元空間中。
在完成字節(jié)碼文件解析之后,接下來就需要類加載器閃亮登場了,類加載器會(huì)將類文件加載到JVM內(nèi)存中,并為該類生成一個(gè)Class對(duì)象。
類加載
加載器啟動(dòng)
我們都知道,Java應(yīng)用的類都是通過類加載器加載到運(yùn)行時(shí)數(shù)據(jù)區(qū)的,這里很多同學(xué)可能會(huì)有疑問,那么類加載器本身又是被誰加載的呢?這有點(diǎn)像先有雞還是先有蛋的靈魂拷問。實(shí)際上類加載器啟動(dòng)大致會(huì)經(jīng)歷如下幾個(gè)階段:
1、以linux系統(tǒng)為例,當(dāng)我們通過"java"啟動(dòng)一個(gè)Java應(yīng)用的時(shí)候,其實(shí)就是啟動(dòng)了一個(gè)JVM進(jìn)程實(shí)例,此時(shí)操作系統(tǒng)會(huì)為這個(gè)JVM進(jìn)程實(shí)例分配CPU、內(nèi)存等系統(tǒng)資源;
2、"java"可執(zhí)行文件此時(shí)就會(huì)解析相關(guān)的啟動(dòng)參數(shù),主要包括了查找jre路徑、各種包的路徑以及虛擬機(jī)參數(shù)等,進(jìn)而獲取定位libjvm.so位置,通過libjvm.so來啟動(dòng)JVM進(jìn)程實(shí)例;
3、當(dāng)JVM啟動(dòng)后會(huì)創(chuàng)建引導(dǎo)類加載器Bootsrap ClassLoader,這個(gè)ClassLoader是C++語言實(shí)現(xiàn)的,它是最基礎(chǔ)的類加載器,沒有父類加載器。通過它加載Java應(yīng)用運(yùn)行時(shí)所需要的基礎(chǔ)類,主要包括JAVA_HOME/jre/lib下的rt.jar等基礎(chǔ)jar包;
4、而在rt.jar中包含了Launcher類,當(dāng)Launcher類被加載之后,就會(huì)觸發(fā)創(chuàng)建Launcher靜態(tài)實(shí)例對(duì)象,而Launcher類的構(gòu)造函數(shù)中,完成了對(duì)于ExtClassLoader及AppClassLoader的創(chuàng)建。Launcher類的部分代碼如下所示:
雙親委派模型
為了保證Java程序的安全性和穩(wěn)定性,JVM設(shè)計(jì)了雙親委派模型類加載機(jī)制。在雙親委派模型中,啟動(dòng)類加載器(Bootstrap ClassLoader)、擴(kuò)展類加載器(Extension ClassLoader)以及應(yīng)用程序類加載器(Application ClassLoader)按照一個(gè)父子關(guān)系形成了一個(gè)層次結(jié)構(gòu),其中啟動(dòng)類加載器位于最頂層,應(yīng)用程序類加載器位于最底層。當(dāng)一個(gè)類加載器需要加載一個(gè)類時(shí),它首先會(huì)委派給它的父類加載器去嘗試加載這個(gè)類。如果父類加載器能夠成功加載這個(gè)類,那么就直接返回這個(gè)類的Class對(duì)象,如果父類加載器無法加載這個(gè)類,那么就會(huì)交給子類加載器去嘗試加載這個(gè)類。這個(gè)過程會(huì)一直持續(xù)到頂層的啟動(dòng)類加載器。
通過這種雙親委派模型,可以保證同一個(gè)類在不同的類加載器中只會(huì)被加載一次,從而避免了類的重復(fù)加載,也保證了類的唯一性。同時(shí),由于每個(gè)類加載器只會(huì)加載自己所負(fù)責(zé)的類,因此可以防止惡意代碼的注入和類的篡改,提高了Java程序的安全性。
數(shù)據(jù)流轉(zhuǎn)過程
當(dāng)類加載器完成字節(jié)碼數(shù)據(jù)加載任務(wù)之后,JVM劃分了專門的內(nèi)存區(qū)域內(nèi)承載這些字節(jié)碼數(shù)據(jù)以及運(yùn)行時(shí)中間數(shù)據(jù)。其中程序計(jì)數(shù)器、虛擬機(jī)棧以及本地方法棧屬于線程私有的,堆以及元數(shù)據(jù)區(qū)屬于共享數(shù)據(jù)區(qū),不同的線程共享這兩部分內(nèi)存數(shù)據(jù)。我們還是以下面這段代碼來說明程序運(yùn)行的時(shí)候,各部分?jǐn)?shù)據(jù)在Runtime data area中是如何流轉(zhuǎn)的。
如上代碼所示,JVM創(chuàng)建線程來承載代碼的執(zhí)行過程,我們可以將線程理解為一個(gè)按照一定順序執(zhí)行的控制流。當(dāng)線程創(chuàng)建之后,同時(shí)創(chuàng)建該線程獨(dú)享的程序計(jì)數(shù)器(Program Counter Register)以及Java虛擬機(jī)棧(Java Virtual Machine Stack)。如果當(dāng)前虛擬機(jī)中的線程執(zhí)行的是Java方法,那么此時(shí)程序計(jì)數(shù)器中起初存儲(chǔ)的是方法的第一條指令,當(dāng)方法開始執(zhí)行之后,PC寄存器存儲(chǔ)的是下一個(gè)字節(jié)碼指令的地址。但是如果當(dāng)前虛擬機(jī)中的線程執(zhí)行的是naive方法,那么程序計(jì)數(shù)器中的值為undefined。
那么程序計(jì)數(shù)器中的值又是怎么被改變的呢?如果是正常進(jìn)行代碼執(zhí)行,那么當(dāng)線程執(zhí)行字節(jié)碼指令時(shí),程序計(jì)數(shù)器會(huì)進(jìn)行自動(dòng)加1指向下一條字節(jié)碼指令地址。但是如果遇到判斷分支、循環(huán)以及異常等不同的控制轉(zhuǎn)移語句,程序計(jì)數(shù)器會(huì)被置為目標(biāo)字節(jié)碼指令的地址。另外在多線程切換的時(shí)候,虛擬機(jī)會(huì)記錄當(dāng)前線程的程序計(jì)數(shù)器,當(dāng)線程切換回來的時(shí)候會(huì)根據(jù)此前記錄的值恢復(fù)到程序計(jì)數(shù)器中,來繼續(xù)執(zhí)行線程的后續(xù)的字節(jié)碼指令。
除了程序計(jì)數(shù)器之外,字節(jié)碼指令的執(zhí)行流轉(zhuǎn)還需要虛擬機(jī)棧的參與。我們先來看下虛擬機(jī)棧的大致結(jié)構(gòu),如下圖所示,棧大家肯定都知道,它是一個(gè)先入后出的數(shù)據(jù)結(jié)構(gòu),非常適合配合方法的執(zhí)行過程。虛擬機(jī)棧操作的基本元素就是棧幀,棧幀的結(jié)構(gòu)主要包含了局部變量、操作數(shù)棧、動(dòng)態(tài)連接以及方法返回地址這幾個(gè)部分。
局部變量:主要存放了棧幀對(duì)應(yīng)方法的參數(shù)以及方法中定義的局部變量,實(shí)際上它是一個(gè)以0為起始索引的數(shù)組結(jié)構(gòu),可以通過索引來訪問局部變量表中的元素,還包括了基本類型以及對(duì)象引用等。非靜態(tài)方法中,第0個(gè)槽位默認(rèn)是用于存儲(chǔ)this指針,而其他參數(shù)和變量則會(huì)從第1個(gè)槽位開始存儲(chǔ)。在靜態(tài)方法中,第0個(gè)槽位可以用來存放方法的參數(shù)或者其他的數(shù)據(jù)。
操作數(shù)棧:和虛擬機(jī)棧一樣操作數(shù)棧也是一個(gè)棧數(shù)據(jù)結(jié)構(gòu),只不過兩者存儲(chǔ)的對(duì)象不一樣。操作數(shù)棧主要存儲(chǔ)了方法內(nèi)部操作數(shù)的值以及計(jì)算結(jié)果,操作數(shù)棧會(huì)將運(yùn)算的參與方以及計(jì)算結(jié)果都?jí)喝氩僮鲾?shù)棧中,后續(xù)的指令操作就可以從操作數(shù)棧中使用這些值來進(jìn)行計(jì)算。當(dāng)方法有返回值的時(shí)候,返回值也會(huì)被壓入操作數(shù)棧中,這樣方法調(diào)用者可以獲取到返回值。
動(dòng)態(tài)鏈接:一個(gè)類中的方法可能會(huì)被程序中的其他多個(gè)類所共享使用,因此在編譯期間實(shí)際無法確定方法的實(shí)際位置到底在哪里,因此需要在運(yùn)行時(shí)動(dòng)態(tài)鏈接來確定方法對(duì)應(yīng)的地址。動(dòng)態(tài)鏈接是通過在棧幀中維護(hù)一張方法調(diào)用的符號(hào)表來實(shí)現(xiàn)的。這張符號(hào)表中保存了當(dāng)前方法中所有調(diào)用的方法的符號(hào)引用,包括方法名、參數(shù)類型和返回值類型等信息。當(dāng)方法需要調(diào)用另一個(gè)方法時(shí),它會(huì)在符號(hào)表中查找所需方法的符號(hào)引用,然后進(jìn)行動(dòng)態(tài)鏈接,確定方法的具體內(nèi)存地址。這樣,就能夠正確地調(diào)用所需的方法。
方法返回地址:當(dāng)一個(gè)方法執(zhí)行完畢后,JVM會(huì)將記錄的方法返回地址數(shù)據(jù)置入程序計(jì)數(shù)器中,這樣字節(jié)碼執(zhí)行引擎可以根據(jù)程序計(jì)數(shù)器中的地址繼續(xù)向后執(zhí)行字節(jié)碼指令。同時(shí)JVM會(huì)將方法返回值壓入調(diào)用方的操作棧中以便于后續(xù)的指令計(jì)算,操作完成之后從虛擬機(jī)棧中獎(jiǎng)棧幀進(jìn)行彈出。
知道了虛擬機(jī)棧的結(jié)構(gòu)之后,我們來看下方法執(zhí)行的流轉(zhuǎn)過程是怎樣的。
1、JVM啟動(dòng)完成.class文件加載之后,它會(huì)創(chuàng)建一個(gè)名為"main"的線程,并且該線程會(huì)自動(dòng)調(diào)用定義在該類中的名為"main"的靜態(tài)方法,這也是Java程序的入口點(diǎn)。
2、當(dāng)JVM在主線程中調(diào)用當(dāng)方法的時(shí)候就會(huì)創(chuàng)建當(dāng)前線程獨(dú)享的程序計(jì)數(shù)器以及虛擬機(jī)棧,在Test.class類中,開始執(zhí)行mian方法 ,因此JVM會(huì)虛擬機(jī)棧中壓入main方法對(duì)應(yīng)的幀棧幀。
3、在棧幀的操作數(shù)棧中存儲(chǔ)了操作的數(shù)據(jù),JVM執(zhí)行字節(jié)碼指令的時(shí)候從操作數(shù)棧中獲取數(shù)據(jù),執(zhí)行計(jì)算操作之后再將結(jié)果壓入操作數(shù)棧。
4、當(dāng)進(jìn)行calculate方法調(diào)用的時(shí)候,虛擬機(jī)棧繼續(xù)壓入calculate方法對(duì)應(yīng)的棧幀,被調(diào)用方法的參數(shù)、局部變量和操作數(shù)棧等信息會(huì)存儲(chǔ)在新創(chuàng)建的棧幀中。其中該棧幀中的方法返回地址中存放了main方法執(zhí)行的地址信息,方便在調(diào)用方法執(zhí)行完成后繼續(xù)恢復(fù)調(diào)用前的代碼執(zhí)行。
5、對(duì)于age + 3一條加法指令,在執(zhí)行該指令之前,JVM會(huì)將操作數(shù)棧頂部的兩個(gè)元素彈出,并將它們相加,然后將結(jié)果推入操作數(shù)棧中。在這個(gè)例子中,指令的操作碼是“add”,它表示執(zhí)行加法操作;操作數(shù)是0,它表示從操作數(shù)棧的頂部獲取第一個(gè)操作數(shù);操作數(shù)是1,它表示從操作數(shù)棧的次頂部獲取第二個(gè)操作數(shù)。
6、程序計(jì)數(shù)器中存儲(chǔ)了下一條需要執(zhí)行操作的字節(jié)碼指令的地址,因此Java線程執(zhí)行業(yè)務(wù)邏輯的時(shí)候必須借助于程序計(jì)數(shù)器才能獲得下一步命令的地址。
7、當(dāng)calculate方法執(zhí)行完成之后,對(duì)應(yīng)的棧幀將從虛擬機(jī)棧中彈出,其中方法執(zhí)行的結(jié)果會(huì)被壓入main方法對(duì)應(yīng)的棧幀中的操作數(shù)棧中,而方法返回地址被重置到main現(xiàn)場對(duì)應(yīng)的程序計(jì)數(shù)器中,以便于后續(xù)字節(jié)碼執(zhí)行引擎從程序計(jì)數(shù)器中獲取下一條命令的地址。如果方法沒有返回值,JVM仍然會(huì)將一個(gè)null值推送到調(diào)用該方法的棧幀的操作數(shù)棧中,作為占位符,以便恢復(fù)調(diào)用方的操作數(shù)棧狀態(tài)。
8、字節(jié)碼執(zhí)行引擎中的解釋器會(huì)從程序計(jì)數(shù)器中獲取下一個(gè)字節(jié)碼指令的地址,也就是從元空間中獲取對(duì)應(yīng)的字節(jié)碼指令,在獲取到指令之后,通過翻譯器翻譯為對(duì)應(yīng)的匯編語言而再交給硬件解析為機(jī)器指令,最終由CPU進(jìn)行執(zhí)行,而后再將執(zhí)行結(jié)果進(jìn)行寫回。
CPU執(zhí)行程序
通過上文我們知道無論什么編程語言最終都需要轉(zhuǎn)化為機(jī)器語言才能被CPU執(zhí)行,但是CPU、內(nèi)存這些硬件資源并不是直接可以和應(yīng)用程序打交道,而是通過操作系統(tǒng)來進(jìn)行統(tǒng)一管理的。對(duì)于CPU來說,操作系統(tǒng)通過調(diào)度器(Scheduler)來決定哪些進(jìn)程可以被CPU執(zhí)行,并為它們分配時(shí)間片。它會(huì)從就緒隊(duì)列中選擇一個(gè)進(jìn)程并將其分配給CPU執(zhí)行。當(dāng)一個(gè)進(jìn)程的時(shí)間片用完或者發(fā)生了I/O等事件時(shí),CPU會(huì)被釋放,操作系統(tǒng)的調(diào)度器會(huì)重新選擇一個(gè)進(jìn)程并將其分配給CPU執(zhí)行。也就是說操作系統(tǒng)通過進(jìn)程調(diào)度算法來管理CPU的分配以及調(diào)度,進(jìn)程調(diào)度算法的目的就是為了最大化CPU使用率,避免出現(xiàn)任務(wù)分配不均空閑等待的情況。主要的進(jìn)程調(diào)度算法包括了FCFS、SJF、RR、MLFQ等。
CPU如何執(zhí)行指令?
前文中我們大致搞清楚了類是如何被加載的,各部分類字節(jié)碼數(shù)據(jù)在運(yùn)行時(shí)數(shù)據(jù)區(qū)怎么流轉(zhuǎn)以及字節(jié)碼執(zhí)行引擎翻譯字節(jié)碼。實(shí)際上在運(yùn)行時(shí)數(shù)據(jù)區(qū)數(shù)據(jù)流轉(zhuǎn)的過程中,CPU已經(jīng)參與其中了。程序的本質(zhì)是為了根據(jù)輸入獲得相應(yīng)的輸出,而CPU本質(zhì)就是根據(jù)程序的指令一步步執(zhí)行獲得結(jié)果的工具。對(duì)于CPU來說,它核心工作主要分為如下三個(gè)步驟;
1、獲取指令
CPU從PC寄存器中獲取對(duì)應(yīng)的指令地址,此處的指令地址是將要執(zhí)行指令的地址,根據(jù)指令地址獲取對(duì)應(yīng)的操作指令到指令寄存中,此時(shí)如果是順存執(zhí)行則PC寄存器地址會(huì)自動(dòng)加1,但是如果程序涉及到條件、循環(huán)等分支執(zhí)行邏輯,那么PC寄存器的地址就會(huì)被修改為下一條指令執(zhí)行的地址。
2、指令譯碼
將獲取到的指令進(jìn)行翻譯,搞清楚哪些是操作碼哪些是操作數(shù)。CPU首先讀取指令中的操作碼然后根據(jù)操作碼來確定該指令的類型以及需要進(jìn)行的操作,CPU接著根據(jù)操作碼來確定指令所需的寄存器和內(nèi)存地址,并將它們提取出來。
3、執(zhí)行指令
經(jīng)過指令譯碼之后,CPU根據(jù)獲取到的指令進(jìn)行具體的執(zhí)行操作,并將指令運(yùn)算的結(jié)果存儲(chǔ)回內(nèi)存或者寄存器中。
因此一旦CPU上電之后,它就像一個(gè)勤勞的小蜜蜂一樣,一直不斷重復(fù)著獲取指令-》指令譯碼-》執(zhí)行指令的循環(huán)操作。
CPU如何響應(yīng)中斷?
當(dāng)操作系統(tǒng)需要執(zhí)行某些操作時(shí),它會(huì)發(fā)送一個(gè)中斷請(qǐng)求給CPU。CPU在接收到中斷請(qǐng)求后,會(huì)停止當(dāng)前的任務(wù),并轉(zhuǎn)而執(zhí)行中斷處理程序,這個(gè)處理程序是由操作系統(tǒng)提供的。中斷處理程序會(huì)根據(jù)中斷類型,執(zhí)行相應(yīng)的操作,并返回到原來的任務(wù)繼續(xù)執(zhí)行。
在執(zhí)行完中斷處理程序后,CPU會(huì)將之前保存的程序現(xiàn)場信息恢復(fù),然后繼續(xù)執(zhí)行被中斷的程序。這個(gè)過程叫做中斷返回(Interrupt Return,IRET)。在中斷返回過程中,CPU會(huì)將處理完的結(jié)果保存在寄存器中,然后從棧中彈出被中斷的程序的現(xiàn)場信息,恢復(fù)之前的現(xiàn)場狀態(tài),最后再次執(zhí)行被中斷的程序,繼續(xù)執(zhí)行之前被中斷的指令。
那么CPU又是如何響應(yīng)中斷的呢?主要經(jīng)歷了以下幾個(gè)步驟:
1、保存當(dāng)前程序狀態(tài)
CPU會(huì)將當(dāng)前程序的狀態(tài)(如程序計(jì)數(shù)器、寄存器、標(biāo)志位等)保存到內(nèi)存或棧中,以便在中斷處理程序執(zhí)行完畢后恢復(fù)現(xiàn)場。
2、確定中斷類型
CPU會(huì)檢查中斷信號(hào)的類型,以確定需要執(zhí)行哪個(gè)中斷處理程序。
3、轉(zhuǎn)移控制權(quán)
CPU會(huì)將程序的控制權(quán)轉(zhuǎn)移到中斷處理程序的入口地址,開始執(zhí)行中斷處理程序。
4、執(zhí)行中斷處理程序
中斷處理程序會(huì)根據(jù)中斷類型執(zhí)行相應(yīng)的操作,這些操作可能包括保存現(xiàn)場信息、讀取中斷事件的相關(guān)數(shù)據(jù)、執(zhí)行特定的操作,以及返回到原來的程序繼續(xù)執(zhí)行等。
5、恢復(fù)現(xiàn)場
中斷處理程序執(zhí)行完畢后,CPU會(huì)從保存的現(xiàn)場信息中恢復(fù)原來程序的狀態(tài),然后將控制權(quán)返回到原來的程序中,繼續(xù)執(zhí)行被中斷的指令。
后記
很多時(shí)候看似理所當(dāng)然的問題,當(dāng)我們深究下去就會(huì)發(fā)現(xiàn)原來別有一番天地。正如阿里王堅(jiān)博士說的那樣,要想看一個(gè)人對(duì)某個(gè)領(lǐng)域的知識(shí)掌握的情況,那就看他能就這個(gè)領(lǐng)域的知識(shí)能講多長時(shí)間。想想的確如此,如果我們能夠?qū)δ硞€(gè)知識(shí)點(diǎn)高度提煉同時(shí)又可以細(xì)節(jié)滿滿的進(jìn)行展開闡述,那我們對(duì)于這個(gè)領(lǐng)域的理解程度就會(huì)鞭辟入里。這種檢驗(yàn)自己知識(shí)學(xué)習(xí)深度的方式也推薦給大家。