天天都是面對對象編程,你真的瞭解你的對象嗎?

推薦閱讀:

Java是一種面向對象的編程語言,詳細自己對對象的理解是否只有一句話來描述:一切皆對象,new出來的對象都在堆上!等等,這不是2句話?不,後面這句只是我寫這篇文章的原由。初學Java大家都說new出來的對象都在堆上,對此深信不疑!但是後續越發對這句話產生懷疑,想想每個類的toString方法都會new一個StringBuffer,這樣做堆內存豈不是增大一倍?For循環中創建對象爲什麼沒有堆溢出?創建的對象到底在堆中佔用多少內存?懷着以上疑問往下看,本篇文章作爲Java對象的綜合整理來描述何謂對象。

Java中一切皆對象,對象的創建主要如下:

People people = new People();

現在面試都是各種文字坑,例如:問這個對象是否在堆上分配內存?怎麼回答,是?不是?

這個問題,要根據上下文來回答,就是要根據這行代碼所處的環境來回答,何謂環境:運行環境JRE、書寫位置,不同環境結果不一樣。想知道結果,先Get到以下知識點:

逃逸分析是JDK6+版本後默認開啓的技術(現在都JDK15了,都是舊技術了==!),主要分析方法內部的局部變量的引用作用域,用於做後續優化。逃逸分析之後一個方法內的局部變量被分爲3類逃逸對象

  • 全局逃逸對象: 對外部而言,該對象可以在類級別上直接訪問到(調用類獲取對象實例)
  • 參數逃逸對象:對外部而言,該對象可以在方法級別上直接訪問到(調用方法獲取對象實例)
  • 未逃逸對象:對外部而言,該對象彷彿不存在一樣,不可嗅探

後續優化指的是對未逃逸的優化,主要分爲標量替換和鎖消除

標量替換:在Java中8種基本數據類型已經是可以直接分配空間的,不可再被細化,稱爲標準變量,簡稱標量。對象的引用是內存地址也不可再被細化,也可以稱爲標量。而Java對象則是由多個標量聚合而來,稱爲聚合量。按照這種標準將Java對象的成員變量拆分替換爲標量的過程,稱爲標量替換。這個過程會導致對象的分配不一定在堆中,而是在棧上或者寄存器中。

鎖消除:Java鎖是針對多線程而使用的,當在單線程環境下使用鎖後被JIT編譯器優化後就會移除掉鎖相關代碼,這個過程就是鎖消除(屬於優化,不影響對象)。

指針壓縮:32位機器對象的引用指針使用32位表示,在64位使用64位表示,同樣的配置而內存佔用增多,這樣真的好嗎?JDK給出指針優化技術,將64位(8字節)指針引用(Refrence類型)壓縮爲32位(4字節)來節省內存空間。

對象的逃逸

一個標準大小=32byte的Java對象(後面會寫如何計算)

class People {
    int i1;
    int i2;
    int i3;
    byte b1;
    byte b2;
    String str;
}

未逃逸對象

public class EscapeAnalysis {

    public static void main(String[] args) throws IOException {
        // 預估:在不發生GC情況下32M內存
        for (int j = 0; j < 1024 * 1024; j++) {
            unMethodEscapeAnalysis();
        }
        // 阻塞線程,便於內存分析
        System.in.read();
    }

    /**
     * people對象引用作用域未超出方法作用域範圍
     */
    private static void unMethodEscapeAnalysis() {
        People people = new People();
        // do  something
    }
}

未開啓逃逸分析

啓動JVM參數

-server -Xss108k -Xmx1G -Xms1G -XX:+PrintGC -XX:-UseTLAB -XX:-DoEscapeAnalysis -XX:-EliminateAllocations

啓動控制檯:無輸出:未發生GC

堆內存查看

$ jps
3024 Jps
16436 EscapeAnalysis
24072 KotlinCompileDaemon
$ jmap -histo 16436

 num     #instances         #bytes  class name
