1. 程式人生 > >深入java虛擬機器-jvm高階特性和實戰

深入java虛擬機器-jvm高階特性和實戰

第一部分 走近java

第一章 java技術體系

  官方所定義的java技術體系

  • java程式設計語言
  • 各硬體平臺上的java虛擬機器
  • Class檔案格式
  • java api類庫
  • 來自商業機構和開源社群的第三方java類庫

  我們可以把java程式設計語言、java虛擬機器、java api類庫這三部分統稱為JDK,是用於支援java程式開發的
最小環境把java api類庫總的javaSE api子集和java虛擬機器統稱為JRE,是支援java程式執行的標準環境。

第二部分 自動記憶體管理機制

第二章 記憶體區域與溢位異常

2.1 執行時資料區域

  java虛擬機器執行時資料區

  • 程式計數器
      程式計數器( Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。在虛擬機器的概念模型裡(僅是概念模型,各種虛擬機器可能會通過一些更高效的方式去實現) 位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳 轉、異常處理、執行緒恢復等基礎功能都需**每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。
      如果正在執行的是 Native方法,這個計數器值則為空( Undefined)。此記憶體區域是唯一一個在Java虛擬機器規範中沒有規定任何OutofMemory Error

    情況的區域。

  • Java虛擬機器棧
      java虛擬機器( Java Virtual Machine Stacks)也是執行緒私有的。它的生命週期與執行緒相同。虛擬機器棧
    描述的是Java方法執行的記憶體模型;每個方法在執行的同部分自動記憶體管理機制時都會建立一個棧幀用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程,區域性變量表**存放了編譯期可知的各種基本資料型別( boolean、byte、char、 short、int、loat、long、 double)、物件引用( reference型別,它不等同於物件本身,可能是一個指向物件起始地址的引用指標,也可能是指向一個代表物件的控制代碼或其他與此物件相關的位置)和returnAddress型別(指向了一條位元組碼指令的地址)。其中64位長度的long和 double型別的資料會佔用2個區域性變數空間(Slot),其餘的資料型別只佔用1個。區域性變量表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小。
      在Java虛擬機器規範中,對這個區域規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflow Error

    異常;如果虛擬機器棧可以動態擴充套件(當前大部分的Java虛擬機器都可動態擴充套件,只不過Java虛擬機器規範中也允許固定長度的虛擬機器棧),如果擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemory Error異常。

  • 本地方法棧
      本地方法棧NativeMethodStack與虛擬機器棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。在虛擬機器規範中對本地方法棧中方法使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。甚至有的虛擬機器(譬如SunHotSpot虛擬機器)直接就把本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowErrorOutOfMemoryError異常

  • java堆
      Java堆(Java Heap)是Java虛擬機器所管理的記憶體中最大的塊Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項, 幾乎所有的物件例項都在這裡分配記憶體。這一點在Java虛擬機器規範中的描述是: 所有的物件例項以及陣列都要在堆上分配,但是隨著J編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也漸漸變得不是那麼“絕對”了。
      Jav堆是垃圾收集器管理的主要區域,因此很多時候也被稱做"GC堆",據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可。在實現時,既可以實現成固定大小的, 也可以是可擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過-XmxXms控制)如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemory Error

  • 方法區
      方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與Java堆區分開來。

  • 執行時常量池
      執行時常量池( Runtime Constant pool)是方法區的一部分。 Class件中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。當常量池無法再申請到記憶體時,也會丟擲OutOfMemory Error

  • 直接記憶體
      直接記憶體( Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。在JDK14中新加入了NIO(New Input/Output)類,引人了一種基於通道( Channel )與緩衝區( Buffer )的IO方式,它可以使用 Native 函式庫直接分配堆外記憶體,然後通過儲存在Java堆中的DirectByte Buffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java堆和 Native堆中來回複製資料。但經常忽略直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理和作業系統級限制)從而導致動態擴充套件時出現 OutOfMemory Error 異常。

