讀《深入理解Java虛擬機》


 Java虛擬機運行時數據區

 

對象的創建

Java創建對象,在語言層面上使用new關鍵字。虛擬機遇到new關鍵字時,會檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被加載、解析和初始化過。如果沒有,那就必須先執行類加載過程。類加載通過之后,虛擬機將會為新生對象分配內存。對象所需的內存在類加載完成后就能完全確定。分配內存的方法有“指針碰撞”和“空閑列表”兩種方式,如果Java堆是規整的,則采用前者;否則,采用后者。Java堆是否規則和虛擬機有關。

OOM

在虛擬機中,能夠發生OOM的有:虛擬機棧,本地方法棧,Java堆,方法區,運行時常量。分析OOM使用eclipse memory analyzer插件或者JProfiler工具。添加jvm參數(-XX:+PrintGCDetails)可以打印出GC的詳細日志,-Xloggc:gc.log 日志文件的輸出路徑。

Java堆

幾乎所有的對象的視力都在這里分配內存。通過參數-Xmx(JVM最大可用內存)和-Xms(JVM初始可用內存)控制對的大小。如果在堆中沒有內存完成實例分配並且堆無法擴展,就會OOM。棧-Xss設置棧的容量大小。HotSpot不區分本地方法棧和虛擬機棧。如果虛擬機在申請棧擴展時,沒有足夠的空間,則會OOM。

垃圾收集器與內存分配策略

判斷對象是否存活,兩種方式:引用計數法和可達性分析。

引用計數法,不能解決對象相互引用的情況,所以主流虛擬機都采用的是可達性分析。

可達性分析采用采用GCROOTS策略,可作為GCROOTS的有:

  • 虛擬機棧中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象
  • Native方法中引用的對象

一、不同虛擬機對比表

 

虛擬機 新生代 老年代 垃圾算法 備注
Serial   復制算法  
ParNew   復制算法  
Parallel Scavenge   復制算法  
Serial Old   標記-整理算法  
Parallel Old   標記-整理算法  
CMS   標記-清除算法  
G1   綜合算法 目前最先進的垃圾回收器

 

二、理解GC日志

下面是一次GC產生的日志(jvm參數-XX:+PrintGCDetails):