----------------------------------------------
   1:       1048576       33554432  cn.tinyice.demo.object.People
   2:          1547        1074176  [B
   3:          6723        1009904  [C
   4:          4374          69984  java.lang.String

此時堆中共創建了1024*1024個實例,每個實例32byte,共32M內存

開啓逃逸分析

啓動JVM參數

-server -Xss108k -Xmx1G -Xms1G -XX:+PrintGC -XX:-UseTLAB -XX:-DoEscapeAnalysis -XX:-EliminateAllocations

啓動控制檯:無輸出:未發生GC

堆內存查看

$ jps
3840 Jps
24072 KotlinCompileDaemon
25272 EscapeAnalysis
$ jmap -histo 25272

 num     #instances         #bytes  class name
----------------------------------------------
   1:       1048576       33554432  cn.tinyice.demo.object.People
   2:          1547        1074176  [B
   3:          6721        1009840  [C
   4:          4372          69952  java.lang.String

此時與未開啓一致,仍然是在堆中創建了1024*1024個實例,每個實例32byte,共32M內存

開啓逃逸分析和標量替換

啓動JVM參數

-server -Xss108k -Xmx1G -Xms1G -XX:+PrintGC -XX:-UseTLAB -XX:+DoEscapeAnalysis -XX:+EliminateAllocations

堆內存查看

$ jps
7828 Jps
21816 EscapeAnalysis
24072 KotlinCompileDaemon
$ jmap -histo 21816

 num     #instances         #bytes  class name
----------------------------------------------
   1:         92027        2944864  cn.tinyice.demo.object.People
   2:          1547        1074176  [B
   3:          6721        1009840  [C
   4:          4372          69952  java.lang.String

此時堆中僅創建了92027個實例,內存佔用少了11倍。

啓動控制檯:無輸出:未發生GC,說明實例的確未分配到堆中

未分配到堆中,是因爲一部分分配到了棧中,這種未逃逸對象如果分配到棧上,則其生命週期隨棧一起,使用完畢自動銷燬。下面爲java對象分配的具體細節。

對象的內存分配

實例分配原則


  1. 嘗試棧上分配

    • 基於逃逸分析和標量替換,將線程私有對象直接分配在棧上

    • 在函數調用完畢後自動銷燬對象,不需要GC回收

    • 棧空間很小,默認108K,不能分配大對象

  2. 嘗試TLAB

    • 判斷是否使用TLAB(Thread Local Allocation Buffer)技術

      • 虛擬機參數 -XX:+UseTLAB,-XX:-UseTLAB,默認開啓

      • 虛擬機參數-XX:TLABWasteTargetPercent 來設置TLAB佔用eEden空間百分比,默認1%

      • 虛擬機參數-XX:+PrintTLAB 打印TLAB的使用情況

      • TLAB本身佔用eEden區空間,空間很小不能存放大對象,

      • 每個線程在Java堆中預先分配了一小塊內存,當有對象創建請求內存分配時,就會在該塊內存上進行分配

      • 使用線程控制安全,不需要在Eden區通過同步控制進行內存分配

  3. 嘗試老年代分配(堆分配原則)

    • 如果可以直接進入老年代,直接在老年代分配
  4. 以上都失敗時(注意分配對象時很容易觸發GC,堆分配原則)

    • 內存連續時:使用指針碰撞(Serial、ParNew等帶Compact過程的收集器)

      • 分配在堆的Eden區,該區域內存連續

      • 指針始終指向空閒區的起始位置。

      • 在新對象分配空間後,指針向後移動了該對象所佔空間的大小個單位,從而指向新的空閒區的起始位置

      • 對象分配過程中使用了CAS加失敗重試的方式來保證線程安全(CAS即原子操作)

      • 如果成功:則進行對象頭信息設置

    • 內存不連續時:使用空閒列表(CMS這種基於Mark-Sweep算法的收集器)

      • 如果堆空間不是連續的,則JVM維護一張關係表,來使內存邏輯上連續從而達到對象分配的目

堆分配原則:


  • 優先在Eden(伊甸園)區進行分配

    • 可通過-XX:SurvivorRation=8來確定Eden與Survivor比例爲 8:1

    • 新生代存在2個Survivor區域(From和To),當新生代10份時,Survivor共佔2份,Eden佔8份

    • 新建對象會先在Eden中分配

      • 空間足夠時直接分配

      • 當Eden空間不足時

        • 將Eden內的對象進行一次Minor Gc 回收準備放入進入From類型的Survivor區

          • From類型的Survivor區

            • 空間足夠時,放置GC對象時將GC對象回收進來

            • 空間不足時,將GC對象直接放入老年代中

        • Minor GC後Eden空間仍然不足

          • 新建對象直接進入老年代
  • 長期存活的對象移交老年代(永久代)

    • 在Eden的對象經過一次Minor GC進入Survivo 區後,對象的對象頭信息年齡字段Age+1

    • Survivor區對象每經過一次Minor GC對象頭信息年齡字段Age+1

      • 會在From Survivor和ToSurvivor 區進行來回GC(複製算法)
    • 當對象的年齡達到一定值(默認15歲)時就會晉升到老年代

    • -XX:MaxTenuringThreshold=15設置分代年齡爲15

  • 大對象直接進入老年代(永久代)

    • 大對象爲佔用堆內大量連續空間的對象(數組類、字符串)

    • -XX:MaxTenuringThreshold=4M 可以設置大於4M的對象直接進入老年代

  • 動態年齡判斷

    • GC回收對象時並不一定必須嚴格要求分代年齡進行晉升老年代

    • 當Survivor區的同年齡對象的總和大於Survivor空間1/2時

      • 年齡大於等於該年齡(相同年齡)的對象都可以直接進入老年代
  • 老年代對象分配使用空間分配擔保

    • 新生代所有對象大小小於老年代可用空間大小時,Minor GC是安全的

      • 相當於新生代所有對象都可以放到老年代裏面,因而不會出現溢出等現象
    • 相反,Minor GC是不安全的

      • 相當於新生代對象只能有一部分可以放入老年代,另一部分會因爲空間不足而放入失敗

      • 安全措施-XX:HandlePromotionFailure=true,允許擔保失敗

      • 發生MinorGC之前,JVM會判斷之前每次晉升到老年代的平均大小是否大於老年代剩餘空間的大小

        • 若小於於並且允許擔保失敗則進行一次Minor GC

          • 對象GC預測平穩,不會發生大量對象突然進入老年代導致其空間不足而溢出
        • 若小於並且不允許擔保失敗則進行一次full GC

          • 即使對象GC預測平穩,但是不保證不會激增,所以安全點還是先去Full GC下

          • 回收所有區域,給老年代清理出更多空間

        • 若小於即使允許擔保失敗也進行一次full GC

          • 即Minor GC後的存活對象數量突然暴增,即使允許擔保失敗但是還是極大可能是不安全的

          • 回收所有區域,給老年代清理出更多空間

對象實例組成

  • 對象頭

    • MarkWord(必須)

    • 類型指針:指向對象的類元數據(非必須)

    • 數組長度(數組類型對象纔有)

  • 實例數據

    • 對象的字段屬性,方法等,存儲在堆中
  • 數據填充

    • JVM要求java的對象佔的內存大小應該是8bit的倍數

    • 實例數據有可能不是8的倍數,需要使用0進行填充對齊

MarkWord結構

對象的初始化

由於對象初始化涉及到類加載,這裏不多描述

  • 分配到的空間設置爲0

  • 數據填充0,8字節對齊

  • 對象頭信息設置

  • 調用<init>進行初始化(類的實例化)

給個示例先體會下

public class ClinitObject {

    static ClinitObject clinitObject;

    static {
        b = 2;
        clinitObject = new ClinitObject();
        System.out.println(clinitObject.toString());
    }

    int a = 1;
    static int b;
    final static int c = b;
    final static String d = new String("d");
    String e = "e";
    String f = "f";

    public ClinitObject() {
        e = d;
        a = c;
    }

    @Override
    public String toString() {
        return "ClinitObject{" + "\n" +
                "\t" + "a=" + a + "\n" +
                "\t" + "b=" + b + "\n" +
                "\t" + "c=" + c + "\n" +
                "\t" + "d=" + d + "\n" +
                "\t" + "e=" + e + "\n" +
                "\t" + "f=" + f + "\n" +
                '}';
    }

    public static void main(String[] args) {
        System.out.println(clinitObject.toString());
    }
}

控制檯

ClinitObject{
    a=0
    b=2
    c=0
    d=null
    e=null
    f=f
}
ClinitObject{
    a=0
    b=2
    c=2
    d=d
    e=null
    f=f
}

對象的大小計算

  • 普通對象

    • 4或8字節(MarkWord)+4或8字節(klass Reference)+實例數據長度+ 0填充(Padding)
  • 數組對象

    • 4或8字節(MarkWord)+4或8字節(klass Reference)+4字節(ArrayLength)+實例數據長度+0填充(Padding)
  • 其它說明:

    • 對象頭(MarkWord)在32位JVM中爲4字節,在64位JVM中爲8字節

    • 爲了節約空間,使用了指針壓縮技術:

      • JDK6開始對類型指針(Reference)進行壓縮,壓縮前8字節,壓縮後4字節

        • 參數 -XX:+UseCompressedOops
      • JDK8開始新增元數據空間metaSpace,於是新增參數來控制指針壓縮:

        • -XX:+UseCompressedClassPointers(指針壓縮開關,堆內存>=32G時,自動關閉)

        • -XX:CompressedClassSpaceSize (Reference指向的類元數據空間大小,默認1G,上限32G)

    • 數據填充(Padding)爲保證對象大小爲8的整數倍的數據填充,使數據對齊

  • 常用數據類型大小

對象的定位

java源碼中調用對象在JVM中是通過虛擬機棧中本地變量標的reference來指向對象的引用來定位和訪問堆中的對象的,訪問方式存在主流的2種

  • 句柄訪問

    • jvm堆中單獨維護一張reference與對象實例數據(實例化數據)和對象類型數據(ClassFile數據)的關係表

    • 通過該關係表來查找到java實例對象

  • 直接訪問(Sun HotSpot 採用該方式)

    • reference直接指向了java堆中對象的實例數據(實例化數據),該實例對象的類型指針(Reference)指向類型數據(ClassFile數據)

指針壓縮示例

public class CompressedClassPointer {

    public static void main(String[] args) throws IOException {
        People people=new People();
        System.in.read();
    }
}

啓用指針壓縮(默認)

JVM參數

-server -XX:+UseCompressedOops -XX:+UseCompressedClassPointers -XX:CompressedClassSpaceSize=1G

堆內存查看

$ jps
11440
11744 RemoteMavenServer
14928 KotlinCompileDaemon
15540 Launcher
15908 Jps
9996 CompressedClassPointer
$ jmap.exe -histo 9996

 num     #instances         #bytes  class name
----------------------------------------------
... 
233:             1             32  cn.tinyice.demo.object.People

關閉指針壓縮

JVM參數

-server -XX:-UseCompressedOops

堆內存查看

$ jps
11440
11744 RemoteMavenServer
14928 KotlinCompileDaemon
8448 CompressedClassPointer
$ jmap.exe -histo 8448

 num     #instances         #bytes  class name
----------------------------------------------
...
254:             1             40  cn.tinyice.demo.object.People

示例解析

示例中開啓之後對象大小會減少8byte。而指針壓縮是8字節變4字節,按理說應該少4字節即32位,爲什麼這個樣子?

開啓壓縮指針時的對象大小計算

/**
 * Size(People) =
 * 8(mark word)+4(klass reference)+ 4(i1)+4(i2)+4(i2)+1(b1)+1(b2)+4(str reference) + 2(padding)
 * |----------------------------------- 30 byte ---------------------------------|----00-------/
 * |---------------------------------------- 32 byte ------------------------------------------/
 */

關閉壓縮指針時的對象大小計算

/**
 * Size(People) =
 * 8(mark word)+8(klass reference)+ 4(i1)+4(i2)+4(i2)+1(b1)+1(b2)+8(str reference) + 2(padding)
 * |----------------------------------- 38 byte ---------------------------------|----00-------/
 * |---------------------------------------- 40 byte ------------------------------------------/
 */

這裏就看到區別了,是數據填充造成的,java爲了便於數據管理,於是對象都是8字節對齊的,不足的使用0進行填充(padding)。

至於對象的實例化,會在寫類加載流程是再做描述。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章