2.2 hostpot虛擬機器物件探祕

  • 物件的建立
    • 檢查
        虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入 、解析和初始化過。如果沒有,那必須先執行相應的類載入過程

    • 分配記憶體
        接下來將為新生物件分配記憶體,物件所需記憶體在類載入完畢之後就可以完全確定,為物件分配記憶體空間的任務等同於把一塊確定的大小的記憶體從java堆中劃分出來。
        假設Java堆中記憶體是絕對規整的,所有用過的記憶體放在一遍,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標指向空閒空間那邊挪動一段與物件大小相等的距離,這個分配方式叫做“指標碰撞”
        如果Java堆中的記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式成為“空閒列表”
        選擇那種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。
        在分配記憶體的時候會出現併發的問題,比如在給A物件分配記憶體的時候,指標還沒有來得及修改,物件B又同時使用了原來的指標進行了記憶體的分片。
        有兩個解決方案:
         1、對分配的記憶體進行同步處理:CAS配上失敗重試的方式保證更新操作的原子性
         2、把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在java堆中分配一塊小記憶體,稱為本地緩衝區,那個執行緒需要分配記憶體,就需要在本地緩衝區上進行,只有當緩衝區用完並分配新的緩衝區的時候,才需要同步鎖定
    • init
        虛擬機器將分配的記憶體空間都初始化為零值(不包括物件頭),這一操作保證了例項物件欄位在java程式碼中可以不賦值就可以直接使用。最後,執行new指令後會接著執行Init方法,按程式的意願進行初始化,這樣一個物件才算完全產生出來。  

  • 物件的記憶體佈局
      在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、例項資料(Instance Data)和對齊填充(Padding)。
    物件頭包括兩部分:
      a) 儲存物件自身的執行時資料,如雜湊碼、GC分帶年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳

      b) 另一部分是指型別指標,即物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是那個類的例項

      例項資料:
      是物件正常儲存的有效資訊,也是程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄下來。

    對齊填充:
      不是必然存在的,僅僅是起到佔位符的作用。物件的大小必須是8位元組的整數倍,而物件頭剛好是8位元組的整數倍(1倍或者2倍),當例項資料沒有對齊的時候,就需要通過對齊填充來補全

  • 物件的訪問定位
    • 使用控制代碼訪問
        Java堆中將會劃分出一塊記憶體來作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自的具體地址
        優勢:reference中儲存的是穩定的控制代碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,而reference本身不需要修改

      通過控制代碼訪問

    • 使用直接指標訪問
        Java堆物件的佈局就必須考慮如何訪問型別資料的相關資訊,而refreence中儲存的直接就是物件的地址。
        優勢:速度更快,節省了一次指標定位的時間開銷,由於物件的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本

      通過指標訪問

