java對象的生命周期


  要理解java對象的生命周期,我們需要要明白兩個問題,

 1、java是怎么分配內存的 ,2、java是怎么回收內存的。

 

喜歡java的人,往往因為它的內存自動管理機制,不喜歡java的人,往往也是因為它的內存自動管理。我屬於前者,這幾年的coding經驗讓我認識到,要寫好java程序,理解java的內存管理機制是多么的重要。任何語言,內存管理無外乎分配和回收,在C中我們可以用malloc動態申請內存,調用free釋放申請的內存;在C++中,我們可以用new操作符在堆中動態申請內存,編寫析構函數調用delete釋放申請的內存;那么在java中究竟是內存怎樣管理的呢?要弄清這個問題,我們首先要了解java內存的分配機制,在java虛擬機規范里,JVM被分為7個內存區域,但是規范這畢竟只是規范,就像我們編寫的接口一樣,雖然最終行為一致,但是個人的實現可能千差萬別,各個廠商的JVM實現也不盡相同,在這里,我們只針對sun的Hotspot虛擬機討論,該虛擬機也是目前應用最廣泛的虛擬機。

  虛擬器規范中的7個內存區域分別是三個線程私有的和四個線程共享的內存區,線程私有的內存區域與線程具有相同的生命周期,它們分別是: 指令計數器、 線程棧和本地線程棧,四個共享區是所有線程共享的,在JVM啟動時就會分配,分別是:方法區、 常量池、直接內存區和堆(即我們通常所說的JVM的內存分為堆和棧中的堆,后者就是前面的線程棧)。接下來我們逐一了解這幾個內存區域。

 

    1 指令計數器。我們都知道java的多線程是通過JVM切換時間片運行的,因此每個線程在某個時刻可能在運行也可能被掛起,那么當線程掛起之后,JVM再次調度它時怎么知道該線程要運行那條字節碼指令呢?這就需要一個與該線程相關的內存區域記錄該線程下一條指令,而指令計數器就是實現這種功能的內存區域。有多少線程在編譯時是不確定的,因此該區域也沒有辦法在編譯時分配,只能在創建線程時分配,所以說該區域是線程私有的,該區域只是指令的計數,占用的空間非常少,所以虛擬機規范中沒有為該區域規定OutofMemoryError。

 

   2 線程棧。先讓我看以下一段代碼:

   class Test{

 

public static void main(String[] args) {

Thread th = new Thread();

th.start();

}

}

在運行以上代碼時,JVM將分配一塊棧空間給線程th,用於保存方法內的局部變量,方法的入口和出口等,這些局部變量包括基本類型和對象引用類型,這里可能有人會問,java的對象引用不是分配在堆上嗎?有這樣疑惑的人,可能是沒有理解java中引用和對象之前的區別,當我們寫出以下代碼時:

public Object test()

{

Object obj = new Object();

return obj;

}

其中的Object obj就是我們所說的引用類型,這樣的聲明本身是要占用4個字節,而這4個字節在這里就是在棧空間里分配的,准確的說是在線程棧中為test方法分配的棧幀中分配的,當方法退出時,將會隨棧幀的彈出而自動銷毀,而new Object()則是在堆中分配的,由GC在適當的時間收回其占用的空間。每個棧空間的默認大小為0.5M,在1.7里調整為1M,每調用一次方法就會壓入一個棧幀,如果壓入的棧幀深度過大,即方法調用層次過深,就會拋出StackOverFlow,,SOF最常見的場景就是遞歸中,當遞歸沒辦法退出時,就會拋此異常,Hotspot提供了參數設置改區域的大小,使用-Xss:xxK,就可以修改默認大小。

 

3 本地線程棧.顧名思義,該區域主要是給調用本地方法的線程分配的,該區域和線程棧的最大區別就是,在該線程的申請的內存不受GC管理,需要調用者自己管理,JDK中的Math類的大部分方法都是本地方法,一個值得注意的問題是,在執行本地方法時,並不是運行字節碼,所以之前所說的指令計數器是沒法記錄下一條字節碼指令的,當執行本地方法時,指令計數器置為undefined。

 

接下來是四個線程共享區。

1 方法區。這塊區域是用來存放JVM裝載的class的類信息,包括:類的方法、靜態變量、類型信息(接口/父類),我們使用反射技術時,所需的信息就是從這里獲取的。

2 常量池。當我們編寫如下的代碼時:

class Test1{

private final int size=50;

}

