Java基礎學習總結(64)——Java內存管理


本文介紹的Java虛擬機(JVM)的自動內存管理機制主要是參照《深入理解Java虛擬機》(第2版)一書中的內容,主要分為兩個部分:Java內存區域和內存溢出異常、垃圾回收和內存分配策略。因此我也會分為兩個部分來講解,但這並不代表這兩個部分在JVM中是分割的。反之,其實這兩個部分關聯性很強。只不過為了便於介紹,所以我才分開來講。在介紹它們詳細內容之前,我首先會給出兩幅思維導圖以便讀者可以了解一下里面所包含的內容,然后我會根據思維導圖中的知識點一一為大家進行介紹。

第一部分 Java內存區域和內存溢出異常

Java內存區域與內存溢出異常

下面我將對圖中所涉及到的部分進行介紹

運行時數據區域

由於直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規范中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致內存溢出異常(OutOfMemoryError)出現,所以也放到這部分進行介紹。

Java虛擬機在執行Java程序的過程中會把它所管理的內存划分為若干個不同的數據區域。這些區域都有各自的用途以及創建和銷毀的時間。有的區域 (線程共享的數據區域) 隨着虛擬機的啟動而存在,有的區域 (線程隔離的數據區域) 則要依賴用戶線程的啟動和結束來創建或者是銷毀。

程序計數器

程序計數器(Program Counter Register) 是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。學過《計算機組成原理》這門課之后我們知道----在計算機中,其實程序計數器就是一個寄存器,依據不同計算機細節的差異,它可以存放當前正在被執行的指令,也可以存放下一個要被執行的指令。由此,我們可以對 “當前線程所執行的字節碼的行號指示器” 有更好的理解。

在虛擬機的概念模型中,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令。由於Java虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個內核)都只會執行一條線程中的指令。因此為了線程切換之后能夠恢復到正確的執行位置,每條線程都需要擁有一個獨立的程序計數器,各條線程之間計數器互補影響,獨立存儲。所以程序計數器是線程私有的內存(線程隔離)。

如果線程正在執行的是一個Java方法,這個計數器記錄的就是正在執行的虛擬機字節碼指令的地址;如果正在執行的是Native方法,那么這個計數器的值就為空(Undefined)。此內存區域是唯一一個在Java虛擬機規范中沒有規定任何OutOfMemoryError情況的區域。

Java虛擬機棧

和程序計數器一樣,Java虛擬機棧(Java Virtual Machine Stack)也是線程私有的,即它的生命周期和線程的相同。虛擬機棧描述的是Java方法執行的內存模型: 每個方法在執行時都會創建一個棧幀(Stack Frame)用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息 。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。

我們常常說的棧內存其實就是現在講的虛擬機棧,或者說是虛擬機棧中局部變量表部分。

局部變量表 存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等同於對象本身,可能是指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。

其中64位長度的long和double類型的數據會占用2個局部變量空間(Slot),其余數據類型只占用1個。局部變量表所需要的內存空間在編譯時期完成分配。當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。

本地方法棧

本地方法棧(Native Method Stack)與虛擬機棧所發揮的作用是非常相似的,它們之間的區別就是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的Native方法服務。其實虛擬機規范中對本地方發棧中方法所使用的語言、使用方式以及數據結構都沒有強制規定,因此具體的虛擬機可以自由地實現它。甚至在有的虛擬機(如Sun HotSpot虛擬機)直接就把本地方法棧和虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧區域也會拋出StackOverflowError和OutOfMemory異常。

Java堆

對於大多數應用來說,Java堆(Java Heap)是Java虛擬機所管理的內存中最大的一塊。 Java堆是被所有線程共享的一塊數據區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這里分配內存 。 但是隨着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也逐漸變得不是那么“絕對” 。

Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為“GC堆”。Java堆還可以細分為新生代和老年代等等。這一部分在講垃圾回收算法的時候還會繼續介紹。

