JAVA學習總結之JVM概述

來源:互聯網
上載者:User

標籤:綁定   釋放   擷取   方法區   垃圾收集器   理解   二進位   生命週期   執行個體   

參考部落格
JVM理解其實並不難!
JVM的記憶體地區劃分
Java記憶體回收機制

JVM記憶體地區

由於Java程式是交由JVM執行的,所以我們在談Java記憶體地區劃分的時候事實上是指JVM記憶體地區劃分。在討論JVM記憶體地區劃分之前,先來看一下Java程式具體執行的過程:

如所示,首先Java原始碼檔案(.java尾碼)會被Java編譯器編譯為位元組碼檔案(.class尾碼),然後由JVM中的類載入器載入各個類的位元組碼檔案,載入完畢之後,交由JVM執行引擎執行。在整個程式執行過程中,JVM會用一段空間來儲存程式執行期間需要用到的資料和相關資訊,這段空間一般被稱作為Runtime Data Area(運行時資料區),也就是我們常說的JVM記憶體。因此,在Java中我們常常說到的記憶體管理就是針對這段空間進行管理(如何分配和回收記憶體空間)。
根據《Java虛擬機器規範》的規定,運行時資料區通常包括這幾個部分:程式計數器(Program Counter Register)、Java棧(VM Stack)、本地方法棧(Native Method Stack)、方法區(Method Area)、堆(Heap)。JVM運行時資料區如下:

如所示,JVM中的運行時資料區應該包括這些部分。在JVM規範中雖然規定了程式在執行期間運行時資料區應該包括這幾部分,但是至於具體如何?並沒有做出規定,不同的虛擬機器廠商可以有不同的實現方式。

程式計數器

程式計數器(Program Counter Register),也有稱作為PC寄存器。想必學過組合語言的朋友對程式計數器這個概念並不陌生,在組合語言中,程式計數器是指CPU中的寄存器,它儲存的是程式當前執行的指令的地址(也可以說儲存下一條指令的所在儲存單元的地址),當CPU需要執行指令時,需要從程式計數器中得到當前需要執行的指令所在儲存單元的地址,然後根據得到的地址擷取到指令,在得到指令之後,程式計數器便自動加1或者根據轉移指標得到下一條指令的地址,如此迴圈,直至執行完所有的指令。
雖然JVM中的程式計數器並不像組合語言中的程式計數器一樣是物理概念上的CPU寄存器,但是JVM中的程式計數器的功能跟組合語言中的程式計數器的功能在邏輯上是等同的,也就是說是用來指示執行哪條指令的。
由於在JVM中,多線程是通過線程輪流切換來獲得CPU執行時間的,因此,在任一具體時刻,一個CPU的核心只會執行一條線程中的指令,因此,為了能夠使得每個線程都線上程切換後能夠恢複在切換之前的程式執行位置,每個線程都需要有自己獨立的程式計數器,並且不能互相被幹擾,否則就會影響到程式的正常執行次序。因此,可以這麼說,程式計數器是每個線程所私人的。
在JVM規範中規定,如果線程執行的是非native方法,則程式計數器中儲存的是當前需要執行的指令的地址;如果線程執行的是native方法,則程式計數器中的值是undefined。
由於程式計數器中儲存的資料所佔空間的大小不會隨程式的執行而發生改變,因此,對於程式計數器是不會發生記憶體溢出現象(OutOfMemory)的。

Java虛擬機器棧