2.3 OutOfMemoryError 異常(OOM)

  • java堆溢位
      Java堆用於儲存物件例項,只要不斷的建立物件,並且保證GCRoots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在數量到達最大堆的容量限制後就會產生記憶體溢位異常

      如果是記憶體洩漏,可進一步通過工具檢視洩漏物件到GC Roots的引用鏈。於是就能找到洩露物件是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收它們的。掌握了洩漏物件的型別資訊及GC Roots引用鏈的資訊,就可以比較準確地定位出洩漏程式碼的位置

       如果不存在洩露,換句話說,就是記憶體中的物件確實都還必須存活著,那就應當檢查虛擬機器的堆引數(-Xmx與-Xms),與機器實體記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長的情況,嘗試減少程式執行期的記憶體消耗

  • 虛擬機器棧和本地方法棧溢位
      對於HotSpot來說,不區分虛擬機器棧和本地方法棧,雖然-Xoss引數(設定本地方法棧大小)存在,但實際上是無效的,棧容量只由-Xss引數設定。關於虛擬機器棧和本地方法棧,在Java虛擬機器規範中描述了兩種異常:
      如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError
      如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲OutOfMemoryError異常

      在單執行緒下,無論由於棧幀太大還是虛擬機器棧容量太小,當記憶體無法分配的時候,虛擬機器丟擲的都是StackOverflowError異常

      如果是多執行緒導致的記憶體溢位,與棧空間是否足夠大並不存在任何聯絡,這個時候每個執行緒的棧分配的記憶體越大,反而越容易產生記憶體溢位異常。解決的時候是在不能減少執行緒數或更換64為的虛擬機器的情況下,就只能通過減少最大堆和減少棧容量來換取更多的執行緒

  • 方法區和執行時常量池溢位
      String.intern()是一個Native方法,它的作用是:如果字串常量池中已經包含一個等於此String物件的字串,則返回代表池中這個字串的String物件;否則,將此String物件包含的字串新增到常量池中,並且返回此String物件的引用

      由於常量池分配在永久代中,可以通過-XX:PermSize和-XX:MaxPermSize限制方法區大小,從而間接限制其中常量池的容量。

    Intern():
      JDK1.6 intern方法會把首次遇到的字串例項複製到永久代,返回的也是永久代中這個字串例項的引用,而由StringBuilder建立的字串例項在Java堆上,所以必然不是一個引用
      JDK1.7 intern()方法的實現不會再複製例項,只是在常量池中記錄首次出現的例項引用,因此intern()返回的引用和由StringBuilder建立的那個字串例項是同一個

    第三章 垃圾收集器和記憶體分配策略

    3.1 物件是否存活

  • 引用計數演算法
      給物件新增一個應用計數器,每當一個地方引用,計數器加一,當引用失效計數器減一。任何時刻計數器為零物件就是不可能再被引用,但是主流的java虛擬機器沒有選用引用計數演算法來管理記憶體,注意原因是問題它很難解決物件之間互相迴圈引用的問題。

  • 可達性分析演算法
      在主流的商用程式語言(Java、C#,甚至包括前面提到的古老的Lisp)的主流實現中,都是稱通過可達性分析來判定物件是否存活的。這個演算法的基本思路就是通過一系列的稱為“ GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈( Reference Chain),當一個物件到 GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。

  • 是否存活
      即使在可達性分析演算法中不可達的物件,它們也只是暫時處於“緩刑”階段,要真正宣告一個物件被銷燬至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize方法。當物件沒有覆蓋該方法或者該方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。如果這個物件被判定為有必要執行該方法,那這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的 Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束這樣做的原因是,如果一個物件在 finalized方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致 F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。finalize方法是物件逃脫銷燬命運的最後一次機會,稍後GC將對F- Queue 中的物件進行第二次小規模的標記,如果物件要在finalized中成功拯救自己——只要重新與引用鏈上的任何一個物件建立關聯即可,如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。從程式碼清單中我們可以看到一個物件的finalize被執行,但是它仍然可以存活。
    任何一個物件的 finalize0方法都只會被系統自動呼叫一次
    finalize執行的代價昂貴,不確定性大,無法保證各個物件的呼叫順序,它能做的所有工作,使用try-finally或者其它方式都可以做的更好

      public class FinalizeEscapseGC {
    
      public static FinalizeEscapseGC SAVE_HOOK=null;
    
      public void isAlive(){
        System.out.println("yes, i am still alive");
       }
    
       @Override
       protected void finalize() throws Throwable {
          super.finalize();
          System.out.println("finalize method executed!");
          FinalizeEscapseGC.SAVE_HOOK = this;
       }
    
      public static void main(String[] args) throws Exception{
      SAVE_HOOK=new FinalizeEscapseGC();
    
      // 物件第一次拯救自己
      SAVE_HOOK=null;
      System.gc();
      //finalize 優先順序極地,等待0.5秒
      Thread.sleep(500);
    
      if (SAVE_HOOK != null) {
          SAVE_HOOK.isAlive();
      } else {
          System.out.println("no ,FinalizeEscapseGC target am dead");
      }
    
      // 下面這段程式碼跟上面一樣,但是這次自救卻失敗了
      SAVE_HOOK=null;
      System.gc();
      //finalize 優先順序極地,等待0.5秒
      Thread.sleep(500);
    
      if (SAVE_HOOK != null) {
          SAVE_HOOK.isAlive();
      } else {
          System.out.println("no ,FinalizeEscapseGC target am dead");
      }
    
      }
    
      }
      /*  執行結果:
      finalize method executed!
       yes, i am still alive
       no ,FinalizeEscapseGC target am dead
    
      從程式碼清單的執行結果可以看出, SAVE HOOK物件的 finalize方法確實被GC收集器觸發過,並且在被收集前成功逃脫了
      另外一個值得注意的地方是,程式碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個對
      象的 finalize0方法都只會被系統自動呼叫一次,如果物件面臨下一次回收,它的 finalize0方法不會被再次執行,因此第二
      段程式碼的自救行動失敗.
      finalize執行的代價昂貴,不確定性大,無法保證各個物件的呼叫順序,,它能做的所有工作,使用try-finally或者其它方式都可以做的更好
    
      */

3.2 垃圾收集演算法

  • 標記清除演算法
      演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件,它的主要不足有兩個:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。
      標記清除演算法示意圖

  • 複製演算法
      為了解決效率問題,一種稱為“複製”(Copying)的收集演算法出現了,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。只是這種演算法的代價是將記憶體縮小為了原來的一半,未免太高了一點。
      複製演算法

      現在商業虛擬機器都採用這種收集演算法來回收新生代,IBM公司的專門研究表明,新生代中物件98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。虛擬機器HotSpot預設Eden和Survivor的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體會被“浪費”。當然,98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當 Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保( Handle Promotion)

  • 標記整理演算法
      複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。根據老年代的特點,有人提出了另外一種標記-整理演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

    標記整理示意圖

  • 分代收集演算法
      這種演算法並沒有什麼新的思想只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中每次垃圾收集時都發現有大批物件死去只有少量存活那就選用複製演算法只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保就必須使用“標記一清理”或者“標記一整理”演算法來進行回收。

3.3 記憶體分配與回收策略

  Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體以及回收分配給物件的記憶體。

  MinorGC:清理新生代
  MajorGC:清理老年代
  FullGC :清理整個堆空間

  • 物件優先在Eden分配
      大多數情況下,物件在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC。另:-XX:+PrintGCDetails收集器日誌引數,告訴虛擬機發生垃圾回收時,列印日誌

  • 大物件直接進入老年代
      大物件就是指需要大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。虛擬機器提供-XX:PretenureSizeThreshold,令大於這個設定值的物件直接在老年代分配這樣做的目的是避免Eden區及兩個Servivor之間發生大量的記憶體複製

  • 長期存活的物件將進入老年代
      虛擬機器給每個物件定義了一個年齡計數器,如果物件在Eden區出生並且經歷過一次Minor GC後仍然存活,並且能夠被Servivor容納,將被移動到Servivor空間中,並且把物件年齡設定成為1.物件在Servivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度,就將會被晉級到老年代中。對於晉升到老年代的年齡閾值可以通過-XX:MaxTenuringThreshold設定

  • 動態物件年齡判定
      為了更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件的年齡必須達到了MaxTenuringThreshold才能晉級到老年代,如果在Servivor空間中相同年齡所有物件的大小總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入到老年代,無須登到MaxTenuringThreshold中要求的年齡

  • 空間分配擔保
      在發生Minor GC 之前,虛擬機器會檢查老年代最大可 用的連續空間是否大於新生代所有物件總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機器會檢視HandlePromotionFailure設定值是否允許擔保失敗。如果允許那麼會繼續檢查老年代最大可用的連續空間是否大於晉級到老年代物件的平均大小,如果大於,將嘗試進行一次Minor GC,儘管這次MinorGC 是有風險的:如果小於,或者HandlePromotionFailure設定不允許冒險,那這時也要改為進行一次Full GC

  • Minor GC和Full GC有什麼不一樣嗎?
      新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java物件大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
      老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Full GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裡就有直接進行 Full GC的策略選擇過程)。Full GC的速度一般會比Minor GC慢10倍以上。

