1. 程式人生 > >java虛擬機器-ClassLoader和GC 的瞭解

java虛擬機器-ClassLoader和GC 的瞭解

1.總覽

閱讀深入java 虛擬機器,
解決下面的問題.
1. JVM的概念和原理. 2.類的生命週期. 3.連線模型. 4.GC+ GC中的演算法使用,怎麼才能進行垃圾回收?條件是啥? 5.java中物件的引用

2.jvm的結構和概念.

首先我們來俯瞰一下jvm裡面的結構是怎麼樣的.
Java程式碼編譯和執行的整個過程包含了以下三個重要的機制:
  • Java原始碼編譯機制
  • 類載入機制
  • 類執行機制
這裡分別簡單的將一下三個機制.

2.1類載入機制.

因為這部分相對java原始碼編譯機制來說重要的多,所以重點看這個部分. 首先在講類載入器之前我們要說一下類的生命週期
  1. 類載入: 查詢並載入類的二進位制資料.同時也是ClassLoader 工作的地方.
  2. 連線
    • 驗證:確保被載入的類的正確性
    • 準備:為類的靜態變數分配記憶體,並將其初始化為預設值.
    • 解析:吧類中的符號引用轉換為直接引用初始化
  3. 初始化:為類的靜態變數賦予正確的初始化.
