1. 程式人生 > >JVM、棧(stack)、堆(heap)和靜態區(static area)以及記憶體溢位的認識

JVM、棧(stack)、堆(heap)和靜態區(static area)以及記憶體溢位的認識

一、認識JVM

1. 什麼是JVM?

JVM是Java Virtual Machine(Java虛擬機器)的縮寫,JVM是一種用於計算裝置的規範,它是一個虛構出來的計算機,是通過在實際的計算機上模擬模擬各種計算機功能來實現的。Java虛擬機器包括一套位元組碼指令集、一組暫存器、一個棧、一個垃圾回收堆和一個儲存方法域。 JVM遮蔽了與具體作業系統平臺相關的資訊,使Java程式只需生成在Java虛擬機器上執行的目的碼(位元組碼),就可以在多種平臺上不加修改地執行。JVM在執行位元組碼時,實際上最終還是把位元組碼解釋成具體平臺上的機器指令執行。

Java語言的一個非常重要的特點就是與平臺的無關性。而使用Java虛擬機器是實現這一特點的關鍵。一般的高階語言如果要在不同的平臺上執行,至少需要編譯成不同的目的碼。而引入Java語言虛擬機器後,Java語言在不同平臺上執行時不需要重新編譯。Java語言使用Java虛擬機器遮蔽了與具體平臺相關的資訊,使得Java語言編譯程式只需生成在Java虛擬機器上執行的目的碼(位元組碼),就可以在多種平臺上不加修改地執行。Java虛擬機器在執行位元組碼時,把位元組碼解釋成具體平臺上的機器指令執行。這就是Java的能夠“一次編譯,到處執行”的原因。

2. JRE/JDK/JVM是什麼關係?

JRE(JavaRuntimeEnvironment,Java執行環境),也就是Java平臺。所有的Java 程式都要在JRE下才能執行。普通使用者只需要執行已開發好的java程式,安裝JRE即可。

JDK(Java Development Kit)是程式開發者用來來編譯、除錯java程式用的開發工具包。JDK的工具也是Java程式,也需要JRE才能執行。為了保持JDK的獨立性和完整性,在JDK的安裝過程中,JRE也是 安裝的一部分。所以,在JDK的安裝目錄下有一個名為jre的目錄,用於存放JRE檔案。

JVM(JavaVirtualMachine,Java虛擬機器)是JRE的一部分。它是一個虛構出來的計算機,是通過在實際的計算機上模擬模擬各種計算機功能來實現的。JVM有自己完善的硬體架構,如處理器、堆疊、暫存器等,還具有相應的指令系統。Java語言最重要的特點就是跨平臺執行。使用JVM就是為了支援與作業系統無關,實現跨平臺。

3. JVM原理

JVM是java的核心和基礎,在java編譯器和os平臺之間的虛擬處理器。它是一種利用軟體方法實現的抽象的計算機基於下層的作業系統和硬體平臺,可以在上面執行java的位元組碼程式。

由於Java程式是交由JVM執行的,所以我們在談Java記憶體區域劃分的時候事實上是指JVM記憶體區域劃分。在討論JVM記憶體區域劃分之前,先來看一下Java程式具體執行的過程:

如上圖所示,首先Java原始碼檔案(.java字尾)會被Java編譯器編譯為位元組碼檔案(.class字尾),然後由JVM中的類載入器載入各個類的位元組碼檔案,載入完畢之後,交由JVM執行引擎執行(將每一條指令翻譯成不同平臺機器碼,通過特定平臺執行)。

4. JVM執行程式的過程
1) 載入.class檔案 2) 管理並分配記憶體 3) 執行垃圾收集
JRE(java執行時環境)由JVM構造的java程式的執行環,也是Java程式執行的環境,但是他同時一個作業系統的一個應用程式一個程序,因此他也有他自己的執行的生命週期,也有自己的程式碼和資料空間。JVM在整個jdk中處於最底層,負責於作業系統的互動,用來遮蔽作業系統環境,提供一個完整的Java執行環境,因此也就虛擬計算機。作業系統裝入JVM是通過jdk中Java.exe來完成,通過下面4步來完成JVM環境:1) 建立JVM裝載環境和配置 2) 裝載JVM.dll 3) 初始化JVM.dll並掛界到JNIENV(JNI呼叫介面)例項4) 呼叫JNIEnv例項裝載並處理class類。
 