根據Java虛擬機規范規定,Java堆可以處於物理上不連續的內存空間中,即只要邏輯上是連續的即可,就像我們磁盤空間一樣。在實現時,可以固定大小,也可是可拓展的,主流的虛擬機都是按照可拓展來實現的(通過-Xmx和-Xms來控制)。如果在堆中沒有內存完成實例分配,並且堆也無法繼續拓展時,將會拋出OutOfMemortError異常。

方法區

方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。雖然Java虛擬機將其描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆)。目的是與Java堆區分開來。(以前很多人把方法區稱為永久代,現在JDK1.8中已經用元數據區域取代了永久代)。

運行時常量池

運行時常量池是方法區(Runtime Constant Pool)的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息就是常量池,用於存放編譯時期生成的各種字面量和符號引用,這部分內容將在類加載后進入方法區的運行時常量池中存放。Java虛擬機對於運行時常量池沒有做任何細節的要求。

運行時常量池具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。

直接內存

由於直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規范中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致內存溢出異常(OutOfMemoryError)出現,所以也放到這部分進行介紹。

顯然,本機直接內存的分配不會受到Java堆大小的限制。但是肯定還是會受到本機總內存大小以及處理器尋址空間的限制。管理員在配置虛擬機參數時,會根據實際內存設置-Xmx等參數信息,但經常忽略直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操作系統級的限制),從而導致動態拓展時出現OutOfMemoryError異常。

對象的創建方式

在Java程序當中每時每刻都有對象被創建出來。在語言層面上,創建對象通常僅僅是使用一個new關鍵字而已,而在虛擬機中,對象(僅限於普通Java對象)的創建又是怎樣一個過程呢?

虛擬機遇到一條new指令時,首先將去檢查這個指令的參數能否在常量池中定位到一個類的符號引用。並且檢查這個符號引用代表的類是否已經被加載、解析和初始化過。如果沒有,那就先執行類加載的過程(關於類加載過程在后面的博客中會進行介紹)。

在類加載檢查通過后,接下來虛擬機將為新生對象分配內存。對象所需內存的大小在類加載完成之后便可完全確定(在對象的內存布局部分會介紹)。

為對象分配空間的任務等同於把一塊確定大小的內存從Java堆中划分出來。有兩種方式:

  • 指針碰撞:假設Java堆中內存是規整的,所有用過的內存都放在一邊,空閑的內存放在另一邊,中間放着一個指針作為分界點的指示器,那分配內存就是將指針往空間空間挪動一段與對象大小相等的距離,這種分配內存的方式就被稱為指針碰撞;
  • 空閑列表:如果Java堆中的內存並不是規整的,已經使用的內存和空閑內存相互交錯,那就沒有辦法簡單地使用指針碰撞的方法進行內存分配了。虛擬機此時必須維護一個列表用來記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間為分配給對象實例,並且更新列表上的記錄,這種分配方式就被稱為空閑列表。

選擇哪一種分配方式由Java堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。

除了如何划分可用空間之外,還要考慮的一個問題就是對象創建在虛擬機中是非常頻繁的行為,即使是僅僅修改一個指針的位置,在並發的情況之下也並不是線程安全的----可能出現正在給對象A分配內存,指針還沒來得及修改,對象B同時使用了原來的指針來分配內存的情況。解決方案也有兩種:

  • 一種是對分配內存空間的動作進行同步處理----實際上虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性;
  • 另一種是把內存分配的動作按照線程划分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱為本地線程緩沖分配(Thread Local Allocation Buffer,TLAB)。哪個線程需要分派內存,就在哪個線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。

內存分配完成之后,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),如果使用TLAB,則此工作可以提前至TLAB分配時進行。這一步操作保證了對象的實例字段在Java代碼中可以不賦初值就可以直接使用,程序能訪問到這些字段的數據類型所對應的零值。

接下來,虛擬機要對對象進行一些必要的設置,比如這個對象是哪個類的實例、如何才能找到類的元數據、對象的哈希碼、對象的GC分代年齡等信息。