第四章 虛擬機器效能監控和故障工具(待伺服器恢復在研究)

4.1 jdk命令列工具

4.2 jdk視覺化工具

4.3 調優案例分析與實戰

第三部分

第五章 虛擬機器類載入機制

  類載入機制:虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

  在Java語言裡面,型別的載入。連線和初始化過程都是在程式執行期間完成的,這種策略java提供了高度靈活性,java天生可以動態擴充套件的語言特性就是依賴執行期動態載入和連線這個特點實現的。

5.1 類載入的時機

  類的生命週期

  類被載入到虛擬機器記憶體中開始,到解除安裝為止,整個生命週期包括:載入、驗證、準備、解析、初始化、使用和解除安裝7個階段
  載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以再初始化階段之後再開始,這個是為了支援Java語言執行時繫結(也成為動態繫結或晚期繫結)

  虛擬機器規範規定有且只有5種情況必須立即對類進行初始化

  • 遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要觸發其初始化。生成這4條指令的最常見的Java程式碼場景是:使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及呼叫一個類的靜態方法的時候

  • 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化

  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化

  • 當虛擬機器啟動時候,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類

  • 當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化

  被動引用:

  1.通過子類引用父類的靜態欄位,不會導致子類初始化

  2.通過陣列定義來引用類,不會觸發此類的初始化

    例:DemoClass[] arr=new DemoClss[10];
    但是會初始化Lorg.fenixsoft.classloading.SuperClass
    該類由虛擬機器自動生成,繼承自Object。

  3.常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

