天天看點

Android性能調優之需要掌握的JVM知識

今天開始學習性能調優,跟着網上大神的blog整理。方向是劉望舒大神的《Android進階解密》

性能調優有分很多種:

  1. 繪制優化
  2. 記憶體優化
  3. 電量優化
  4. 啟動優化
  5. 存儲優化
  6. 流量優化
  7. 圖檔優化
  8. Apk包體優化

既然要深入到這些優化去,僅僅是掌握一些工具 TraceView、Lint、LeakCanary是不夠的,我們要去學習更多的知識、架構,從系統源碼、虛拟機即低層的角度去看待這些優化。

是以在去學工具架構之前,我們有必要從頭梳理一遍Android本身。第一步,就是了解Java虛拟機。

1.Android角度應該學習JVM的什麼

我們常用的 JDK、JRE都是建立在 JVM的基礎上。它有各種指令集和運作時資料區域。雖然叫做Java虛拟機,但其實在它之上運作的語言可不僅僅是Java,還包括Koltin、Groovy、Scala等。是以就算你用Kotlin開發Android,你也會和JVM打交道。

本篇也并不完全的解析JVM,也沒有必要,隻是從Android開發的角度,我們需要去了解JVM的哪些東西。

需要學的大概是:

  1. JVM執行流程
  2. JVM結構
  3. 面向對象在JVM的展現
  4. 對象的堆記憶體布局
  5. opp-klass模型
  6. Java對象在虛拟機中的生命周期
  7. Java的GC機制

注意的是,Android中的Dalvik和ART并不屬于JVM。

2.JVM執行流程

當我們執行一個Java程式時,它的執行流程如圖所示:

Android性能調優之需要掌握的JVM知識

圖中可以看出,JVM執行流程分為兩個部分,分别是編譯時環境和運作時環境,當一個Java檔案經過Java編譯器編譯後會生成一個 ​

​.class​

​​檔案,這個 ​

​.class​

​會交由JVM來處理。

Jvm和Java語言沒有什麼必然的聯系,它隻跟特定的二進制檔案 Class檔案有關。是以任何語言隻要能編譯出 ​

​.class​

​檔案,就能被JVM識别且執行。

3.JVM結構體系

這裡講的結構,并不是JVM實體上的結構,而且是其實作邏輯,是抽象層面上的結構。

我說我是個車輪,是因為我走路的時候把自己當成車轱辘來滾,而不是我真的是個輪子。

按照Java虛拟機規範,抽象的JVM如圖所示:

Android性能調優之需要掌握的JVM知識

可以看出Java虛拟機包括 運作時資料區域、執行引擎、本地庫接口和 本地方法庫。類加載子系統并不時JVM的内部結構。

在這些區域裡,像 方法區、Java堆、本地庫接口,垃圾回收器、即時編譯器都是線程共享的。

3.1 Class檔案格式

Java檔案被編譯後生成了 Class檔案,這種二進制格式的檔案不依賴與特定的硬體和作業系統。

每一個class檔案都對應着唯一的類或接口的定義資訊,但是類或者接口并不一定定義在檔案中,比如類可以通過類加載器直接生成。之前說過,任何語言隻要能編譯成Class檔案,就可以被Java虛拟機識别并且執行,Class檔案的重要性可見一斑。

下面我們來學習Class檔案格式:

ClassFile { 
    u4 magic;  // 魔數,表明目前檔案是.class檔案,固定0xCAFEBABE
    u2 minor_version; // Class檔案的副版本号
    u2 major_version;  //Class檔案主版本号
    u2 constant_pool_count; // 常量池計數
    cp_info constant_pool[constant_pool_count-1];  // 常量池内容
    u2 access_flags; // 類/接口通路辨別
    u2 this_class; // 目前類索引
    u2 super_class; // 父類索引
    u2 interfaces_count; // 接口計數器
    u2 interfaces[interfaces_count]; // 接口表
    u2 fields_count; // 字段計數器
    field_info fields[fields_count]; // 字段表 
    u2 methods_count; // 方法計數器
    method_info methods[methods_count]; // 方法表
    u2 attributes_count;  // 屬性計數器
    attribute_info attributes[attributes_count]; // 屬性表
}      

其中:uX 代表 X位元組的無符号類型。比如u4就是4位元組的無符号類型

3.2 類的生命周期

一個Java檔案被加載到JVM記憶體中到從記憶體中被解除安裝的過程被稱為類的生命周期。

類的生命周期包括的階段分别是:加載、連結、初始化、使用和解除安裝。其中連結包括驗證、準備和解析。是以類的生命周期被分為了7個階段,順序如下所示。

  1. 加載

    查找并加載Class檔案

  2. 連結

    包括驗證、準備和解析

    (1) 驗證:確定被導入類型的正确性

    (2)準備:為類的靜态字段配置設定字段,并使用

    (3)解析:虛拟機将常量池内的符号引用替換為直接引用

  3. 初始化

    将類變量初始化為正确初始值

  4. 使用
  5. 解除安裝

其中前三個階段稱為類的加載階段。

在《深入了解JVM》中,上述第一點,加載階段(非類加載階段)主要做了3件事情:

  • 根據特定名稱查找類或接口類型的二進制位元組流
  • 将這個二進制位元組流所代表的靜态存儲結構 轉化成 方法區的運作時資料結構
  • 在記憶體中生成了一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種資料的通路入口。

其中第一件事情就是由Java虛拟機外部的類加載子系統來完成的。

3.3 類加載子系統

類加載子系統通過多種類加載器來查找和加載Class檔案到JVM中,JVM有兩種類加載器,分别是系統加載器和自定義加載器。之前對類加載機制做過了解:​​Java ClassLoader總結​​ 這裡就複制其中比較關鍵的東西吧:

  • Bootstrp ClassLoader(引導類加載器)

    Bootstrp加載器是由C++語言編寫的,它是在JVM啟動後初始化的,主要負責加載​​

    ​%JAVA_HOME%/jre/lib​

    ​​ ,​

    ​-Xbootclasspath​

    ​​參數指定的路徑以及%JAVA_HOME%/jre/classes中的類。

    因為其是由C++寫的,是以不能被Java代碼通路到,但是可以查詢某個類是否被引導類加載器加載過。

  • Extensions ClassLoader(拓展類加載器)

    Bootstrp Loader加載ExtClassLoader,并且設定其父加載器(是父加載器而不是父類哦)為自己,這個ExtClass Loader是java編寫的,它主要加載​​

    ​%JAVA_HOME%/jre/lib/ext​

    ​​這個路徑下所有的classes目錄以及​

    ​java.ext.dirs​

    ​系統變量指定路徑中的類庫。
  • Application ClassLoader(應用程式類加載器)

    Bootstrp Loader加載完ExtClassLoader之後會加載AppClassLoader,并指定其父加載器為ExtClassLoader,它的作用是加載目前應用程式​​

    ​Classpath​

    ​​目錄,以及系統屬性​

    ​java.class.path​

    ​所指定位置的類或者jre文檔,它也是Java的預設加載器。

關于ClassLoader的學問我們後邊再寫一篇,加深了解

4 運作時資料區域

Java的記憶體不僅僅是堆記憶體和棧記憶體。

1.程式計數器

為了保證程能夠連續的執行下去,處理器必須具有某些手段來确定下一條指令的位址。而程式計數器正是起到這種作用。

程式計數器也叫PC寄存器,是一塊較小的記憶體空間。在虛拟機概念模型中,位元組碼解釋器的工作時就時通過改變程式計數器來選取下一個條需要執行的位元組碼指令的。

JVM的多線程是通過輪流切換并配置設定處理器執行時間的方式來實作的。在一個确定的時刻隻有一個處理器執行一條線程中的指令。為了線上程切換後能恢複到正确的執行位置,每個線程都會有一個獨立的程式計數器,是以程式計數器是私有的。

如果線程執行的方法不是native方法,則程式計數器儲存在正在執行的位元組碼指令位址,否則程式計數器的值為空。程式計數器是JVM規範中唯一沒有任何OOM情況的資料區域

2.Java虛拟機棧

每一條Java虛拟機線程都有一個線程私有的Java虛拟機棧。它的生命周期與線程相同。

Java虛拟機棧存儲線程中Java方法調用的狀态,比如局部變量、參數、傳回值以及運算的中間結果等。

一個Java虛拟機棧包含了多個棧幀,一個棧幀用來存儲局部變量、操作數棧、動态連結、方法出口等資訊。當線程調用一個Java方法時,虛拟機就壓入一個新的棧幀到該線程的Java虛拟機棧中,在該方法執行完成後,這個棧幀就從Java虛拟機棧中彈出。