在上面的工作完成之后,從虛擬機的角度來看,一個新的對象已經產生了。但從Java程序的角度來看,對象創建才剛剛開始----<init>方法還沒執行,所有的字段都還為零。一般來說(由字節碼中是否跟隨invokespecial指令所決定),執行new指令之后會接着執行<init>方法,把對象按照程序員的意願進行初始化,這樣一個真正的對象才算創建完成。

對象的內存布局

對象頭

  • 第一部分:用於存儲自身的運行時數據,包括哈希碼、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等。
  • 第二部分:類型指針,即對象指向它的元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。不過並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不一定要經過對象本身。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因為虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中卻無法確定數組的大小。

實例數據

實例數據部分是對象真正存儲的有效信息,也是在程序代碼中所定義的各種類型的字段內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的存儲順序會受到虛擬機分配策略參數和字段在Java源碼中定義順序的影響。

對齊填充

對齊填充並不是必然存在的,也沒有特殊的含義,它僅僅起着占位符的作用。由於HotSpot VM的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說,就是對象的大小必須是8字節的整數倍。而對象頭部分正好是8字節的倍數(一倍或者兩倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

對象的訪問定位

建立對象是為了使用對象,我們的Java程序需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在Java虛擬機規范中只規定了一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中的對象的具體位置,所以對象訪問方法也是取決於虛擬機的實現而決定的。目前主流的訪問方式有使用句柄和直接指針兩種。

通過句柄訪問對象

通過句柄訪問對象

優點:reference存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要改變;

缺點:增加了一次指針定位的時間開銷。

通過直接指針訪問對象

通過直接指針訪問對象

優點:節省了一次指針定位的開銷

缺點:在對象被移動時reference本身需要被修改。

常見的內存溢出異常

Java堆溢出

Java堆用於存儲對象實例,只要不停地創建對象,並且保證GC Roots到對象之間有可達路徑類避免垃圾回收機制清除這些對象對象,那么在對象數量達到最大堆的容量限制后就會產生內存溢出異常。

虛擬機棧和本地方法棧溢出

關於虛擬機棧和本地方法棧,在Java虛擬機規范中描述了兩種異常:

  • 如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常;
  • 如果虛擬機在擴展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常。

這里把異常分為兩種情況,看似較為嚴謹,但卻存在着一些互相重疊的地方:當棧空間無法繼續分配時,到底是已使用的棧空間太大,還是內存太小,其本質上都只是對同一件事情的兩種描述而已。

方法區和運行時常量池溢出

本機直接內存溢出

第二部分 垃圾收集器與內存分配策略

垃圾收集器與內存分配策略

其實當我們在討論垃圾回收的時候,我們常常要思考垃圾收集(Garbage Collection)需要完成的三件事情:

  • 哪些內存需要回收?(What?)
  • 什么時候回收?(When?)
  • 如何回收?(How?)

那么對於Java虛擬機來說,垃圾收集主要是發生在哪些區域呢?

由於程序計數器、虛擬機棧、本地方法棧這三個區域是隨線程而生,隨線程而亡的;棧中的棧幀隨着方法的進入和退出有條不紊地執行着入棧和出棧操作,每一個棧幀中分配多少內存基本上都是在類結構確定下來時就已知的。因此這幾個區域的內存分配和回收策略都具備確定性,在這幾個區域就不需要過多考慮回收的問題。因為方法結束或者線程結束之后。這部分內存自然也就隨着回收了。

但是Java堆和方法區則不一樣,因為一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存可能也不一樣,我們只有在程序運行期間才能知道到底會創建哪些對象,這部分內存的分配是動態的,是不確定的。所以我們要針對這兩塊區域制訂合適的垃圾收集策略。因此,在后面我們提到的對內存進行垃圾回收,說的主要也是針對Java堆和方法區這兩塊區域。


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2021 ITdaan.com