Android效能優化篇之(二)序言及JVM篇
前言
在記憶體方面,相比於C/C++程式設計師,咱們java系程式設計師算是比較幸運的,因為對於記憶體的分配和回收,都交給了JVM來處理了,而不需要手動在程式碼中去完成。有了虛擬機器記憶體管理機制,也就不那麼容易出現記憶體洩漏和記憶體溢位的問題了。不那麼容易出現,並不代表就不會出現。正是由於程式設計師將記憶體的控制大權交了出去,那麼一旦出現了記憶體洩漏和記憶體溢位的問題,如果虛擬機器如何分配記憶體的工作機制不瞭解,那這就成了一個難以處理的問題了。所以說,放權可以,但不能完全失去控制,否則,就有被架空的危險,出了問題,你只能幹
本文的主要內容如下:

image
一、記憶體的家庭住址
我們這麼關心的記憶體,到底是何方神聖呢?看圖比看文字舒服,咱們先上圖:

image
似曾相識吧!這個就是第一節中JVM執行java程式的流程。ClassLoader載入完畢.class檔案後,交由執行引擎執行。整個程式執行過程中,JVM會用一段空間來儲存執行期間需要用到的資料和相關資訊,這段空間一般被稱作Runtime Data Area (執行時資料區),這就是咱們常說的JVM記憶體,我們常說到的記憶體管理就是針對這段空間進行管理。這樣,我們就找到記憶體的家庭住址了。
二、記憶體大家庭中都有哪些成員呢?
咱們仍然先上圖:

image
Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體(執行時資料區)劃分為若干個不同的資料區域。這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程序的啟動而存在,有些區域則依賴使用者執行緒的啟動和結束而建立和銷燬。根據《Java虛擬機器規範(Java SE 7版)》的規定,Java虛擬機器所管理的記憶體包含了上圖中的5個區域:程式計數器,虛擬機器棧,本地方法棧,GC堆,方法區。
三、記憶體的家庭成員分別都是幹嘛的呢?
這一部分比較理論,文字描述比較多,但是如果有一定的基礎而且認真讀的話,其實很容易懂的,同時要想更好地理解記憶體這方面的知識,也需要耐著性子好好看。
1、程式計數器
程式計數器(Program Counter Register)是一塊較小的記憶體空間,也有的稱為PC暫存器。
學過組合語言或者計算機機構與組成原理的童鞋,應該對著個概念不陌生,在組合語言中,程式計數器是指CPU中的暫存器,它儲存的是程式當前執行的指令的地址,當CPU需要執行指令的時候,就從中取出這條地址,並根據這條地址獲取到指令。獲取到指令後,程式計數器會自動+1或者根據轉移指標得到下一條指令的地址,如此迴圈,直到執行完所有的指令。位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。
雖然JVM中的程式計數器並不像組合語言中的程式計數器一樣是物理概念上的CPU暫存器,但是JVM中的程式計數器的功能跟組合語言中的程式計數器的功能在邏輯上是等同的,也就是說是用來指示執行哪條指令的。
由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。
如果執行緒執行的是非native方法,則程式計數器中儲存的是當前需要執行的指令的地址;如果執行緒執行的是native方法,則程式計數器中的值為空(undefined)。這塊記憶體中儲存的資料所佔空間的大小不會隨程式的執行而發生改變,所以,此記憶體區域不會發生記憶體溢位(OutOfMemory)問題,該記憶體區域也是唯一一個在JVM規範中沒有規定任何OutOfMemoryError情況的區域。
2、Java虛擬機器棧
Java虛擬機器棧(Java Vitual Machine Stack)簡稱為Java棧,也就是我們常常說的棧記憶體。它是Java方法執行的記憶體模型。