5. JVM的生命週期
1) JVM例項對應了一個獨立執行的java程式它是程序級別
a) 啟動。啟動一個Java程式時,一個JVM例項就產生了,任何一個擁有public static void
main(String[] args)函式的class都可以作為JVM例項執行的起點
b) 執行。main()作為該程式初始執行緒的起點,任何其他執行緒均由該執行緒啟動。JVM內部有兩種執行緒:守護執行緒和非守護執行緒,main()屬於非守護執行緒,守護執行緒通常由JVM自己使用,java程式也可以表明自己建立的執行緒是守護執行緒
c) 消亡。當程式中的所有非守護執行緒都終止時,JVM才退出;若安全管理器允許,程式也可以使用Runtime類或者System.exit()來退出
 
2) JVM執行引擎例項則對應了屬於使用者執行程式的執行緒它是執行緒級別的
 
6. JVM的體系結構

    類裝載器(ClassLoader)(類載入器的作用就是載入類檔案到記憶體中,編寫了一個HelloWord.java程式,通過javadoc編譯成class檔案,然後載入到記憶體中)
    執行引擎(負責解釋命令,提交到作業系統執行)

    本地介面(融合不同的程式語言為java所使用,初衷是融合C/C++程式,於是在記憶體中開闢了一塊標記為native的程式碼。目前該方法使用的越來越少,除非是與硬體相關的操作,比如通過java程式驅動印表機,或者java系統管理生產裝置)
    執行時資料區(方法區、堆、java棧、PC暫存器、本地方法棧)

二、執行時資料區的劃分

1.程式計數器

程式計數器(Program Counter Register),也有稱作為PC暫存器。想必學過組合語言的朋友對程式計數器這個概念並不陌生,在組合語言中,程式計數器是指CPU中的暫存器,它儲存的是程式當前執行的指令的地址(也可以說儲存下一條指令的所在儲存單元的地址),當CPU需要執行指令時,需要從程式計數器中得到當前需要執行的指令所在儲存單元的地址,然後根據得到的地址獲取到指令,在得到指令之後,程式計數器便自動加1或者根據轉移指標得到下一條指令的地址,如此迴圈,直至執行完所有的指令。

雖然JVM中的程式計數器並不像組合語言中的程式計數器一樣是物理概念上的CPU暫存器,但是JVM中的程式計數器的功能跟組合語言中的程式計數器的功能在邏輯上是等同的,也就是說是用來指示 執行哪條指令的。

由於在JVM中,多執行緒是通過執行緒輪流切換來獲得CPU執行時間的,因此,在任一具體時刻,一個CPU的核心只會執行一條執行緒中的指令,因此,為了能夠使得每個執行緒都線上程切換後能夠恢復在切換之前的程式執行位置,每個執行緒都需要有自己獨立的程式計數器,並且不能互相被幹擾,否則就會影響到程式的正常執行次序。因此,可以這麼說,程式計數器是每個執行緒所私有的。

在JVM規範中規定,如果執行緒執行的是非native方法,則程式計數器中儲存的是當前需要執行的指令的地址;如果執行緒執行的是native方法,則程式計數器中的值是undefined。

由於程式計數器中儲存的資料所佔空間的大小不會隨程式的執行而發生改變,因此,對於程式計數器是不會發生記憶體溢位現象(OutOfMemory)的。
2.Java棧

Java棧也稱作虛擬機器棧(Java Vitual Machine Stack),也就是我們常常所說的棧,跟C語言的資料段中的棧類似。事實上,Java棧是Java方法執行的記憶體模型。為什麼這麼說呢?下面就來解釋一下其中的原因。

