1. 程式人生 > >java記憶體分配與溢位

java記憶體分配與溢位

  Java程式而言,Java虛擬機器有自動記憶體管理機制,不需要開發人員去手動釋放內空間,也不容易出現記憶體洩漏和溢位的問題,一切看起來都很完美。一旦出現記憶體洩漏和溢位方面的問題,如果不瞭解Java虛擬機器是怎麼樣使用記憶體的,那麼排查起來將困難。以往對記憶體的理解僅僅停留在棧、堆這兩個部分,其實Java虛擬機器的還有其他分割槽遠比這複雜。接下來將介紹Java虛擬機器主要的幾個區域及其作用、記憶體溢位。

  java虛擬機器在執行Java程式時會把其管理的記憶體劃分為若干個不同的資料區域,這些區域都有各自的用途、建立和銷燬時間。執行緒共享區域的資料區域隨著虛擬機器啟動而存在,執行緒隔離的資料區域

依賴執行緒的啟動而建立、執行緒結束而銷燬。

Java虛擬機器執行時資料區

程式計數器

  程式計數器是一塊較小的記憶體空間,它可以看成是當前執行緒執行的位元組碼的行號指示器。其實程式計數器就是一個暫存器用來存放當前正在被執行的指令,也可以存放下一個要被執行的指令。

  在虛擬機器的概念模型中,位元組碼直譯器工作時就是通過改變這個計數器的值來選擇下一條需要執行的位元組碼指令,由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理執行時間的方式實現的,在任何一個確定的時刻,一個處理器(一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後還能恢復到正確的執行位置,每條執行緒都需要擁有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,所以這部份記憶體區域我們稱之為執行緒私有記憶體,即執行緒隔離。

Java虛擬機器棧

   和程式計數器一樣,Java虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行時都會建立一個棧幀用來儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

  區域性變量表存放了編譯期可知的各種基本資料型別(boolean-1、byte-1、char-2、short-2、int-4、float-4、long-8、double-8),物件引用(reference型別,可能是一個物件起始地址的引用指標,也可能是指向一個代表物件的控制代碼或其他與此物件相關的位置)和returnAddress型別(指向一條位元組碼指令的地址)。區域性變量表所需要的記憶體空間在編譯時期完成分配。進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小。

本地方法棧

  本地方法棧是與虛擬機器棧所發揮的作用是非常相似的,他們之間的區別就是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。虛擬機器規範中對本地方法棧中方法所使用的語音、使用方式以及資料結構 都沒有強制規定,因此具體的虛擬機器可以自由地實現它。甚至在有的虛擬機器中直接將虛擬機器棧和本地方法棧合併為一個。和虛擬機器棧一樣,本地方法棧區也會丟擲StackOverflowError和OutOfMemory異常。

Java 堆

  對應大多數應用來說,Java堆是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時候建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的記憶體例項都在這裡分配記憶體。Java虛擬機器規範中的描述是:所有的物件例項以及陣列都要在堆上分配,但隨著JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化 ,所喲的物件都分配在堆上也漸漸變得不是那麼“絕對”了。

  Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱為GC堆,現在收集器基本採用分代收集演算法,所以Java堆中還可以細分為:新生代和老年代。根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,可以固定大小,也可是可拓展的,主流的虛擬機器都是按照可拓展來實現的(通過-Xmx和-Xms來控制)。如果在堆中沒有記憶體完成例項分配,並且堆也無法繼續拓展時,將會丟擲OutOfMemortError異常。

方法區

  方法區與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯後的程式碼等資料。雖然Java虛擬機器將其描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆)。目的是與Java堆區分開來。

  Java虛擬機器規範對方法區的限制非常鬆,除了和Java堆一樣,不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。相對而言,垃圾收集在這個區域是比較少出現的,這個區域記憶體回收的主要目標是針對常量池的回收和對型別的解除安裝。根據Java規範的規定,當方法區無法滿足記憶體分配需要時,將丟擲OutOfMemoryError異常。