image
如上圖所示,Java棧中存放的是一個個的棧幀,每個棧幀對應的是一個被呼叫的方法。每一個棧幀中包括瞭如下部分:區域性變量表(Local Variables)、運算元棧(Operand Stack)、指向當前方法所屬的類的執行時常量池(執行時常量池的概念在方法區部分會談到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些額外的附加資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。因此可以知道,執行緒當前執行的方法所對應的棧必定位於Java虛擬機器棧的頂部。在Java虛擬機器規範中,對Java棧區域規定了兩種異常狀況:1)如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲棧記憶體溢位(StackOverflowError)異常;2) 如果虛擬機器棧可以動態擴充套件,而且擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。到這裡,我們就容易理解,在使用遞迴方法的時候,如果這個方法的層次太深,就會導致Java棧中的棧幀過多,從而導致棧記憶體溢位。這部分空間的分配和釋放都是由系統自動實施的,而不需要程式設計師去管理了。
經常有人把Java的記憶體區分為堆記憶體(Heap)和棧記憶體(Stack),這種分法比較粗糙,實際劃分遠比這複雜。之所以這種分法能夠流行,說明大多數程式設計師最關注的、與物件記憶體分配關係最密切的記憶體區域主要是這兩塊。下面咱們對棧幀再做細緻的描述。
(1)區域性變量表。顧名思義,它是一組變數值儲存空間,用於存放對應方法的形參和方法內部定義的非static區域性變數。其中存放的資料的型別有如下幾種:a)基本資料型別。boolean,char,byte,short,int,long,float,double,java中定義的8種基本資料型別。b)物件引用(reference)。不是物件本身,而是指向物件例項的一個引用,這個就是Java中的指標,他的值為一個地址,在堆中該例項的首地址。例如,Date date = new Date(...);new Date(...)表示在堆記憶體中開闢了一個空間來儲存該例項物件,而date就是物件的引用,區域性變量表中儲存的就是指向堆中該物件的首地址。c) retunAddress型別。它指向了一條位元組碼指令的地址。這一點沒有查得很明白,筆者估計應該是方法執行完畢後,返回給程式計數器的當前指令的地址吧。區域性變量表所需的記憶體空間在編譯期間完成分配,即在Java程式被編譯成.class檔案時,就確定了所需要分配的最大區域性變量表的容量。當進入一個方法時,這個方法需要在棧中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變其大小。
(2)運算元棧。其又常被稱為操作棧,它的最大深度也是在編譯的時候就確定了。當一個方法開始執行時,它的操作棧是空的,在方法執行過程中,會有各種位元組碼指令(比如:加操作、賦值運算等)向操作棧中寫入內容,也就是入棧,計算完畢後提取內容,即出棧操作。學過資料結構的童鞋,一定對錶達式求職問題不會陌生,棧最典型的一個應用就是用來對錶達式求值。一個執行緒執行方法的過程中,實際上就是不斷執行語句的過程,歸根到底就是進行計算的過程,可以說,程式中的所有計算過程都是在藉助於運算元棧來完成的。Java虛擬機器的解釋執行引擎也被稱為“基於棧的執行引擎”,這裡“棧”就是運算元棧。因此我們也稱Java虛擬機器是基於棧的,這點不同於Android虛擬機器,Android虛擬機器是基於暫存器的。基於棧的指令集最主要的優點是可移植性強,主要缺點是執行速度相對較慢;而由於暫存器由硬體直接提供,所以基於暫存器指令集最主要的優點是執行速度快,主要缺點是可移植性差。
(3)指向當前方法所屬的類的執行時常量池的引用。很多地方也稱這個部分為動態連線。每個棧幀都包含一個指向執行時常量池(在方法區中詳細介紹)的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。Class檔案的常量池中存在有大量的符號引用,位元組碼中的方法呼叫指令就是以常量池中指向方法的符號引用為引數。這些符號引用,一部分會在類載入階段或一第一次使用的時候轉化為直接引用(如final,static域等),稱為靜態解析,另一部分將在每一次的執行期間轉化為直接引用,這部分稱為動態連線。簡單點說,就是因為在方法執行的過程中有可能需要用到類中的常量,所以需要有一個引用指向執行時常量。
(4)方法返回地址。當一個方法執行完畢之後,要返回之前呼叫它的地方,因此在棧幀中必須儲存一個方法返回地址。方法被執行後,有兩種方式退出該方法:一種是執行引擎遇到任意一個方法返回的位元組碼指令,也就是遇到了return,或者void函式執行完畢;另外一種是遇到了異常,並且該異常沒有在方法體內得到處理,即沒有用try-catch進行捕獲。無論是哪種退出方式,在退出後都需要返回到方法被呼叫的位置,程式才能繼續執行。方法返回時可能需要在幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的PC計數器的值就可以作為返回地址,棧幀中很可能儲存了這個計數器值。而方法異常退出時,返回地址是要通過異常處理來確定的,棧幀一般不會儲存這部分資訊。方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層區域性變量表和運算元棧,如果有返回值,則把它壓入呼叫者棧幀的運算元棧中,調整程式計數器的值以指向方法方法呼叫指令後面的一條指令。
每個執行緒擁有自己的Java棧,呼叫自己的方法,互不干擾,屬於“私有記憶體”。
3、本地方法棧(Native Method Stack)
本地方法棧與Java虛擬機器棧的作用和原理非常相似,區別在與前者為執行Nativit方法服務的,而後者是為執行Java方法服務的。在JVM規範中對本地方法棧中方法使用的語言,使用方式和資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。在HotSpot虛擬機器中,直接把本地方法棧和Java棧合二而一了,而我們平時Java開發中,最常用到的就是HotSpot虛擬機器。與Java虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError和OutOfMemoryError異常。
4、GC堆
對於大多數應用來說,Java堆(Java Heap)是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配。堆是被所有執行緒共享的,在JVM中只有一個堆。這一點在Java虛擬機器規範中的描述為:所有的物件例項以及陣列都要在對上分配,但是隨著JIT編譯器(即時編譯器:是一種提高程式執行效率的方法,通常由兩種執行方式,靜態編譯與動態編譯。靜態編譯是指執行前全部翻譯為機器碼,動態編譯時指,一句一句地邊翻譯邊執行)的發展與逃逸技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在對上也漸漸變得不那麼絕對了。
Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱作“GC堆”(Garbage Collected Heap)。如果還細分,有新生代和老年代等的劃分,此處不詳細展開,有興趣和需要深入的可以自行研究。根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,既可以實現成固定大小的,也可以是可擴充套件的,不過當前主流還是可以擴充套件的(通過-Xmx和-Xms控制)。如果在堆中沒有記憶體完成例項分配,並且也無法再擴充套件時,將會丟擲OutOfMemoryError異常。
5、方法區
方法區(Method Area)在JVM中也是一個非常重要的區域,它與堆一樣,是被執行緒共享的區域,一般用來儲存不容易改變的資料,所以一般也被稱為“永久代”。在方法區中,儲存了每個類的資訊(包括類名,方法資訊,欄位資訊)、靜態變數、常量以及編譯器編譯後的程式碼等。在Class檔案中除了類的欄位、方法、介面等描述資訊外,還有執行時常量池,用來儲存編譯期間生成的字面量和符號引用。在方法區中有一個非常重要的部分就是執行時常量池,它是每一個類或者介面的常量池的執行時表示形式,在類和介面被載入到JVM後,對應的執行時常量池就被創建出來。當然並非Class檔案常量池中的內容才能進入執行時常量池,在執行期間也可將新的常量放入執行時常量池中,比如String的intern方法(這一段是不是看得比較蒙?這裡先整體提一下,後面還會對該段內容做詳細整理,畢竟這一段全是知識點)。
JVM垃圾收集器可以像管理堆區一樣管理這部分割槽域,從而不需要專門為這部分設計垃圾回收機制。不過,從JDK7之後,HotSpot虛擬機器便將執行時常量池從永久代中移除了。
Java虛擬機器規範把方法區描述為Java堆的一個邏輯部分,而且它和Java Heap一樣不需要連續的記憶體,可以選擇固定大小或可擴充套件,可以允許該區域選擇不實現垃圾回收。相對而言,垃圾收集行為在這個區域出現比較少,該區域的記憶體回收目標主要是針對廢棄常量和無用類的回收。為了區別於Java-Heap,方法區也被稱為Non-Heap區。根據規範,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。
四、方法區到底儲存了哪些資訊?
上一節中的第5點中,概括性地講到了方法區儲存的資訊,說得比較籠統,那樣是遠遠不夠的,筆者仍然需要再更細緻地探究一下,才能更深入地理解。