5.2 類的載入過程

5.2.1 載入

載入階段虛擬機器完成的3件事:
  1)通過一個類的全限定名類獲取定義此類的二進位制位元組流

  2)將這位元組流所代表的靜態儲存結構轉化為方法區執行時資料結構

  3)在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口

怎麼獲取二進位制位元組流?

  1)從ZIP包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎

  2)從網路中獲取,這種場景最典型的應用就是Applet

  3)執行時計算生成,這種常見使用得最多的就是動態代理技術

  4)由其他檔案生成,典型場景就是JSP應用

  5)從資料庫中讀取,這種場景相對少一些(中介軟體伺服器)
陣列類本身不通過類載入器建立,它是由Java虛擬機器直接建立的

陣列類的建立過程遵循以下規則:

  1)如果陣列的元件型別(指的是陣列去掉一個維度的型別)是引用型別,那就遞迴採用上面的載入過程去載入這個元件型別,陣列C將在載入該元件型別的類載入器的類名稱空間上被標識

  2)如果陣列的元件型別不是引用型別(列如int[]組數),Java虛擬機器將會把陣列C標識為與引導類載入器關聯

  3)陣列類的可見性與它的元件型別的可見性一致,如果元件型別不是引用型別,那陣列類的可見性將預設為public

雖然載入尚未完成,連線階段可能也肯能開始了,但是載入與連線階段依然存在固定的執行順序

5.2.1 驗證

  驗證是連線階段的第一步,這一階段的目的是確保claa位元組流中的資訊符合虛擬機器規範,並且不會危害虛擬機器自身的安全。

  驗證階段會完成下面4個階段的檢驗動作:檔案格式驗證,元資料驗證,位元組碼驗證,符號引用驗證

  • 檔案格式驗證
      第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。這個階段的驗證時基於二進位制位元組流進行的,只有通過類這個階段的驗證後,位元組流才會進入記憶體的方法區進行儲存,所以後面的3個驗證階段全部是基於方法區的儲存結構進行的,不會再直接操作位元組流。

