以前寫博客一直比較隨性,主題也很隨意,就是想到什麼寫什麼,對什麼感興趣就寫什麼。雖然寫起來無拘無束,自在隨意,但也帶來了一些問題,每次寫完一篇後就要去糾結下一篇到底寫什麼,看來選擇太多也不是好事兒,更重要的是不成體系的內容對讀者也不夠友好。因此之後的博客儘可能按系列來寫,不過偶爾也會穿插其餘的內容。接下來一段時間我會把寫博客的重點放在 JVM (Java Virtual Machine) 和 JUC (java util concurrent ) 上,對 Java 虛擬機和 Java 併發編程進行一系列的介紹,歡迎關注。html
瞭解 JVM 是對 Java 開發人員的基本要求,JVM 的相關內容天然也成了如今 Java 程序員面試的重要考點。不過估計不少小夥伴和我同樣,長時間醉心於 CRUD,卻忘了去了解一下更底層、更基礎的東西,卻不知這些纔是決定你能在這條路上走多遠的關鍵因素,那接下來咱們就一塊兒來深刻學習一下看似神祕的 JVM 吧。JVM 整體來看內容仍是不少的,我會把最重要的內容介紹給你們,不過若是你有時間和精力的話,仍是推薦你去看一下《深刻理解Java虛擬機》這本書,確實是有口皆碑。本系列文章也會引用不少此書的內容並加上我本身的理解,若是你堅持看下去的話,相信會有很大的收穫。java
首先對 JVM 作個簡單的介紹,JVM 是 JDK 的一部分,《Java 虛擬機規範》(The Java Virtual Machine Specification) 是平行於《Java 語言規範》(The Java Language Specification)的一套獨立的規範,不一樣的公司對其有不一樣的實現 (相似於一個接口被不一樣的類實現),比較著名的 Java 虛擬機實現版本有 HotSpot、JRockit 和 J9 等。程序員
本文分爲兩大部分,將分別爲你們介紹 JVM 的總體架構和運行時數據區,這兩部分的依據均是《Java 虛擬機規範》,而不針對任何特定的 JVM 具體實現版本。面試
在我看來,無論學習什麼樣的知識或技術,首先要作的就是從全局上去認識它,這樣才能避免盲人摸象,事倍功半的狀況發生。既然要學習 JVM,就要先了解它的總體架構,因而我畫了個 JVM 架構圖來幫助你們認識它。編程
Java 虛擬機架構圖
對 JVM 還不太瞭解的同窗第一次看到這張花裏胡哨的圖確定會一臉懵逼,不用怕,其實咱們只須要重點理解並掌握其中一部分 (同時也是面試重點) 就行了,好比運行時數據區、垃圾收集器、內存分配策略和類加載機制等,類文件結構也能夠學習一下,其餘的稍做了解便可。既然本篇文章是要帶領你們認識 JVM 架構的,那就先把圖中各個部分都介紹一下吧 (注:本文只作介紹,讓各位先對 JVM 有個總體的認識,本系列後續文章會作深刻探討)。數組
Java 之因此號稱「一次編寫,到處運行」,就是得益於虛擬機和 Class 文件 (注:CLass 文件、字節碼文件和類文件是一個意思) 的組合機制。程序員並不須要本身去適配不一樣的操做系統,你們都知道咱們平時編寫的 java 代碼在編譯成 Class 文件後才能執行,而 Class 文件能夠在任何操做系統上的 JVM 上執行,這樣就作到了「平臺無關性」。下面是一個最簡單的 HelloWorld 程序及其對應的 Class 文件。緩存
HelloWorld 程序及其編譯後的 Class 文件
得益於 Class 文件,JVM 還能夠作到「語言無關性」,也就是說不僅有 Java 程序能夠運行於 JVM 之上,不少其餘語言例如最近在安卓開發者中大火的 Kotlin 語言,還有 Scala、Groovy 等語言也都是基於 JVM 平臺的,這些語言的代碼均可以編譯成 Class 文件,而後在 JVM 上運行。
安全
JVM提供的平臺無關性和語言無關性
要執行 Class 文件就須要先將其加載進內存,這一工做正是由類加載器 (ClassLoader) 完成的,系統爲咱們提供了三種類加載器,分別是啓動類加載器 (Bootstrap ClassLoader)、擴展類加載器 (Extension ClassLoader) 和應用程序類加載器 (Application ClassLoader),若是有必要,咱們也能夠加入自定義的類加載器。類加載過程以下:數據結構
類加載過程
類加載過程分爲加載、鏈接和初始化三個階段,其中的鏈接階段又分爲驗證、準備和解析三個階段 (詳細的類加載機制在後續文章中進行介紹)。多線程
這部份內容較多,放在本文第二部分單獨進行介紹。
字節碼被加載進運行時數據區後,執行引擎會進行讀取並執行,執行引擎主要包含如下模塊:
若是你常常看 JDK 源碼的話,必定會注意到 native 這個關鍵詞,被它修飾的方法是沒有方法體的,是由於它調用了計算機本地的方法庫 (一般是 C 或 C++ 代碼)。JDK 源碼中有不少類的方法,特別是一些須要操做計算機硬件的方法,都調用了本地方法庫,畢竟與硬件打交道仍是用 C 和 C++ 更方便,好比下面這些方法:
// 例一:這是 Thread 類中的 currentThread 方法,用於獲取當前正在執行的線程 public static native Thread currentThread(); // 例二:這是 FileInputStream 類中 open0 方法,用於打開指定文件 private native void open0(String name) throws FileNotFoundException;
本地庫接口所調用的對象正是位於這個庫中,通常是位於計算機本地的 C 或 C++ 語言代碼。
Java 虛擬機運行時數據區是咱們須要重點了解並熟悉的部分,由於這與咱們寫的程序息息相關,平時常見的 StackOverflowError 和 OutOfMemoryError 也幾乎都是來自這個區域。說「幾乎」是由於當本機直接內存不夠用時也會拋出 OutOfMemoryError。以下圖所示,程序計數器、Java 虛擬機棧和本地方法棧是線程私有的,堆和方法區是線程共享的,其中方法區又包含了運行時常量池。下面就對這個部分作個詳細的介紹吧 (注:本部分引用內容來自《深刻理解Java虛擬機》)。
Java 虛擬機運行時數據區
怕有些小夥伴不清楚,提示一下:下面這樣的段落格式就是 Markdown 裏的引用格式,,通常用於引用他人的文章或別處的內容。
程序計數器(Program Counter Register)是一塊較小的內存空間,它能夠看做是當前線程所執行的字節碼的行號指示器。在Java虛擬機的概念裏,字節碼解釋器工做時就是經過改變這個計數器 的值來選取下一條須要執行的字節碼指令,它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都須要依賴這個計數器來完成。因爲Java虛擬機的多線程是經過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個肯定的時刻,一個處理器(對於多核處理器來講是一個內核)都只會執行一條線程中的指令。所以,爲了線程切換後能恢復到正確的執行位置,每條線程都須要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,咱們稱這類內存區域爲「線程私有」的內存。
若是線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;若是正在執行的是本地 (Native) 方法,這個計數器值則應爲空 (Undefined)。此內存區域是惟一一個在《Java虛擬機規範》中沒有規定任何 OutOfMemoryError 狀況的區域。
這裏引用了《深刻理解Java虛擬機》書中的內容,其實不難理解,程序計數器的做用就是保存線程的執行狀態,引用部分的第三段中說「若是線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址」,這個地址就是字節碼執行到的位置。咱們平時說的 Java 多線程上下文切換就須要程序計數器的輔助,當 CPU 從一個線程切換到另外一個線程時,要從程序計數器中讀取線程執行狀態從而恢復現場。後面又說「若是執行的是本地 (Native)方法,這個計數器值爲空(Undefined)」,這是爲什麼呢?是由於本地方法執行的是 C / C++ 代碼,在原平生臺直接運行,也就不存在 Java 虛擬機的概念,天然也沒法保存字節碼指令地址,此時要想記錄代碼運行狀態的話,只能使用原生 CPU 的 PC 寄存器。
與程序計數器同樣,Java虛擬機棧(Java Virtual Machine Stack)也是線程私有的,它的生命週期與線程相同。虛擬機棧描述的是 Java 方法執行的線程內存模型:每一個方法被執行的時候,Java 虛擬機都 會同步建立一個棧幀(Stack Frame)用於存儲局部變量表、操做數棧、動態鏈接、方法出口等信息。每個方法被調用直至執行完畢的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。局部變量表存放了編譯期可知的各類Java虛擬機基本數據類型(boolean、byte、char、short、int、 float、long、double)、對象引用 (reference 類型,它並不等同於對象自己,多是一個指向對象起始地址的引用指針,也多是指向一個表明對象的句柄或者其餘與此對象相關的位置) 和 returnAddress 類型(指向了一條字節碼指令的地址)。
這些數據類型在局部變量表中的存儲空間以局部變量槽 (Slot) 來表示,其中64位長度的 long 和 double 類型的數據會佔用兩個變量槽,其他的數據類型只佔用一個。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法須要在棧幀中分配多大的局部變量空間是徹底肯定的,在方法運行期間不會改變局部變量表的大小。請讀者注意,這裏說的「大小」是指變量槽的數量,虛擬機真正使用多大的內存空間 (譬如按照1個變量槽佔用32個比特、64個比特,或者更多)來實現一個變量槽,這是徹底由具體的虛擬機實現自行決定的事情。
在《Java虛擬機規範》中,對這個內存區域規定了兩類異常情況:若是線程請求的棧深度大於虛擬機所容許的深度,將拋出 StackOverflowError 異常;若是 Java 虛擬機棧容量能夠動態擴展,當棧擴展時沒法申請到足夠的內存會拋出 OutOfMemoryError 異常。
Java 虛擬機棧的內部結構以下圖所示:
Java 虛擬機棧
局部變量表是存放方法參數和局部變量的區域。 局部變量沒有準備階段, 必須顯式初始化。若是是非靜態方法,則在 index[0] 位置上存儲的是方法所屬對象的實例引用,一個引用變量佔 4 個字節,隨後存儲的是參數和局部變量。
操做數棧是個初始狀態爲空的桶式結構棧。在方法執行過程當中, 會有各類指令往棧中寫入和提取信息。JVM 的執行引擎是基於棧的執行引擎,其中的棧指的就是操做數棧。字節碼指令集的定義都是基於棧類型的,棧的深度在方法元信息的 stack 屬性中。下面使用 i++ 和 ++i 的區別來幫助理解操做數棧:
i++ 和 ++i 的區別:
之因此說 i++ 不是原子操做,即便使用 volatile 修飾也不是線程安全,就是由於,可能 i 被從局部變量表(內存)取出,壓入操做棧(寄存器),操做棧中自增,使用棧頂值更新局部變量表(寄存器更新寫入內存),其中分爲 3 步,volatile 保證可見性,保證每次從局部變量表讀取的都是最新的值,但可能這 3 步可能被另外一個線程的 3 步打斷,產生數據互相覆蓋問題,從而致使 i 的值比預期的小。
每一個棧幀中包含一個在常量池中對當前方法的引用, 目的是支持方法調用過程的動態鏈接。
方法執行時有兩種退出狀況:
不管何種退出狀況,都將返回至方法當前被調用的位置。方法退出的過程至關於彈出當前棧幀,退出可能有三種方式:
本地方法棧與虛擬機棧所發揮的做用是很是類似的,其區別只是虛擬機棧爲虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則是爲虛擬機使用到的本地 (Native) 方法服務。《Java虛擬機規範》對本地方法棧中方法使用的語言、使用方式與數據結構並無任何強制規定,所以具體的虛擬機能夠根據須要自由實現它,甚至有的Java虛擬機 (譬如Hot-Spot虛擬機)直接就把本地方法棧和虛擬機棧合二爲一。與虛擬機棧同樣,本地方法棧也會在棧深度溢出或者棧擴展失 敗時分別拋出 StackOverflowError 和OutOfMemoryError 異常。
這部分比較好理解,就不作解析了。
對於Java應用程序來講,Java 堆 (Java Heap)是虛擬機所管理的內存中最大的一塊。Java 堆是被全部線程共享的一塊內存區域,在虛擬機啓動時建立。此內存區域的惟一目的就是存放對象實例,Java 世界裏「幾乎」全部的對象實例都在這裏分配內存。Java 堆是垃圾收集器管理的內存區域,所以也常被稱爲「GC 堆」。根據《Java虛擬機規範》的規定,Java堆能夠處於物理上不連續的內存空間中,但在邏輯上它應該被視爲連續的,這點就像咱們用磁盤空間去存儲文件同樣,並不要求每一個文件都連續存放。但對於大 對象(典型的如數組對象),多數虛擬機實現出於實現簡單、存儲高效的考慮,極可能會要求連續的內存空間。
Java 堆既能夠被實現成固定大小的,也能夠是可擴展的,不過當前主流的Java虛擬機都是按照可擴展來實現的(經過參數-Xmx和-Xms設定)。若是在 Java 堆中沒有內存完成實例分配,而且堆也沒法再擴展時,Java 虛擬機將會拋出 OutOfMemoryError 異常。
Java 堆的惟一做用就是存放對象實例,這也是垃圾收集器最關注的內存區域,由於大多數對象實例的存活時間都很短,好比在方法內部建立的實例在方法執行完以後就沒有存在價值了,因此這個區域的垃圾回收性價比最高。關於垃圾回收的詳細內容,見後續文章。
方法區 (Method Area)與 Java 堆同樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載 的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。雖然《Java虛擬機規範》中把方法區描述爲堆的一個邏輯部分,可是它卻有一個別名叫做「非堆」(Non-Heap),目的是與 Java 堆區分開來。說到方法區,不得不提一下「永久代」這個概念,尤爲是在JDK 8之前,許多 Java 程序員都習慣在 HotSpot 虛擬機上開發、部署程序,不少人都更願意把方法區稱呼爲「永久代」(Permanent Generation),或將二者混爲一談。本質上這二者並非等價的,由於僅僅是當時的 HotSpot 虛擬機設計團隊選擇把收集器的分代設計擴展至方法區,或者說使用永久代來實現方法區而已,這樣使得 HotSpot的垃圾收集器可以像管理Java堆同樣管理這部份內存,省去專門爲方法區編寫內存管理代碼的工做。可是對於其餘虛擬機實現,譬如 BEA JRockit、IBM J9 等來講,是不存在永久代的概念的。原則上如何實現方法區屬於虛擬機實現細節,不受《Java虛擬機規範》管束,並不要求統一。但如今回頭來看,當年使用永久代來實現方法區的決定並非一個好主意,這種設計致使了 Java 應用更容易遇到 內存溢出的問題(永久代有-XX:M axPermSize 的上限,即便不設置也有默認大小,而 J9 和 JRockit 只要沒有觸碰到進程可用內存的上限,例如32位系統中的4GB限制,就不會出問題 ),並且有極少數方法 (例如 String :: intern() ) 會因永久代的緣由而致使不一樣虛擬機下有不一樣的表現。當 Oracle 收購 BEA 得到了 JRockit 的全部權後,準備把 JRockit 中的優秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虛擬機時,但由於二者對方法區實現的差別而面臨諸多困難。考慮到 HotSpot 將來的發展,在 JDK 6 的 時候 HotSpot 開發團隊就有放棄永久代,逐步改成採用本地內存 (Native Memory) 來實現方法區的計劃了,到了JDK 7 的 HotSpot,已經把本來放在永久代的字符串常量池、靜態變量等移出,而到了 JDK 8,終於徹底廢棄了永久代的概念,改用與 JRockit、J9 同樣在本地內存中實現的元空間(Metaspace)來代替,把JDK 7中永久代還剩餘的內容(主要是類型信息)所有移到元空間中。
《Java虛擬機規範》對方法區的約束是很是寬鬆的,除了和 Java 堆同樣不須要連續的內存和能夠選擇固定大小或者可擴展外,甚至還能夠選擇不實現垃圾收集。相對而言,垃圾收集行爲在這個區域的確是比較少出現的,但並不是數據進入了方法區就如永久代的名字同樣「永久」存在了。這區域的內存回收目標主要是針對常量池的回收和對類型的卸載,通常來講這個區域的回收效果比較難使人滿意,尤爲是類型的卸載,條件至關苛刻,可是這部分區域的回收有時又確實是必要的。
根據《Java虛擬機規範》的規定,若是方法區沒法知足新的內存分配需求時,將拋出 OutOfMemoryError 異常。
這部分引用內容對方法區的介紹十分全面,切記不要將方法區和永久代混爲一談,從JDK 8 之後已經沒有永久代的概念了。
運行時常量池 (Runtime Constant Pool) 是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表 (Constant Pool Table),用於存放編譯期生成的各類字面量與符號引用,這部份內容將在類加載後存放到方法區的運行時常量池中。既然運行時常量池是方法區的一部分,天然受到方法區內存的限制,當常量池沒法再申請到內存 時會拋出OutOfMemoryError異常。
常量池是爲了不頻繁的建立和銷燬對象而影響系統性能,其實現了對象的共享。
本文做爲 Java 虛擬機系列的第一篇文章,爲你們介紹了 Java 虛擬機的總體架構和運行時數據區,相信你們對 JVM 已經有了總體的認識。但這還遠遠不夠,JVM 還有更多而內容和細節等着咱們去探索,後續文章敬請期待。
最後是參考文章和文獻: