JVM內存管理
與其他高級語言(例如C和C++)不同,在Java中我們基本上不會顯式地調用分配內存的函數,我們甚至都不用關心哪些程序指令需要分配內存,需要分配多少的內存。因為在JVM當中,內存的分配和回收都已經由JVM自動完成了,很少會遇到像C++程序中OutOfMemoryError這樣的內存泄露問題。
雖然Java語言的這些特點在很大程度上減少了開發人員的麻煩,但是我們最好還是了解一下Java是如何管理內存的,當我們真正遇到內存泄漏問題時,能夠根據報錯信息迅速找到內存泄漏的代碼并成功dubug!
本章將會從以下幾個方面介紹JVM的內存管理機制。
從操作系統層面介紹物理內存的分配和Java運行的內存分配之間的關系。
Java如何使用從物理內存申請下來的內存,以及如何來劃分他們。
如何分配和回收內存
物理內存和虛擬內存
所謂物理內存就是我們通常所說的RAM(隨機存儲器,內存條)。在計算機中,還有一個存儲單元叫寄存器(在CPU中),它用于存儲計算單元執行指令的中間結果。寄存器的大小決定了一次計算可使用的最大數值。通常操作系統管理內存的申請空間是按照進程來管理的,即每個進程擁有一段獨立的物理空間地址,每個進程之間不會相互重合,操作系統也會保證每個進程只能訪問自己的內存空間。
上面所說的進程的內存空間的獨立主要是指邏輯上獨立,這個獨立是由操作系統來保證的。但是真正的物理空間是不是只能由一個進程來使用就不一定了。因為隨著程序越來越大,物理內存無法滿足程序的需求,在這種情況下就有了虛擬內存的出現。
虛擬內存的出現使得多個進程在同時運行時可以共享物理內存,這里的共享只是空間上的共享,在邏輯上他們仍是不能相互訪問的。虛擬地址不但可以讓進程共享物理內存,提高內存的利用率,同時還能夠擴展內存的地址空間。如一段虛擬地址可能被映射到一段物理內存、文件或者其他可以尋址的存儲上。一個進程在不活動的情況下,操作系統將這個線程的數據從物理內存移到磁盤文件中,而真正高效的物理內存留給正在活動的程序使用。當喚醒了一個很長時間沒有使用到的程序時,操作系統又會把磁盤上的數據重新交互到物理內存中,但是我們要避免這種情況經常出現,因為數據的頻繁交互會影響計算機的性能。
用戶空間和內核空間
一個計算機通常具有一定大小的內存空間(平常所說的內存條),比如4GB,8GB,16GB等,但是程序并不能完全使用這些地址空間。因為這些地址空間被分為用戶空間和內核空間,程序只能使用用戶空間。
內核空間是指操作系統運行時所使用的用于程序調度,虛擬內存的使用和連接硬件資源的內存空間。為何需要劃分內核空間和用戶空間呢?也是出于安全的考慮,類似于上文所說的每個線程都獨立使用屬于自己的內存,互不干擾一樣,用戶程序也不能訪問操作系統本身所使用的內存空間。
但如果用戶程序也有訪問硬件資源的需求時怎么辦呢(例如網絡連接)?可以通過調用操作系統所提供的接口來實現,這個調用操作系統接口的過程也就是系統調用。每一次系統調用都會存在兩個內存空間的切換,例如網絡連接就是一次系統調用,通過網絡傳輸的數據先是從內核空間接收到遠程主機的數據,然后再從內核空間復制到用戶空間,供用戶程序使用。然而,這種數據在內核空間和用戶空間之間的轉化很費時,雖然保證了程序運行的安全性和穩定性,但是也犧牲了一部分效率。現在已經出現了很多其他技術能夠減少這種從內核空間到用戶空間的數據復制的方式,如Linux系統提供的sendfile文件傳輸方式。
內核空間和用戶空間大小如何分配也是一個問題,要根據計算機的工作重心分配不同大小的內核空間和用戶空間。
用戶態和內核態(擴展)
由于需要限制不同的程序之間的訪問能力,防止他們獲取別的程序的內存數據,或者獲取外圍設備的數據,CPU劃分出兩個權限等級----用戶態和內核態。
用戶態:CPU只能受限的訪問內存,而且不允許訪問外圍設備,占用CPU的能力被剝奪,CPU資源可以被其他程序獲取。
內核態:CPU可以訪問內存所有的數據,包括外圍設備例如硬盤和網卡等,CPU也可將自己從一個程序切換到另一個程序。
JVM內存結構
JVM是按照運行時數據的存儲結構來劃分內存結構的,JVM在運行Java程序時,將他們劃分成幾種不同格式的數據,分別存儲在不同的區域,這些數據統一稱為運行時數據(Runtime Data)。在JVM中,將Java運行時數據劃分為以下六種:
- PC寄存器數據
- Java棧
- 堆
- 方法區
- 本地方法區
- 運行時常量池
PC寄存器
PC寄存器嚴格來說是一種數據結構,(PC寄存器在CPU當中,線程是CPU分配的基本單位,每個線程都擁有自己的PC寄存器)它用于保存當前正在執行的程序的內存地址。由于Java程序是支持多線程執行的,所以不可能保證每個線程都按照線性執行下去,可能線程1執行到一半cpu資源被線程2奪去,線程1發生中斷。被中斷的線程當前執行到哪條內存地址必然要保存下來,以便它被恢復執行時可以從中斷處繼續執行,這個用于保存線程當前正在執行的內存地址的數據結構就是PC寄存器,它就像一個記錄員一樣記錄下哪個線程執行到哪條指令了。
Java棧
Java棧總是與Java線程關聯在一起,每創建了一個線程時,JVM就會為這個線程創建一個對應的Java棧。這個Java棧中又會含有多個棧幀(Frames),每個棧幀都會對應一個方法,每個棧幀會含有一些內部變量(方法內部定義的變量)、操作棧和方法返回值等信息。
每當一個方法執行完成時,這個棧幀就會彈出棧幀的元素作為這個方法的返回值,并清除這個棧幀,Java棧的棧頂的棧幀就是這個當前正在執行的活動棧,也就是當前正在執行的方法,PC寄存器也會指向這個棧幀的內存地址。只有這個活動的棧幀的本地變量可以被操作棧使用,當在這個棧幀中調用另外一個方法時,與之對應的一個新的棧幀又被創建,這個新創建的棧幀又被放在Java棧的頂部,變為當前的活動棧幀。同樣現在只有這個棧幀的本地變量能夠被操作棧使用,當這個棧幀中所有的指令執行完時這個棧幀移出Java棧,剛才的那個棧幀又變為活動棧幀,前面的棧幀的返回值又變為這個棧幀的操作棧中的一個操作數。如果前面的棧幀沒有返回值,那么當前棧幀的操作棧的操作數沒有變化。
堆
堆是存儲Java對象的地方,它是JVM管理Java對象的核心區域,所有new出來的對象都存儲在這里。堆也是我們的應用程序與內存關系最密切的存儲區域。
每一個存儲在堆中的Java對象都會是這個對象的類的一個副本,它會復制包括繼承自它父類的所有靜態屬性。由于堆是被所有Java線程所共享的,所以對它的訪問要注意同步問題,方法和對應的屬性都要保持一致性。Java堆可以處于物理上的不連續的內存空間之中,只要邏輯上是連續的。
方法區
JVM方法區是用來存儲類結構信息的地方。當通過類加載器將一個class文件解析成JVM能識別的幾個部分時,這些不同的部分會被存儲在不同的數據結構當中,其中的常量池、域、方法數據、方法體、構造函數,包括類中的專有方法、接口初始化等都存儲在這個區域。
方法區也屬于堆的一部分,也就是我們通常說的Java堆中的永久區,這個區域可以被所有的線程共享,并且它的大小可以通過參數來設置。
對于方法區來說,它所存儲區域的大小一般在程序啟動后的一段時間就是固定的了,JVM運行一段時間后,需要加載的類通常都已經加載到JVM當中了。但是有一種情況需要注意,那就是項目中存在動態編譯的情況(Java語言是編譯完再統一執行的,那么如果一個java程序在執行時調用了另外一個程序或者class文件,那么就要在執行的過程中編譯這個新的被調用的文件,這就叫做動態編譯),那么此時需要觀察方法區的大小能否滿足類存儲。
本地方法棧
本地方法棧是為JVM運行Native方法準備的空間,它和前面介紹的Java棧的作用類似,只不過棧所存儲的是JVM所調用的Native方法(JVM會使得Java程序運行時調用本地的方法,這就是native方法)。由于Native方法很多都是由C語言實現的,所以它通常又叫C棧。除了Native方法外,在JVM利用JIT技術時會將一些JAVA方法重新編譯成本地的機器碼,這些機器碼也存儲在本地方法棧當中。
在JVM規范中沒有對這個區域嚴格限制,它可以由不同的JVM實現者自由實現,但是它和其他存儲區一樣也會拋出OutofMemoryError和StackOverflowError異常。
JVM內存分配區域
通常的內存分配策略
靜態內存分配
棧內存分配
-
堆內存分配
靜態內存分配是指在程序編譯時就能確定每個程序在運行時的存儲空間需求,因此在編譯時就可以給他們分配固定的內存空間。這種分配策略不允許程序中存在可變數據結構(鏈表和動態數組等)和嵌套遞歸語句的出現,這些都會導致編譯時無法準確得計算存儲空間需求。
棧式內存分配也可稱作動態存儲分配,是由一個類似于棧的運行棧來實現的。和靜態內存分配相反,在棧式內存方案中,程序對于數據區域的需求在編譯時是完全未知的,只有在運行時才能知道,但是規定在運行中進入一個程序模塊時,必須知道該程序模塊所需要的數據區大小才能夠為其分配內存。和我們熟悉的數據結構當中的棧一樣,棧式內存分配按照先進后出的原則進行分配。
除了在編譯時能確定數據的存儲空間(靜態內存分配)和運行時在程序入口處確定數據的存儲空間(棧式內存分配)這兩種外,還有一種情況就是當程序真正運行到相應代碼時才會知道空間的大小,這時我們就需要堆這種分配策略。
這幾種內存分配策略中,堆分配策略是最自由的,但是這種分配策略對操作系統和內存管理程序來說是一種挑戰。另外,這個動態的內存分配是在程序運行時才執行的,它的運行效率也是比較差的。
Java中的內存分配詳解(從分配區域角度)
從JVM內存結構來看。JVM內存主要基于兩種,分別是堆和棧。
Java棧的分配是和線程綁定在一起的,當我們創建一個線程時,JVM會為這個線程創建一個新的Java棧,一個線程的方法的調用和返回對應于這個Java棧的壓棧和出棧。每當線程調用了一個新的方法時,會在這個棧的頂部創建一個新的棧幀數據結構,這個棧幀自然成為當前幀。這個棧幀會保留這個方法的一些元信息,比如方法中定義的局部變量,正常方法返回以及異常處理機制等。棧中主要存放一些基本類型的變量數據(int,short,long,byte,float,double,boolean,char)和對象引用(句柄)。棧的優點是存取速度比堆要快,僅次于寄存器,棧數據可以共享。缺點是,存在棧中的數據大小和生存期必須是確定的,這也導致其缺乏靈活性。 (棧內存是JVM自動管理的,不需要GC回收機制,棧的內存隨著函數的開始執行和結束自動分配和銷毀)。
每個Java應用都唯一對應一個JVM實例,每個實例唯一對應一個堆。應用程序在運行中所創建的所有類實例或者數組都存放在這個堆中,并由應用程序所有的線程共享。建立一個對象時在堆和棧兩個地方都要分配內存,在堆中分配的內存實際建立這個對象,在棧中分配的內存只是一個指向這個堆對象的指針。Java的堆是一個運行時數據區,堆是由GC機制來負責回收的。堆的優勢是可以動態地分配內存大小,生存期也不必事先告訴編譯器,Java的垃圾收集器會自動收走這些不再使用的對象.但缺點是,由于要在運行時動態分配內存,存取速度較慢。
綜上,從堆和棧的功能和作用來通俗的比較,堆主要用來存放對象,棧主要用來執行程序!
Java中的內存分配詳解(從硬件角度)
兩種方式:
1.內存絕對規整(正在使用的區域和沒有使用的區域分別在兩邊),用指針作為分界點的指示器,移動指針位置即可。——————“指針碰撞”方式
2.內存不規整,虛擬機必須維護一個列表,記錄哪些內存塊可以用,分配時在列表找到足夠大空間分配給對象實例并更新維護列表。——————空閑列表
JVM內存回收策略
Java語言和其他語言一個很大的不同之處就是Java開發人員不需要了解內存這個概念,不像C或C++當中由malloc這種語法直接操作內存,在Java當中沒有什么語法和內存直接有聯系。但是任何語言都離不開內存的申請和回收,那Java又是如何做到的呢?就Java來說,內存的分配和回收主要有兩種:一種是靜態內存分配,另一種是動態內存分配。
靜態內存分配和回收
在上文內存分配策略部分有講到,在Java中靜態內存分配是指在Java被編譯時就已經能夠確定需要的內存空間,當程序被加載時系統把內存一次性分配給它,這些內存不會再程序執行時發生變化,直到程序執行結束時內存才被回收。在Java的類和方法中的局部變量包括原生數據類型(int,char,long等等)和對象的引用都是靜態分配內存的,這部分都儲存在棧當中,如下邊這段代碼。
public void staticData(int arg)
{
String s="miao";
long l=1;
Long lg=1L;//注意這里的Long的L大寫,是基本類型long的封裝類Long,=后邊的l表示lg是一個長整型,要不會默認為int類型的‘1’
Object o=new Object();
Integer i=0;
}
其中參數arg、l是基本數據類型,s,o,i和lg是指向對象的引用。在Javac編譯時就已經確認了這些變量的靜態內存空間。其中arg會分配四個字節,long會分配8個字節,String,Long,Object和Integer是對象類型,他們的引用會占用4個字節,所以整個方法占用的靜態內存空間是4+8+4+4+4+4=28個字節。
靜態內存空間當這段代碼運行結束時回收,根據之前文章(JVM體系結構與工作方式)的介紹,靜態內存空間是在棧上分配的,當這個方法結束時,對應的棧幀被銷毀,分配的靜態內存空間自動回收。
動態內存分配和回收
前面的例子中變量lg存儲的值雖然和l變量一樣,但是他們存儲的位置是不一樣的,后者是基本數據類型,存儲在Java棧當中,方法執行結束就會被回收,前者是對象類型,存儲在Java堆當中,他們是可以被共享的。變量l和lg的內存空間大小顯然也是不一樣的,l在java棧中被分配8個字節空間,lg在棧中被分配4個字節的地址指針空間,這個地址指針指向這個對象在堆中的地址。很顯然在堆中long類型所占用的空間肯定比8個字節大,所以在代表相同數字時,Long所占用的空間要比long大的多。
在Java中對象的內存空間是動態分配的,所謂的動態分配就是在程序執行時才知道要分配的存儲空間大小,而不是在編譯時就能夠確定的。lg代表的是Long對象,只有JVM在解析Long類時才知道這個類中有哪些信息,然后才能根據這些信息分配相應的存儲空間存儲相應的值。等到這個對象不再使用時會被JVM的GC機制回收。
那么如何確定這個對象什么時候不被使用,又如何來回收它們,這正是JVM很重要的一個組件————垃圾收集器要解決的問題。
如何檢測垃圾
JVM中的堆和方法區主要用來存放對象(方法區中也存儲了一些靜態變量和全局變量等信息),那么我們要使用GC算法對其進行回收時首先要考慮的就是該對象是否應該被回收,我們需要將不被使用的對象標記出,以便GC回收.主要有引用計數法和可達性分析算法。
引用計數法
在對象頭處維護一個counter,對象每被引用一次,counter++。如果對該對象的引用失聯,則計數器自減,當count為0時,表明該對象已經被廢棄,不處于存活狀態,此時所占用的內存區域可以被GC回收。但是引用計數器存在兩個比較明顯的錯誤:1.這種方式無法區分軟、虛、弱、強引用類型。2.會造成死鎖,假設兩個對象相互引用則始終無法釋放counter,會造成死鎖永遠不會GC。
可達性分析法
通過一系列為GC roots的對象作為起點,從這些節點開始向下搜索,搜索所走過的路徑成為引用鏈,當一個對象到GC roots沒有任何引用鏈相連時,則證明該對象是不可達的。它會被暫時標記上并且進行一次篩選,篩選的條件為是否有必要執行finalize()方法(在被GC回收之前需要執行的一個方法,在這個方法中要指定在一個對象被回收之前必須執行的操作)。如果被判定有必要執行finalize()方法,就會進入F-Queue隊列中,并有一個虛擬機自動建立的,低優先級的線程去執行它。稍后GC將對F-Queue中的對象進行第二次小規模的標記,如果此時仍是不可達的,那基本上就是真的被回收了。
哪些對象會被選作根節點呢?
- 虛擬機棧中引用的對象
- 方法區中類靜態屬性所引用的對象
- 方法區中常量引用的對象
- 本地方法區中native方法引用的對象
JVM在做垃圾回收時會檢查堆中的所有對象是否都會被這些根對象直接或間接引用,能夠被引用的對象就是活動對象,否則就可以被垃圾回收器回收。
再談引用
1.強引用:類似于Object obj=new Object(),只要強引用還在,垃圾回收器永遠不會回收掉被引用的對象。
2.軟引用:在系統要發生內存溢出異常之前,將會把這些對象列進回收范圍之中進行第二次回收。
3.弱引用:被弱引用的對象只能生存到下一次垃圾收集發生之前,不論到時內存是否足夠。
4.虛引用:唯一目的是能在這個對象被收集器回收時收到一個系統通知。(一個對象是否有虛引用,不會對其生存時間構成影響。)
垃圾收集算法
1.標記——清除算法(老生代)
對每塊內存區域進行檢測,如果需要回收則打上標記。不足:標記和清楚兩個過程的效率都不高;空間問題,會產生大量不連續碎片,當分配較大對象時,無法找到足夠連續內存而不得不提前觸發另一次GC操作。
2.標記——復制算法(新生代,新生代中的對象有98%都是“朝生夕死”的)
80% Eden,10% Survivor,10% Survivor 每次只使用Eden和另一塊Survivor區域,GC之后對存活的對象進行判斷,存活時間較長的移入老生代,存活期短的移入另一塊Survivor區域(區域A),然后再將之前的那塊Survivor區域(區域B)和Eden區域整塊清除。在使用這塊內存時,使用Eden區域和被移入的Survivor區域(區域A)。缺點:每次只能使用部分內存區域。
3.標記——整理算法(老生代)
讓所有存活的對象都向一端移動,直接清除邊界外的內存區域。