前言
本打算花一篇文章來聊聊JVM內存管理機制,結果發現越扯越多,於是分了四遍文章(文章講解JVM以Hotspot虛擬機為例,jdk版本為1.8),本文為其中第一篇。from 你必須了解的java內存管理機制-運行時數據區
相關鏈接(注:文章講解JVM以Hotspot虛擬機為例,jdk版本為1.8,個人技術博客www.17coding.info)
1、 你必須了解的java內存管理機制-運行時數據區
2、 你必須了解的java內存管理機制-內存分配
3、 你必須了解的java內存管理機制-垃圾標記
4、 你必須了解的java內存管理機制-垃圾回收
正文
C++與java之間有一堵由內存動態分配和垃圾收集技術所圍成的“高牆”,牆外的人想進去,牆里的人卻想出來……
與C、C++程序員時刻要關注着內存的分配與釋放,會不會又有哪里出現了內存泄露不同是,java程序員可以“高枕無憂”。因為這一切都已經有jvm來幫我們管理了,java程序員只需要關注具體的業務邏輯就可以了,至於內存分配與回收,交給jvm去干吧。但這樣也帶來一個問題,我們不再去關注內存分配了,不再去關注內存回收了。一旦出現內存泄露就束手無策了,在不同的應用場景,怎么樣去做性能調優就成了一個問題。所以,對於java程序員來說,這些是必須了解的一部分。
沒有對象怎么辦?new一個啊。單身狗程序員每次提到new對象都激動不已,可是你的對象是怎么new出來的?new出來又放在哪里?怎么引用的?你的對象被別人動了怎么辦?使用完成之后又是如何釋放的?何時釋放的?等等等等這些問題,如果你不能很輕松的回答出來,那么在本系列文章中你可能會找到一些答案。當然,本人才疏學淺,文筆拙劣,只是拋磚引玉,理解不周到或者有誤的地方,歡迎拍磚。
JVM內存區域可以大致划分為“線程隔離區域”和“線程共享區域”。所謂“線程隔離區域”即線程非共享區域,每個線程獨享的,執行指令操作機存放私有數據。不管做什么操作,不會影響到其他線程。可以想象成,你個人電腦硬盤中的蒼老師,只能你一個人在夜深人靜的時候拉上窗簾獨自享受,別人無法同你分享,你刪除或者新下載也不會對別人造成影響。而“線程共享區域”則是所有的線程共同擁有的,主要存放對象實例數據。如果A線程對這塊區域的某個數據進行了修改,而剛好B線程正在使用或者需要使用該數據,則A線程對數據的修改在B線程中也會得到體現。可以想象成你把蒼老師傳到了某社區,這時候網上其他人都能共享你的蒼老師了。當大家看得正興奮的時候,你突然刪掉了你上傳的老師,這時候大家都只能去尋找新的素材了………,不知道你是否對“線程隔離區域”和“線程共享區域”的概念有了個大致了解。在jvm中,線程隔離區域包含程序計數器、本地方法棧、虛擬機棧。線程共享區域包含堆區、永久代(jdk1.8中廢除永久代)、直接內存(jdk1.8中新增)(看下圖)
一、這是我的私人住所,我不同意,你們別來!-線程隔離區域
線程隔離區域存放什么數據呢?局部變量、方法調用的壓棧操作等。線程隔離區域包含巴拉巴拉……(看下圖)
1、睡了一覺,剛剛我做到哪了?-程序計數器
我們都知道在多線程的場景下,會發生線程切換,如果當前執行的線程讓出執行權,則線程會被掛起,當線程再次被喚醒的時候,如果沒有程序計數器線程可能就懵逼了,我是誰?我在哪?我要做什么?。但是如果有了程序計數器,線程就能找到上次執行到的字節碼的位置繼續往下執行。程序計數器可以理解為當前線程正在執行的字節碼指令的行號指示器。分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
查閱了一些資料,列出了程序計數器的三個特點,這里也列舉一下
1)、如果線程正在執行的是Java 方法,則這個計數器記錄的是正在執行的虛擬機字節碼指令地址
2)、如果正在執行的是Native 方法,則這個計數器值為空(Undefined)。因為Native方法大多是通過C實現並未編譯成需要執行的字節碼指令。那native 方法的多線程是如何實現的呢? native 方法是通過調用系統指令來實現的,那系統是如何實現多線程的則 native 就是如何實現的。Java線程總是需要以某種形式映射到OS線程上,映射模型可以是1:1(原生線程模型)、n:1(綠色線程 / 用戶態線程模型)、m:n(混合模型)。以HotSpot VM的實現為例,它目前在大多數平台上都使用1:1模型,也就是每個Java線程都直接映射到一個OS線程上執行。此時,native方法就由原生平台直接執行,並不需要理會抽象的JVM層面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎樣就是怎樣。就像一個用C或C++寫的多線程程序,它在線程切換的時候是怎樣的,Java的native方法也就是怎樣的。
3)、此內存區域是唯一一個在Java虛擬機規范中沒有規定任何OutOfMemoryError情況的區域(程序運行過程中計數器中改變的只是值,而不會隨着程序的運行需要更大的空間)
2、自己的事情自己做!-虛擬機棧
這個區域就是我們經常所說的棧,是java方法執行的內存模型,也是我們在開發中接觸得很多的一塊區域。虛擬機棧存放當前正在執行方法的時候所需要的數據、地址、指令。每個線程都會獨享一塊棧空間,每次方法調用都會創建一個棧幀,棧幀保存了方法的局部局部變量、操作數棧、動態鏈接、出口等信息。棧幀的深度也是有限制的,超過限制會拋出StackOverflowError異常。
我們結合一個例子來了解一下虛擬機棧和棧幀,我們有如下代碼:
public class myProgram { public static void main(String[] args) { String str = "my String"; methodOne(1); } public static void methodOne(int i) { int j = 2; int sum = i + j; // ...... methodTwo(); // ..... } public static void methodTwo() { if (true) { int j = 0; } if (true) { int k = 1; } return; } }
代碼很簡單,main調用methodOne,methodOne調用methodTwo,如果當前正在執行methodTwo方法,則虛擬機棧中棧幀的情況應該是如下圖情況,棧頂為正在執行的方法。
我們能看到,每個棧幀都包含局部變量表,操作數棧、動態鏈接、返回地址等……
1)、局部變量表
顧名思義,局部變量表就是存放局部變量的表,局部變量包括方法形參、方法內部定義的局部變量。局部變量表由多個變量槽(slot)組成,每個槽位都有個索引號,索引的范圍是從0開始至局部變量最大的slot空間,虛擬機就是通過索引定位的方式使用局部變量表。比如在methodOne方法中,形參i就是在0號索引的slot中,局部變量j就放在1號索引的slot中,我們看看結合methodOne方法的字節碼進行分析(通過javap -verbose myProgram查看字節碼文件)。
public static void methodOne(int); descriptor: (I)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: iconst_2 1: istore_1 2: iload_0 3: iload_1 4: iadd 5: istore_2 6: invokestatic #4 // Method methodTwo:()V 9: return LineNumberTable: line 8: 0 line 9: 2 line 12: 6 line 14: 9
0:加載int類型常量2
1:存儲到索引為1的變量中(這里指源程序中的j)
2:加載索引為0的變量(這里指源程序中的i)
3:加載索引為1的變量(這里指源程序中的j)
4:執行add指令
5:將執行結果存儲到索引為2的變量中(這里指源程序中的sum)
6:靜態調用
需要注意的一點是,為了盡可能節省棧幀的空間,局部變量表中的slot是可以重用的,方法體重定義的變量,其作用域不一定會覆蓋整個方法體,我們看看methodTwo的源碼,第一個if和第二個if的作用域不一樣,所以內部變量可能是用的同一個slot,我們可以通過methodTwo方法的字節碼來驗證一下
public static void methodTwo(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=0 0: iconst_0 1: istore_0 2: iconst_1 3: istore_0 4: return LineNumberTable: line 19: 0 line 23: 2 line 26: 4
你看,我沒騙你吧,methodTwo方法兩個if中的變量j和k,使用的都是索引為0的slot。這樣的設計可以節省棧幀的空間,同時也會影響jvm的垃圾回收,因為局部變量表是GC Root的一部分,局部變量表slot中當前存放的變量關聯的對象為可達對象(后面講到垃圾回收時候再詳細講)。
2)、操作數棧
操作數棧也是一個棧,也看可以成為表達式棧。操作數棧和局部變量表在訪問方式上有着較大的差異,它不是通過索引來訪問,而是通過標准的棧操作—壓棧和出棧—來訪問的。我們對變量的操作都是在操作數棧中完成的,我們依然拿methodOne方法來舉例。再看一下methodOne方法的字節碼:
public static void methodOne(int); descriptor: (I)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: iconst_2 1: istore_1 2: iload_0 3: iload_1 4: iadd 5: istore_2 6: invokestatic #4 // Method methodTwo:()V 9: return LineNumberTable: line 8: 0 line 9: 2 line 12: 6 line 14: 9
下圖為每一行字節碼對應操作數棧和本地變量表之間的關系,具體看圖,不用多做描述了。
3)、動態鏈接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接。剛開始看這一段的時候總是覺得很生澀,比較拗口。我們還是繼續看那段代碼的字節碼文件,其中有一段叫做“Constant pool”,里面存儲了該Class文件里的大部分常量的內容(包括類和接口的全限定名、字段的名稱和描述符以及方法的名稱和描述符)。
不知道你有沒有注意我們字節碼中是怎么處理menthodOne方法的調用的?在main方法中調用methodone方法的字節碼為invokestatic #3,這里的#3就是一個” 符號引用”,我們發現#3還引用着另外的常量池項目,順着這條線把能傳遞到的常量池項都找出來(標記為Utf8的常量池項)。由此我們可以看出,invokestatic 指令就是以常量池中指向方法的符號引用作為參數,完成方法的調用。這些符號引用一部分在類的加載階段(解析)或第一次使用的時候就轉化為了直接引用(指向數據所存地址的指針或句柄等),這種轉化稱為靜態鏈接。而相反的,另一部分在運行期間轉化為直接引用,就稱為動態鏈接。我們看一下字節碼中的常量池和符號引用,注意main方法中的#2 #3:
Constant pool: #1 = Methodref #6.#18 // java/lang/Object."<init>":()V #2 = String #19 // my String #3 = Methodref #5.#20 // myProgram.methodOne:(I)V #4 = Methodref #5.#21 // myProgram.methodTwo:()V #5 = Class #22 // myProgram #6 = Class #23 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 methodOne #14 = Utf8 (I)V #15 = Utf8 methodTwo #16 = Utf8 SourceFile #17 = Utf8 myProgram.java #18 = NameAndType #7:#8 // "<init>":()V #19 = Utf8 my String #20 = NameAndType #13:#14 // methodOne:(I)V #21 = NameAndType #15:#8 // methodTwo:()V #22 = Utf8 myProgram #23 = Utf8 java/lang/Object public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 0: ldc #2 // String my String 2: astore_1 3: iconst_1 4: invokestatic #3 // Method methodOne:(I)V 7: return LineNumberTable: line 3: 0 line 4: 3 line 5: 7
4)、返回地址
我們的經常使用return x;來使方法返回一個值給方法調用者,如果沒有返回值的方法也可以在方法的方法需要返回的地方加上return;當然,這不是必須的,因為源碼在轉化為字節碼的時候,總是會在方法的最后加上return指令,不信你看上面methodTwo方法的字節碼那張圖片。
正常情況下,方法遇到返回指令退出,這種退出方法的方式稱為正常完成出口。如果方法正常返回,則當前棧幀從java棧中彈出,恢復發起調用者的方法的棧幀,如果方法有返回值,jvm會把返回值壓入到發起調用方法的操作數棧。但是在異常情況下,方法執行遇到了異常,且這個異常在方法體內未得到處理,方法則會異常退出,這種退出方式稱為異常完成出口。當異常拋出且沒有被捕捉時,則方法立即終止,然后JVM恢復發起調用的方法的棧幀,如果在調用者中也未對異常進行捕捉,則調用者也會立即終止,層層向上,直到最外層拋出異常。
3、樓上做不了的事情,來我這做!-本地方法棧
本地方法是什么?本地方法就是在jdk中(也可以自定義)那些被Native關鍵字修飾的方法(下圖)。這類方法有點類似java中的接口,沒有實現體,但實際上是由jvm在加載時調用底層實現的,實現體是由非java語言(如C、C++)實現的,所以本地方法可以理解為連接java代碼和其他語言實現的代碼的入口。而本地方法棧的功能就類似於虛擬機棧,只是一個服務於java方法執行,一個服務於執行本地方法執行。
二、來啊,快活啊!反正有大把空間!-線程共享區域
1、 喂,你的對象都在這里!-堆
堆區域在jvm中是非常重要的一塊區域,因為我們平常創建的對象的實例就存在在這個區域,這個區域的幾乎是被所有線程共享。同時也是java虛擬機管理的內存中最大的一塊。由於目前主流的垃圾收集器都采用分代收集算法,所以通常將堆細分為新生代、老年代,新生代又分為兩塊Eden區、From Survivor區、To Survivor區(這里主要針對通常使用的分代收集器,G1收集器采用不同的划分策略,后面有機會再講)。不過不管怎么划分,目的都是為了更合理的利用內存,提高內存空間使用率,提高垃圾回收的效率和回收質量。下圖展示了堆區域的划分
我們在這篇文章里只談堆區內存的划分,關於內存分配、內存回收等會在下篇文章細講,因為涉及的內容太多了……不過我們可以先思考幾個問題1、為什么需要區分新生代、老年代?2、為什么將新生代分為Eden、Survivor區?各區大小怎么分配?有什么分配依據?
2、 治不了你?那我就廢了你!-方法區
看標題可能會有些誤解,其實這里廢除的是永久代的概念,而不是方法區。剛開始總是搞不清這兩者的關系,后來就去查閱了一些資料總算是搞清楚了一些,書上是這么說的:“JVM的虛擬機規范只是規定了有方法區這么個概念和它的作用,並沒有規定如何去實現它。不同JVM的方法區的實現會不一樣,比如在HotSpot中使用永久代實現方法區,其他JVM並沒有永久代的概念。方法區是一種規范,永久代是一種實現。”
所以,我們常說的新生代、老年代、永久代中的永久代就是方法區的一種實現,且只存在於HotSpot虛擬機中有這種概念。用過jdk1.8之前的版本(HotSpot虛擬機)的同學應該經常能碰到永久代溢出的異常“java.lang.OutOfMemoryError: PermGen space”,這里的PermGen space指的是永久代。在jdk6中,永久代包含方法區和常量池,但是在jdk1.7的版本中規划去除永久代,於是在1.7中將常量池移到了老年代中。在jdk1.8中徹底廢除了永久代,取而代之的是元空間。
3、 會有天使替我去愛你!-直接內存
永久代設置太大吧,浪費資源!永久代設置太小吧,溢出了!於是讓人惱火的永久代溢出的異常時常發生,並且永久代的GC效率低下,於是,在jdk1.8中徹底廢除了永久區,放到了直接內存的元空間中!元空間的本質和永久代類似,都是對JVM規范中方法區的實現。元空間相比永久代有什特性呢?永久代在物理上是堆的一部分,與新生代老年代的地址是連續的,而元空間屬於本地內存,不受JVM控制,也不會發生永久代溢出的異常。
直接內存也可以稱為堆外內存,為什么要將方法區放入到直接內存呢?
1、 永久代會為 GC 帶來不必要的復雜度,並且回收效率偏低。
2、 類及方法的信息等比較難確定其大小,因此永久代調優較為困難,容易發生內存溢出。
3、 加快了復制的速度。因為堆內在flush到遠程時,會先復制到直接內存(非堆內存),然后再發送,而堆外內存相當於省略掉了這個工作。
4、 Oracle 可能會將HotSpot 與 JRockit 合二為一