Java棧中存放的是一個個的棧幀,每個棧幀對應一個被呼叫的方法,在棧幀中包括區域性變量表(Local Variables)、運算元棧(Operand Stack)、指向當前方法所屬的類的執行時常量池(執行時常量池的概念在方法區部分會談到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些額外的附加資訊。當執行緒執行一個方法時,就會隨之建立一個對應的棧幀,並將建立的棧幀壓棧。當方法執行完畢之後,便會將棧幀出棧。因此可知,執行緒當前執行的方法所對應的棧幀必定位於Java棧的頂部。講到這裡,大家就應該會明白為什麼 在 使用 遞迴方法的時候容易導致棧記憶體溢位的現象了以及為什麼棧區的空間不用程式設計師去管理了(當然在Java中,程式設計師基本不用關係到記憶體分配和釋放的事情,因為Java有自己的垃圾回收機制),這部分空間的分配和釋放都是由系統自動實施的。對於所有的程式設計語言來說,棧這部分空間對程式設計師來說是不透明的。下圖表示了一個Java棧的模型:

區域性變量表,顧名思義,想必不用解釋大家應該明白它的作用了吧。就是用來儲存方法中的區域性變數(包括在方法中宣告的非靜態變數以及函式形參)。對於基本資料型別的變數(java中定義的八種資料型別:boolean、int等),則直接儲存它的值,對於引用型別的變數,則存的是指向物件的引用。區域性變量表的大小在編譯器就可以確定其大小了,因此在程式執行期間區域性變量表的大小是不會改變的。

運算元棧,想必學過資料結構中的棧的朋友想必對錶達式求值問題不會陌生,棧最典型的一個應用就是用來對錶達式求值。想想一個執行緒執行方法的過程中,實際上就是不斷執行語句的過程,而歸根到底就是進行計算的過程。因此可以這麼說,程式中的所有計算過程都是在藉助於運算元棧來完成的。

指向執行時常量池的引用,因為在方法執行的過程中有可能需要用到類中的常量,所以必須要有一個引用指向執行時常量。

方法返回地址,當一個方法執行完畢之後,要返回之前呼叫它的地方,因此在棧幀中必須儲存一個方法返回地址。

由於每個執行緒正在執行的方法可能不同,因此每個執行緒都會有一個自己的Java棧,它的生命週期也與執行緒相同,互不干擾。
3.本地方法棧

本地方法棧與Java棧的作用和原理非常相似。區別只不過是Java棧是為執行Java方法服務的,而本地方法棧則是為執行本地方法(Native Method)服務的。在JVM規範中,並沒有對本地方發展的具體實現方法以及資料結構作強制規定,虛擬機器可以自由實現它。在HotSopt虛擬機器中直接就把本地方法棧和Java棧合二為一。
4.堆

在C語言中,堆這部分空間是唯一一個程式設計師可以管理的記憶體區域。程式設計師可以通過malloc函式和free函式在堆上申請和釋放空間。那麼在Java中是怎麼樣的呢?

Java中的堆是用來儲存物件本身的以及陣列(當然,陣列引用是存放在Java棧中的)。只不過和C語言中的不同,在Java中,程式設計師基本不用去關心空間釋放的問題,Java的垃圾回收機制會自動進行處理。因此這部分空間也是Java垃圾收集器管理的主要區域。另外,堆是被所有執行緒共享的,因此在其上進行物件記憶體分配均需要進行枷鎖,這也導致了new物件的開銷是比較大的,在JVM中只有一個堆。但是,Sun Hotspot JVM為了提升物件記憶體分配的效率,對於所建立的執行緒都會分配一塊獨立的空間TLAB(Thread Local Allocation Buffer),其大小由JVM根據執行的情況計算而得,在TLAB上分配物件時不需要加鎖,因此JVM在給執行緒的物件分配記憶體時會盡量的在TLAB上分配,在這種情況下JVM中分配物件記憶體的效能和C基本是一樣高效的,但如果物件過大的話則仍然是直接使用堆空間分配。
5.方法區

方法區在JVM中也是一個非常重要的區域,它與堆一樣,是被執行緒共享的區域。在方法區中,儲存了每個類的資訊(包括類的名稱、方法資訊、欄位資訊)、靜態變數、定義為final型別的常量以及編譯器編譯後的程式碼等。

在Class檔案中除了類的欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用來儲存編譯期間生成的字面量和符號引用。

在方法區中有一個非常重要的部分就是執行時常量池,它是每一個類或介面的常量池的執行時表示形式,在類和介面被載入到JVM後,對應的執行時常量池就被創建出來。當然並非Class檔案常量池中的內容才能進入執行時常量池,在執行期間也可將新的常量放入執行時常量池中,比如String的intern方法。

在JVM規範中,沒有強制要求方法區必須實現垃圾回收。很多人習慣將方法區稱為“永久代”,是因為HotSpot虛擬機器以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分割槽域,從而不需要專門為這部分設計垃圾回收機制。不過自從JDK7之後,Hotspot虛擬機器便將執行時常量池從永久代移除了。

三、JAVA記憶體區域與記憶體溢位

JAVA虛擬機器棧:

   1、如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常。

    2、如果虛擬機器在動態擴充套件棧時無法申請到足夠的記憶體空間,則丟擲OutOfMemoryError異常。

    這兩種情況存在著一些互相重疊的地方:當棧空間無法繼續分配時,到底是記憶體太小,還是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。在單執行緒的操作中,無論是由於棧幀太大,還是虛擬機器棧空間太小,當棧空間無法分配時,虛擬機器丟擲的都是StackOverflowError異常,而不會得到OutOfMemoryError異常。而在多執行緒環境下,則會丟擲OutOfMemoryError異常。

    下面給出個記憶體區域記憶體溢位的簡單測試方法


    這裡有一點要重點說明,在多執行緒情況下,給每個執行緒的棧分配的記憶體越大,反而越容易產生記憶體溢位異常。作業系統為每個程序分配的記憶體是有限制的,虛擬機器提供了引數來控制Java堆和方法區這兩部分記憶體的最大值,忽略掉程式計數器消耗的記憶體(很小),以及程序本身消耗的記憶體,剩下的記憶體便給了虛擬機器棧和本地方法棧,每個執行緒分配到的棧容量越大,可以建立的執行緒數量自然就越少。因此,如果是建立過多的執行緒導致的記憶體溢位,在不能減少執行緒數的情況下,就只能通過減少最大堆和每個執行緒的棧容量來換取更多的執行緒。

    另外,由於Java堆內也可能發生記憶體洩露(Memory Leak),這裡簡要說明一下記憶體洩露和記憶體溢位的區別:

    記憶體洩露是指分配出去的記憶體沒有被回收回來,由於失去了對該記憶體區域的控制,因而造成了資源的浪費。Java中一般不會產生記憶體洩露,因為有垃圾回收器自動回收垃圾,但這也不絕對,當我們new了物件,並儲存了其引用,但是後面一直沒用它,而垃圾回收器又不會去回收它,這邊會造成記憶體洩露,

    記憶體溢位是指程式所需要的記憶體超出了系統所能分配的記憶體(包括動態擴充套件)的上限。

物件例項化分析

    對記憶體分配情況分析最常見的示例便是物件例項化:

    Object obj = new Object();

   這段程式碼的執行會涉及java棧、Java堆、方法區三個最重要的記憶體區域。假設該語句出現在方法體中,及時對JVM虛擬機器不瞭解的Java使用這,應該也知道obj會作為引用型別(reference)的資料儲存在Java棧的本地變量表中,而會在Java堆中儲存該引用的例項化物件,但可能並不知道,Java堆中還必須包含能查詢到此物件型別資料的地址資訊(如物件型別、父類、實現的介面、方法等),這些型別資料則儲存在方法區中。

    另外,由於reference型別在Java虛擬機器規範裡面只規定了一個指向物件的引用,並沒有定義這個引用應該通過哪種方式去定位,以及訪問到Java堆中的物件的具體位置,因此不同虛擬機器實現的物件訪問方式會有所不同,主流的訪問方式有兩種:使用控制代碼池和直接使用指標。

    通過控制代碼池訪問的方式如下:

   通過直接指標訪問的方式如下:

    這兩種物件的訪問方式各有優勢,使用控制代碼訪問方式的最大好處就是reference中存放的是穩定的控制代碼地址,在物件唄移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,而reference本身不需要修改。使用直接指標訪問方式的最大好處是速度快,它節省了一次指標定位的時間開銷。目前Java預設使用的HotSpot虛擬機器採用的便是是第二種方式進行物件訪問的。

參考文章1    參考文章2