這一階段可能包括:

  1).是否以魔數oxCAFEBABE開頭

  2).主、次版本號是否在當前虛擬機器處理範圍之內

  3.)常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)

  4.)指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量

  5.)CONSTANT_Itf8_info 型的常量中是否有不符合UTF8編碼的資料

  6.)Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊等等

  • 元資料驗證
      第二階段的主要目的是對類元資料資訊進行語義校驗,保證不存在不符合Java語言規範的元資料資訊

  1.這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)

  2.這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)

  3.如果這個類不是抽象類,是否實現類其父類或介面之中要求實現的所有方法

  4.類中的欄位、方法是否與父類產生矛盾(列如覆蓋類父類的final欄位,或者出現不符合規則的方法過載,列如方法引數都一致,但返回值型別卻不同等)等等

  • 位元組碼驗證
      第三階段是整個驗證過程中最複雜的一個階段,主要目的似乎通過資料流和控制流分析,確定程式語言是合法的、符合邏輯的。在第二階段對元資料資訊中的資料型別做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件。

  1.保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,列如,列如在運算元棧放置類一個int型別的資料,使用時卻按long型別來載入入本地變量表中

  2.保證跳轉指令不會跳轉到方法體以外的位元組碼指令上

  3.保證方法體中的型別轉換時有效的,列如可以把一個子類物件賦值給父類資料型別,這個是安全的,但是吧父類物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係、完全不相干的一個數據型別,則是危險和不合法的等等

  • 符號引用驗證
      發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段——解析階段中發生。

  1.符號引用中通過字串描述的全限定名是否能找到相對應的類

  2.在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位

  3.符號引用中的類、欄位、方法的訪問性是否可被當前類訪問

  對於虛擬機器的類載入機制來說,驗證階段是非常重要的,但是不一定必要(因為對程式執行期沒有影響)的階段。如果全部程式碼都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間

5.2.2 準備

  準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數都在方法區中進行分配。這個時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。其次,這裡說的初始值通常下是資料型別的零值。

  假設public static int value = 123;那變數value在準備階段過後的初始值為0而不是123,因為這時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器

5.2.3 解析

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

5.2.4 初始化

  類的初始化階段是類載入過程的最後一步,前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才正真開始執行類中定義的Java程式程式碼(或者說是位元組碼)

5.3 類載入器

  • 類與類載入器
      對於任意一個類,都需要有它的類載入器和類本身來確定唯一性,換句話說,比較兩個類是否相等,只有在這兩個類是由同一個類載入器載入的前提下才有意義。

  • 雙親委派模型

    類載入雙親委派模型

    這張圖表示類載入器的雙親委派模型(Parents Delegation model). 雙親委派模型要求除了頂層的啟動載入類外,其餘的類載入器都應當有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承的關係來實現,而是使用組合關係來複用父類載入器的程式碼。

    從java虛擬機器的角度,只存在兩種不同的類載入器:啟動類載入器(Bootstrap ClassLoader),使用C++實現,是虛擬機器自身的一部分。另一種是所有其他的類載入器,使用JAVA實現,獨立於JVM,並且全部繼承自抽象類java.lang.ClassLoader.

    • 從java程式的角度,一般會提供3中類載入器:
        1. 啟動類載入器(Bootstrap ClassLoader),負責將存放在<JAVA+HOME>\lib目錄中的,或者被-Xbootclasspath引數所制定的路徑中的,並且是JVM識別的(僅按照檔名識別,如rt.jar,如果名字不符合,即使放在lib目錄中也不會被載入),載入到虛擬機器記憶體中,啟動類載入器無法被JAVA程式直接引用。
        2. 擴充套件類載入器,由sun.misc.Launcher$ExtClassLoader實現,負責載入

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

    • 這樣做的好處就是
        Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類java.lang.Object,它存放在rt.jar中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類載入器自行去載入的話,如果使用者自己編寫了一個稱為java.lang.object的類,並放在程式的ClassPath中,那系統中將會出現多個不同的Object類,Java型別體系中最基礎的行為也就無法保證,應用程式也將會變得一片混亂
        就是保證某個範圍的類一定是被某個類載入器所載入的,這就保證在程式中同 一個類不會被不同的類載入器載入。這樣做的一個主要的考量,就是從安全層 面上,杜絕通過使用和JRE相同的類名冒充現有JRE的類達到替換的攻擊方式