執行時常量池

  執行常量池是方法區的一部分。class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯時期生成的各種字面量和符號引用,這部份內容將在類載入後進入方法區的執行時常量池中存放。

  執行時常量池具備動態性,Java語音並不要求常量一定只有編譯期才能產生,也就是並非預置入class檔案中常量池的內容才能進入方法區執行時常量池,執行時期也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲OutOfMemoryError異常。

 

直接記憶體

  由於直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致記憶體溢位異常(OutOfMemoryError)出現,所以也放到這部分進行介紹。顯然,本機直接記憶體的分配不會受到Java堆大小的限制。但是肯定還是會受到本機總記憶體大小以及處理器定址空間的限制。管理員在配置虛擬機器引數時,會根據實際記憶體設定-Xmx等引數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統級的限制),從而導致動態拓展時出現OutOfMemoryError異常。

 

物件的建立方式

  虛擬機器遇到一條new指令時,首先去檢查這個指令的引數能否在常量池中定位到一個類的符合引用。並且檢查這個符號引用代表的類是否已經被載入、解析和初始化過。如果沒有,那就先執行載入過程。在類載入完成後,虛擬機器將為新生物件分配記憶體。物件所需要的記憶體大小在類載入完成之後便可完全確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。

  為物件分配記憶體空間有兩種方式:

  指標碰撞:假設Java堆中記憶體是規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那分配記憶體就是將指標往空間空間挪動一段與物件大小相等的距離,這種分配記憶體的方式就被稱為指標碰撞;

   空閒列表:如果Java堆中的記憶體並不是規整的,已經使用的記憶體和空閒記憶體相互交錯,那就沒有辦法簡單地使用指標碰撞的方法進行記憶體分配了。虛擬機器此時必須維護一個列表用來記錄哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間為分配給物件例項,並且更新列表上的記錄,這種分配方式就被稱為空閒列表。   選擇哪一種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

 物件的記憶體佈局

  HotSpot虛擬機器中,物件在記憶體中儲存的佈局分為3塊區域:物件頭、例項資料、對齊填充。

  物件頭由兩部分資訊組成,第一部分用於儲存物件自身執行時的資料,如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。第二部分是型別指標,即物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項,並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,換句話說,查詢物件的元資料資訊並不一定要經過物件本身。另外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以通過普通Java物件的元資料資訊確定Java物件的大小,但是從陣列的元資料中卻無法確定陣列的大小。

  例項資料是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的儲存順序會受到虛擬機器分配策略引數和欄位在Java原始碼中定義順序的影響。

    對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8個位元組的整數倍,換句話說,就是物件物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的整數倍,因此,當物件例項資料部分沒有對齊時,就需要通過補齊填充來補全。

物件的訪問定位

  建立物件是為了使用物件,我們的Java程式需要通過棧上的reference資料來操作堆上的具體物件。由於reference型別在Java虛擬機器規範中只規定了一個指向物件的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中的物件的具體位置,所以物件訪問方法也是取決於虛擬機器的實現而決定的。目前主流的訪問方式有使用控制代碼和直接指標兩種。

  如果使用控制代碼的話,那麼Java堆中將會劃分一塊記憶體來作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中儲存的就是物件例項資料與型別資料具體地址資訊。優點:reference儲存的是穩定的控制代碼地址,在物件被移動時只會改變控制代碼中的例項資料指標,而reference本身不需要修改。缺點:增加了一次指標定位的時間開銷。

 

通過控制代碼訪問物件

  如果使用直接指標訪問方式最大的好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本。缺點:在物件被移動時reference本身需要被修改。

通過直接指標訪問物件

記憶體溢位

  堆溢位

  Java堆唯一的作用就是儲存物件例項,只要保證不斷建立物件並且物件不被回收,那麼物件數量達到最大堆容量限制後就會產生記憶體溢位異常了。

  虛擬機器棧和本地方法棧溢位

  Java虛擬機器規範中描述瞭如果執行緒請求的棧深度太深(換句話說方法呼叫的深度太深),就會產生棧溢位了。那麼,我們只要寫一個無限呼叫自己的方法,自然就會出現方法呼叫的深度太深的場景了。

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

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

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