5. java 對象是如何創(chuàng)建的?new背后到底做了什么

對象創(chuàng)建的主要流程:


1. 類加載檢查
虛擬機遇到一條new指令時,首先將去檢查這個指令的參數(shù)是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執(zhí)行相應(yīng)的類加載過程。 new指令對應(yīng)到語言層面上講是,new關(guān)鍵詞、對象克隆、對象序列化等。
2. 分配內(nèi)存
在類加載檢查通過后,接下來虛擬機將為新生對象分配內(nèi)存。對象所需內(nèi)存的大小在類加載完成后便可完全確定,為對象分配空間的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來。 這個步驟有兩個問題:
1).如何劃分內(nèi)存。
2).在并發(fā)情況下,可能出現(xiàn)正在給對象A分配內(nèi)存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內(nèi)存的情況。

劃分內(nèi)存的方法:

  • “指針碰撞”(Bump the Pointer)(默認用指針碰撞)
    如果Java堆中內(nèi)存是絕對規(guī)整的,所有用過的內(nèi)存都放在一邊,空閑的內(nèi)存放在另一邊,中間放著一個指針作為分界點 的指示器,那所分配內(nèi)存就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離。
  • “空閑列表”(Free List)
    如果Java堆中的內(nèi)存并不是規(guī)整的,已使用的內(nèi)存和空 閑的內(nèi)存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內(nèi)存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例, 并更新列表上的記錄。