從日志中可以看到,System.gc()表示程序調用了System.gc()方法觸發了垃圾回收,[PSYoungGen:3932k->592k(76288k)]中PSYoungGen代表了GC發生的區域,這個名稱和GC收集器密切相關:如果顯示的是[DefNew,則表明是Serial收集器的新生代;如果是ParNew則表明是ParNew收集器的新生代;我們這里是PSYoungGen,則表明是Parallel Scavenge收集器的新生代。再看后面是數字,3932k->592k(76288k)代表的是:GC前該內存已經使用的容量->GC后該內存區域使用的容量(該內存區域總容量)。3932K->600K(251392K)表示GC前Java堆已經使用的容量->GC后Java堆已經使用的容量(Java堆總容量)。后面的時間表示GC所花費的時間,單位s。 

三、內存分配與回收策略

1、對象優先分配在Eden區

大多數情況下,對象首先會被分配到Eden區,當Eden區滿了,會觸發一次Minor GC。

大對象直接進入老年區

所謂的大對象是指,需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串以及數組(筆者列出的例子中的byte[]數組就是典型的大對象)。虛擬機提供了一個-XX:PretenureSizeThreshold參數,令大於這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存復制(復習一下:新生代采用復制算法收集內存)。

2、長期存活的對象進入老年區

對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。

3、動態對象年齡的判斷

虛擬機並不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

4、空間分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那么Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那么會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次Minor GC,盡管這次Minor GC是有風險的,如果擔保失敗則會進行一次Full GC;如果小於,或者HandlePromotionFailure設置不允許冒險,那這時也要改為進行一次Full GC。

虛擬機性能監控與故障處理工具

jps:虛擬機進程狀況工具

jps格式:jps [options] [hostid]

功能:列出正在執行的虛擬機進程,並顯示虛擬機執行主類(Main Class,main()函數所在的類)名稱以及這些進程的本地虛擬機唯一ID(Local Virtual Machine Identifier,LVMID)。

選項 作用
-q 只輸出LVMID,省略主類的名稱
-m 輸出虛擬機進程啟動時傳給主類main()的參數
-l 輸出主類的全名,如果主類執行的是jar,則輸出jar的路徑
-v 輸出虛擬機進程啟動時傳給jvm的參數

jstat:虛擬機統計信息監視工具

功能:它可以顯示本地或者遠程[1]虛擬機進程中的類裝載、內存、垃圾收集、JIT編譯等運行數據。

命令格式:jstat [option vmid [ interval [s|ms] [count] ] ],參數interval和count代表查詢間隔和次數。

其中,如果進程是本地虛擬機進程,則vmid與lvmid一致;如果是遠程虛擬機進程,則vmid格式為:[protocol:][//]lvmid[@hostname[:port]/servername]

舉例:

S0代表Survivor0;S1代表Survivor1;E代表Eden區;O代表Old區;M代表Metaspace元數據區域(JDK1.8用Metaspace代替了1.7以前的PermGen永久代);CCS表示的是NoKlass Metaspace的使用率也就是CCSU,/CCSC算出來的,具體可參看這里,總之在1.7之前,M所在的位置是P,表示永久代使用的比例;YGC代表程序運行以來共發生Minor GC;YGCT表示其所用的時間;FGC代表程序運行以來共發生Full GC;FGCT表示其所用的時間;GCT表示GC總共花費了所長時間。

jinfo:Java配置信息工具

格式:jinfo [option] pid

作用:實時的查看和調整java虛擬機各項參數。

jmap:Java內存映像工具

格式:jmap [option] vmid

作用:用於生成堆轉儲快照(一般稱為heapdump或dump文件)。

選項 作用
-dump 生成Java堆轉儲快照。格式:-dump:[live,] format -b,file=<filename>,其中live字參數說明是否只dump存活的對象。
-heap 顯示Java堆詳細信息,只在Linux/Solaris平台下有效
-histo 顯示Java堆中對象統計信息
-F 當虛擬機對-dump選項沒有響應時,該參數可以強制生成dump快照。只在Linux/Solaris平台下有效

jhat:虛擬機堆轉儲快照分析工具

作用:配合jmap命令,分析dump文件。jhat內置了一個微型的HTTP/HTML服務器,生成dump文件的分析結果可以在瀏覽器中查看。

命令:jhat dumpfile

一般使用專業的工具進行分析,例如:VisualVM,Eclipse Memory Analyzer,IBM HeapAnalyzer等。

jstack:Java堆棧跟蹤工具

格式:jstack [option] vmid

作用:用於生成虛擬機當前時刻的線程快照,生成線程快照的主要目的是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間等待等都是導致線程長時間停頓的常見原因。線程出現停頓的時候通過jstack來查看各個線程的調用堆棧,就可以知道沒有響應的線程到底在后台做些什么事情,或者等待着什么資源。

類文件結構

1. 無關性的基石

任何語言,不局限於Java,都可以在虛擬機上運行。Java虛擬機不care .class文件來自何種語言。

2. Class類文件結構(這部分看不懂,需要匯編知識,后續補充)

Class文件是一組以8位字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地排列在Class文件之中,中間沒有添加任何分隔符

根據Java虛擬機規范規定,Class文件中采用類似C語言結構體中的偽結構體來存儲數據,這種結構體中只存在兩種數據結構:無符號數和表。

  • 無符號數屬於基本的數據類型,以u1、u2、u4、u8來分別代表1個字節、2個字節、4個字節和8個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。
  • 表是由多個無符號數或者其他表作為數據項構成的復合數據類型,所有表都習慣性地以“_info”結尾。表用於描述有層次關系的復合結構的數據,整個Class文件本質上就是一張表

2.1 魔數和Class文件的版本

每個Class文件的頭4個字節被稱為魔數,它的唯一作用是確定這個文件是否為一個能被虛擬機接受的Class文件。Class文件的魔數為:0xCAFFEEBABE。緊接着魔數的4個字節存儲的是Class文件的版本號:第5和第6個字節是次版本號(Minor Version),第7和第8個字節是主版本號(Major Version)。Java的版本號是從45開始的,JDK 1.1之后的每個JDK大版本發布主版本號向上加1(JDK 1.0~1.1使用了45.0~45.3的版本號),高版本的JDK能向下兼容以前版本的Class文件,但不能運行以后版本的Class文件,即使文件格式並未發生任何變化,虛擬機也必須拒絕執行超過其版本號的Class文件。 

2.2 常量池

緊接着主版本號后面的是常量池入口。

虛擬機類加載機制

類的生命周期

 

上圖所示,類加載過程包含:加載,驗證,准備,解析,初始化,使用,卸載。其中加載,驗證,准備,初始化,卸載這5個階段的順序是確定。而解析階段不一定,某些時候可以在初始化之后才進行解析,這是為了支持Java中動態綁定。什么時候開始類加載的一個過程:加載?虛擬機規范中沒有嚴格的約束,交給虛擬機的實現去具體把握。但是對於初始化,虛擬機規范中嚴格規定了5中場景必須進行初始化:

  • 使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。 
  • 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  • 當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REFgetStatic、REFputStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

以上5種場景稱為主動引用,會首先進行初始化,除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。

被動引用例子一:

 1 public class SuperClass {
 2 
 3     public static int value = 123;
 4     
 5     static{
 6         System.out.println("SuperClass init !");
 7     }
 8 }
 9 
10 public class SubClass extends SuperClass {
11     
12     static{
13         System.out.println("SubClass init !");
14     }
15 }
16 
17 public class TestInit {
18     public static void main(String[] args) {
19         System.out.println(SubClass.value);
20     }
21 }

以上執行的結果如下:

為什么只會打印出"SuperClass init !"?因為對於靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態,只會觸發父類的初始化而不會觸發子類的初始化。

被動引用例子二:

public class ConstClass {
    static {
        System.out.println("ConstClass Init ~~");
    }

    public final static String HELLO_STRING = "HelloString";
}

public class TestConst{
    public static void main(String[] args) {
            System.out.println(ConstClass.HELLO_STRING);
        }
    }

以上執行結果也沒有輸出ConstClass Init ~~,原因是常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。

類加載機制

加載

“加載”是“類加載”過程中的一個階段。在加載階段,虛擬機會做3件事:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流。
  • 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
  • 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

這三點並不具體,例如第一點,Java虛擬機規范並沒有指定二進制字節流要從哪一個Class文件中獲取,怎么去獲取。所以Java可以從:

  • zip,jar,ear,war等格式中加載
  • 從網絡中獲取,典型應用場景就是Applet
  • 運行時計算生成,這種場景用的最多的就是動態代理
  • 由其他文件生成,如JSP

驗證

驗證是連接階段的第一步。目的是確保Class文件的字節流中包含的信息不會危害到虛擬機自身的安全。包含:文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證。

准備

准備階段是正式為類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。其中初始值“通常情況下”是數據類型的零值。假設一個類變量的定義為:

public static int value = 123;

那變量在准備階段過后的初始值是0而不是123,因為這時候尚未進行任何的Java方法,而把value賦值為123是在初始化階段。但是如果上面的類變量定義為:

public static final int value = 123;

那么在編譯階段javac會為value生成ConstantValue屬性,並且在准備階段value就會被賦值為123。

解析

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

初始化

類初始化階段是類加載的最后一步,初始化階段是執行類構造器<clinit>()方法的過程,<clinit>()方法是由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合並產生的。

<clinit>()方法與類的構造函數<init>()不同,它不需要顯示的調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。所以在虛擬機中第一個被執行的<clinit>()方法的類必定是java.lang.Object。

<clinit>()對於類或接口來說並不是必須的,如果類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器不會生成該方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口也會生成<clinit>()。但接口和類不同的是:執行接口的<clinit>()不需要先執行父接口的<clinit>(),只有當父接口中定義的變量被使用時,父接口才會初始化。另外接口的實現類在初始化也一樣不會執行接口的<clinit>()

類加載器

類與類加載器

對於任意一個類,都需要由他的類加載器和這個類本身共同確立其在Java虛擬機中的唯一性。每一個類加載器,都擁有一個獨立的類名稱空間。簡言之,比較兩個類是否“相等”只有在這兩個類是由同一個類加載器加載的前提下才有意義。

如下例子:

 1 /**
 2  * @author zhouxuanyu
 3  * @date 2017/06/03
 4  */
 5 public class ClassLoaderTest {
 6     public static void main(String[] args) {
 7         ClassLoader myLoader = new ClassLoader() {
 8             @Override
 9             public Class<?> loadClass(String name) throws ClassNotFoundException {
10                 try {
11                     String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
12                     InputStream is = getClass().getResourceAsStream(fileName);
13 
14                     if (is == null) {
15                         return super.loadClass(name);
16                     }
17                     byte[] b = new byte[is.available()];
18                     is.read(b);
19                     return defineClass(name, b, 0, b.length);
20                 } catch (IOException e) {
21                     throw new ClassNotFoundException();
22                 }
23             }
24         };
25 
26         try {
27             Object o = myLoader.loadClass("com.alibaba.jvm.ClassLoaderTest").newInstance();
28             System.out.println(o.getClass());
29             System.out.println(o instanceof com.alibaba.jvm.ClassLoaderTest);
30         } catch (InstantiationException e) {
31             e.printStackTrace();
32         } catch (IllegalAccessException e) {
33             e.printStackTrace();
34         } catch (ClassNotFoundException e) {
35             e.printStackTrace();
36         }
37     }
38 }

最后的運行結果為:

1 class com.alibaba.jvm.ClassLoaderTest
2 false

可以看出,我們自己實現的類加載器確實加載並實例化了類com.alibaba.jvm.ClassLoaderTest的,但是實例化對象在與類com.alibaba.jvm.ClassLoaderTest做類型檢查的時候卻返回了false。因為虛擬機中存在兩個com.alibaba.jvm.ClassLoaderTest類,一個是系統類加載器加載,另一個是由我們自己實現的加載器加載的,雖然這兩個類來自同一個Class文件但是卻是兩個獨立的類。

雙親委派模型

從Java虛擬機的角度來講,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都由Java語言實現,獨立於虛擬機外部,並且全都繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度來看,有三種類加載器:

  • 啟動類加載器(Bootstrap ClassLoader):負責加載<java_home>\lib目錄或者由參數-Xbootclasspath指定路徑中並且是虛擬機識別的類庫加載到虛擬機內存中。

  • 擴展類加載器(Extension ClassLoader):負責加載<java_home>\lib\ext目錄中或者被java.ext.dirs系統變量指定路徑中所有的類庫。

  • 應用程序加載器(Application ClassLoader):負責加載由CLASSPATH指定的類庫,如果程序沒有自定義類加載器,程序默認使用該加載器

下圖是類加載器的雙親委派模型:

雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。

Q:為什么要采用雙親委派模型?

A:例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型最頂端的啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為java.lang.Object的類,並放在程序的ClassPath中,那系統中將會出現多個不同的Object類,Java類型體系中最基礎的行為也就無法保證,應用程序也將會變得一片混亂。

實現雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中,邏輯如下:先檢查是否已經被加載過,若沒有加載則調用父加載器的loadClass()方法,若父加載器為空則默認使用啟動類加載器作為父加載器。如果父類加載失敗,拋出ClassNotFoundException異常后,再調用自己的findClass()方法進行加載。下面是loadClass()方法:

 1 protected Class<?> loadClass(String name, boolean resolve)
 2         throws ClassNotFoundException
 3     {
 4         synchronized (getClassLoadingLock(name)) {
 5             // First, check if the class has already been loaded
 6             Class<?> c = findLoadedClass(name);
 7             if (c == null) {
 8                 long t0 = System.nanoTime();
 9                 try {
10                     if (parent != null) {
11                         c = parent.loadClass(name, false);
12                     } else {
13                         c = findBootstrapClassOrNull(name);
14                     }
15                 } catch (ClassNotFoundException e) {
16                     // ClassNotFoundException thrown if class not found
17                     // from the non-null parent class loader
18                 }
19 
20                 if (c == null) {
21                     // If still not found, then invoke findClass in order
22                     // to find the class.
23                     long t1 = System.nanoTime();
24                     c = findClass(name);
25 
26                     // this is the defining class loader; record the stats
27                     sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
28                     sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
29                     sun.misc.PerfCounter.getFindClasses().increment();
30                 }
31             }
32             if (resolve) {
33                 resolveClass(c);
34             }
35             return c;
36         }
37     }

虛擬機字節碼執行引擎

一. 運行時棧幀

棧幀是用於支持虛擬機進行方法調用和方法執行的數據結構。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用開始到執行完成的過程都對應着一個棧幀在虛擬機棧里面從入棧到出棧的過程。

1.局部變量表

局部變量表是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。Java程序編譯為Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需要分配的局部變量表的最大容量。局部變量表的銅梁以變量槽(Slot)為最小單位,Java虛擬機規范說一個Slot應該能存放一個boolean,byte,char,short,int,floot,reference以及returnAddress類型的數據。為了節省空間,Slot是可以重用的。

2. 操作數棧

3. 動態連接

4. 方法返回地址

二. 方法調用

方法調用不等同於方法執行。方法調用階段唯一的任務就是確定被調用方法的版本,即調用哪一個方法,不涉及方法內部的具體運行過程。

解析

所有方法在調用中的目標方法在Class文件中都是一個常量池中的符號引用,在類加載期間,會將其中部分符號引用轉化為直接引用。這類方法必須滿足:方法在程序真正運行之前就有一個可確定的調用版本,並且這個版本在運行期間是不可改變的。

在Java中符合“編譯器可知,運行期間不可變” 這個要求的方法主要包括靜態方法和私有方法。因為這兩類方法都不能被繼承或重寫。在JVM中,凡是能被invokestatic和invokespecial指令調用的方法都可以在解析階段唯一確定調用版本,符合這個條件的方法有:靜態方法,私有方法,實例構造方法,父類方法4類。

解析調用一定是一個靜態的過程,在編譯器就完全的確定,不會延遲到運行期間再去完成。

Tomcat類加載器架構

服務器都會解決幾個問題:部署在同一個服務器上的兩個web應用程序所使用的java類庫可以實現相互隔離以及相互共享,服務器自身的類庫應該與應用程序的類庫隔離。

解決辦法:服務器會提供好幾個classpath路徑供用戶存放第三方類庫,被放置到不同路徑中的類庫,具備不同的訪問范圍和服務對象。通常,每個目錄都會有一個相應的自定義類加載器去加載放在里面的Java類庫。以tomcat為例:tomcat目錄結構中,有4組目錄(/common/*,/server/*,/shared/*,/WEB-INF/*)可以存放Java類庫:

  • /common目錄:類庫可以被Tomcat和所有的web應用程序共用
  • /shared目錄:類庫可以被所有web應用程序共用,但對tomcat不可見
  • /server目錄:類庫可以被tomcat使用,但是對所有的web應用程序不可見
  • /WEB-INF目錄:類庫僅僅可以被此web程序使用


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM