深入理解對象
我們知道,Java是一門面向?qū)ο笤O(shè)計的語言,面向?qū)ο蟮某绦蛟O(shè)計語言中有類和對象的概念。類就是具備某些共同特征的實(shí)體的集合,它是一種抽象的數(shù)據(jù)類型,它是對所具有相同特征實(shí)體的抽象。在面向?qū)ο蟮某绦蛟O(shè)計語言中,類是對一類“事物”的屬性與行為的抽象。
而對象是類的具體的個體。比如,小王是Person類的一個對象。Person可能存在無數(shù)個對象(就好像地球上存在數(shù)十億人一樣)。而一個對象的創(chuàng)建,包括兩個過程:初始化和實(shí)例化。
虛擬機(jī)中對象的創(chuàng)建過程
下面是對象創(chuàng)建過程的一個圖示:

我們通常使用new關(guān)鍵字去創(chuàng)建一個對象,JVM首先會去檢查相關(guān)類型是否已經(jīng)加載并初始化,如果沒有,JVM就會調(diào)用類加載器完成類的初始化。接下來,就會為對象分配內(nèi)存空間。
我們知道Java堆是被所有線程共享的一塊內(nèi)存區(qū)域,主要用于存放對象實(shí)例,為對象分配內(nèi)存就是把一塊大小確定的內(nèi)存從堆內(nèi)存中劃分出來,而這段內(nèi)存又必須是連續(xù)的。分配的方式通常有指針碰撞和空閑列表兩種實(shí)現(xiàn)。
- 指針碰撞法
假設(shè)Java堆中內(nèi)存時完整的,已分配的內(nèi)存和空閑內(nèi)存分別在不同的一側(cè),通過一個指針作為分界點(diǎn),需要分配內(nèi)存時,僅僅需要把指針往空閑的一端移動與對象大小相等的距離。使用的GC收集器:Serial、ParNew,適用堆內(nèi)存規(guī)整(即沒有內(nèi)存碎片)的情況下。這類垃圾收集器帶有壓縮整理功能。
- 空閑列表法
事實(shí)上,Java堆的內(nèi)存并不是完整的,已分配的內(nèi)存和空閑內(nèi)存相互交錯,JVM通過維護(hù)一個列表,記錄可用的內(nèi)存塊信息,當(dāng)分配操作發(fā)生時,從列表中找到一個足夠大的內(nèi)存塊分配給對象實(shí)例,并更新列表上的記錄。使用的GC收集器:CMS,適用堆內(nèi)存不規(guī)整的情況下。而這也就是現(xiàn)代Java虛擬機(jī)的垃圾回收機(jī)制:標(biāo)機(jī)-清除法。
我們都知道JVM是多線程的,假設(shè)線程1正在給A對象分配內(nèi)存,指針還沒有來的及修改,同時線程2在為B對象分配內(nèi)存,仍引用這之前的指針指向等,這時候就會帶來并發(fā)安全問題。為了解決并發(fā)安全問題,JVM采用了CAS加失敗重試的機(jī)制以及本地線程分配緩沖的機(jī)制。前者屬于樂觀鎖的一種,而CAS操作是一個原子操作,線程會先嘗試去分配內(nèi)存,更新的時候進(jìn)行比較,如果內(nèi)存塊與期待值相同,則提交修改,如果不同,則自旋,重新移動指針。
而本地線程分配緩沖的機(jī)制的話,則是預(yù)先給線程分配一塊空間TLAB(Thread Local Allocation Buffer),后面分配空間先在TLAB上分配,TLAB不夠了再從堆上分配。
其實(shí)TLAB只是讓每個線程在堆上有私有的分配指針,但底下存對象的內(nèi)存空間還是給所有線程訪問的,只是其它線程無法在這個區(qū)域分配而已,。當(dāng)一個TLAB用滿(分配指針top撞上分配極限end了),就新申請一個TLAB,而在老TLAB里的對象還留在原地什么都不用管。
當(dāng)我們?yōu)橐粋€對象分配好內(nèi)存空間之后,還需要進(jìn)行內(nèi)存空間的初始化為零值,這一操作保證了對象的實(shí)例字段在java代碼中,不賦初始值就可以直接使用。例如一個Integer對象,就初始化為0,一個Boolean對象初始化為false。
接下來,就會對內(nèi)存空間進(jìn)行設(shè)置,將其與對象的實(shí)例關(guān)聯(lián)起來,就是在對象頭中記錄相關(guān)的信息。到這里對象的初始化,就完成了。
對象的內(nèi)存布局
在一個對象里面,包括對象頭、實(shí)例數(shù)據(jù)和對齊填充三個部分。在Hotspot里面,對象必須是8字節(jié)的整數(shù)倍大小,假如對象頭加實(shí)例數(shù)據(jù)加起來為30字節(jié)的話,則會填充2字節(jié)的填充數(shù)據(jù),以達(dá)到規(guī)整的目的。
在對象頭里面,包含了多個數(shù)據(jù),一是存儲自身對象的運(yùn)行時數(shù)據(jù),包括哈希碼、GC分代年齡、鎖狀態(tài)表示、線程持有的幀、偏向線程ID,偏向時間戳等。二是類型指針,確定對象是來自哪個實(shí)例。三是假如對象是數(shù)組對象,還有會一部分?jǐn)?shù)據(jù)用來記錄數(shù)組長度的數(shù)據(jù)。
對象的訪問定位
我們在new出一個對象的時候,其實(shí)是使用一個引用去指向一個對象的實(shí)例。那么引用是怎么訪問定位到對象上呢。
使用句柄
在這里,句柄被定義為了存放到對象實(shí)例數(shù)據(jù)的指針和到對象類型數(shù)據(jù)的指針的個體,而Java堆又劃分了一塊句柄池用于存放句柄。而Java棧幀中的局部變量表中存放了對象的reference(引用),而這個引用則指向了句柄池中的句柄,并進(jìn)而通過指針去定位到對象的實(shí)例數(shù)據(jù)。
直接指針
而使用直接指針的話,引用直接指向了Java堆中的對象實(shí)例數(shù)據(jù),相比句柄的使用無需二次映射,更加高效了。
對象的存活以及各種引用
我們知道,Java堆是用于分配對象實(shí)例的,當(dāng)堆空間已經(jīng)滿的情況下,JVM就會進(jìn)行垃圾回收。那么我們就必須區(qū)分,哪些對象需要回收,也就是哪些對象是死的,哪些對象是活的。在虛擬機(jī)規(guī)范里面,有那么幾種方法:
引用計數(shù)算法(Reference Counting):
給對象添加一個引用計數(shù)器,每當(dāng)有一個地方引用它時,計數(shù)器值加1;當(dāng)引用失效時,計數(shù)器值減1;任何時刻計數(shù)器為0的對象,就會被認(rèn)為是垃圾。使用引用計數(shù)器,具有較高的效率,但是它有一個缺陷,那就是很難解決對象之間的循環(huán)引用問題,例如objA.instance = objB及objB.instance = objA,如果除此之外這兩個對象再無任何引用,實(shí)際上這兩個對象已經(jīng)不可能再被訪問,但是它們因?yàn)榛ハ嘁弥鴮Ψ?,?dǎo)致它們的引用計數(shù)器都不為0,于是引用計數(shù)算法無法通知GC收集器回收它們。
public class IsAlive {
public Object instance =null;
//占據(jù)內(nèi)存,便于判斷分析GC
private byte[] bigSize = new byte[10*1024*1024];
public static void main(String[] args) {
IsAlive objectA = new IsAlive();
IsAlive objectB = new IsAlive();
//相互引用
objectA.instance = objectB;
objectB.instance = objectA;
//切斷可達(dá)
objectA =null;
objectB =null;
//強(qiáng)制垃圾回收
System.gc();
}
}
可達(dá)性分析算法(Reachability Analysis):
通過一系列的稱為“GC Roots”的對象作為起點(diǎn),從這些節(jié)點(diǎn)開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當(dāng)一個對象到GC Roots沒有任何引用鏈相連(即從GC Roots到這個對象不可達(dá))時,則證明此對象是不可用的。
在Java語言中,可作為GC Roots的對象包括以下幾種:
(1) 虛擬機(jī)棧中引用的對象;
(2) 方法區(qū)中類靜態(tài)屬性引用的對象;
(3) 方法區(qū)中常量引用的對象;
(4) 本地方法棧中JNI(即一般說的Native方法)引用的對象;
但是如果有引用鏈,就一定不會被回收嗎?還要看對象之間的引用關(guān)系。JVM里面有四種引用關(guān)系。
- 強(qiáng)引用
- 軟引用 SoftReference
- 弱引用 WeakReference
- 虛引用 PhantomReference
強(qiáng)引用通常就是用等號來引用,GC不會回收被強(qiáng)引用的對象。而軟引用的話,當(dāng)快要發(fā)生內(nèi)存溢出的話,就會被GC回收。如果是弱引用的話,當(dāng)發(fā)生GC的時候,就會被回收掉。虛引用被定義出來之后則隨時都可能會被GC回收。
測試軟引用
public static void main(String[] args) {
User u = new User(1,"King"); //new是強(qiáng)引用
SoftReference<User> userSoft = new SoftReference<User>(u);//軟引用
u = null;//干掉強(qiáng)引用,確保這個實(shí)例只有userSoft的軟引用
System.out.println(userSoft.get()); //看一下這個對象是否還在
System.gc();//進(jìn)行一次GC垃圾回收 千萬不要寫在業(yè)務(wù)代碼中。
System.out.println("After gc");
System.out.println(userSoft.get());
//往堆中填充數(shù)據(jù),導(dǎo)致OOM
List<byte[]> list = new LinkedList<>();
try {
for(int i=0;i<100;i++) {
//System.out.println("*************"+userSoft.get());
list.add(new byte[1024*1024*1]); //1M的對象
}
} catch (Throwable e) {
//拋出了OOM異常時打印軟引用對象
System.out.println("Exception*************"+userSoft.get());
}
}
測試弱引用
public static void main(String[] args) {
User u = new User(1,"King");
WeakReference<User> userWeak = new WeakReference<User>(u);
u = null;//干掉強(qiáng)引用,確保這個實(shí)例只有userWeak的弱引用
System.out.println(userWeak.get());
System.gc();//進(jìn)行一次GC垃圾回收,千萬不要寫在業(yè)務(wù)代碼中。
System.out.println("After gc");
System.out.println(userWeak.get());
}
對象的分配策略
對象的分配原則
JVM在創(chuàng)建對象的過程中,分配存儲空間的時候通常需要遵循以下原則:
- 對象優(yōu)先在Eden中分配
- 空間分配擔(dān)保
- 大對象直接進(jìn)入老年代
- 長期存活的對象進(jìn)入老年代
- 動態(tài)對象年齡判斷
分配策略優(yōu)化
同時,分配的時候也有如下的優(yōu)化技術(shù):
- 棧中優(yōu)化對象:逃逸分析
- 堆中的優(yōu)化技術(shù):本地線程分配緩沖(TLAB)
當(dāng)JVM遇到一條new指令的時候,首先判斷是否在棧上分配,這時候就會使用到逃逸分析技術(shù)。在JIT的過程中,如果發(fā)現(xiàn)一個對象在方法中被定義后,作用域僅僅限于方法內(nèi),那么就稱為沒有發(fā)生方法逃逸,這時候,對象的分配就可以在棧上分配,當(dāng)方法體執(zhí)行結(jié)束了,棧上的空間也就跟著釋放了,這樣就可以提高JVM的效率,減少了GC的次數(shù)。
通過-XX:±DoEscapeAnalysis : 表示開啟或關(guān)閉逃逸分析
接下來,如果不滿足逃逸分析,就判斷是否在TLAB上分配,如果不滿足,再進(jìn)一步判斷是否是大對象,如果不是的話,就會在Eden中分配。所以說,無論是TLAB,還是小對象,都滿足對象優(yōu)先在Eden上分配的原則。如果是大對象的話,則會在Tenured中分配。
垃圾回收算法
Oracle公司曾經(jīng)進(jìn)行過概率統(tǒng)計,在新生代中約98%的對象會在創(chuàng)建出來之后的第一次GC就會被回收掉,所以并不需要按照1:1的比例來劃分內(nèi)存空間,而是將內(nèi)存分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。
我們知道Eden上只存放新生對象,而堆上又頻繁著在發(fā)生GC回收,如果某個對象在GC中沒有被回收掉,那個在對象的對象頭的年齡上就會+1,然后從Eden移動到Survivor中。當(dāng)再次發(fā)生回收時,將Survivor中還存活著的對象一次性的復(fù)制到另外一塊Survivor,最后清理剛才用過的Survivor空間。HotSpot虛擬機(jī)默認(rèn)Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內(nèi)存為整個新生代容量的90%(80%+10%),只有10%的內(nèi)存會被“浪費(fèi)”。這種發(fā)生在新生代上的GC回收算法被稱為復(fù)制回收算法。
| Eden | From | To | Tenured |
|---|---|---|---|
| 8 | 1 | 1 | 20 |
而在對象頭上,64位的JVM中age的比特位是只有4位的,因此能記錄的最大年齡也就是1111(15次)。達(dá)到這個次數(shù)之后,對象就會進(jìn)入老年代。
當(dāng)然,98%的對象可回收只是一般場景下的數(shù)據(jù),我們沒有辦法保證每次回收都只有不多于10%的對象存活,當(dāng)Survivor空間不夠用時,需要依賴其他內(nèi)存(這里指老年代)進(jìn)行分配擔(dān)保(Handle Promotion)。
空間分配擔(dān)保,這是在老年代中的垃圾回收算法。正常的流程來講,對象的跨代移動都是從Eden到From到To,直到GC年齡達(dá)到閾值后才會進(jìn)入Tenured。
在發(fā)生Minor GC之前,虛擬機(jī)會檢查老年代最大可用的連續(xù)空間是否大于新生代所有對象的總空間,
- 如果大于,則此次Minor GC是安全的
- 如果小于,則虛擬機(jī)會查看HandlePromotionFailure設(shè)置值是否允許擔(dān)保失敗。
如果HandlePromotionFailure=true,那么會繼續(xù)檢查老年代最大可用連續(xù)空間是否大于歷次晉升到老年代的對象的平均大小,如果大于,則嘗試進(jìn)行一次Minor GC,但這次Minor GC依然是有風(fēng)險的;如果小于或者HandlePromotionFailure=false,則改為進(jìn)行一次Full GC。
上面提到了Minor GC依然會有風(fēng)險,是因?yàn)樾律捎脧?fù)制收集算法,假如大量對象在Minor GC后仍然存活(最極端情況為內(nèi)存回收后新生代中所有對象均存活),而Survivor空間是比較小的,這時就需要老年代進(jìn)行分配擔(dān)保,把Survivor無法容納的對象放到老年代。老年代要進(jìn)行空間分配擔(dān)保,前提是老年代得有足夠空間來容納這些對象,但一共有多少對象在內(nèi)存回收后存活下來是不可預(yù)知的,因此只好取之前每次垃圾回收后晉升到老年代的對象大小的平均值作為參考。使用這個平均值與老年代剩余空間進(jìn)行比較,來決定是否進(jìn)行Full GC來讓老年代騰出更多空間。
取平均值仍然是一種概率性的事件,如果某次Minor GC后存活對象陡增,遠(yuǎn)高于平均值的話,必然導(dǎo)致?lián)J?,如果出現(xiàn)了分配擔(dān)保失敗,就只能在失敗后重新發(fā)起一次Full GC。雖然存在發(fā)生這種情況的概率,但大部分時候都是能夠成功分配擔(dān)保的,這樣就避免了過于頻繁執(zhí)行Full GC。