解決并發(fā)問題的方法:

  • CAS(compare and swap)
    虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性來對分配內(nèi)存空間的動作進行同步處理。
  • 本地線程分配緩沖(Thread Local Allocation Buffer,TLAB)
    把內(nèi)存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預(yù)先分配一小塊內(nèi)存。通過-XX:+/- UseTLAB參數(shù)來設(shè)定虛擬機是否使用TLAB(JVM會默認開啟-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。

3.初始化
內(nèi)存分配完成后,虛擬機需要將分配到的內(nèi)存空間都初始化為零值(不包括對象頭), 如果使用TLAB,這一工作過程也 可以提前至TLAB分配時進行。這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問 到這些字段的數(shù)據(jù)類型所對應(yīng)的零值。

4.設(shè)置對象頭
初始化零值之后,虛擬機要對對象進行必要的設(shè)置,例如這個對象是哪個類的實例、如何才能找到類的元數(shù)據(jù)信息、對象的哈希碼、對象的GC分代年齡等信息。
這些信息存放在對象的對象頭Object Header之中。 在HotSpot虛擬機中,對象在內(nèi)存中存儲的布局可以分為3塊區(qū)域:對象頭(Header)實例數(shù)據(jù)(Instance Data)對齊填充(Padding)。 HotSpot虛擬機的對象頭包括兩部分信息,第一部分用于存儲對象自身的運行時數(shù)據(jù), 如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標志、線程持有的鎖、偏向線程ID、偏向時間戳等。對象頭的另外一部分是類型指針,即對象指向它的類元數(shù)據(jù)的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。


對象頭在hotspot的C++源碼里的注釋如下:

5.執(zhí)行<init>方法
執(zhí)行<init>方法,即對象按照程序員的意愿進行初始化。對應(yīng)到語言層面上講,就是為屬性賦值(注意,這與上面的賦 零值不同,這是由程序員賦的值),和執(zhí)行構(gòu)造方法。

對象大小與指針壓縮

對象大小可以用jol-core包查看,引入依賴

 <dependency> 
    <groupId>org.openjdk.jol</groupId> 
    <artifactId>jol‐core</artifactId>
    <version>0.9</version> 
 </dependency>
public class JOLSample {
    public static void main(String[] args) {
        ClassLayout layout = ClassLayout.parseInstance(new Object());
        System.out.println(layout.toPrintable());

        System.out.println();
        ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
        System.out.println(layout1.toPrintable());
        System.out.println();
        ClassLayout layout2 = ClassLayout.parseInstance(new A());
        System.out.println(layout2.toPrintable());

    }

    // ‐XX:+UseCompressedOops 默認開啟的壓縮所有指針
    // ‐XX:+UseCompressedClassPointers 默認開啟的壓縮對象頭里的類型指針Klass Pointer
    // Oops : Ordinary Object Pointers
    public static class A {
        //8B mark word
        //4B Klass Pointer 如果關(guān)閉壓縮‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,則占用8B
        int id; //4B
        String name; //4B 如果關(guān)閉壓縮‐XX:‐UseCompressedOops,則占用8B
        byte b; //1B
        Object o; //4B 如果關(guān)閉壓縮‐XX:‐UseCompressedOops,則占用8B 31 }
    }
}

運行結(jié)果


什么是java對象的指針壓縮?
1.jdk1.6 update14開始,在64bit操作系統(tǒng)中,JVM支持指針壓縮
2.jvm配置參數(shù):UseCompressedOops,compressed----壓縮、oop(ordinary object pointer)----對象指針
3.啟用指針壓縮:-XX:+UseCompressedOops(默認開啟),禁止指針壓縮:-XX:--UseCompressedOops

為什么要進行指針壓縮?

  1. 在64位平臺的HotSpot中使用32位指針,內(nèi)存使用會多出1.5倍左右,使用較大指針在主內(nèi)存和緩存之間移動數(shù)據(jù),占用較大寬帶,同時GC也會承受較大壓力
  2. 為了減少64位平臺下內(nèi)存的消耗,啟用指針壓縮功能
  3. 在jvm中,32位地址最大支持4G內(nèi)存(2的32次方),可以通過對對象指針的壓縮編碼、解碼方式進行優(yōu)化,使得jvm 只用32位地址就可以支持更大的內(nèi)存配置(小于等于32G)
  4. 堆內(nèi)存小于4G時,不需要啟用指針壓縮,jvm會直接去除高32位地址,即使用低虛擬地址空間
  5. 堆內(nèi)存大于32G時,壓縮指針會失效,會強制使用64位(即8字節(jié))來對java對象尋址,這就會出現(xiàn)1的問題,所以堆內(nèi)存不要大于32G為好

對象內(nèi)存分配

  • 對象內(nèi)存分配流程圖


  • 對象棧上分配
    我們通過JVM內(nèi)存分配可以知道JAVA中的對象都是在堆上進行分配,當對象沒有被引用的時候,需要依靠GC進行回收內(nèi)存,如果對象數(shù)量較多的時候,會給GC帶來較大壓力,也間接影響了應(yīng)用的性能。為了減少臨時對象在堆內(nèi)分配的數(shù)量,JVM通過逃逸分析確定該對象不會被外部訪問。如果不會逃逸可以將該對象在棧上分配內(nèi)存,這樣該對象所占用的內(nèi)存空間就可以隨棧幀出棧而銷毀,就減輕了垃圾回收的壓力。
    對象逃逸分析:就是分析對象動態(tài)作用域,當一個對象在方法中被定義后,它可能被外部方法所引用,例如作為調(diào)用參 數(shù)傳遞到其他地方中。

    很顯然test1方法中的user對象被返回了,這個對象的作用域范圍不確定,test2方法中的user對象我們可以確定當方法結(jié) 束這個對象就可以認為是無效對象了,對于這樣的對象我們其實可以將其分配在棧內(nèi)存里,讓其在方法結(jié)束時跟隨棧內(nèi)存一起被回收掉。
    JVM對于這種情況可以通過開啟逃逸分析參數(shù)(-XX:+DoEscapeAnalysis)來優(yōu)化對象內(nèi)存分配位置,使其通過標量替換優(yōu)先分配在棧上(棧上分配),JDK7之后默認開啟逃逸分析,如果要關(guān)閉使用參數(shù)(-XX:-DoEscapeAnalysis)
    標量替換:通過逃逸分析確定該對象不會被外部訪問,并且對象可以被進一步分解時,JVM不會創(chuàng)建該對象,而是將該對象成員變量分解若干個被這個方法使用的成員變量所代替,這些代替的成員變量在棧幀或寄存器上分配空間,這樣就不會因為沒有一大塊連續(xù)空間導(dǎo)致對象內(nèi)存不夠分配。開啟標量替換參數(shù)(-XX:+EliminateAllocations),JDK7之后默認開啟。
    標量與聚合量:標量即不可被進一步分解的量,而JAVA的基本數(shù)據(jù)類型就是標量(如:int,long等基本數(shù)據(jù)類型以及 reference類型等),標量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在JAVA中對象就是可以被進一 步分解的聚合量。

棧上分配示例:

public class AllotOnStack {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();}
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
    private static void alloc() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
    }
}

結(jié)論:棧上分配依賴于逃逸分析和標量替換

對象在Eden區(qū)分配

大多數(shù)情況下,對象在新生代Eden 區(qū)分配。當 Eden 區(qū)沒有足夠空間進行分配時,虛擬機將發(fā)起一次Minor GC。我 們來進行實際測試一下。
在測試之前我們先來看看 Minor GC和Full GC 有什么不同呢?

  • Minor GC/Young GC:指發(fā)生新生代的的垃圾收集動作,Minor GC非常頻繁,回收速度一般也比較快。
  • Major GC/Full GC:一般會回收老年代 ,年輕代,方法區(qū)的垃圾,Major GC的速度一般會比Minor GC的慢 10倍以上。

Eden與Survivor區(qū)默認8:1:1