這個程序中size因為用final修飾,不能再修改它的值,所以就成為常量,而這常量將會存放在常量區,這些常量在編譯時就知道占用空間的大小,但並不是說明該區域編譯就固定了,運行期也可以修改常量池的大小,典型的場景是在使用String時,你可以調用String的 intern(),JVM會判斷當前所創建的String對象是否在常量池中,若有,則從常量區取,否則把該字符放入常量池並返回,這時就會修改常量池的大小,比如JDK中java.io.ObjectStreamField的一段代碼:

  ....

ObjectStreamField(Field field, boolean unshared, boolean showType) {

this.field = field;

this.unshared = unshared;

name = field.getName();

Class ftype = field.getType();

type = (showType || ftype.isPrimitive()) ? ftype : Object.class;

signature = ObjectStreamClass.getClassSignature(ftype).intern();

}

這段代碼將獲取的類的簽名放入常量池。HotSpot中並沒有單獨為該區域分配,而是合並到方法區中。

 

3 直接內存區。直接內存區並不是JVM可管理的內存區。在JDK1.4中提供的NIO中,實現了高效的R/W操作,這種高效的R/W操作就是通過管道機制實現的,而管道機制實際上使用了本地內存,這樣就避免了從本地源文件復制JVM內存,再從JVM復制到目標文件的過程,直接從源文件復制到目標文件,JVM通過DirectByteBuffer操作直接內存。

 

4 堆。主角總是最后出場,堆絕對是JVM中的一等公民,絕對的主角,我們通常所說的GC主要就是在這塊區域中進行的,所有的java對象都在這里分配,這也是JVM中最大的內存區域,被所有線程共享,成千上萬的對象在這里創建,也在這里被銷毀。

java內存分配到這就算是一個完結了,接下來我們將討論java內存的回收機制,

內存回收主要包含以下幾個方面理解:

第一,局部變量占用內存的回收,所謂局部變量,就是指在方法內創建的變量,其中變量又分為基本類型和引用類型。如下代碼:

...

  public void test()

{

int x=1;

char y='a';

long z=10L;

} 

變量x y z即為局部變量,占用的空間將在test()所在的線程棧中分配,test()執行完了后會自動從棧中彈出,釋放其占用的內存,再來看一段代碼:

....

public void test2()

{

Date d = new Date();

System.out.println("Now is "+d);

}

我們都知道上述代碼會創建兩個對象,一個是Date d另一個是new Date。Date d叫做聲明了一個date類型的引用,引用就是一種類型,和int x一樣,它表明了這種類型要占用多少空間,在java中引用類型和int類型一樣占用4字節的空間,如果只聲明引用而不賦值,這4個字節將指向JVM中地址為0的空間,表示未初始化,對它的任何操作都會引發空指針異常。

如果進行賦值如d = new Date()那么這個d就保存了new Date()這個對象的地址,通過之前的內存分配策略,我知道new Date()是在jvm的heap中分配的,其占用的空間的回收我們將在后面着重分析,這里我們要知道的是這個Date d所占用的空間是在test2()所在的線程棧分配的,方法執行完后同樣會被彈出棧,釋放其占用的空間。

第二.非局部變量的內存回收,在上面的代碼中new Date()就和C++里的new創建的對象一樣,是在heap中分配,其占用的空間不會隨着方法的結束而自動釋放需要一定的機制去刪除,在C++中必須由程序員在適當時候delete掉,在java中這部分內存是由GC自動回收的,但是要進行內存回收必須解決兩問題:那些對象需要回收、怎么回收。判定那些對象需要回收,我們熟知的有以下方法:

一,引用計數法,這應是絕大數的的java 程序員聽說的方法了,也是很多書上甚至很多老師講的方法,該方法是這樣描述的,為每個對象維護一個引用計數器,當有引用時就加1,引用解除時就減1,那些長時間引用為0的對象就判定為回收對象,理論上這樣的判定是最准確的,判定的效率也高,但是卻有一個致命的缺陷,請看以下代碼:

package com.mail.czp;

 

import java.util.ArrayList;

import java.util.List;

 

public class Test {

 

private byte[] buffer;

private List ls;

 

public Test() {

this.buffer = new byte[3*1024*1024+512];//3.5M

this.ls = new ArrayList();

}

private List getList() {

return ls;

}

 

public static void main(String[] args) {

Test t1 = new Test();

Test t2 = new Test();

t1.getList().add(t2);

t2.getList().add(t1);

t1 = t2 = null;

Test t3 = new Test();

System.out.println(t3);

}

}