第六章

6.1 java記憶體模型與執行緒

  Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣底層細節。此處的變數與Java程式設計時所說的變數不一樣,指包括了例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數與方法引數,後者是執行緒私有的,不會被共享。

  Java記憶體模型中規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體(類似於作業系統中處理器與主記憶體之間的快取記憶體),執行緒的工作記憶體中儲存了該執行緒使用到的變數到主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要在主記憶體來完成,執行緒、主記憶體和工作記憶體的互動關係如下圖所示:

java記憶體模型

  關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作來完成

  lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。

  unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。

  read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用

  load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。

  use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。

  assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。

  store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。

  write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

  如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行read和load操作, 如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store和write操作。Java記憶體 模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load之間, store和write之間是可以插入其他指令的,如對主記憶體中的變數a、b進行訪問時,可能的順 序是read a,read b,load b, load a。

  Java記憶體模型還規定了在執行上述八種基本操作時,必須滿足如下規則

  1. 不允許read和load、store和write操作之一單獨出現。

  2. 不允許一個執行緒丟棄它的最近assign的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。

  3. 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中。

  4. 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。

  5. 一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。lock和unlock必須成對出現。

  6. 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始化變數的值。

  7. 如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。

  8. 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)。

6.2 重排序

  在執行程式時為了提高效能,編譯器和處理器經常會對指令進行重排序。重排序分成三種類型:

  1.編譯器優化的重排序。編譯器在不改變單執行緒程式語義放入前提下,可以重新安排語句的執行順序。

  2.指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

  3.記憶體系統的重排序。由於處理器使用快取和讀寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

  從Java原始碼到最終實際執行的指令序列,會經過下面三種重排序:

  為了保證記憶體的可見性,Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。Java記憶體模型把記憶體屏障分為LoadLoad、LoadStore、StoreLoad和StoreStore四種

6.3 對於volatile型變數的特殊規則

  當一個變數定義為volatile之後,它將具備兩種特性

  第一:保證此變數對所有執行緒的可見性,這裡的可見性是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。普通變數的值線上程間傳遞需要通過主記憶體來完成

  由於valatile只能保證可見性,在不符合一下兩條規則的運算場景中,我們仍要通過加鎖來保證原子性

  1.運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。

  2.變數不需要與其他的狀態變數共同參與不變約束

  第二:禁止指令重排序,普通的變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中執行順序一致,這個就是所謂的執行緒內表現為序列的語義

6.4 原子性、可見性和有序性

  原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。Java記憶體模型是通過在變數修改後將新值同步會主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性,valatile特殊規則保障新值可以立即同步到祝記憶體中。Synchronized是在對一個變數執行unlock之前,必須把變數同步回主記憶體中(執行store、write操作)。被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有吧this的引用傳遞出去,那在其他執行緒中就能看見final欄位的值

  可見性:可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

  有序性:即程式執行的順序按照程式碼的先後順序執行。

  Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內表現為序列的語義”( Within- Thread As- f-Serial Semantics),後半句是指“指令重排序”現象和“工作
記憶體與主記憶體同步延遲”現象。
  Java語言提供了 volatile和 synchronized兩個關鍵字來保證執行緒之間操作的有序性,
volatile關鍵字本身就包含了禁止指令重排序的語義,而 synchronized則是由“一個變數在同
一個時刻只允許一條執行緒對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個
鎖的兩個同步塊只能序列地進入

