一. 前言
時隔多日, 我又來做知識總結(jié)了, 這次是Java對象的創(chuàng)建以及內(nèi)存的分配機制. 本來說好的這篇文章打算寫垃圾收集器, 但是想了想, 對象相關(guān)的東西作為前置知識, 還是得先說說.
二. 對象創(chuàng)建
一個對象的創(chuàng)建到底經(jīng)歷了哪些步驟? 看圖說話:
加載類這一步, 在下就不多說了, 詳細(xì)可以看在下的上...上篇文章. 不過, 涉及到對象創(chuàng)建的語句可不只是new, 比如說還有克隆, 或者反序列化等等. 好, 還是著重說說后邊兒的步驟:
- 分配內(nèi)存: 為即將要創(chuàng)建的對象分配合適的內(nèi)存大小.
也沒那么簡單, Jvm一共設(shè)計了兩種分配內(nèi)存的策略, 分別是: 指針碰撞和空閑列表
先來說指針碰撞, 接著看圖:
假設(shè)這是一塊非常規(guī)整的內(nèi)存, 粉紅色代表已使用, 白色表示未使用, 藍色是一個指針; 當(dāng)要給對象分配內(nèi)存時, 藍色的指針會根據(jù)對象的大小, 向后空閑的位置移動若干的格子. 比如這個對象計算后發(fā)現(xiàn)要占用3個格子, 則藍色的指針就會移動到當(dāng)前行的最后一個格子. 這個過程就叫做指針碰撞.
再來說空閑列表, 還是看圖:
假設(shè)這是我們的內(nèi)存, 由于選擇了不恰當(dāng)?shù)睦占惴? 就可能出現(xiàn)這種情況. 內(nèi)存上分配的對象很凌亂, 完全不規(guī)則. 這種情況肯定就沒有辦法使用指針碰撞的方式來分配內(nèi)存了. 這時候, Jvm會采用另外一種方式來給即將要創(chuàng)建的對象分配內(nèi)存空間, 那就是空閑列表. 空閑列表是指Jvm維護在堆中的一個列表, 上面記錄了所有空閑的可用的內(nèi)存地址, 也就是上圖中的白塊兒.當(dāng)計算完該對象需要多大空間的內(nèi)存時, 則從列表選擇出相應(yīng)的連續(xù)的白塊兒用于創(chuàng)建對象.
這時候, 新的問題產(chǎn)生了. 如果多個線程同時要創(chuàng)建對象, 到堆中分配內(nèi)存怎么辦?
Jvm提供了兩種方式來解決這個問題, 分別是CAS以及TLAB.
①. CAS(compare and swap): 這是一種算法, 不知道的沒關(guān)系, 可以先百度一下. 簡而言之, 通過這種算法, 多個線程同時搶一塊內(nèi)存的時候, 只有一個會成功, 其余失敗的會進行重試.
②. TLAB(Thread Local Allocation Buffer): 這是一種機制, Jvm事先給每個線程在堆中預(yù)先分配一小塊兒內(nèi)存空間, 每個線程要創(chuàng)建對象分配內(nèi)存時, 先到自身線程分配到的內(nèi)存中嘗試分配. 如果分配失敗, 再使用CAS的方式在Eden區(qū)中分配內(nèi)存.
可以通過參數(shù): -XX:+UseTLAB來開啟TLAB(默認(rèn)開啟); 通過參數(shù): -XX:TLABSize指定TLAB的大小, 不過一般不用也不推薦去修改它.
以上是關(guān)于分配內(nèi)存的所有內(nèi)容, 下面來講初始化:
- 初始化: 為對象所有成員變量賦零值
這也是為什么一個對象的成員變量不用賦值也可以使用的原因.
- 設(shè)置對象頭: 一個對象分為三部分信息, 分別是對象頭, 實例數(shù)據(jù), 對齊填充
①. 對象頭: 不知道你有沒有想過為什么使用instanceof關(guān)鍵字可以判斷一個對象的類型; 使用 對象.getClass() 可以取得該對象類對象; Jvm如何判斷一個對象經(jīng)歷了15次垃圾回收, 從而將其放入老年代. 其實這些信息都存放在對象的對象頭中.
對象頭中也包含了三部分信息, 分別是:
(markword): 對象hash code值宵蛀、gc分代年齡、鎖狀態(tài)標(biāo)識(這也是對象可以作為鎖的原因)
(klass pointer): 一個指針, 指向方法區(qū)中該對象對應(yīng)的類元信息
(數(shù)組長度): 如果該對象是一個數(shù)組, 才會有該信息, 記錄數(shù)組的長度
②. 實例數(shù)據(jù): 這個就不多講了, 就是對象的成員變量嘛.
③. 對齊填充: 我們的對象由于成員變量的數(shù)據(jù)類型不同, 都有不同的大小. 而Jvm會將這些對象大小都填充成8的倍數(shù)次方. 例如一個對象占用28字節(jié), Jvm會填充4個字節(jié), 使其變成32字節(jié)大小. 為什么要這樣呢? 了解計算機底層的朋友應(yīng)該知道, 對于8的倍數(shù)值, 計算機尋址的速度是最快的.
④. 還有一個指針壓縮: 我們的對象頭中有一個klass pointer, 它是一個指針, 也會占用相對應(yīng)的內(nèi)存, Jvm為了節(jié)省內(nèi)存空間會對其進行壓縮. 另外, 我們的對象中也可能包含其它對象, 這些個對象也會以指針的方式存放在內(nèi)存中. 對其壓縮后, 可以節(jié)省我們的內(nèi)存空間, 降低指針移動時所耗費的帶寬.
關(guān)于指針壓縮, 不能在32G以上的內(nèi)存中生效. 所以這也是為什么jvm內(nèi)存配置小了不好, 配置大了也不好的原因之一.
三. 對象內(nèi)存分配
上面說的對象分配內(nèi)存是指對象創(chuàng)建時分配內(nèi)存的規(guī)則, 下面要說的是對象分配的內(nèi)存區(qū)域.
1. 棧上分配對象
看到這個的小伙伴兒可能有點兒奇怪, 哎, 你之前不是說對象在堆上分配, 然后棧內(nèi)存上只存放對象的引用嗎? 記住了, 我說的是大多數(shù)時候, 這里要扯到一個概念, 叫做對象逃逸分析, 請看偽代碼:
public void test() {
User user = new User();
System.out.print("User對象能在test()方法外被使用嗎?");
}
上述代碼在test()方法內(nèi)new了一個User對象, 這個對象僅在該方法內(nèi)有效. 換句話說, 這個User對象已經(jīng)逃不出test()方法的作用域了. 這個時候Jvm就有可能在棧上存放該對象.
可是, 存放對象需要一塊兒連續(xù)的內(nèi)存空間呀, 棧幀沒有怎么辦?
這就涉及到對象內(nèi)存棧上分配的一種方式, 標(biāo)量替換, 什么是標(biāo)量? 其
實就是變量, Jvm會將該對象的成員屬性拆分成一個一個的標(biāo)量存放在棧
上.使用參數(shù): -XX:+DoEscapeAnalysis 開啟逃逸分析(默認(rèn)開啟)
使用參數(shù): -XX:+EliminateAllocations 開啟標(biāo)量替換(默認(rèn)開啟)
2. 大對象直接進入老年代
大對象直接進入老年代? 多大的對象算大對象嘞?
為了避免一些體積過大的對象頻繁在年輕代參與gc占用I/O帶寬, Jvm直接
將大對象放在老年代.
通過參數(shù): -XX:PretenureSizeThreshold=1048576(字節(jié)) 設(shè)置大對象大小
3. 長期存活的對象進入老年代
這個之前提到過, 一個對象經(jīng)歷一次minor gc, 則將對象頭中存儲的gc年齡加1(最大15), Jvm默認(rèn)當(dāng)一個對象的gc年齡達到15時將其移入老年代(不同的垃圾回收器默認(rèn)值不同).
這是為了避免一些本就應(yīng)該永久存在, 或者說長期存活的的對象一直在年輕
代頻繁經(jīng)歷minor gc卻始終回收不掉, 消耗性能.
通過參數(shù): -XX:MaxTenuringThreshold=5 可以設(shè)置移入老年代的年齡閾值
4. 對象動態(tài)年齡判斷
這個就有意思了, 前一篇文章說過, 當(dāng)年輕代的eden區(qū)滿了之后, 會執(zhí)行minor gc, 然后將存活的對象移動到survivor區(qū). 前面還說過, 每個對象都有一個gc年齡. 還是結(jié)合圖吧:
當(dāng)minor gc后如果移動到幸存區(qū)的對象總大小超過了幸存區(qū)大小的50%, 則
會進行對象動態(tài)年齡判斷機制, 該機制會根據(jù)對象年齡進行排序, 然后相加并判斷是否大于了幸存區(qū)的50% ,如果大于, 則將年齡n及以上對象全部移入老年代.
以上圖舉例:
- 對象1 + 對象2 + 對象3 + 對象4 + 對象5 + 對象6 = 600kb
- 600kb > (1mb * 0.5) = true
- 將對象6 ~ 對象9 移入老年代.
通過參數(shù): -XX:TargetSurvivorRatio 指定判定閾值
5. 老年代空間分配擔(dān)保機制
該機制發(fā)生在minor gc前, 就是在執(zhí)行minor gc之前, 會判斷每一次minor gc移入老年代的對象的平均大小, 如果大于老年代剩余空間, 直接執(zhí)行full gc.
舉例說明: 年輕代gc完存活了一批對象要進入老年代, 而老年代放不下這么多的對象, 怎么辦? 只能接著再執(zhí)行一次full gc.
6. 對象在Eden區(qū)分配
除了上述規(guī)則, 一個對象創(chuàng)建時, 會被分配到Eden區(qū).
四. 對象內(nèi)存回收
1. 如何判定一個對象是可回收的對象?
有兩種方式可以判斷一個對象是不是一個無用的對象, 分別是: 引用計數(shù)器算法 和 可達性分析算法, 而且目前主流在用的只有可達性分析算法.
聽上去很高大上? 聽在下解釋:
引用計數(shù)器算法: 給對象添加一個計數(shù)器, 每當(dāng)有變量引用它時, +1, gc就判斷這個對象的計數(shù)器是否為0即可. 十分簡單, 不過問題也很明顯, 比如:A a = new A(); B b = new B(); a.b = b; b.a = a;
只要對象相互引用一下, 好家伙, gg了...這兩對象回收不掉了.
可達性分析算法: 將棧的 局部變量 以及方法區(qū)中的 靜態(tài)變量 作為根, 沿著向下查找對象, 并將所有找到的對象標(biāo)記為非垃圾對象. 然后回收掉所有未被標(biāo)記的對象. 被作為根的對象也被稱之為"gc roots". 如下圖:
2. 聽說Java中有好幾種引用? 對于回收它們有什么區(qū)別?
Java中的引用一般分為 強, 軟, 弱, 虛 四種引用, 在編碼上區(qū)別如下:
強引用: 就是普通的變量直接引用, 只有在失去引用后才會被回收.User user = new User();
軟引用: 使用SoftReference軟引用類型包裹一個對象, 即可將該對象轉(zhuǎn)換為軟引用. 該引用正常情況下不回被回收, 但如果gc之后仍沒有足夠的空間可以使用, 則會回收這類引用的對象
public static SoftReference<User> user = newSoftReference<>(new User());
弱引用: 使用WeekSoftReference弱引用類型包裹, 即可轉(zhuǎn)換引用類型為弱引用. 這類引用會在垃圾回收時直接被回收掉, 與無引用幾乎無異.
public static WeakReference<User> user = new WeakReference<>(new User());
虛引用: 在下沒弄明白這個, 完全沒有使用場景...前兩種還可以用做緩存...如果有小伙伴兒知道這個虛引用的使用場景, 歡迎評論.
3. 垃圾回收調(diào)用finalize()干了啥?
- 我的理解, 這個finalize方法是Jvm的一個生命周期鉤子, 在對象即將被回收之前調(diào)用.
- 其實在可達性分析算法結(jié)束后, 所有未被標(biāo)記的"垃圾對象"在真正回收前會判斷該對象是否重寫了finalize()方法, 如果重寫了, 則執(zhí)行, 如果未重寫, 直接回收.
- 也可以在finalize()方法中給對象重新建立引用, 這樣做則可以挽救該對象被回收.
- 需要注意的是, 每個對象的finalize()方法僅會被調(diào)用一次.
4. 如何判斷一個類是可回收的類呢?
我們的類在full gc時也可能被回收, 前面說過類的信息是存放在方法區(qū)的, 其實full gc回收方法區(qū)主要就是回收類.
而判斷一個類是否可回收必須滿足三個條件:
- 該類的所有實例對象被回收
- 該類的類對象已不存在任何引用, 也就是java.lang.class對象. 這意味著, 不能再通過反射創(chuàng)建該類的對象
- 加載該類的類加載器已被回收. 這一點很有意思, 因為這說明我們自己寫的類, 也就是classpath路徑下的類永遠不會被回收. 因為它們都是AppClassLoader加載的. 這也是為啥熱更新jsp需要tomcat自定義類加載器的原因之一(tomcat每次熱更新jsp都會重新創(chuàng)建一個類加載器實例, 并重新加載產(chǎn)生變化的jsp).
好了, 今天的總結(jié)就到這里. 如果有錯誤的地方, 希望大家能不吝指教, 也歡迎大家向我提問, 一起討論技術(shù).