大量的對象被分配在eden區(qū),eden區(qū)滿了后會觸發(fā)minor gc,可能會有99%以上的對象成為垃圾被回收掉,剩余存活的對象會被挪到為空的那塊survivor區(qū),下一次eden區(qū)滿了后又會觸發(fā)minor gc,把eden區(qū)和survivor區(qū)垃圾對象回收,把剩余存活的對象一次性挪動到另外一塊為空的survivor區(qū),因為新生代的對象都是朝生夕死的,存活時間很短,所以JVM默認的8:1:1的比例是很合適的,讓eden區(qū)盡量的大,survivor區(qū)夠用即可,JVM默認有這個參數(shù)-XX:+UseAdaptiveSizePolicy(默認開啟),會導(dǎo)致這個8:1:1比例自動變化,如果不想這個比例有變化可以設(shè)置參數(shù)-XX:-UseAdaptiveSizePolicy。
示例:



我們可以看出eden區(qū)內(nèi)存幾乎已經(jīng)被分配完全(即使程序什么也不做,新生代也會使用至少幾M內(nèi)存)。假如我們再為 allocation2分配內(nèi)存會出現(xiàn)什么情況呢?


簡單解釋一下為什么會出現(xiàn)這種情況: 因為給allocation2分配內(nèi)存的時候eden區(qū)內(nèi)存幾乎已經(jīng)被分配完了,我們剛剛講了當Eden區(qū)沒有足夠空間進行分配時,虛擬機將發(fā)起一次Minor GC,GC期間虛擬機又發(fā)現(xiàn)allocation1無法存入Survior空間,所以只好把新生代的對象提前轉(zhuǎn)移到老年代中去,老年代上的空間足夠存放allocation1,所以不會出現(xiàn)Full GC。執(zhí)行Minor GC后,后面分配的對象如果能夠存在eden區(qū)的話,還是會在eden區(qū)分配內(nèi)存。
驗證:



大對象直接進入老年代

大對象就是需要大量連續(xù)內(nèi)存空間的對象(比如:字符串、數(shù)組)。JVM參數(shù) -XX:PretenureSizeThreshold 可以設(shè)置大對象的大小,如果對象超過設(shè)置大小會直接進入老年代,不會進入年輕代,這個參數(shù)只在 Serial 和ParNew兩個收集器下有效。
比如設(shè)置JVM參數(shù):-XX:PretenureSizeThreshold=1000000 (單位是字節(jié)) -XX:+UseSerialGC ,再執(zhí)行下上面的第一個程序會發(fā)現(xiàn)大對象直接進了老年代,為什么要這樣呢? 為了避免為大對象分配內(nèi)存時的復(fù)制操作而降低效率。

長期存活的對象將進入老年代

既然虛擬機采用了分代收集的思想來管理內(nèi)存,那么內(nèi)存回收時就必須能識別哪些對象應(yīng)放在新生代,哪些對象應(yīng)放在老年代中。為了做到這一點,虛擬機給每個對象一個對象年齡(Age)計數(shù)器。 如果對象在Eden 出生并經(jīng)過第一次Minor GC后仍然能夠存活,并且能被Survivor容納的話,將被移動到Survivor空間中,并將對象年齡設(shè)為1。對象在Survivor中每熬過一次MinorGC,年齡就增加1歲,當它的年齡增加到一定程度 (默認為15歲,CMS收集器默認6歲,不同的垃圾收集器會略微有點不同),就會被晉升到老年代中。對象晉升到老年代 的年齡閾值,可以通過參數(shù) -XX:MaxTenuringThreshold 來設(shè)置。

對象動態(tài)年齡判斷

當前放對象的Survivor區(qū)域里(其中一塊區(qū)域,放對象的那塊s區(qū)),一批對象的總大小大于這塊Survivor區(qū)域內(nèi)存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此時大于等于這批對象年齡最大值的對象,就可以直接進入老年代了, 例如Survivor區(qū)域里現(xiàn)在有一批對象,年齡1+年齡2+年齡n的多個年齡對象總和超過了Survivor區(qū)域的50%,此時就會 把年齡n(含)以上的對象都放入老年代。這個規(guī)則其實是希望那些可能是長期存活的對象,盡早進入老年代。對象動態(tài)年齡判斷機制一般是在minor gc之后觸發(fā)的。

老年代空間分配擔保機制

年輕代每次minor gc之前JVM都會計算下老年代剩余可用空間。
如果這個可用空間小于年輕代里現(xiàn)有的所有對象大小之和(包括垃圾對象)。
就會看一個“-XX:-HandlePromotionFailure”(jdk1.8默認就設(shè)置了)的參數(shù)是否設(shè)置了。
如果有這個參數(shù),就會看看老年代的可用內(nèi)存大小,是否大于之前每一次minor gc后進入老年代的對象的平均大小。
如果上一步結(jié)果是小于或者之前說的參數(shù)沒有設(shè)置,那么就會觸發(fā)一次Full gc,對老年代和年輕代一起回收一次垃圾, 如果回收完還是沒有足夠空間存放新的對象就會發(fā)生"OOM"。
當然,如果minor gc之后剩余存活的需要挪動到老年代的對象大小還是大于老年代可用空間,那么也會觸發(fā)full gc,full gc完之后如果還是沒有空間放minor gc之后的存活對象,則也會發(fā)生“OOM”。