image
1、類資訊
(1)型別的全限定名:即類的完整有效名。在Java原始碼中,完整有效名由類的所屬包名稱加一個".",再加上類名組成。如,Object類所屬的包為java.lang,那它的完整名稱為java.lang.Object,但是在類的檔案裡,所有的“.”都被斜槓 “/” 代替,就成為java/lang/Object。完整有效名在方法區中的表示根據不同的實現而不同。
(2)超類的全限定名:即直接父類的完整有效名。
(3)直接超介面的全限定名:即實現的介面的完整有效名。
(4)型別標誌:即該類是普通類型別還是介面型別。
(5)類的訪問描述符:如publlic,private,default,protected,abstract,final,static等
2、類的常量池
JVM為每個已載入的型別都維護一個常量池,是這個類用到的常量的一個有序集合,包括實際的常量(String,Integer,Floating Point常量)和對類、域(屬性)和方法的符號引用(符號引用在後面會講到)。池中的資料項像陣列項一樣,是通過索引訪問的。因為常量池儲存了一個型別所使用到的所有類、域和方法的符號引用,所以它在Java程式的動態連結中起了核心的作用。(這一部分下一節會和執行時常量一起詳細講到)
3、欄位資訊(該類宣告的所有欄位,也稱Field,屬性,域)
(1)欄位修飾符:如public、protected,private,default
(2)欄位的型別:比如int,float等8種基本型別和引用型別
(3)欄位的名稱:這個好理解
4、方法資訊(方法資訊中包含類中的所以方法,每個方法又包含了如下資訊)
(1)方法修飾符:public、protected,private,default,static,final,synchronized,native,abstract等
(2)方法返回型別:比如public String getName(String id)中的String即為返回型別,包括void
(3)方法名:如上述中的getName
(4)方法引數個數、型別、順序等
(5)方法位元組碼
(6)運算元棧和方法棧幀的區域性變數區的大小
5、類變數
即靜態成員變數,被static修飾的變數,為該類所有物件共享的變數,即使沒有任何例項物件時,也可以訪問的類變數,它們與類進行繫結,成為類資料在邏輯上的一部分。這個和第3)點區分開來,第3)點為例項變數。在JVM使用一個類之前,它必須在方法區中為每一個non-final的類變數提前分配空間。對於被final修飾的類變數(常量),會在常量池中有一個拷貝,而non-final 類變數則被儲存在宣告它的類資訊中,這裡要注意final和no-final修飾的區別。
注意:Java類中的成員變數有靜態和非靜態之分。靜態成員變數在方法區,為共享資料;非靜態成員變數,在new 一個物件的時候被分配在堆記憶體中。區域性變數則是方法內定義的變數,前面已經講過,它會被分配在Java虛擬機器棧記憶體中。虛擬機器棧記憶體中會為當前方法非配一個棧幀,棧幀中有一個區域性變量表,該表儲存了該變數的值(基礎型別)或物件在堆中的地址(引用型別)。
舉個栗子:

image
- int i; 在類中定義(不是在方法中定義),為第3)點中講到的,為例項變數,需要類的例項才能呼叫,儲存在堆中對應的物件例項中。
- static int i ;non-final修飾的類變數,儲存方法區中的類資訊中。
- final static int I=0; final修飾的類變數,此時I就成為了一個常量了,必須賦值,否則報錯。它會在常量池中有一個拷貝。
6、指向類載入器的引用
每一個被JVM載入的類,都儲存這個類載入器的引用,類載入器動態連結時會用到。當解析一個類到另一個類的引用時,JVM需要保證這兩個類的載入器是相同的,這對JVM區分名字空間的方式是至關重要的。
7、指向Class例項的引用
類載入的過程中,虛擬機器會為每個載入的類(包括類和介面)都建立一個java.lang.Class的例項,JVM必須以某種方式把這個Class例項和儲存在方法區中的類資料聯絡起來。在Class類中有個靜態方法可以得帶這個例項的引用,public static Class forName(String className),通過Class.forName(String className)(反射)來查詢獲得該例項的引用,然後建立該類的物件(這裡和直接new一個物件區分開來)。例如,通過呼叫 Class.forName(“java.lang.Object”),可以得到與java.lang.Object對應的類物件(這裡用到了工廠模式),甚至可以通過這個函式得到任何包中任何已經載入的類引用,只要這個類能夠被載入到當前的名字空間。如果不能把類載入到當前名字空間,forName就會丟擲ClassNotFoundException。
Class類還提供瞭如下方法,獲取到類的物件後,可以用這些方法得到對應的類儲存在方法區中的類資訊:
- public String getName(); //獲取類名
- public Class getSuperClass(); //獲取父類物件
- public boolean isInterface(); // 判斷是否為介面
- public Class[] getInterfaces(); //返回一組介面物件,對應該類實現的介面物件。
- public ClassLoader getClassLoader(); //返回類載入器的引用。
8、方法表
為了提高訪問效率,JVM可能會對每個裝載的非抽象類和非介面,都建立一個數組,陣列的每個元素都是例項可能呼叫的方法的直接引用(注意,這裡說的是引用,不是方法本身,方法本身是在Java虛擬機器棧的棧幀中),包括父類中繼承過來的方法。JVM可以通過方法錶快速啟用例項方法。
9、執行時常量
JDK7後已經移除了方法區。結合第2點類的常量池,後面會有個小節再繼續擴充套件分析。
10、即時編譯(JIT)後的程式碼
Java的位元組碼檔案.class檔案,被JVM載入後,會一句一句翻譯程機器碼執行。這個區域就儲存了這些機器碼。(這個是筆者自己的理解,沒有查到權威的結論)
五、常量池
在上一節中,我們提到了“類的常量池”和“執行時常量池”,這裡我們接著來講。
常量池分為靜態常量池和執行時常量池,它們的區別在於動態性。
1、靜態常量池
靜態常量就是我上面提到的“類常量池”,即*.class檔案中的常量池。當java檔案被編譯為.class檔案的時候,會專門有一部分割槽域用於儲存類中的常量,這個區域就是類常量池。.class檔案中的常量池不僅僅包含字串(數字)字面量,還包含類、方法的資訊,他們佔據了class檔案的絕大部分空間。總體來說,它主要儲存了兩大類常量:字面量和符號引用。在這裡,我們解釋幾個名詞:
- 常量:有兩種情況,第一種是一個值,如1024(整型常量)、‘a’'b''c'(字元常量)、“abc”(字串常量)、true/false(boolean型常量)等。第二種就是被final修飾的變數,因為它的值不能再改變,也被稱作常量,比如final int I = 0; 這裡,I 就成為了一個常量。
- 字面量:相當於Java語言層面常量的概念。比如 String s = “abc”,這裡"abc"就是一個字面量。
- 符號引用:屬於編譯原理方面的概念,包含了如下三種類型的的常量:
I)類和介面的全限定名:即前面第4節第1)點類資訊中提到過的,比如Object類的全限定名就是java.lang.Object
II)欄位名稱和描述符:即前面第4節第3)點中對應的名稱和修飾符。
III)方法名稱和描述符:即前面第4節第4)點中對應的名稱和修飾符。