我們用以下參數運行:-Xmx10M -Xms10M M  將jvm的大小設置為10M,不允許擴展,按引用計數法,t1和t2相互引用,他們的引用計數都不可能為0,那么他們將永遠不會回收,在我們的環境中JVM共10M,t1 t2占用8m,那么剩下的2M,是不足以創建t3的,理論上應該拋出OOM。但是,程序正常運行了,這說明JVM應該是回收了t1和t2的我們加上-XX:+PrintGCDetails運行,將打印GC的回收日記:

[GC [DefNew: 252K->64K(960K), 0.0030166 secs][Tenured: 8265K->137K(9216K), 0.0109869 secs] 8444K->137K(10176K), [Perm : 2051K->2051K(12288K)], 0.0140892 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] 

com.mail.czp.Test@2ce908

Heap

 def new generation   total 960K, used 27K [0x029e0000, 0x02ae0000, 0x02ae0000)

  eden space 896K,   3% used [0x029e0000, 0x029e6c40, 0x02ac0000)

  from space 64K,   0% used [0x02ad0000, 0x02ad0000, 0x02ae0000)

  to   space 64K,   0% used [0x02ac0000, 0x02ac0000, 0x02ad0000)

 tenured generation   total 9216K, used 4233K [0x02ae0000, 0x033e0000, 0x033e0000)

   the space 9216K,  45% used [0x02ae0000, 0x02f02500, 0x02f02600, 0x033e0000)

 compacting perm gen  total 12288K, used 2077K [0x033e0000, 0x03fe0000, 0x073e0000)

   the space 12288K,  16% used [0x033e0000, 0x035e74d8, 0x035e7600, 0x03fe0000)

No shared spaces configured.

從打印的日志我們可以看出,GC照常回收了t1  t2,這就從側面證明jvm不是采用這種策略判定對象是否可以回收的。

二,根搜索算法,這是當前的大部分虛擬機采用的判定策略,GC線程運行時,它會以一些特定的引用作為起點稱為GCRoot,從這些起點開始搜索,把所用與這些起點相關聯的對象標記,形成幾條鏈路,掃描完時,那些沒有與任何鏈路想連接的對象就會判定為可回收對象。具體那些引用作為起點呢,一種是類級別的引用:靜態變量引用、常量引用,另一種是方法內的引用,如之前的test()方法中的Date d對new Date()的引用,在我們的測試代碼中,在創建t3時,jvm發現當前的空間不足以創建對象,會出發一次GC,雖然t1和t2相互引用,但是執行t1=t2=null后,他們不和上面的3個根引用中的任何一個相連接,所以GC會判定他們是可回收對象,並在隨后將其回收,從而為t3的創建創造空間,當進行回收后發現空間還是不夠時,就會拋出OOM。

接下來我們就該討論GC 是怎么回收的了,目前版本的Hotspot虛擬機采用分代回收算法,它把heap分為新生代和老年代兩塊區域,如下圖:

                                               

 

默認的配置中老年代占90% 新生代占10%,其中新生代又被分為一個eden區和兩個survivor區,每次使用eden和其中的一個survivor區,一般對象都在eden和其中的一個survivor區分配,但是那些占用空間較大的對象,就會直接在老年代分配,比如我們在進行文件操作時設置的緩沖區,如byte[] buffer = new byte[1024*1024],這樣的對象如果在新生代分配將會導致新生代的內存不足而頻繁的gc,GC運行時首先會進行會在新生代進行,會把那些標記還在引用的對象復制到另一塊survivor空間中,然后把整個eden區和另一個survivor區里所有的對象進行清除,但也並不是立即清除,如果這些對象重寫了finalize方法,那么GC會把這些對象先復制到一個隊列里,以一個低級別的線程去觸發finalize方法,然后回收該對象,而那些沒有覆寫finalize方法的對象,將會直接被回收。在復制存活對象到另一個survivor空間的過程中可能會出現空間不足的情況,在這種情況下GC回直接把這些存活對象復制到老年代中,如果老年代的空間也不夠時,將會觸發一次Full GC,Full gc會回收老年代中那些沒有和任何GC Root相連的對象,如果Full GC后發現內存還是不足,將會出現OutofMemoryError。

Hotspot虛擬機下java對象內存的分配和回收就算完結了,后續我們將討論java代碼的重構。

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM