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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章