虛擬機器棧(Java Vitual Machine Stack)也就是我們常常所說的棧,跟C語言的資料區段中的棧類似。事實上,Java棧是Java方法執行的記憶體模型。
Java棧中存放的是一個個的棧幀,每個棧幀對應一個被調用的方法,在棧幀中包括局部變數表(Local Variables)、運算元棧(Operand Stack)、指向當前方法所屬的類的運行時常量池(運行時常量池的概念在方法區部分會談到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些額外的附加資訊。當線程執行一個方法時,就會隨之建立一個對應的棧幀,並將建立的棧幀壓棧。當方法執行完畢之後,便會將棧幀出棧。因此可知,線程當前執行的方法所對應的棧幀必定位於Java棧的頂部。講到這裡,大家就應該會明白為什麼在使用遞迴方法的時候容易導致棧記憶體溢出的現象了以及為什麼棧區的空間不用程式員去管理了(當然在Java中,程式員基本不用關係到記憶體配置和釋放的事情,因為Java有自己的記憶體回收機制),這部分空間的分配和釋放都是由系統自動實施的。對於所有的程式設計語言來說,棧這部分空間對程式員來說是不透明的。表示了一個Java棧的模型:

  • 局部變數表,顧名思義,想必不用解釋大家應該明白它的作用了吧。就是用來儲存方法中的局部變數(包括在方法中聲明的非靜態變數以及函數形參)。對於基礎資料型別 (Elementary Data Type)的變數,則直接儲存它的值,對於參考型別的變數,則存的是指向對象的引用。局部變數表的大小在編譯器就可以確定其大小了,因此在程式執行期間局部變數表的大小是不會改變的。
  • 運算元棧,想必學過資料結構中的棧的朋友想必對錶達式求值問題不會陌生,棧最典型的一個應用就是用來對錶達式求值。想想一個線程執行方法的過程中,實際上就是不斷執行語句的過程,而歸根到底就是進行計算的過程。因此可以這麼說,程式中的所有計算過程都是在藉助於運算元棧來完成的。
  • 指向運行時常量池的引用,因為在方法執行的過程中有可能需要用到類中的常量,所以必須要有一個引用指向運行時常量。
  • 方法返回地址,當一個方法執行完畢之後,要返回之前調用它的地方,因此在棧幀中必須儲存一個方法返回地址。

由於每個線程正在執行的方法可能不同,因此每個線程都會有一個自己的Java棧,互不干擾。
注意這個地區可能出現的兩種異常:一種是StackOverflowError,當前線程請求的棧深度大於虛擬機器所允許的深度時,會拋出這個異常。製造這種異常很簡單:將一個函數反覆遞迴自己,最終會出現棧溢出錯誤(StackOverflowError)。另一種異常是OutOfMemoryError異常,當虛擬機器棧可以動態擴充時(當前大部分虛擬機器都可以),如果無法申請足夠多的記憶體就會拋出OutOfMemoryError,如何製作虛擬機器棧OOM呢,參考一下代碼:

public void stackLeakByThread(){    while(true){        new Thread(){            public void run(){                while(true){                }            }        }.start()    }}
本地方法棧

本地方法棧與虛擬機器棧所發揮的作用很相似,他們的區別在於虛擬機器棧為執行Java代碼方法服務,而本地方法棧是為Native方法服務。與虛擬機器棧一樣,本地方法棧也會拋出StackOverflowError和OutOfMemoryError異常。在JVM規範中,並沒有對本地方發展的具體實現方法以及資料結構作強制規定,虛擬機器可以自由實現它。在HotSopt虛擬機器中直接就把本地方法棧和Java棧合二為一。

Java堆

Java堆可以說是虛擬機器中最大一塊記憶體了。它是所有線程所共用的記憶體地區,幾乎所有的執行個體對象都是在這塊地區中存放。當然,隨著JIT(just in time,即時編譯技術)編譯器的發展,所有對象在堆上分配漸漸層得不那麼“絕對”了。
Java堆是垃圾收集器管理的主要區域。由於現在的收集器基本上採用的都是分代收集演算法,所有Java堆可以細分為:新生代和老年代。在細緻分就是把新生代分為:Eden空間、From Survivor空間、To Survivor空間。當堆無法再擴充時,會拋出OutOfMemoryError異常。

方法區

方法區在JVM中也是一個非常重要的地區,在方法區中,儲存了每個類的資訊(包括類的名稱、方法資訊、欄位資訊)、靜態變數、常量以及編譯器編譯後的代碼等。它與堆一樣,是被線程共用的地區,很容易理解,我們在寫Java代碼時,每個線程度可以訪問同一個類的靜態變數對象。
在Class檔案中除了類的欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用來儲存編譯期間產生的字面量和符號引用。
在方法區中有一個非常重要的部分就是運行時常量池,它是每一個類或介面的常量池的運行時表示形式,在類和介面被載入到JVM後,對應的運行時常量池就被建立出來。當然並非Class檔案常量池中的內容才能進入運行時常量池,在運行期間也可將新的常量放入運行時常量池中,比如String的intern方法。
在JVM規範中,沒有強制要求方法區必須實現記憶體回收。很多人習慣將方法區稱為“永久代”,是因為HotSpot虛擬機器以永久代來實現方法區,從而JVM的垃圾收集器可以像管理堆區一樣管理這部分地區,從而不需要專門為這部分設計記憶體回收機制。不過自從JDK7之後,Hotspot虛擬機器便將運行時常量池從永久代移除了。
同樣,當方法區無法滿足記憶體配置需求時,會拋出OutOfMemoryError。 製造方法區記憶體溢出,注意,必須在JDK1.6及之前版本才會導致方法區溢出,原因後面解釋,執行之前,可以把虛擬機器的參數-XXpermSize和-XX:MaxPermSize限制方法區大小。

List<String> list =new ArrayList<String>();int i =0;while(true){    list.add(String.valueOf(i).intern());} 

運行後會拋出java.lang.OutOfMemoryError:PermGen space異常。 解釋一下,String的intern()函數作用是如果當前的字串在常量池中不存在,則放入到常量池中。上面的代碼不斷將字串添加到常量池,最終肯定會導致記憶體不足,拋出方法區的OOM。
下面解釋一下,為什麼必須將上面的代碼在JDK1.6之前運行。我們前面提到,JDK1.7後,把常量池放入到堆空間中,這導致intern()函數的功能不同,具體怎麼個不同法,且看看下面代碼:

String str1 =new StringBuilder("hua").append("chao").toString();System.out.println(str1.intern()==str1);String str2=new StringBuilder("ja").append("va").toString();System.out.println(str2.intern()==str2);

這段代碼在JDK1.6和JDK1.7啟動並執行結果不同。JDK1.6結果是:false,false ,JDK1.7結果是true, false。原因是:JDK1.6中,intern()方法會吧首次遇到的字串執行個體複製到常量池中,返回的也是常量池中的字串的引用,而StringBuilder建立的字串執行個體是在堆上面,所以必然不是同一個引用,返回false。在JDK1.7中,intern不再複製執行個體,常量池中只儲存首次出現的執行個體的引用,因此intern()返回的引用和由StringBuilder建立的字串執行個體是同一個。為什麼對str2比較返回的是false呢?這是因為,JVM中內部在載入類的時候,就已經有”java”這個字串,不符合“首次出現”的原則,因此返回false。

記憶體回收(GC)

JVM的記憶體回收機制中,判斷一個對象是否死亡,並不是根據是否還有對象對其有引用,而是通過可達性分析。對象之間的引用可以抽象成樹形結構,通過樹根(GC Roots)作為起點,從這些樹根往下搜尋,搜尋走過的鏈稱為引用鏈,當一個對象到GC Roots沒有任何引用鏈相連時,則證明這個對象是停用,不過要注意的是被判定為不可達的對象不一定就會成為可回收對象。被判定為不可達的對象要成為可回收對象必須至少經曆兩次標記過程,如果在這兩次標記過程中仍然沒有逃脫成為可回收對象的可能性,則基本上就真的成為可回收對象了。
那麼那些對象可作為GC Roots呢?主要有以下幾種:
1. 虛擬機器棧(棧幀中的本地變數表)中引用的對象。
2. 方法區中類靜態屬性引用的對象。
3. 方法區中常量引用的對象
4. 本地方法棧中JNI(即一般說的Native方法)引用的對象。

另外,Java還提供了軟引用和弱引用,軟引用是在JVM記憶體不夠的情況下才進行回收,軟引用使用在想儘可能的保留這個對象的時候使用;而弱參考型別是不管JVM記憶體是否夠用都會回收該對象,弱引用使用在想儘可能的去釋放這個對象的時候使用,我們將一些比較占記憶體但是又可能後面用的對象,比如Bitmap對象,可以聲明為軟引用;而對於Handle中引用的Activity,我們儘可能的想Handler去釋放它,所以可以聲明為弱引用。但是注意一點,每次使用這個對象時候,需要顯示判斷一下是否為null,以免出錯。
下面用三個常見的例子來總結一下平常遇到的比較常見的將對象判定為可回收對象的情況:
- 顯示地將某個引用賦值為null或者將已經指向某個對象的引用指向新的對象,比如下面的代碼:

Object obj = new Object();obj = null;Object obj1 = new Object();Object obj2 = new Object();obj1 = obj2;
  • 局部引用所指向的對象,比如下面這段代碼:
void fun() {.....    for(int i=0;i<10;i++) {        Object obj = new Object();        System.out.println(obj.getClass());    }   }

迴圈每執行完一次,產生的Object對象都會成為可回收的對象。
- 只有弱引用與其關聯的對象,比如:

WeakReference<String> wr = new WeakReference<String>(new String("world"));
三種常見的垃圾收集演算法

在確定了哪些垃圾可以被回收後,垃圾收集器要做的事情就是開始進行記憶體回收,但是這裡面涉及到一個問題是:如何高效地進行記憶體回收。由於Java虛擬機器規範並沒有對如何?垃圾收集器做出明確的規定,因此各個廠商的虛擬機器可以採用不同的方式來實現垃圾收集器,所以在此只討論幾種常見的垃圾收集演算法的核心思想。
- 標記-清除演算法
首先,通過可達性分析將可回收的對象進行標記,標記後再統一回收所有被標記的對象,標記過程其實就是可達性分析的過程。這種方法有2個不足點:效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量的不連續的記憶體片段,片段太多可能會導致後續過程中需要為大對象分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。

  • 複製演算法
    為瞭解決效率問題,複製演算法是將記憶體分為大小相同的兩塊,每次只使用其中一塊。當這塊記憶體用完了,就將還存活的對象複製到另一塊記憶體上面。然後再把已經使用過的記憶體一次清理掉。這使得每次只對半個地區進行記憶體回收,記憶體配置時也不用考慮記憶體片段情況。

但是,這代價實在是讓人無法接受,需要犧牲一半的記憶體空間。顯然,Copying演算法的效率跟存活對象的數目多少有很大的關係,如果存活對象很多,那麼Copying演算法的效率將會大大降低。
- 標記-整理演算法
標記整理演算法很簡單,就是先標記需要回收的對象,在完成標記之後,它不是直接清理可回收對象,而是將存活對象都向一端移動,然後清理掉端邊界以外的記憶體。具體過程如所示:

  • 分代收集演算法
    分代收集演算法是目前大部分JVM的垃圾收集器採用的演算法。它的核心思想是根據對象存活的生命週期將記憶體劃分為若干個不同的地區。一般情況下將堆區劃分為老年代(Tenured Generation)和新生代(Young Generation),老年代的特點是每次垃圾收集時只有少量對象需要被回收,而新生代的特點是每次記憶體回收時都有大量的對象需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。
    目前大部分垃圾收集器對於新生代都採取複製(Copying)演算法,因為新生代中每次記憶體回收都要回收大部分對象,也就是說需要複製的操作次數較少,但是實際中並不是按照1:1的比例來劃分新生代的空間的,一般來說是將新生代劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,預設比例為Eden:Survivor=8:1。新生代地區就是這麼劃分,每次執行個體在Eden和一塊Survivor中分配,回收時,將存活的對象複製到剩下的另一塊Survivor。這樣只有10%的記憶體會被浪費,但是帶來的效率卻很高。當剩下的Survivor記憶體不足時,可以去老年代記憶體進行分配擔保。如何理解分配擔保呢,其實就是,記憶體不足時,去老年代記憶體空間分配,然後等新生代記憶體緩過來了之後,把記憶體歸還給老年代,保持新生代中的Eden:Survivor=8:1.另外,兩個Survivor分別有自己的名稱:From Survivor、To Survivor。二者身份經常調換,即有時這塊記憶體與Eden一起參與分配,有時是另一塊。因為他們之間經常相互複製。
    而由於老年代的特點是每次回收都只回收少量對象,一般使用的是標記-整理(Mark-Compact)演算法
    注意,在堆區之外還有一個代就是永久代(Permanet Generation),它用來儲存class類、常量、方法描述等。對永久代的回收主要回收兩部分內容:廢棄常量和無用的類。
類載入機制

類從被載入到虛擬機器記憶體開始,到卸載出記憶體為止,整個生命週期包括:載入、驗證、準備、解析、初始化、使用和卸載七個階段。

其中載入、驗證、準備、初始化、和卸載這5個階段的順序是確定的。而解析階段不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java的運行時綁定。
在JVM的實現規範中要求,所有類的“主動使用“虛擬機器才執行上述過程初始化相應的類,那麼問題就歸結為“主動使用”的意義。
- 建立類的執行個體。Object A = new ClassA();
- 訪問某個類或介面的靜態變數或對靜態變數賦值;
- 調用類的靜態方法;
- 使用反射機制;
- 初始化一個類的子類時,父類也被主動使用;
- 啟動類.

另外要注意的是:通過子類來引用父類的靜態欄位,不會導致子類初始化:

public class SuperClass{    public static int value=123;    static{        System.out.printLn("SuperClass init!");    }}public class SubClass extends SuperClass{    static{        System.out.println("SubClass init!");    }}public class Test{    public static void main(String[] args){        System.out.println(SubClass.value);    }}//output//SuperClass init! 

對於靜態變數,只有直接定義這個欄位的類才會被初始化,因此通過子類類引用父類中定義的靜態變數只會觸發父類初始化而不會觸發子類初始化。
通過數組定義來引用類,不會觸發此類的初始化:

public class Test{    public static void main(String[] args){        SuperClass[] sca=new SuperClass[10];    }}

常量會在編譯階段存入調用者的常量池,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類初始化,範例程式碼如下:

public class ConstClass{    public static final String HELLO_WORLD="hello world";    static {        System.out.println("ConstClass init!");    }}public class Test{    public static void main(String[] args){        System.out.print(ConstClass.HELLO_WORLD);    }}//上面代碼不會出現ConstClass init!
載入

類的載入指的是將類的.class檔案中的二進位手讀入到記憶體中,將其放在運行時資料區的方法區內,然後再堆區建立一個java.lang.Class對象,用來封裝類在方法去內的資料結構。
所以載入過程主要做以下3件事
- 通過一個類的全限定名稱來擷取此類的二進位流
- 將這個位元組流所代表的靜態儲存結構轉化為方法區的運行時資料結構
- 在記憶體中產生一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料訪問入口。

驗證

這個階段主要是為了確保Class檔案位元組流中包含資訊符合當前虛擬機器的要求,並且不會出現危害虛擬機器自身的安全。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都在方法區中分配。首先,這個時候分配記憶體僅僅包括類變數(被static修飾的變數),而不包括執行個體變數。執行個體變數會在對象執行個體化時隨著對象一起分配在java堆中。其次這裡所說的初始值“通常情況下”是資料類型的零值,假設一個類變數定義為

public static int value=123;

那變數value在準備階段後的初始值是0,而不是123,因為還沒有執行任何Java方法,而把value賦值為123是在程式編譯後,存放在類建構函式clinit()方法中。

解析

解析階段是把虛擬機器中常量池的符號引用替換為直接引用的過程。

初始化

類初始化時類載入的最後一步,前面類載入過程中,除了載入階段使用者可以通過自訂類載入器參與以外,其餘動作都是虛擬機器主導和控制。到了初始化階段,才是真正執行類中定義Java程式碼。
準備階段中,變數已經賦過一次系統要求的初始值,而在初始化階段,根據程式員通過程式制定的主觀計劃初始化類變數。初始化過程其實是執行類構造器clinit()方法的過程。
clinit()方法是由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊中的語句合并產生的。收集的順序是按照語句在源檔案中出現的順序。靜態語句塊中只能訪問定義在靜態語句塊之前的變數,定義在它之後的變數可以賦值,但不能訪問。如下所示:

public class Test{    static{        i=0;//給變數賦值,可以通過編譯        System.out.print(i);//這句編譯器會提示:“非法向前引用”    }    static int i=1;}

clinit()方法與類建構函式(或者說執行個體構造器init())不同,他不需要顯式地調用父類構造器,虛擬機器會保證子類的clinit()方法執行之前,父類的clinit()已經執行完畢。

JAVA學習總結之JVM概述

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.