https://m.toutiao.com/is/Y1bgY9L/?=我從馮
在開始前,先說為什么我從馮·諾依曼計算機體系,追溯到了JVM,一切原來如此。因為一顆剽悍種子發(fā)現(xiàn)的一個問題,就是多數(shù)人對待知識總想問是什么,卻常常很少問為什么(或者說知道了是什么后也很少再問為什么),正如這篇所要暢談的JVM,看了很多文章'什么是JVM',我其實楞是沒搞懂什么是JVM,或者說為什么JVM是要這樣子呢?
直到回到知識歷史的起點,再來看現(xiàn)在知識,一切原來如此。
(理解知識背后一整條脈絡遠比純粹記住硬邦邦知識更為深刻,因為前者會帶來思考上的愉悅,而后者只會讓人喪失樂趣。)
誰說的?我一顆剽悍的種子眼里飽含淚水說的。
所以我回到馮·諾依曼的計算機體系再到操作系統(tǒng)去追溯 JVM 來看看這整條脈絡。
回顧計算機發(fā)展歷史,我們都知道被稱為“現(xiàn)代計算機之父”的馮·諾依曼在1945提出并奠定到至今的馮·諾依曼計算機體系,也就是計算機結(jié)構(gòu)。所以要知道一個真相是到至今為止不管在物理機之上構(gòu)建的是什么樣的操作系統(tǒng)、還是操作系統(tǒng)上構(gòu)建什么樣的虛擬機,或者再上層的應用,追溯到源頭都離不開這套體系下。(通俗的說我們被籠罩在這套體系之下)
(所以為什么你總能在JVM虛擬機身上看到計算機結(jié)構(gòu)熟悉的身影。)
在計算機結(jié)構(gòu)中由 輸入設備、控制器、運算器、存儲器、輸出設備 這五大部分組成。而 控制器+運算器 就是我們再熟悉不過的CPU(中央處理器),存儲器就是內(nèi)存、磁盤等,輸入設備例如:鍵盤、鼠標,輸出設備如:顯示器等。而整個計算機結(jié)構(gòu)的運行如下圖所示:
從上面可以看到,整個硬件已經(jīng)布好了局,當一個程序被執(zhí)行時,程序會被當成數(shù)據(jù)一樣,所有組成結(jié)構(gòu)都可以被看成數(shù)據(jù)/指令流、控制流(圖下方箭頭所示),程序就像數(shù)據(jù)(或者指令)一樣,只要經(jīng)過流就可以被牢牢操控。這也正是馮·諾依曼的思想 “程序應該像數(shù)據(jù)一樣可以被存儲”。
在計算機結(jié)構(gòu)之上還有一層,那就是操作系統(tǒng),操作系統(tǒng)可以說是虛擬機的鼻祖,因為操作系統(tǒng)目的就是:如何操控物理機器。也就是如何將現(xiàn)實的物理資源(CPU、內(nèi)存、磁盤)虛擬化成一臺能被你通過盯著屏幕、敲著鍵盤、劃著鼠標就能操控現(xiàn)實的物理資源。
不錯,操作系統(tǒng)本質(zhì)就是將 “物理資源虛擬化”!
(想想元宇宙的起點是不是從操作系統(tǒng)這里開始了呢?)
在我們最常用的操作系統(tǒng)有Windows和Linux這兩種,不過這兩個操作系統(tǒng)之間是不兼容的,那么就會存在編寫的程序并不能同時運行在兩個操作系統(tǒng)上,而開發(fā)程序時就得要分別開發(fā)。那么就會觸發(fā)人類(程序員)的第一生產(chǎn)力 懶!
千萬別笑!人類發(fā)明蒸汽機以來,就從未停止過釋放重復勞動力的工作,人類發(fā)明洗衣機替代洗衣、洗碗機替代洗碗、汽車替代馬車,以及再到20世紀30年代就提出,但至今仍在火的自動駕駛即將替代司機。但是它們并不具備通用性,就像問鼎了圍棋界的谷歌機器 AlphaGo ,就算再厲害也只會下圍棋這一件事(圍棋曾被認為是計算機沒法逾越的天花板,但被AlphaGo戰(zhàn)勝,不過AlphaGo也只是屬于弱人工智能),可是從整個人類科技發(fā)展來看,人類正在一步步開發(fā)著具備更通用性的人工智能,也就是有一天超越強人工智能而跨越起點的超人工智能,相信未來程序員唯一能做可能就是一段對話:
程序員:“ 嘿 Siri*:幫我開發(fā)一個掘金,順便幫我安排一個叫 一顆剽悍的種子 的家伙寫一篇JVM。”
程序員:“嘿,對了,Siri,看文章前別忘了給那家伙一鍵三連。”
Siri:“f*ck 又提需求。”
程序員:“什么?”
Siri:“安排”
(扯遠了題外話,希望你能Get到人類追求通用性(懶)遠不止在JVM中體現(xiàn)。)
JVM的全稱Java Virtual Machine也就是Java虛擬機,拋開Java虛擬機前綴Java,虛擬機其實就是在“虛擬(抽象)計算機”,也就是在操作系統(tǒng)之上再次虛擬出一臺計算機,來屏蔽不同硬件和操作系統(tǒng)之間的差異(如果說操作系統(tǒng)是用戶與物理資源之間的橋梁,那么JVM就是不同硬件和操作系統(tǒng)上的橋梁),JVM目的就是具備通用性,也就是我們常說的Java可以跨平臺開發(fā)。
那么回到JVM這時容易懂了,JVM可以像計算機結(jié)構(gòu)一樣去運行所編寫的程序,而JVM主要由:類裝載器、運行時數(shù)據(jù)區(qū)、執(zhí)行引擎、本地庫接口這四個部分所構(gòu)成。
我們知道一個可運行的程序其實不過是我們所定義的一個個 .java源代碼文件組成(這些文件其實就是我們程序員對于事物的抽象,然后交由計算機幫我們實現(xiàn))。而JVM只規(guī)定.class字節(jié)碼文件格式,這樣的好處很明顯,那就是不僅只是Java可以運行在JVM上,而是只要是能生成.class字節(jié)碼文件也都可以運行在JVM之上,所以Java虛擬機并不是Java語言專有的。所以JVM通過字節(jié)碼存儲格式統(tǒng)一了所有平臺,字節(jié)碼是構(gòu)成平臺無關(guān)性的基石。如:Python、Ruby、Scala等語言(可以說JVM野心真大!)。
所以.java文件想要在JVM上被執(zhí)行,就必須先由javac編譯器轉(zhuǎn)成后.class再交給Class Loader 類裝載器去進行類的加載等。而這也相當于開始像計算機結(jié)構(gòu)一樣的輸入設備正式的進去到整個機器內(nèi)部。如下圖所示:
說到虛擬機類裝載器,就不得不說類加載的機制,而一套機制就一定會有一個過程,也就是一個類進入入口(類裝載器)到虛擬機中必經(jīng)的整個生命周期:Loading 加載、Linking 連接、Initialization 初始化、Using 使用、Unlading 卸載。如下圖所示:
但其中最主要的Class文件裝載過程是加載、連接、初始化這三步。
類加載過程的第一個過程毫無疑問是加載,如果虛擬機還沒有加載過此類,會通過類加載器將字節(jié)碼文件加載到內(nèi)存中。當然也可以是從ZIP壓縮包,或者JAR、WAR等格式,最終也不過是從中取出類文件而已。
雖然任何二進制都可以是Class類型,但是只有JVM能夠裝載的Class文件類型才能運行在JVM之上。(也就是要符合虛擬機的規(guī)范的字節(jié)碼文件才能通過)加載后是連接的過程(連接通俗的說就是將類文件與虛擬機建立關(guān)聯(lián)),從上圖可以看到連接的包括了三個過程:驗證、準備、解析。 連接的第一步是 驗證,驗證會從四個階段的檢驗依次進行:
驗證的四個階段:
在上面我們知道 .class 文件是經(jīng)過編譯器編譯后的文件,而 .class 文件里面的內(nèi)容就是字節(jié)碼,通過字節(jié)碼中記錄著自己將要使用的其他類或方法等。
例如下面這段代碼,我們定義一個字符串類型變量的 userName,通過 System.out.println() 打印。
String userName System.out.println(userName);
我們看上面代碼編譯成字節(jié)碼后是什么樣子的,如下所示:
0 ldc #2 2 astore_1 3 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;> 6 aload_1 7 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>10 return
可以看到在代碼中最常見的表達式:
System.out.println(userName)
而轉(zhuǎn)換后的字節(jié)碼中是使用符號引用來“代替”表達:
invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
所以符號引用驗證就是通過檢查符號引用所“代替”的類、屬性、方法是否存在且有權(quán)限被訪問。
上面的驗證東西很多,但是不管什么樣的驗證,都是為了 確保類符合 Java 規(guī)范與符合 JVM 規(guī)范,同時 避免危害到虛擬機的安全。
連接的第二步是 準備,這個階段主要做兩件事,為通過驗證了的類來分配內(nèi)存空間并設置初始化。
如對于 final static 修飾的變量,會直接賦值我們的定義值。可以看下面這段代碼,會在準備階段分配內(nèi)存,并初始化值。
private final static String value = '一顆剽悍的種子'
各數(shù)據(jù)類型默認初始值,如下圖所示:
注意:上圖中并沒有 boolean 類型,Java中的 boolean 類型的底層實現(xiàn)實際上就是 int 類型,int 類型默認值 0,對應的就是 boolean 類型默認值 false。)
連接的第三步是 解析,解析階段的工作就是將 符號引用轉(zhuǎn)為直接引用。因為在編譯時類、方法等都是用符號引用來代替(所以為什么叫符號引用,符號只是個標識),而符號引用是并不知道這些數(shù)據(jù)所引用的 實際地址。
所以如果僅僅用符號引用就面臨一個問題,就是 不能確定一定存在該對象。所以通過解析將符號引用轉(zhuǎn)化為 JVM可直接獲取的內(nèi)存地址或指針,也就是 直接引用。
當解析將符號引用轉(zhuǎn)成直接引用時,也就是目標必定已經(jīng)在虛擬機的內(nèi)存中存在(說白了用直接引用就是確定了存在該類、方法或?qū)傩裕?/p>
類裝載過程中最后階段是初始化。而這個階段將會執(zhí)行構(gòu)造器<clinit>方法,它是在通過我們前面提到過的Javac將 .java 文件編譯成 .class 字節(jié)碼文件時,所有類初始化代碼,也就是包括靜態(tài)變量賦值語句、靜態(tài)代碼塊、靜態(tài)方法,收集在一起后成為 <clinit>() 方法。
簡單的概括初始化目的就是 初始化給類靜態(tài)變量或靜態(tài)代碼塊為程序員自己所定義的值。
到此,類的加載過程就像馮·諾依曼計算機結(jié)構(gòu)中的輸入設備,負責將數(shù)據(jù)丟進了入口后就是真正到JVM內(nèi)部(JVM運行時數(shù)據(jù)區(qū))去操縱數(shù)據(jù),直至將我們的想法通過代碼最后交給機器來完成。
JVM運行時數(shù)據(jù)區(qū)主要分為 堆、程序計數(shù)器、方法區(qū)、虛擬機棧和本地方法棧 這五個分區(qū)。其中按 線程共享 和 線程私有 兩類:
在JVM 內(nèi)存中最大的一塊內(nèi)存空間就是堆,而且堆也被所有線程共享,所以堆也幾乎存儲著所有的對象。堆被按年代進行劃分為 新時代、 老年代 以及 持久代,新生代又接著被分為 Eden (伊甸園區(qū)) 和 Surivor (幸存區(qū))。而 Surivor 進一步由 From Survivor 和 To Survivor 進行劃分。
JDK 8之前以及之后堆按年代劃分的變化,如下圖所示:
方法區(qū)跟堆一樣是線程共享的區(qū)域,當.class字節(jié)碼文件在JVM加載時會被分配到不同的數(shù)據(jù)結(jié)構(gòu),如常量池、方法、構(gòu)造函數(shù),同時也主要包括用來存儲已被虛擬機加載的類相關(guān)信息(類信息又包括了類的版本、字段、方法、接口和父類等信息)都存放在方法區(qū)。
我們知道Java程序是多線程執(zhí)行的,所以即想要能滿足多個線程的交叉執(zhí)行,又想要確保多個線程都能完整的執(zhí)行完各自的工作,那么一旦出現(xiàn)被中斷的線程,線程執(zhí)行到哪條的內(nèi)存地址(指令)就必須被保存下來,這樣當被中斷的線程恢復時就又可以接著執(zhí)行下去。
而這就是程序計數(shù)器的工作,用來記錄哪個線程當前執(zhí)行到哪條指令。所以分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復等都需要依賴計數(shù)器完成。(形象的說就是程序控制流的指示器)
虛擬機棧是線程私有的區(qū)域,所以不用關(guān)心數(shù)據(jù)一致性問題。當我們創(chuàng)建一個線程時,同樣在JVM中也會創(chuàng)建一個與之對應的棧,稱為虛擬機棧。
而虛擬機棧的內(nèi)部其實是一個或多個的棧幀,每一個棧幀又都對應著一個Java方法的調(diào)用。
其運行過程是,當我們創(chuàng)建一個新方法同時,與之對應會在虛擬機棧中同樣創(chuàng)建一個新的棧幀(當前棧幀)會被放在棧頂(只要是棧就會有棧頂和棧底),同時程序計數(shù)器也會指向這個當前棧地址。如下圖所示:
在每個棧幀里又存儲著方法的 局部變量表、操作數(shù)棧、動態(tài)鏈接、方法返回地址、附加信息 參與著方法的調(diào)用和返回。
所以 如果說堆解決了數(shù)據(jù)存儲的問題,那么棧就是解決了程序如何運行的問題。
本地方法棧是為了運行JVM本地方法(也就是 Native 方法)而準備的空間。而從字面上之所以被稱為本地方法棧,也是因為 Natice 方法很多也都是由C語言所實現(xiàn)的。
如果說 “類加載子系統(tǒng)是計算機結(jié)構(gòu)中的輸入設備,那么運行時數(shù)據(jù)區(qū)就是計算機結(jié)構(gòu)中的CPU(控制器和運算器)和存儲器,那么最后的輸出設備就是執(zhí)行引擎。”
到了執(zhí)行引擎,可以說是JVM最后的一個環(huán)節(jié),從最先的一個Java源文件編譯成.class字節(jié)碼文件經(jīng)過了類加載子系統(tǒng),也經(jīng)過了上面的JVM運行時數(shù)據(jù)區(qū),經(jīng)過了這一整系列下來,通俗的說字節(jié)碼文件已經(jīng)被重新打碎重組成了一個可以由JVM所操控的一系列數(shù)據(jù)(再回看計算機結(jié)構(gòu),數(shù)據(jù)成流,被布好的局安排的明明白白),但是問題是代碼并不能被執(zhí)行。因為JVM并不是將高級語言直接轉(zhuǎn)成機器指令,而是字節(jié)碼,所以字節(jié)碼的真正的運行得由執(zhí)行引擎去將字節(jié)碼翻譯成機器指令后,由真正物理機去執(zhí)行。
但是我們知道JVM只是對計算機的抽象,它的一切都只是建立在軟件層面自行實現(xiàn)的。而物理計算機只認識機器碼指令,這些機器碼指令運行通過處理器、緩存、指令集和操作系統(tǒng)等構(gòu)建了物理機的執(zhí)行引擎。
所以JVM想要讓Java程序運行起來,同樣也是要在 “虛擬(通俗說是模擬)一個執(zhí)行引擎”,而執(zhí)行引擎的目的就是 將字節(jié)碼指令解釋(編譯)為機器指令。
因為回到本質(zhì),真正干活做事的是物理機,所以執(zhí)行引擎就是將字節(jié)碼轉(zhuǎn)成為物理機可執(zhí)行的機器碼(從用戶看執(zhí)行引擎就是一臺翻譯機)
我們使用的HotSpot VM是目前虛擬機的代表之一,它是集解釋器和JIT即時編譯器于一身 的架構(gòu)。也就說Java虛擬機運行時,解釋器和JIT(just in time 即時編譯器) 互相協(xié)作。
在JVM早期使用的就是解釋器(大多數(shù)語言同樣也是),解釋器就是在運行時逐行解釋字節(jié)碼轉(zhuǎn)化成機器碼再執(zhí)行程序。(這也解釋了上面所說,JVM為什么不直接將Java語言直接轉(zhuǎn)化為機器指碼指令直接就能在物理機上執(zhí)行。而是要通過加多一層字節(jié)碼文件來具備通用性,所以以這種 “加一個翻譯器”的方式,來避免高級語言直接轉(zhuǎn)成本地機器指令的耦合,重要的事情不要忘了,JVM虛擬機是一個概念,也不要忘了目的是具備通用性)
在Java發(fā)展路程中,從最早期的,也是最古老的字節(jié)碼解釋器。之后到了目前普遍使用的模板解釋器。一共有兩套解釋執(zhí)行器。字節(jié)碼解釋器是在執(zhí)行時通過純軟件代碼模擬字節(jié)碼的執(zhí)行,所以效率也非常低。而模板解釋器是將每一條字節(jié)碼和模板函數(shù)相關(guān)聯(lián),而模板函數(shù)能直接產(chǎn)生這條字節(jié)碼執(zhí)行時的機器碼,從而達到提高解釋器性能。
但是單憑字節(jié)碼解釋器效率還遠不夠,所以 為了追求一把即時速度的推背感,虛擬機又加上了JIT也就是即時編譯器。
從上面我們知道在JVM執(zhí)行引擎擁有字節(jié)碼解釋器之后又加入了JIT,而使用JIT對字節(jié)碼轉(zhuǎn)化為機器碼指令時,關(guān)注的核心一點就是 程序中運行時被調(diào)用頻繁的代碼,被稱為 “熱點代碼”。
而要找到這些 “熱點代碼”就需要使用到JIT的 熱點探測。目前的Host Spot 的JVM采用的熱點探測是基于 計數(shù)器熱點探測。計數(shù)器熱點探測很好理解,就是統(tǒng)計每個方法執(zhí)行次數(shù),當超過認為的熱點閾值,那么就屬于“熱點代碼”。
計數(shù)器熱點探測被分為 方法調(diào)用計數(shù)器 和 回邊計數(shù)器 兩類。方法調(diào)用計數(shù)器用來統(tǒng)計代碼調(diào)用次數(shù),而回邊計數(shù)器則用來統(tǒng)計循環(huán)執(zhí)行次數(shù)。
方法調(diào)用的計數(shù)器除了遞增,也同樣有熱度衰減,也就是當代碼調(diào)用次數(shù)超過一定時間已經(jīng)不足提交給JIT,那么調(diào)用計數(shù)器會遞減。
而回邊計數(shù)器的主要目的是為了觸發(fā) OSR (On StackReplacement)棧上編譯。在一些循環(huán)周期較長代碼會在循環(huán)時間內(nèi),會直接將代碼替換執(zhí)行緩存機器碼。
在JVM中如果需要與一些底層系統(tǒng)實現(xiàn)交互,那么就會使用到本地方法接口與本地方法庫,也就是 Native Method,本地方法接口與本地方法庫其目很簡單,就是借用到C或C++等其他語言的資源。
我們從馮諾依曼計算機體系理解了計算機結(jié)構(gòu)思想 程序應該像數(shù)據(jù)一樣可以被存儲,也就是說程序就像數(shù)據(jù)(或者指令)一樣,只要經(jīng)過像組件構(gòu)建的流就可以被牢牢操控。而接著探究操作系統(tǒng)本質(zhì)其實就是將物理資源虛擬化,可以說是用戶與物理資源之間的橋梁。最后追溯到JVM其實就是在一臺虛擬(抽象)的計算機,如果類加載子系統(tǒng)是計算機結(jié)構(gòu)中的輸入設備,那么運行時數(shù)據(jù)區(qū)就是計算機結(jié)構(gòu)中的CPU(控制器和運算器)和存儲器,那么到最終的輸出設備就是執(zhí)行引擎。
到此,JVM還有類加載的雙親委派機制,以及JVM的垃圾回收機制、JVM的堆棧等異常以及JVM配置參數(shù)等內(nèi)容沒有聊,因為這些內(nèi)容都值得再獨立拎出來細說。所以這一篇文章可以當成JVM的一個開篇,也可以說是JVM的一張地圖。地圖作用不是告訴你應該去哪的路標,而是能縱覽整個全貌后,至于你對這整個知識是想怎么理解的都取決于的是你自己的思考。
(最后,我們至今還沒有非馮·諾依曼體系下的新計算機結(jié)構(gòu),但不妨大膽設想未來,也許會在距實用還相去甚遠的量子計算機上看到呢。)
原文創(chuàng)作:
https://juejin.cn/post/7098500546297856030