image
2、執行時常量
上述中的靜態常量池(類常量池),是在編譯的時候,存在於.class檔案中的,而JVM在完成.class檔案的裝載後,靜態常量池就被載入到記憶體中用於程式的執行,此時,靜態常量池搖身一變,成為了執行時常量池。JDK7之前的版本中,執行時常量是方法區中的一部分,可能由於方法區的空間有限,JDK7及以後的版本就把它移除了方法區,這一點在前面也多次提到過。有些資料說是移到了Java堆中,沒有看到權威的資料,筆者也不敢去確定。
一點疑惑:從上面的描述來看,類/介面、方法、欄位的相關資訊,在上訴第4節中方法區中的類資訊、欄位資訊、方法資訊儲存了一份,在類常量池中又儲存一次,這樣是不是冗餘了?方法區是記憶體中的一部分,在執行期出現,而類常量池是.class檔案中的一部分,在執行前就出現了,為什麼方法區中會存在類變數? 是筆者參考的資料中描述有誤?還是筆者理解有誤?這裡如果有幸被讀者讀到,可以自己研究一下,順便告知於我,3Q!
3、常量池的好處
常量池是為了避免頻繁地建立和銷燬物件而影響到系統性能,而實現的對物件的共享。例如,字串常量池,在編譯階段就把所有的字串文字放到一個常量池中,這樣做有兩個好處:I)節省記憶體空間:常量池中所有相同的字串常量合併,只佔用一個空間。II)節省執行時間:比較字元時,==比equals()快。對於兩個引用變數,只用==判斷引用是否相等,也就可以判斷實際值是否相等。
六、總結
本章中理論性的東西太多了,下圖對這一章節的內容做個簡單的梳理和歸納。

image