Java虛拟機規範中定義了兩種異常情況:

  1. 如果線程請求配置設定的棧容量超過Java虛拟機所允許的最大容量,Java虛拟機就會抛出 StackOverflowError,即爆棧
  2. 如果JVM棧可以動态擴充,但是擴充時無法申請到足夠的記憶體,或者在建立新的線程時,沒有足夠的記憶體去建立對應的JVM,就會抛出 OutOfMemoryError異常,即OOM

因為大部分JVM都是可以擴充的,是以相比于爆棧,我們見到OOM的情況更多。

3.本地方法棧

JVM可能要用到C Stacks來支援Native語言,這個C Stacks就是本地方法棧。

它與JVM棧類似,隻不過本地方法棧是用來支援Native方法的,如果Java虛拟機不支援Native方法,并且也不依賴于C Stacks,可以無需支援本地方發展。Jvm可以自由的實作本地方法棧,比如 HotSpot VM将本地方發展和Java虛拟機棧合二為一。

本地方法棧也會抛出 StackOverflowError和OutOfMemoryError的異常。

4.Java堆

Java堆是被所有線程共享的運作時記憶體區域。Java堆用來存放對象執行個體。

幾乎所有的對象執行個體都在這裡配置設定記憶體。Java堆存儲的對象被垃圾收集器管理,這些受管理的對象無法顯式的銷毀。

從記憶體回收的角度來分,Java堆可以粗略的分為新生代和老年代。

從記憶體配置設定的角度來分,Java堆中可能劃分出多個線程私有的配置設定緩沖區。

Java虛拟機規範中定義了一種異常情況:如果在堆中沒有足夠的記憶體來完成執行個體配置設定,并且堆也無法進行擴充式時,也會抛出OutOfMemoryError異常。

5.方法區

方法區是被線程共享時的記憶體區域,用來存儲已經被Java虛拟機加載的類的結構資訊。包括運作時常量池、字段和方法資訊、靜态變量等資料。方法區是Java堆的邏輯組成部分,它一樣在實體上不用連續,并且可以選擇在方法區中不實作垃圾收集。

方法區并不等同于永久代,隻是因為HotSpot VM使用永久代來實作方法區,對于其他的JVM,比如J9和JRockit等,并不存在永久代等概念。

如果方法區不滿足記憶體配置設定需求時,JVM也會抛出OOM異常。

6.運作時常量池

并不是運作時資料區域的一份子,而是方法區的一部分。

在前面的Class檔案結構中我們看到了,Class檔案不僅包含類的版本号、接口、字段等,還包含常量池。

它用來存放編譯時期生成的字面量和符号引用,這些内容會在類加載後存放在方法區的運作時常量池中。

運作時常量池可以了解為是類或接口的常量池的運作時表現形式。

5.對象的建立

對象的建立是我們經常要做的事情,通常是通過new指令來完成一個對象的建立,當虛拟機接收到一個new指令時,它會做如下的操作:

  1. 判斷對象對應的類是否加載、連結和初始化
  2. 為對象配置設定記憶體

    類加載完成後,接着會在Java堆中劃分一塊記憶體配置設定給對象。記憶體配置設定是根據Java堆是否規整。有兩種方式:

    (1)指針碰撞,如果Java堆的記憶體是規整的,即所有用過的記憶體放在一邊,而空閑的記憶體放在一邊。配置設定記憶體時将位于中間的指針訓示器向空閑的記憶體一動一段與對象大小想等的距離,這樣便完成配置設定記憶體的工作

    (2)空閑清單,如果Java堆的記憶體是不規整的,則需要由虛拟機維護一個清單來記錄哪些記憶體是可以用的。

    這樣在配置設定的時候可以從清單中查詢足夠大的記憶體配置設定給對象。

    Java堆的記憶體是否規整根據所采用的垃圾收集器是否帶有壓縮整理功能有關。

  3. 處理并發安全問題

    建立對象是一個非常頻繁的操作,是以需要解決并發的問題,有兩種方式:

    (1)對配置設定記憶體空間的動作進行同步處理,比如在虛拟機采用CAS算法并配上失敗重試的方式保證更新操作的原子性

    (2)每個線程在Java堆中預先配置設定一小塊記憶體,這塊記憶體成為本地線程配置設定緩沖,線程需要配置設定記憶體時,就在對應線程的TLAB上配置設定記憶體,當線程的TLAB用完并且被配置設定到了新的TLAB時,這時候才需要同步鎖定。通過 -XX:+/-UserTLAB參數來設定虛拟機是否使用TLAB。

  4. 初始化配置設定到的記憶體空間

    将配置設定到的記憶體,除了對象頭外都初始化為零值

  5. 設定對象的對象頭

    将對象的所屬類、對象的HashCode和對象的GC分代年齡等資料存儲在對象的對象頭中。

    對象頭的知識後面會梳理

  6. 執行init方法進行初始化

    執行​​

    ​init()​

    ​,初始化對象的成員變量、調用類的構造方法,這樣一個對象就被建立出來的。