對象內(nèi)存回收

堆中幾乎放著所有的對象實例,對堆垃圾回收前的第一步就是要判斷哪些對象已經(jīng)死亡(即不能再被任何途徑使用的對象)。

引用計數(shù)法

給對象中添加一個引用計數(shù)器,每當有一個地方引用它,計數(shù)器就加1;當引用失效,計數(shù)器就減1;任何時候計數(shù)器為0 的對象就是不可能再被使用的。
這個方法實現(xiàn)簡單,效率高,但是目前主流的虛擬機中并沒有選擇這個算法來管理內(nèi)存,其最主要的原因是它很難解決對象之間相互循環(huán)引用的問題。 所謂對象之間的相互引用問題,如下面代碼所示:除了對象objA 和 objB 相互引用著對 方之外,這兩個對象之間再無任何引用。但是他們因為互相引用對方,導(dǎo)致它們的引用計數(shù)器都不為0,于是引用計數(shù)算 法無法通知 GC 回收器回收他們。


可達性分析算法

將“GC Roots” 對象作為起點,從這些節(jié)點開始向下搜索引用的對象,找到的對象都標記為非垃圾對象,其余未標記的對象都是垃圾對象 。
GC Roots根節(jié)點:線程棧的本地變量、靜態(tài)變量、本地方法棧的變量等等。


常見引用類型

java的引用類型一般分為四種:強引用、軟引用、弱引用、虛引用

  • 強引用:普通的變量引用
    public static User user = new User();
  • 軟引用
    將對象用SoftReference軟引用類型的對象包裹,正常情況不會被回收,但是GC做完后發(fā)現(xiàn)釋放不出空間存放 新的對象,則會把這些軟引用的對象回收掉。軟引用可用來實現(xiàn)內(nèi)存敏感的高速緩存。
    public static SoftReference<User> user = new SoftReference<User>(new User());
    軟引用在實際中有重要的應(yīng)用,例如瀏覽器的后退按鈕。按后退時,這個后退時顯示的網(wǎng)頁內(nèi)容是重新進行請求還是從 緩存中取出呢?這就要看具體的實現(xiàn)策略了。
    (1)如果一個網(wǎng)頁在瀏覽結(jié)束時就進行內(nèi)容的回收,則按后退查看前面瀏覽過的頁面時,需要重新構(gòu)建
    (2)如果將瀏覽過的網(wǎng)頁存儲到內(nèi)存中會造成內(nèi)存的大量浪費,甚至會造成內(nèi)存溢出
  • 弱引用
    將對象用WeakReference軟引用類型的對象包裹,弱引用跟沒引用差不多,GC會直接回收掉,很少用
    public static WeakReference<User> user = new WeakReference<User>(new User());
  • 虛引用
    虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關(guān)系,幾乎不用

finalize()方法最終判定對象是否存活

即使在可達性分析算法中不可達的對象,也并非是“非死不可”的,這時候它們暫時處于“緩刑”階段,要真正宣告一 個對象死亡,至少要經(jīng)歷再次標記過程。
標記的前提是對象在進行可達性分析后發(fā)現(xiàn)沒有與GC Roots相連接的引用鏈。

  1. 第一次標記并進行一次篩選。
    篩選的條件是此對象是否有必要執(zhí)行finalize()方法。 當對象沒有覆蓋finalize方法,對象將直接被回收。
  2. 第二次標記
    如果這個對象覆蓋了finalize方法,finalize方法是對象脫逃死亡命運的最后一次機會,如果對象要在finalize()中成功拯救 自己,只要重新與引用鏈上的任何的一個對象建立關(guān)聯(lián)即可,譬如把自己賦值給某個類變量或?qū)ο蟮某蓡T變量,那在第 二次標記時它將移除出“即將回收”的集合。如果對象這時候還沒逃脫,那基本上它就真的被回收了。 注意:一個對象的finalize()方法只會被執(zhí)行一次,也就是說通過調(diào)用finalize方法自我救命的機會就一次。

如何判斷一個類是無用的類

方法區(qū)主要回收的是無用的類,那么如何判斷一個類是無用的類的呢? 類需要同時滿足下面3個條件才能算是 “無用的類”

  1. 該類所有的實例都已經(jīng)被回收,也就是 Java 堆中不存在該類的任何實例。
  2. 加載該類的 ClassLoader 已經(jīng)被回收。
  3. 該類對應(yīng)的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容