JVM之Java內存區域與內存溢出異常(二)

      對於從事C、C++程序開發的開發人員來說,在內存管理領域,他們擁有每一個對象的“所有權”,又擔負着對象整個生命週期的維護責任,即維護對象從創建到結束的內存管理;
而對於Java程序員來說,有虛擬機自動內存管理機制幫助,不需要爲每一個new 操作去寫delete/free代碼(釋放內存),不容易出現內存泄漏和內存溢出問題(虛擬機自動內存管理機制來管理),正是Java程序員把內存控制的權利交給Java虛擬機,所以一旦出現內存泄漏和溢出問題,就必須瞭解虛擬機怎樣使用內存,才能更好地排查錯誤。也就是幾乎喪失了內存控制權;
即內存控制權存在巨大區別
    1、Java虛擬機運行時數據區
這裏寫圖片描述
    JVM在執行Java程序的過程中,會把它所管理的內存劃分爲若干個不同的數據區域(如上圖),這些區域都有各自的用途,以及創建和銷燬時間,有的區域隨着虛擬機進程的啓動而存在,有些區域則依賴用戶線程的啓動和結束而建立和銷燬。
    (1)、程序計數器
    程序計數器是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。在JVM的概念模型裏,字節碼解釋器工作時就是通過改變這個計數器的值來選去下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理和線程恢復等基礎功能都是依賴這個計數器來完成。
    程序計數器是線程私有的,因爲JVM多線程是通過線程輪流切換並分配處理器執行時間的方式實現的,即在任何一個確定的時刻,一個處理器(對多核處理器來說是一個內核)都只會執行線程中的一條指令。因此在線程切換後能恢復到正確的執行位置,需要每一個線程都有一個程序計數器。
    線程執行的是一個Java方法,則計數器指示需要執行的字節碼指令地址;如果執行的是Native方法,這個計數器的值爲空。這個內存區域是唯一一個在Java規範中沒有規定任何OutOfMemoryError情況的區域。
    (2)、Java虛擬機棧
    JVM Stacks也是線程私有的,它的生命週期與線程相同。JVM Stacks描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(後面會有詳細信息)用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。
    局部變量表存放方法中定義的局部變量和方法中的參數,即編譯期可知的各種基本數據類型、對象引用(可能是指向對象起始地址的引用指針,或者代表一個對象的句柄或與該對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。
    操作數棧用來存放操作數,我們知道,Java 程序編譯之後就變成了一條條字節碼指令,其形式類似彙編,但和彙編有不同之處:彙編指令的操作數存放在數據段和寄存器中,可通過存儲器或寄存器尋址找到需要的操作數;而 Java 字節碼指令的操作數存放在操作數棧中,當執行某條帶 n 個操作數的指令時,就從棧頂取 n 個操作數,然後把指令的計算結果(如果有的話)入棧。因此,當我們說 JVM 執行引擎是基於棧的時候,其中的“棧”指的就是操作數棧。
局部變量表所需的內存空間在編譯期間就完成分配(確定大小),在方法運行期間就不會改變局部變量表大小。
    動態鏈接是將符號引用解析爲直接引用的過程,JVM在執行字節碼時,如果遇到操作碼第一次使用一個指向另一個類的符號引用,那麼虛擬機就必須解析這個符號引用:
    查找被引用的類,如果必要就裝載它
    將符號引用替換爲直接引用,這樣下次就可以直接使用直接引用。
    方法出口,當一個方法被執行後,有兩種方法退出該方法:執行引擎遇到了任意一個方法返回的字節碼指令或者遇到了異常,並且該異常沒有在方法體內得到處理,無論採用何種退出方式,方法退出後,都需要返回方法被調用的位置,程序才能繼續執行。方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說方法正常退出時,調用者的pc計數器值就可以作爲返回地址,棧幀中可能保存了這個計數器值(?),而方法異常退出時,返回地址是要通過異常處理器來確定,棧幀中一般不會保存這部分信息。
    方法退出的過程實際上等同於把當前幀出棧,因此退出時間可能執行的操作有:恢復上層方法的局部變量表和操作數棧,如果有返回值,則把它壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的後面的一條指令。
    兩種異常情況:
        1》線程請求的棧深度大於虛擬機允許的深度就會拋出stackoverfllowerror;
        2》如果虛擬機棧可以動態擴展(大部分JVM都可以),如果無法申請足夠的內存,就會拋出OutOfMemoryError異常。
    (3)、本地方法棧
    本地方法棧與虛擬機棧所發揮的作用非常相似,只不過JVM Stacks是爲虛擬機執行Java方法(字節碼)服務的,而本地方法棧則是爲JVM使用Native方法服務的。甚至有的虛擬機(Sun hotspot虛擬機)直接把本地方法棧和虛擬機棧合二爲一,虛擬機棧一樣,也會拋出StackOverFlowError和OutOfMemoryError異常。
    (4)、Java堆
    一般來說Java Heap是Java虛擬機所管理的內存中最大的一塊,被所有的線程共享,在虛擬機啓動的時候創建。幾乎所有的對象都放在這裏分配內存(Java規範裏描述的是所有對象實例以及數組都要在堆上分配,但隨着JIT與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將導致一些微妙的變化發生,所以都在堆上分配內存也不是那麼絕對)。
    Java堆是垃圾收集器管理的主要區域,也被稱爲“GC”堆,由於現在收集器基本採用分代收集算法,堆還可以分爲:新生代和老生代;細緻一點劃分:Eden空間、From Survivor空間、To Survivor空間等。
    (4)、方法區
    方法區與Java堆一樣是各個線程共享內存區域,用於存儲已經被虛擬機加載的類的信息、常量、靜態變量、即時編譯器編譯的靜態代碼等數據。很多人把方法區稱爲“永久代“,本質上兩者並不等價,僅僅是因爲Hotspot虛擬機的設計團隊選擇把GC分代收集擴展至方法區,或者用永久代來實現方法區而已。這樣垃圾收集器就可以像管理Java堆一樣管理這部分內存,可以省去專門爲方法區編寫內存管理代碼工作(Java8以後放棄永久代開始使用native Memory,字符串常量池也移出永久代(Java7))。垃圾收集行爲在該區域比較少出現,主要回收目標是常量池的回收和對類型的卸載,一般來說回收成績都不令人滿意,尤其是類型卸載,條件相當苛刻。
    運行時常量池:是方法區的一部分,Class文件中除了有類的版本字段、方法、接口等描述信息外,還有一項信息時常量池,用於存放編譯期存放的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。運行時常量池相對於Class文件常量池的另一個重要特徵是具備動態性Java語言並不要求常量一定只有編譯期才產生,也是並非預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可以將新的常量放入池中,被開發人員利用的最多的就是String類的intern()方法。
    (5)、直接內存
    直接內存並不是Java虛擬機定義的內存區域,但是卻被頻繁使用,也會出現OutOfMemoryError異常。jdk1.4中的NIO(new Input/Output)類,引入了一種基於通道和緩衝區的I/O方式,它可以使用native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作爲這塊內存的引用進行操作。這樣在一些場景中顯著提高性能,因爲避免了Java堆和Native堆中來回複製數據。
    (6)HotSpot對象虛擬機探祕
探討HotSpot虛擬機在Java堆中對象分配、佈局和訪問的全過程
    1>對象的創建(普通Java對象,除數組和Class對象等)
    類加載檢查:當虛擬機遇到一條new指令時,首先會檢查這個指令的參數是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已經被加載、解析、和初始化過。如果沒有,那必須先執行相應的類加載過程。
    分配內存:對象所需的內存的大小在類加載完成後便可完全確定。分配內存的任務等同於把一塊確定大小的內存從堆中劃分出來,分配方案有兩種:
        指針碰撞:如果Java堆中的內存是規整的,即所有用過的內存都放在一邊,空閒的內存放在另一邊,中間分界點有一個指針作爲指示器。分配內存就是把指針向空閒區域那邊挪動一塊與對象大小相等的距離。
        空閒列表:如果Java堆中內存不是規整的,即空閒去和已使用內存去互相交錯,則虛擬機需要維護一張列表,記錄那些可用,從空閒去找到一塊足夠大的空閒區劃分給對象實例,同時更新列表。
Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定:使用Serial、ParNew等帶Compact過程的收集器時,系統採用指針碰撞分配算法,而使用CMS這種基於Mark-Sweep算法收集器時,採用空閒列表法。
    除了分配內存,還有一個要考慮的問題是:對象創建在虛擬機中是非常頻繁的行爲,即使是修改一個指針所指向的位置,在併發情況下也不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存情況。
    解決的方案有兩種:
    對分配內存空間的動作進行同步處理:虛擬機採用CAS配上失敗重試的方式保證操作的原子性。
把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝區(TLAB),線程需要分配內存就在該線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖。
    把分配的內存空間(初始化對象的實例字段零值或默認值 如int 爲0)初始化爲零值(不包括對象頭):這一步操作保證了對象的實例字段在java代碼中可以不賦初始值就直接使用,程序能訪問這些字段的數據類型所對應的零值。
    虛擬機對對象進行必要的設置:比如這個對象是哪個類的實例,如何才能找到類的元數據信息、對象的哈希碼、對象的gc分代年齡等信息。這些信息存儲在對象的對象頭中。
從虛擬機的角度看,一個新的對象已經產生了,但從java程序的角度看,對象創建纔剛剛開始–《init》方法還沒有執行,所有的字段還都是零值(前面內存初始化)。一般來說(有字節碼中是否跟隨invokespecial指令所決定),執行new指令後執行《init》方法:把對象按照程序員的意願初始化,這樣一個真正可用的對象下完全生產出來。
    2>對象的內存佈局
    在HotSpot中,對象在內存中存儲的佈局可以分爲三塊區域:對象頭、實例數據、對齊填充。
    在HotSpot虛擬機中對象的頭部分爲兩部分:
第一部分是存儲對象自身的運行時數據:哈希碼、GC分代年齡、鎖狀態標制、線程池有鎖、偏向線程ID、偏向時間戳等。這部分信息的長度在32位和64位的虛擬機中分別爲32bit和64bit。官方稱之爲“Mark Word”,由於對象需要存儲的運行時數據很多,已經超出了32位、64位位圖所能表示的限度,綜合其他原因,Mark word 被設計成非固定的數據結構,以便在極小的空間存儲儘量多的信息,根據對象那個的狀態複用自己的空間 。比如對象未被鎖定的狀態下,32位空間25位存儲哈希碼,4位存儲對象分代年齡、2位用於存儲標誌位,1bit固定位0。其他狀態下就不一樣的,比如可能就不是25位表示哈希碼值。
第二部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據並一定要經過對象本身,另外一個對象如果是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因爲普通java對象的元數據可以確定java對象的大小,但是從數組的元數據中卻無法確定數組的大小。
    實例數據部分:
    這部分是對象真正存儲的有效信息,也就是在程序中所定義的各種類型的字段內容。無論是從父類繼承下來,還是在子類中定義,都需要記錄起來。這部分的存儲順序會受到虛擬機分配策略參數和字段在Java源碼中定義順序的影響。
    對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起着佔位符的作用,由於hotspot VM 的自動內存管理系統要求對象起始地址必須是8字節的整數倍,即對象的大小必須是8字節的整數倍。對象頭部分正好是8字節的倍數(1倍或者兩倍),因此實例數據沒有對齊時,需要對齊填充來補全。
    3>對象的訪問定位
    建立了對象是爲了使用對象,我們Java程序需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在Java虛擬機規範中只規定了一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位,訪問堆中的對象的具體位置,所以對象的訪問方式取決於虛擬機實現而定的。
主流的方式有兩種:使用句柄和直接指針:
使用句柄:Java堆中將會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據類型數據各自的具體地址。
這裏寫圖片描述
從圖片中可以看出很多信息,句柄池的組成,java堆的劃分、本地變量表等。
使用直接指針:使用直接指針,那麼指針中的地址就是Java實例對象在堆中的地址,而實例對象要必須指向其對象類型,因此在實例對象所在的地址中數據就必須包含指向方法區中的類型對象數據的指針。
這裏寫圖片描述
區別:
使用句柄時當對象移動時(垃圾收集時)只需要修改句柄中的實例數據指針,而reference不需要修改;
使用直接指針好處是速度快,節省一次指針定位時間,由於java訪問對象十分頻繁,因此積少成多也是客觀的執行成本;
Sun HotSpot使用的是直接指針方式
    4、虛擬機棧和本地方法棧溢出
描述兩種異常:
線程請求的棧的深度大於虛擬機所允許的最大深度,就拋出StackOverflowError異常;
虛擬機在擴展棧時無法申請足夠的內存空間,則拋出OutOfMemoryError異常。
當棧空間無法分配時,到底是內存太小還是已經使用的棧空間太大(棧幀太大)?
由於系統分配內存資源有限,總容量-最大堆容量-最大方法區容量=所有線程可使用的容量,而這些容量是有限的,所以在建立線程過多的情況下導致的內存溢出,且不能減少線程數或更換64位虛擬機的情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。(方法區不能減少?猜想:方法區中數據比較穩定,且垃圾回收比較少(主要是廢棄的常量和無用的類),所以一般只會增加,動態改變少)

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