PS:單從上面就可以知道,建立一個對象其實也會造成一定的COST,是以看了這些東西,你還會輕易的去new對象嗎?你還會再onDraw() 裡面去new對象嗎?是以也請把對象的建立看成是一個輕微級的操作來看!

4.1 對象的堆記憶體布局

我們已經知道對象被建立了,堆又給對象配置設定了空間,那麼對象在堆記憶體是如何進行布局的呢,它長的是什麼樣的呢?就是上一節所講的,對象頭是啥?

以HotSpot VM為例,對象在堆記憶體的布局分為三個區域:

  1. 對象頭

    對象頭包括兩部分資訊,分别是Mark Word和中繼資料指針

    (1)Mark Word:用于存儲對象運作時資料,比如 Hash Code、鎖狀态标志、CG分代年齡,線程持有的鎖

    (2)中繼資料指針:用于指向方法區中的目标類的中繼資料,通過中繼資料可以确定對象的具體類型。後面會細講

  2. 執行個體資料

    用于存儲對象中的各種類型的字段資訊(包括父類繼承來的)

  3. 對齊填充

    對齊填充不一定存在,起到了占位符的作用,沒有特别的含義。

Mark Word在HotSpot中的實作類為​

​markOop.hpp​

​​,markOop被設計成一個非固定的資料結構,這是為了在極小的空間中存儲盡量多的資料。

32位虛拟機的markOop格式如下:

//32位的markOop
hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)  
JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)  
size:32 ------------------------------------------>| (CMS free block)  
PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)      

資料的解釋為:

  1. hash

    對象的哈希碼

  2. age

    對象的分代年齡

  3. biased_lock

    偏向鎖辨別位

  4. lock

    鎖狀态标志位

  5. JavaThread*

    持有偏向鎖的線程Id

  6. epoch

    偏向時間戳

Mark Word經常被用于研究鎖的狀态,我之前在做關于對象鎖的了解時,也有寫過這種東西,隻是角度不同,是從鎖追溯到Mark Word,而這裡是從Mark Word追溯到鎖,這裡對鎖就不多做細講了,這裡有兩篇:​​Java中的幾種鎖機制​​​​Synchronized的鎖優化​​

這裡小結一下:

一個程序的啟動就能産生一個JVM,一個JVM上有多個線程。在程式運作的時候,JVM上堆會配置設定了很多個對象的記憶體空間。

當一個線程想要使用堆上的某一個對象時,會先去通路這個對象的Mark Word,看看這個鎖,這個類鎖、這個對象鎖是不是能用(就是是不是被别的線程使用了),如果可以用,那就用,如果用不了,就根據鎖的狀态進行 自旋/等待 or …

4.2 oop-klass模型

oop-klass是用來描述Java對象執行個體的一種模型,它分為兩個部分:

  1. OOP(Ordinary Object Pointer)

    指的是普通對象指針,用來表示對象的執行個體資訊。

  2. klass

    klass用來描述中繼資料

在HotSpot中就采用了 oop-klass模型,oop實際上是一個家族,JVM内部會定義很多oop類型,如下所示:

typedef class markOopDesc* markOop;     //oop标記對象
typedef class oopDesc* oop;                //oop家族的頂級父類
typedef class instanceOopDesc* instanceOop; //表示Java類執行個體
typedef class arrayOopDesc* arrayOopDesc*;  //數組對象
typedef class objArrayOopDesc* objectArrayOopDes //引用類型數組對象
typedef class typeArrayOopDesc* typeArrayOop;   //基本類型數組對象      

其中oopDesc是所有oop的頂級父類,arrayOopDesc是objArrayOopDesc和typeArrayOopDesc的父類。

instanceOopDesc*和arrayOopDesc都可以用來描述對象頭。

還定義了 klass家族:

class Klass;    //klass家族的父類
class InstanceKlass;  //描述Java類的資料結構
class InstanceMirrorKlass;   //描述java.lang.Class執行個體
class InstanceClassLoaderKlass; //特殊的InstanceKalss,不添加任何字段
class InstanceRefKlass;   //描述Java.lang.ref.Reference的子類
class ArrayKlass;   //描述Java數組資訊
class ObjectArrayKlass;  //描述Java中引用類型數組的資料結構
class TypeArrayKlass;   //描述Java中基本類型數組的資料結構      

其中Klass是klass家族的頂級父類。ArrayKlass是ObjArrayKlass和TypeArrayKlass的父類。

可以發現 oop家族的成員和klass家族成員有着對應的關系。

比如instanceOopDesc對應InstanceKlass。objArrayOopDesc對應ObjeArrayKlass。

我們來看看oop的頂級父類 oopDesc的定義:

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    wideKlassOop    _klass;
    narrowOop       _compressed_klass;
  } _metadata;
  // Fast access to barrier set.  Must be initialized.
  static BarrierSet* _bs;
...
}      

oopDesc中包含了連個資料成員:mark和 _metadata。

其中​​

​markOop​

​​類型的mark對象指的是前面講到的Mark Word。

​​

​metadata​

​​是一個共用體,其中的klass是普通指針,​

​_compressed_klass​

​​是壓縮類指針。這兩個就是對象頭中的中繼資料指針。他們根據對應關系都會指向instanceKlass,instanceKlass用來描述中繼資料。instanceKlass繼承自​

​klass​

​,我們來看看 klass的定義:

jint  _layout_helper;    //對象布局的綜合描述符
Symbol* _name;         //類名
oop  _java_mirror;      //類的鏡像類
Klass* _super;           //父類
Klass* _subklass;      //第一個子類
Klass*  _next_sibling;   //下一個兄弟節點
jint  _modifier_flags;  //修飾符辨別
AccessFlags  _access_flags;  //通路權限辨別      

可以看到klass描述了中繼資料。具體來說就是Java類在Java虛拟機中對等的C++類型描述。

這樣繼承自klass的instanceKlass同樣可以用來描述中繼資料。

了解了oop-klass模型,我們就可以分析JVM是如何通過棧幀中的對象引用找到對應的對象執行個體的。

4.3 Java對象在虛拟機中的生命周期

在Java對象被類被加載到虛拟機中後,Java對象在Java虛拟機中有7個階段:

  1. 建立階段

    建立階段的具體步驟為:

    (1)為對象配置設定存儲空間

    (2)構造對象

    (3)從超類到子類對static成員進行初始化

    (4)遞歸調用超類的構造方法

    (5)調用子類的構造方法。

  2. 應用階段

    當對象被建立,并配置設定給變量指派時,狀态就切換到了應用階段。這一個階段的對象至少要具有一個強引用,或者顯示地使用弱引用、軟引用或者虛引用

  3. 不可見階段

    在程式中找不到對象的任何強引用。

    在不可見階段,對象仍可能特殊的強引用勇GC Roots持有着,比如對象被本地方法棧中JNI引用或者被運作中的線程引用等。

  4. 不可達階段

    在程式中找不到對象的任何強引用,并且垃圾收集器發現對象不可達

  5. 收集階段

    垃圾收集器已經發現對象不可達,并且垃圾收集器已經準備好要對該對象的記憶體空間重新進行配置設定,這個時候如果該對象重寫了​​

    ​finalize()​

    ​,則會調用該方法
  6. 終結階段

    在對象執行完​​

    ​finalize()​

    ​​仍然處于不可達狀态時,或者對象沒有重寫​

    ​finalize()​

    ​,則該對象進入終結階段,并等待垃圾回收器回收該對象空間。
  7. 對象空間重新配置設定階段

    當垃圾收集器對對象的記憶體空間進行回收或者再配置設定時,這個對象就會徹底的消失。

至于不可見和不可達狀态,我們就要在垃圾回收器算法中取解釋它。

6 垃圾标記算法和垃圾收集算法

之前寫過,而且還挺詳細挺準确,是以這節不會贅述。

在這裡:​​​GC算法與種類​​,通過這篇去學習GC。

在看之前,先要知道的是,垃圾标記算法用于标記對象現在是處于什麼階段。它有兩種方式:引用計數法和根搜尋算法。

7. 小結