6.6 先行發生原則

  這些先行發生關係無須任何同步就已經存在,如果不再此列就不能保障順序性,虛擬機器就可以對它們任意地進行重排序,否則虛擬機器可以任意的對它進行重排序。
  1.程式次序規則:在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確的說,應該是控制順序而不是程式程式碼順序,因為要考慮分支。迴圈等結構

  2.管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而後面的是指時間上的先後順序

  3.Volatile變數規則:對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的後面同樣是指時間上的先後順序

  4.執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每一個動作

  5.執行緒終止規則:執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.joke()方法結束、ThradisAlive()的返回值等手段檢測到執行緒已經終止執行

  6.執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷時間的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生

  7.物件終結規則:一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始

  8.傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論

6.7 執行緒

1. Java執行緒排程

  協同式排程:執行緒的執行時間由執行緒本身控制

  搶佔式排程:執行緒的執行時間由系統來分配,執行緒的切換不有執行緒本身決定

2. 狀態轉換

  • 1.新建

  • 2.執行:可能正在執行。可能正在等待CPU為它分配執行時間

  • 3.無限期等待:不會被分配CUP執行時間,它們要等待被其他執行緒顯式喚醒

  • 4.限期等待:不會被分配CUP執行時間,它們無須等待被其他執行緒顯式喚醒,一定時間會由系統自動喚醒

  • 5.阻塞:阻塞狀態在等待這獲取到一個排他鎖,這個時間將在另一個執行緒放棄這個鎖的時候發生;等待狀態就是在等待一段時間,或者喚醒動作的發生

  • 6.結束:已終止執行緒的執行緒狀態,執行緒已經結束執行

3. 執行緒實現中
  相比於Synchronized,ReentrantLock增加了一些高階功能

  • 1).等待可中斷:是指當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助

  • 2)公平鎖:是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;非公平鎖則不能保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。Synchronized中的鎖是非公平的,ReentrantLock預設情況下也是非公平的,但可以通過帶布林值的建構函式要求使用公平鎖

  • 3)鎖繫結多個條件是指一個ReentrantLock物件可以同時繫結多個Condition物件,而在synchronized中,鎖物件的wait()和notify()或notifyAll()方法可以實現一個隱含的條件,如果要和多餘一個的條件關聯的時候,就不得不額外地新增一個鎖,而ReentrantLock則無須這樣做,只需要多次呼叫newCondition方法即可

七、逃逸分析

  逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他方法中,成為方法逃逸。甚至還可能被外部執行緒訪問到,比如賦值給類變數或可以在其他執行緒中訪問的例項變數,稱為執行緒逃逸

  如果一個物件不會逃逸到方法或執行緒之外,也就是別的方法或執行緒無法通過任何途徑訪問到這個物件,則可能為這個變數進行一些高效的優化

  棧上分配:如果確定一個物件不會逃逸出方法外,那讓這個物件在棧上分配記憶體將會是一個不錯的注意,物件所佔用的記憶體空間就可以隨棧幀出棧而銷燬。如果能使用棧上分配,那大量的物件就隨著方法的結束而銷燬了,垃圾收集系統的壓力將會小很多

  同步消除:如果確定一個變數不會逃逸出執行緒,無法被其他執行緒訪問,那這個變數的讀寫肯定就不會有競爭,對這個變數實施的同步措施也就可以消除掉

  標量替換:標量就是指一個數據無法在分解成更小的資料表示了,int、long等及refrence型別等都不能在進一步分解,它們稱為標量。

  如果一個數據可以繼續分解,就稱為聚合量,Java中的物件就是最典型的聚合量

  如果一個物件不會被外部訪問,並且這個物件可以被拆散的化,那程式正整執行的時候將可能不建立這個物件,而改為直接建立它的若干個被這個方法使用到的成員變數來代替