那麼什麼時候才會載入和後面一大坨的操作捏? 是我們平時直接new那麼簡單嘛?jvm給出的答案是不只是那樣. 在核心介面被裝載和連線的時機上,jvm給出標準是:     必須在每個類或介面首次主動使用時初始化.
好了這裡有蹦出來一個觀點了. 主動使用. java 程式使用方式可以分為主動使用和被動使用,主動使用有下面6種情況.其餘的都是屬於被動使用.
  1. 建立類的例項
  2. 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
  3. 呼叫類的靜態方法

  4. 反射(如Class.forName(“com.shengsiyuan.Test”)
  5. 初始化一個類的子類
  6. Java虛擬機器啟動時被標明為啟動類的類(Java Test) 
除了以上六種情況,其他使用Java類的方式都被看作是對類的被動使用,都不會導致類的初始化 

2.1.1類載入

類的載入三步走: 1將類的.class檔案中的二進制資料讀入到記憶體中  2.將其放在執行時數據區的方法區內  3.然後在堆區建立一個java.lang.Class物件,用來封裝類在方法區內的資料結構 
同時載入類的方式也是多樣的.
– 從本地系統中直接載入 – 通過網路下載.class檔案 – 從zip,jar等歸檔檔案中載入.class檔案 – 從專有資料庫中提取.class檔案 – 將Java原始檔動態編譯為.class檔案 

2.1.2類載入器 ClassLoader

JVM的類載入是通過ClassLoader及其子類來完成的,類的層次關係和載入順序可以由下圖來描述:  
  1. Bootstrap ClassLoader   負責載入$JAVA_HOME中jre/lib/rt.jar裡所有的class,由C++實現,不是ClassLoader子類
  2. Extension ClassLoader    負責載入java平臺中擴充套件功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目錄下的jar包
  3. App ClassLoader  負責記載classpath中指定的jar包及目錄中class
  4. Custom ClassLoader  屬於應用程式根據自身需要自定義的ClassLoader,如tomcat、jboss都會根據j2ee規範自行實現ClassLoader
載入過程中會先檢查類是否被已載入,檢查順序是自底向上,從Custom ClassLoader到BootStrap ClassLoader逐層檢查,只要某個classloader已載入就視為已載入此類,保證此類只所有ClassLoader載入一次。而載入的順序是自頂向下,也就是由上層來逐層嘗試載入此類。如果還是不能載入這個類就是直接丟擲ClassNotFoundException
(父親委託載入先父親搞,搞不定兒子搞,一層下來一層    ,聯想一下spring IoC容器裡面的Bean也是用類似的設計理念例項化的.) 父親委託機制的優點是能夠提高軟體系統的安全性.因為在此機制下,使用者自定義的類載入器不可能載入應該由父親加災情載入可靠類,從而防止不可靠/惡意的程式碼代替父親載入器載入. 

2.1.3 連線

類被載入後,就進入連線階段。連線就是將已經讀入到記憶體的類的二進位制資料合併到虛擬機器的執行時環境中去。   類的驗證的內容(確保安全性) – 類檔案的結構檢查 – 語義檢查 – 位元組碼驗證 – 二進位制相容性的驗證  類的準備 在準備階段,jvm為類的靜態變數分配記憶體,並設定預設的初始值.例如對於一下Sample 類,在準備階段,將為int 型別的靜態變數 a分配4個位元組的記憶體空間,並且賦予預設值0,為long型別的靜態變數b分配8個位元組的記憶體空間,並且賦予預設值0. publicclassSample{ //你可能會問不是初始值為1 嗎?為啥是0 ,jvm 是先初始化預設值然後再複製為 1.privatestaticint a =1;privatestaticlong b;static{ b=2;} //.....} 類的解析 在解析階段,java虛擬機器會把類的二進位制資料中的符號引用替換為直接引用.例如在Worker 類中的gotoWork() 方法會引用Car類呼叫run()方法. publicvoid gotoWork(){ car.run();//這裡 一開始二進位制資料中表示為符號引用,但是經過類的解析之後會變成直接引用.}

2.1.4 類的初始化.

詳細的部分可以看看第八章.連線模型. 初始化階段,jvm執行類的初始化語句,為類的靜態變數賦予初始值.靜態變數初始化有兩種途徑:  
  1. 在靜態變數宣告處進行初始化.
  2. 在靜態變數程式碼塊中進行初始化,
publicclassExample{privatestaticint a =1;//在靜態變數的宣告處進行初始化.publicstaticlong b;publicstaticlong c; //c沒有被初始化,所以只是連線時候的預設值 0;static{ b=2;//在靜態變數程式碼塊中進行初始化.}} 類的初始化步驟:
  • 假如這個類還沒有載入和連線, 那就先進行載入和連線.
  • 假如類存在直接的父類, 並且這個父類還沒有被初始化,那就先初始化直接的父類.
  • 假如類中存在初始化語句,那就依次執行這些初始化語句.
類的初始化時機: 跟類的裝載的時機是一樣的.都是那6個. 類的初始化與介面初始化的區別.
類的初始化跟介面的初始化有很大不同的地方.
  • 在初始化一個類時,並不會先初始化它所實現的介面.
  • 在初始化一個介面時4. 並不會先初始化它的父介面.

3.垃圾回收機制.

垃圾回收機制主要完成3個問題.
  1. 哪些記憶體需要回收?
  2. 什麼時候回收?
  3. 如何回收?
這裡其實需要重新去看一下jvm 中的記憶體管理部分的知識,這樣才能理解,jvm 哪些部分不回收,哪些部分要重點回收.

3.1 那些記憶體需要回收? 死了的物件? 那什麼是死了的?

下面的兩種方法是判斷這個引用是否能夠回收,通常用第二種. 3.1.1 引用計數演算法. 這個算是回收演算法裡面最簡單最容易實現的一個演算法了: 給物件中新增一個引用計數器,每當有一個地方引用它的時候,計數器加一,當引用失效的時候計數器就減一,為0的物件就是不可用.但是在java中沒有使用這個演算法.最主要的原因就是它很難解決迴圈引用的問題. A.b = b;B.a = a;//類似這種相互引用的問題,如果用引用計數演算法就不能解決

3.1.2可達性分析演算法.

這個演算法的基本思想是,通過一系列稱為"GC Roots"的物件作為起始點,從這些節點開始向下搜尋,搜尋走過的路徑稱為引用鏈.當一個物件到"GC Roots"沒有任何應用鏈相接 就證明這個物件不可用. 物件在記憶體中的狀態變化.

那麼什麼型別的物件才算是GC Roots ?  GCRoots 包括下面幾種:
  1. 虛擬機器棧(棧幀中的本地變量表)中引用的物件.
  2. 方法區中類靜態屬性引用的物件.
  3. 方法區中常量引用的物件.
  4. 本地方法中棧中JNI(即一般說的Native 方法) 引用的物件.

3.2  java 引用的種類.

java引用型別有4種.
  1. 強引用就是指在程式碼中普遍存在的. 類似 "Object o = new Object( ) "這類的引用,只要強引用還在,垃圾回收器永遠不會回收掉被引用的物件.(因此強引用時造成java 記憶體洩露的主要原因之一.)
  2. 軟引用. 在系統將要發生記憶體溢位異常之前,將會把這些物件列進 回收範圍之中進行第二次回收. 如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常.
    publicclassSoftReferenceTest{publicstaticvoid main(String[] args){SoftReference<Person>[] people =newSoftReference[100];for(int i=0;i<people.length; i++){ people[i]=newSoftReference<Person>(newPerson());}System.out.println(people[2].get());System.out.println(people[4].get());System.gc();System.runFinalization();//垃圾回收機制執行之後,SoftReference 陣列中的元素保持不變,等到記憶體快溢位的時候才會來回收這些地方.System.out.println(people[2].get());System.out.println(people[4].get());}}
  3. 弱引用,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前. 當垃圾回收器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件.
    publicstaticvoid main(String[] args){//建立一個字串物件.String str =newString("瘋狂Java講義");//建立一個弱引用,讓弱引用 引用到這個str 上.WeakReference<String> wr =newWeakReference<String>(str);//切斷str引用和"瘋狂java 講義" 字串之間的引用 str =null;System.out.println(wr.get());//強制垃圾回收System.gc();System.runFinalization();//再次取出來看看那個字串.會發現這裡是null 的.System.out.println(wr.get());}
  4. 虛引用.一個物件是否有虛引用的存在,完全不會對其生命時間構成影響,也無法通過虛引用來取得一個物件例項.  虛引用的唯一目的就是能在這個物件被收集器回收時收到一個系統通知.

3.3 java 記憶體洩露

首先我們來了解一下什麼是記憶體洩露: 程式執行過程中會不斷的分配記憶體空間, 那些不再使用的記憶體空間應該即時回收它們.從而保證系統可以再次使用這些記憶體.如果存在無用的記憶體沒有被收回那就是記憶體洩露 . C++中的記憶體洩露是使用者沒有及時的將沒有用的物件析構,同時這個沒有用的物件也不可達,所以相對的比較危險. java 中不會出現C++ 中的問題,因為存在垃圾回收機制,不可達的物件都可以由垃圾回收器來回收,但是如果是可達的但是卻不可用的物件,並且還是強引用的物件,那麼就會出現記憶體洩露的問題了.
Vector v =newVector(10);for(int i =1;i <100; i ++){Object o =newObject();v.add(o);o =null; //這裡雖然o是將原來的那個的引用為空 ,但是 原來的引用由vector 來可達,所以gc不能回收.時間長可能造成洩露} 避免記憶體洩露: 第一,是在宣告物件引用之前,明確記憶體物件的有效作用域。在一個函式內有效的記憶體物件,應該宣告為 local 變數,與類例項生命週期相同的要宣告為例項變數……以此類推。 第二,在記憶體物件不再需要時,記得手動將其引用置空。
第三,嘗試使用軟引用,弱引用.等等.

3.4垃圾收集演算法.

前面提了兩種演算法,一種是引用計數法,還有一種是可達性分析演算法. 通常我們會用第二種來判斷一個物件是否要回收. 這裡我們介紹幾種演算法來解決怎麼樣來回收.

3.4.1標記-清除演算法(mark - sweep)

演算法分為標記和清除兩個階段: 首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件.標記的判定是前面的可達分析演算法.這個演算法存在兩個不足:  一個是效率問題,標記和清除的效率都不高.  另外一個是空間問題,標記清除後會產生大量不連續的記憶體碎片.空間碎片太多導致後面分配記憶體無法找到足夠的連續的記憶體,

3.4.2複製演算法.

將記憶體按容量劃分大小相等的兩塊,每次只使用一塊,當這塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後在把上面用過的一次清理掉. 解決了空間碎片的問題.但是缺點是,一次只能用記憶體的一半 但是經過統計98%的都是"朝生暮死" 的物件,就是說一次清理之後,剩下了的其實並不多,所以可以 8:1:1 來分配, 8:是指新生代 執行回收的部分, 同時兩分的1 中只用其中的一份,另外一份就是用來做複製演算法的.

3.4.3標記-整理演算法.

跟標記清除差不多,但是是先標記,然後不是直接清除,而是讓存活的物件都向一端移動,後面直接清理掉端邊界以外的記憶體.

3.4.4分代收集演算法.

把java 堆中分為新生代和老年代,key根據各個年代採用適合的收集演算法. 在新生代可以用複製演算法,只需要付出少量空間就可以完成收集, 在老年代可以用 標記清除  或者標記整理演算法.

3.5 垃圾收集器.

收集演算法是記憶體回收的方法論, 那麼收集器就是記憶體回收的具體實現. 總共有7種垃圾收集器 ,重點是是在CMS收集器 和 G1收集器. 3.5.1 Serial收集器  它是一個單執行緒收集器,進行垃圾回收時必須暫停其他所有的工作執行緒,直到結束.就是說使用者操作的時候隔一段時間就卡住,因為垃圾收集器在工作.

3.5.2 ParNew收集器.

ParNew 收集器其實就是Serial收集器的多執行緒版.

3.5.3 Parallel Scavenger收集器(吞吐量優先的收集器)

它的實現跟ParNew 是新生代收集器,也是使用複製演算法的收集器,並行多執行緒. ParalledScavenger 收集器的特點是它的關注點與其他的收集器不同,CMS等收集器是儘可能縮短手機時使用者執行緒的停頓時間,而ParallelScavenger收集器的目的是達到一個可控制的吞吐量.

3.4.4 SerialOld 收集器

Serial Old 是Serial 收集器的老年代版本,它同樣是一個單執行緒收集器,使用"標記-整理"演算法. 在Server模式下,有兩大用途: 一種用途是在JDK1.5之前與ParallelScavenger 搭配使用,(通常都是新生代收集器,與老年代收集器混合使用.)  第二種用途,就是作為CMS收集器的後備預案

3.4.5 Parallel Old 收集器

Paralle Old 是Parallel Scavenger收集器的老年代版本,使用多執行緒和"標記-整理"演算法, 與Parallel Scavenger 搭配使用 成為 吞吐量優先 的來及回收器.

3.4.6 CMS 收集器

聽說這玩意是劃時代的產物! 666! CMS(Concurrent Mark Sweep) 收集器是一種以獲得最短回收停頓時間為目標的收集器, CMS 是基於 "標記-清除"演算法實現的,執行的過程包括4步
  1. 初始標記
  2. 併發標記
  3. 重新標記
  4. 併發清除

由上圖我們可以看出CMS收集器,是一個併發的低停頓的一個收集器, 在初始還有重新標記 的地方還是需要"Stop the world " 就是所有都使用者執行緒都需要停止來等待標記, 但是這個標記時間是非常短的.所以是可以容忍的, 同時在耗費大量時間的併發標記和併發清除過程可以與使用者執行緒一起工作.減少停頓時間. 但是這麼優良的收集器還是會有缺陷的: 
  • 一. 它對CPU 資源非常敏感,非常依賴CPU 的資源(這個是併發程式的通病) 當CPU 是4個以上時不小於25%的CPU資源,並且隨著CPU數量下降,CMS對使用者程式的影響就變大了.
  • 二.CMS無法處理浮動垃圾,因為在清除的過程中也可能產生垃圾,不能夠一次清除,只能等到下一次.同時併發收集 需要預留部分空間給收集執行緒,不能像別的收集器那樣等到滿了才開始收集..預設情況下 當老年代68%的空間後就開始啟用回收機制.設定太高就可能不夠空間給收集執行緒,就會啟動後備方案啟用SerialOld 收集器來重新進行老年代的手機,這樣停頓的時間就長了.
  • 三.最後一個缺點就是 CMS 是基於"標記-清除"演算法, 就會出現大量的碎片,導致無法找到足夠空間,不得不提前觸發一次Full GC.為了解決這種問題,在Full GC之前執行一次碎片整理, 整理碎片是沒有辦法並行的.停頓時間就長了.

3.4.7 G1 收集器

相對於其他的收集器他有自己的特點
  • 並行與併發:通過使用多個CPU來縮短停頓時間.
  • 分代收集:這個與其他收集器一樣.
  • 空間整合:與CMS"標記-清理"演算法不同, G1從整體來看是基於"標記-整理"演算法實現的從區域性來看是基於"複製演算法" 同時也不會出現記憶體碎片的問題.
  • 可預測的停頓:建立可預測的停頓時間模型,能讓使用者制定一個長度為M毫秒的時間片段,消耗垃圾收集上的時間不超過N毫秒.
G1收集器收集範圍是整個新生代/老年代 G1收集器的運作步驟可以分為:
  1. 初始標記
  2. 併發標記
  3. 最終標記
  4. 篩選標記.
前面兩個步驟跟CMS 差不多,主要是後面的兩個步驟.  最終標記的是為了修正在併發標記期間使用者程式繼續運作而導致標記產生變動的部分記錄.同時記錄得到RememberSetLogs 同時合併到Remembered Set中.這幾段需要停頓,但是可以並行執行.  篩選回收,首先對各個Region 的回收價值和成本進行排序,根據使用者期望的GC停頓時間來制定回收計劃,這階段也可以做到與使用者併發執行.