帶你全面瞭解高級 Java 面試中須要掌握的 JVM 知識點

帶你全面瞭解高級 Java 面試中須要掌握的 JVM 知識點。 ——當年明月java

@[toc]程序員

JVM 內存劃分與內存溢出異常

概述面試

若是在大學裏學過或者在工做中使用過 C 或者 C++ 的讀者必定會發現這兩門語言的內存管理機制與 Java 的不一樣。在使用 C 或者 C++ 編程時,程序員須要手動的去管理和維護內存,就是說須要手動的清除那些不須要的對象,不然就會出現內存泄漏與內存溢出的問題。算法

若是你使用 Java 語言去開發,你就會發現大多數狀況下你不用去關心無用對象的回收與內存的管理,由於這一切 JVM 虛擬機已經幫咱們作好了。瞭解 JVM 內存的各個區域將有助於咱們深刻了解它的管理機制,避免出現內存相關的問題和高效的解決問題。編程

引出問題數組

在 Java 編程時咱們會用到許多不一樣類型的數據,好比臨時變量、靜態變量、對象、方法、類等等。 那麼他們的存儲方式有什麼不一樣嗎?或者說他們存在哪?緩存

運行時數據區域安全

Java 虛擬機在執行 Java 程序過程當中會把它所管理的內存分爲若干個不一樣的數據區域,各自有各自的用途。 bash

在這裏插入圖片描述

  • 程序計數器服務器

    線程私有的,能夠看做是當前線程所執行字節碼的行號指示器。字節碼解釋器工做時就是經過改變這個計數器的值來選取下一條須要執行的字節碼指令。分支、循環、異常處理、線程恢復等基礎功能都須要依賴這個計數器來完成。

    這時惟一一個沒有規定任何 OOM 異常的區域。

  • 虛擬機棧

    虛擬機棧也是線程私有的,生命週期與線程相同。棧裏面存儲的是方法的局部變量對象的引用等等。

    在這片區域中,規定了兩種異常狀況,當線程請求的棧深度大於虛擬機所容許的深度,將拋出 StackOverflowError 異常。當虛擬機棧動態擴展沒法申請到足夠的內存時會拋出 OOM 異常。

  • 本地方法棧

    和虛擬機棧的做用相同,只不過它是爲 Native 方法服務。HotSpot 虛擬機直接將虛擬機棧和本地方法棧合二爲一了。

  • 堆是 Java 虛擬機所管理內存中最大的一塊。是全部線程共享的一塊內存區域,在虛擬機啓動時建立。這個區域惟一的做用就是存放對象實例,也就是 NEW 出來的對象。這個區域也是 Java 垃圾收集器的主要做用區域。

    當堆的大小再也沒法擴展時,將會拋出 OOM 異常。

  • 方法區

    方法區也是線程共享的內存區域,用於存儲已經被虛擬機加載的類信息常量靜態變量等等。當方法區沒法知足內存分配需求時,會拋出 OOM 異常。這個區域也被稱爲永久代。

補充

雖然上面的圖裏沒有運行時常量池和直接內存,可是這兩部分也是咱們開發時常常接觸的。因此給你們補充出來。

  • 運行時常量池

    運行時常量池是方法區的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池,用於存放編譯期生成的各類字面量符號引用,這部份內容將在類加載後存放到方法區的運行時常量池中。也會拋出 OOM 異常。

  • 直接內存

    直接內存並非虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的內存區域,可是倒是NIO 操做時會直接使用的一塊內存,雖然不受虛擬機參數限制,可是仍是會受到本機總內存的限制,會拋出 OOM 異常。

JAVA8 的改變

對於方法區,它是線程共享的,主要用於存儲類的信息,常量池,方法數據,方法代碼等。咱們稱這個區域爲永久代

大部分程序員應該都見過 java.lang.OutOfMemoryError:PermGen space 異常,這裏的 PermGen space 其實指的就是方法區。因爲方法區主要存儲類的相關信息,因此對於動態生成類的狀況比較容易出現永久代的內存溢出,典型的場景是在 JSP 頁面比較多的狀況,容易出現永久代內存溢出。

在JDK 1.8中,HotSpot 虛擬機已經沒有 PermGen space 這個區域了,取而代之的是一個叫作Metaspace (元空間)的東西。

在這裏插入圖片描述

變化就是移除了方法區,增長了元空間,與方法區最大的區別是:元空間再也不虛擬機中,而是使用本地內存。默認狀況下,元空間的大小僅受本地內存限制。

這樣更改的好處:

  • 字符串常量存在方法區中,容易出現性能問題和內存溢出。
  • 類和方法的信息等比較難肯定大小,所以對於方法區大小的指定比較困難,過小容易出現方法區溢出,太大容易致使堆的空間不足。
  • 方法區的垃圾回收會帶來沒必要要的複雜度,而且回收效率偏低(垃圾回收會在下一章給你們介紹)。

內存溢出

雖然有 JVM 幫咱們管理內存,可是在實際開發過程當中必定還會遇到內存溢出的問題。堆,棧,方法區都有可能出現內存溢出問題。下面咱們就結合幾個實際的小例子來給你們展現一下,方便你們之後根據不一樣的狀況對內存溢出問題進行快速準確的定位。

  • java.lang.OutOfMemoryError: Java heap space ———>java 堆內存溢出,此種狀況最多見,通常因爲內存泄露或者堆的大小設置不當引發。對於內存泄露,須要經過內存監控軟件查找程序中的泄露代碼,而堆大小能夠經過虛擬機參數 -Xms、 -Xmx 等修改。

    例子:在集合中無限加入對象,效果受到機器配置影響,能夠主動更改堆大小方便演示。

    public class HeapOOM {
      public static void main(String[] args){
          long i= 0;
          try {
              List<Object> objects = new ArrayList<Object>();
              while (true) {
                  i++;
                  objects.add(new Object());
                  System.out.println(i);
              }
          } catch(Throwable ex) {
              System.out.println(i);
              ex.printStackTrace();
          }
      }
    }
    
    複製代碼
    70091068
    70091069
    70091070
    70091071
    java.lang.OutOfMemoryError: Java heap space
      at java.util.Arrays.copyOf(Arrays.java:3210)
      at java.util.Arrays.copyOf(Arrays.java:3181)
      at java.util.ArrayList.grow(ArrayList.java:265)
      at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
      at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
      at java.util.ArrayList.add(ArrayList.java:462)
      at HeapOOM.main(HeapOOM.java:14)
    
    複製代碼
  • java.lang.OutOfMemoryError: PermGen space ------>java永久代溢出,即方法區溢出了,通常出現於大量Class 或者 JSP 頁面,或者採用 CGLIB 等反射機制的狀況,由於上述狀況會產生大量的 Class 信息存儲於方法區。此種狀況能夠經過更改方法區的大小來解決,使用相似 -XX:PermSize=64m -XX:MaxPermSize=256m 的形式修改。另外,過多的常量尤爲是字符串也會致使方法區溢出,由於常量池也是方法區的一部分。

    例子:無限加載 Class,須要在 JDK 1.8 以前的版本運行,由於1.8將方法區改爲了元空間,利用了機器的內存,最好手動設置 -XX:MaxPermSize,將值調小一點。

    public class HeapOOM {
    
      public static void main(String[] args) throws Exception {
          for (int i = 0; i < 100_000_000; i++) {
              generate("cn.paul.test" + i);
          }
      }
    
      public static Class generate(String name) throws Exception {
          ClassPool pool = ClassPool.getDefault();
          return pool.makeClass(name).toClass();
      }
    }
    
    複製代碼
    結果你們本身試一下吧    
    
    複製代碼
  • java.lang.StackOverflowError ------> 不會拋 OOM error,但也是比較常見的 Java 內存溢出。Java 虛擬機棧溢出,通常是因爲程序中存在死循環或者深度遞歸調用形成的,棧大小設置過小就會出現此種溢出。能夠經過虛擬機參數 -Xss 來設置棧的大小。

    例子:沒法快速收斂的遞歸。

    public class HeapOOM {
    
      public static void main(String[] args){
         stackOverFlow(new AtomicLong(1));
      }
    
      public static void stackOverFlow(AtomicLong counter){
          System.out.println(counter.incrementAndGet());
          stackOverFlow(counter);
      }
    
    }
    
    複製代碼
    8769
    8770
    8771
    8772
    8773
    8774
    8775
    Exception in thread "main" java.lang.StackOverflowError
      at java.lang.Long.toString(Long.java:396)
      at java.lang.String.valueOf(String.java:3113)
      at java.io.PrintStream.print(PrintStream.java:611)
      at java.io.PrintStream.println(PrintStream.java:750)
      at HeapOOM.stackOverFlow(HeapOOM.java:14)
      at HeapOOM.stackOverFlow(HeapOOM.java:15)
    
    複製代碼

思考

看完上面的講解後,相信你們對於 Java 中各類變量、類、方法、實例的存儲位置都已經瞭解了。下面結合一些簡單的面試題來加深一下你們的理解。

String a = new String("xyz");
複製代碼

問:這段代碼建立了幾個對象,都存在 JVM 中的哪一個位置?

答:答案是兩個對象,第一個是經過 NEW 關鍵字建立出來的 a 對象,它的存儲位置固然是在堆中。第二個是 xyz 這個對象,它存在常量池中(String 在 Java 中被定義爲不可變的對象,類的定義和方法都是 final 的,因此會被看成常量看待)。

問:a 對象的引用存在哪裏?

答:對象的引用所有存在棧中。

問:Java 中各個對象、變量、類的存儲位置?

答:若是你已經掌握了上面的內容,這個問題應該是不難的。NEW 出來的對象存儲在堆中,局部變量和方法的引用存在棧中,類的相關信息、常量和靜態變量存在方法區中,1.8之後使用元空間存儲類相關信息。

問:Java 中會有內存溢出問題嗎?發生在哪些狀況下?

答:JVM 的堆、棧、方法區、本地方法棧、直接內存都會發生內存溢出問題。典型的堆溢出的例子:集合持有大量對象而且長期不釋放。典型的棧溢出例子:沒法快速收斂的遞歸。典型的方法區溢出例子:加載了大量的類或者 JSP 的程序。

垃圾回收算法與收集器

概述

上一篇文章咱們已經瞭解了 Java 的這幾塊內存區域。對於垃圾回收來講,針對或者關注的是 Java 堆這塊區域。由於對於程序計數器、棧、本地方法棧來講,他們隨線程而生,隨線程而滅,因此這個區域的內存分配和回收能夠看做具有肯定性。對於方法區來講,分配完類相關信息後內存大小也基本肯定了,加上在 JAVA8 中引入的元空間,因此這個部分也不用關注。

方法區回收

不少人認爲方法區是沒有垃圾收集的,Java 虛擬機規範也確實說過能夠不要求在虛擬機方法區實現垃圾收集,並且在這個地方收集性價比比較低。在堆中,一次能夠回收70%~95%的空間,而方法區也就是永久代的回收效率遠低於此。方法區垃圾收集主要回收兩部份內容:廢棄常量和無用的類。

JAVA8 引入的元空間很好的解決了方法區回收效率低下的問題。

引出問題

Java 堆中存儲的是 NEW 出來的對象,那麼什麼樣的對象是須要被垃圾回收器回收掉的那?可能你會回答不用的對象或者死掉的對象。那如何判斷對象已經不用了或者死掉了那?怎麼回收這些死掉了的對象那?

如何判斷對象已死

  • 引用計數器

    每當有一個地方引用它時,計數器值就加一,引用失效時,計數器值減一。簡單高效,可是沒辦法解決循環引用的問題。

  • 可達性分析算法

    這個算法的基本思路就是經過一系列名爲 GC ROOTS 的對象做爲起始點,從這些節點開始向下搜索。當一個對象到 GC ROOTS 沒有任何引用連接時,則證實此對象時不可用的。

    能夠做爲 GC ROOTS 的對象包括下面幾種:

  1. 方法裏引用的對象。
  2. 方法區中的類靜態屬性引用的對象。
  3. 方法區中的常量引用的對象。
  4. 本地方法中引用的對象。

HotSpot 虛擬機採用的是可達性分析算法。

如何回收

當前的商業虛擬機的垃圾收集都採用分代垃圾回收的算法,這種算法並無什麼新的思想。只是根據對象的存活週期將不一樣的內存劃分爲幾塊。通常是把 Java 堆分爲新生代老年代,根據新生代和老年代存活時間的不一樣採起不一樣的算法,使虛擬機的 GC 效率提升了不少。新生代採用複製算法,老年代採用標記-清除或者標記-整理算法。

回收算法

  • 標記-清除

    算法分爲標記清除兩個階段,首先要標記出全部須要回收的對象,在標記完成後統一回收掉全部被標記的對象。

    缺點:效率問題,標記和清除過程的效率都不高,另外會有不連續的內存碎片。

    在這裏插入圖片描述

  • 複製

    爲了解決效率問題,複製算法出現了,將內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。清除後將活着的對象複製到另一塊上面。簡單高效。如今的商業虛擬機都採用這種收集算法來回收新生代。由於新生代中的對象98%都是朝生夕死的,因此並不須要按1:1劃份內存,而是按8:1:1分爲 Eden,survivor,survivor。每次只使用 Eden 和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的對象一次性拷貝到另一塊 Survivor 上。

    當 Survivor 空間不夠用時,須要依賴老年代進行分配擔保。

    比較適合須要清除的對象比較多的狀況。

    在這裏插入圖片描述

    (圖片來源於網絡)

  • 標記-整理

    標記-整理算法和標記-清除算法的標記過程同樣,後序有一個對內存進行整理的動做。和標記-整理算法同樣,比較適合要清除對象很少的狀況。複製算法在對象存活率較高時就要執行較多的複製操做,效率會變的很低。並且若是不想浪費 50% 的空間,就須要有額外的空間進行分配擔保,以應對對象 100% 存活的極端狀況,因此老年代通常不選複製算法,而選擇標記-清除或者標記-整理算法。

    在這裏插入圖片描述

    (圖片來源於網絡)

內存分配與回收策略

首先須要瞭解兩個名詞:

Minor GC:新生代 GC,指的是發生在新生代的垃圾回收動做,由於 Java 對象大多都具備朝生夕滅的特性,因此 Minor GC 很是頻繁,通常回收速度也比較快。

Full GC:老年代 GC,出現了 Full GC,通常也會伴隨至少一次的 Minor GC(非絕對的)。

對象的內存分配,往大了講就是在堆中分配。下面是更細粒度的分配策略。

  • 對象優先在 Eden 中分配,大多數狀況下,對象在新生代 Eden 中分配,當 Eden 沒有足夠的空間進行分配時,虛擬機將發起一次 Minor GC。在 GC 開始的時候,對象只會存在於 Eden 區和名爲 From 的 Survivor 區,名爲 To 的 Survivor 區是空的。緊接着進行 GC,Eden 區中全部存活的對象都會被複制到 To,而在 From 區中,仍存活的對象會根據他們的年齡值來決定去向。年齡達到必定值(年齡閾值,能夠經過 -XX:MaxTenuringThreshold 來設置)的對象會被移動到年老代中,沒有達到閾值的對象會被複制到 To 區域。通過此次 GC 後,Eden 區和 From 區已經被清空。這個時候,From 和 To 會交換他們的角色,也就是新的 To 就是上次 GC 前的 From ,新的 From 就是上次 GC 前的 To 。無論怎樣,都會保證名爲 To 的 Survivor 區域是空的。Minor GC 會一直重複這樣的過程,直到 To 區被填滿,To 區被填滿以後,會將全部對象移動到年老代中。
  • 大對象直接進入老年代,大對象指的是那些須要連續內存空間的 Java 對象,最典型的大對象就是那種很長的字符串以及數組。直接進入老年代避免了大對象在 Eden 區和 Survivor 區之間發生大量的內存拷貝。
  • 長期存活的對象將進入老年代,虛擬機給每一個對象定義了一個對象年齡計數器,若是對象在 Eden 出生並通過一次 Minor GC 後仍然存活,而且能被 Survivor 容納就會被移動到 Survivor 中,而且年齡增長 1。當年齡達到某個闕值(默認爲 15)時,就會晉升到老年代。

垃圾收集器

若是說收集算法是內存回收的方法論,垃圾收集器就是內存回收的具體實現。下面介紹基於 HotSpot 虛擬機中的垃圾收集器。對於垃圾收集器,你們有個概念就能夠了,沒有必要去深究垃圾收集器的底層原理,固然若是有餘力,瞭解底層原理固然是最好的。

在這裏插入圖片描述

(圖片來源於網絡)

  • Serial 收集器

    最先的垃圾收集器,回收新生代,單線程。這裏的單線程不只僅說明它只會使用一個 CPU 或者一條收集線程去完成垃圾收集工做,重要的是,在進行垃圾收集時,必須暫停其餘全部工做線程(Stop The World)。

  • ParNew 收集器

    新生代垃圾回收,ParNew 收集器其實就是 Serial 收集器的多線程版本,在收集算法,Stop The World 和對象分配規則,回收策略上都與 Serial 相同。ParNew 在單核甚至雙核 CPU 上的表現不如 Serial,更多的 CPU 才能體現他的優勢。

  • Parallel Scanvnge 收集器

    新生代垃圾回收,採用複製算法,關注吞吐量,不關注停頓時間。停頓時間越短就越適合須要於用戶交互的程序,良好的響應速度能提高用戶的體驗。高吞吐量則能夠最高效率地利用 CPU 時間,儘快完成運算任務,適合在後臺運算而不須要太多交互的任務。

  • Serial Old 收集器

    Serial 的老年代版本,單線程,使用標記-整理算法。

  • Parallel Old 收集器

    Parallel New 的老年代版本,使用標記-整理算法。

  • CMS 收集器

    CMS 是一種以獲取最短回收停頓時間爲目標的收集器,注重響應速度。基於標記-清除算法實現的。不一樣於其餘收集器的全程 Stop The World,CMS 會有兩次短暫的 Stop The World,垃圾收集和工做線程併發執行。整個過程分爲 4 個步驟:

  1. 初始標記(Stop The World),標記 GC Roots 能關聯到的對象。
  2. 併發標記
  3. 從新標記(Stop The World)
  4. 併發清除
  • G1 收集器

    基於標記-整理實現。能夠實如今基本不犧牲吞吐量的前提下完成低停頓的內存回收,新生代和老年代均可以回收。

思考

問:JVM 中使用了什麼算法進行垃圾回收?

答:根據對象的存活時間採起了分代垃圾回收算法。新生代採起了複製算法(面試時能夠就對象的分配以及 Eden、Survivor、Survivor 繼續說一些),老年代採起了標記-清除或標記-整理算法。

問:如何判斷對象已死?

答:引用計數器和可達性分析算法,HotSpot 虛擬機採起了可達性分析算法。

問:你瞭解哪些垃圾收集器?他們有什麼區別?

答:新生代的有 Serial(單線程),ParNew(Serial 的多線程版本),PS(比較注重吞吐量)。老年代有 Serial Old(單線程),Parallel Old(ParNew 的老年代版本),CMS(兩次Stop The World,實現了併發清除)。G1(基本不犧牲吞吐量的前提下完成低停頓的內存回收,新生代和老年代均可以回收)。

虛擬機中的類加載機制

概述

虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、轉換、解析和初始化,最終造成能夠被虛擬機直接使用的 Java 類型,這就是虛擬機的類加載機制。

類的生命週期

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括了:加載、驗證、準備、解析、初始化、使用、卸載七個階段。其中驗證、準備和解析三個部分統稱爲鏈接。

在這裏插入圖片描述
(圖片來源於網絡)

  • 加載:加載是類加載的第一個階段,這個階段,首先要根據類的全限定名來獲取定義此類的二進制字節流,將字節流轉化爲方法區運行時的數據結構,在 Java 堆生成一個表明這個類的 java.lang.class 對象,做爲方法區的訪問入口。

  • 驗證:這一步的目的時確保 Class 文件的字節流包含的信息符合當前虛擬機的要求。

  • 準備:準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都會在方法區中進行分配。僅僅是類變量,不包括實例變量。

    public static int value = 123;
    複製代碼

    變量在準備階段事後的初始值爲0而不是123,123的賦值要在變量初始化之後纔會完成。

  • 解析:虛擬機將常量池內的符號引用替換爲直接引用的過程。

  • 初始化:初始化是類加載的最後一步,這一步會根據程序員給定的值去初始化一些資源。

何時加載

對於何時進行類的加載,虛擬機規範中並無進行強制約束。可是如下幾種狀況時,必須對類進行初始化(加載、驗證、準備則確定要在此以前完成)。

  • 遇到 new,getstatic,putstatic 或 invokestatic 這四條字節碼指令時,若是沒有初始化則要先觸發其初始化。生成這4條指令的 Java 代碼場景是:使用 new 關鍵字實例化對象的時候,讀取或者設置一個類的靜態字段,或調用一個類的靜態方法時。
  • 使用 java.lang.reflect 包進行反射調用時,若是沒有初始化,則先要進行初始化。
  • 當初始化一個類的時候,發現其父類還沒被初始化,則須要先觸發父類的初始化。
  • 虛擬機啓動時,用戶須要指定一個執行的主類(包含 main 方法的類),虛擬機會先初始化這個主類。

這四種場景稱爲對一個類進行主動引用,除此以外全部引用類的方式都不會出發初始化。

下面演示兩個被動使用類字段的例子,經過子類引用父類的靜態字段,不會致使子類初始化:

class SuperClass{
    static{
        System.out.println("super init");
    }
    public static int value = 123;
}

class SubClass extends SuperClass{
    static{
        System.out.println("sub init");
    }
}
public class Show{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}

//輸出結果
super init
123

複製代碼

常量在編譯階段會存入調用類的常量池,本質上沒有直接應用到定義常量的類,所以不會使定義常量的類的初始化,這段代碼運行後不會輸出 ConstClass init ,由於雖然在 Java 源碼中引用了 ConstClass 類中的常量 HELLOWORLD,可是在編譯階段這個值就已經被存到了常量池中,對 ConstClass.HELLOWORLD 的引用實際都轉化爲了 Show 類對自身常量池的引用了。這兩個類在編譯成 Class 以後就不存在任何聯繫了。

class ConstClass{
    static{
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}
public class Show{
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}

//定義常量的類並無初始化
hello world

複製代碼

接口有一點不一樣,當一個類在初始化時,要求其父類所有都已經初始化過了,可是一個接口在初始化時,並不要求其父接口所有都完成了初始化,只有真正使用到了父接口才會初始化。

類加載器

虛擬機設計團隊把類加載階段中的經過一個類的全限定名來獲取描述此類的二進制字節流這個動做放到 Java 虛擬機外部去實現,以便讓程序本身去決定如何獲取所須要的類,這個動做的代碼模塊稱爲類加載器

對於一個類,都須要由加載它的類加載器和這個類自己一同確立其在 Java 虛擬機中的惟一性,比較兩個類是否相等須要在這兩個類是由同一個類加載器加載的前提下才有意義。

雙親委派模型

雙親委派模型的工做過程是:若是一個類加載器收到了類加載的請求,它首先不會去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此。只有當父類加載器反饋本身沒法完成這個加載請求(他的搜索範圍中沒有找到所需的類)時,子類加載器纔會嘗試去加載。

好處:使用雙親委派模型的好處是,Java 類隨着它的類加載器一塊兒具有了一種帶有優先級的層次關係,好比 java.lang.Object,它存放在 rt.jar 中,不管哪個類加載器要加載這個類,最後都是委派給啓動類加載器進行加載。

若是不使用雙親委派模型,用戶本身寫一個 Object 類放入 ClassPath,那麼系統中將會出現多個不一樣的 Object 類,Java 類型體系中最基礎的行爲也就無從保證。

如今你能夠嘗試本身寫一個名爲 Object 的類,能夠被編譯,但永遠沒法運行。由於最後加載時都會先委派給父類去加載,在 rt.jar 搜尋自身目錄時就會找到系統定義的 Object 類,因此你定義的 Object 類永遠沒法被加載和運行。

Java 虛擬機的類加載器能夠分爲如下幾種:

在這裏插入圖片描述

(圖片來源於網絡)

  • 啓動類加載器(Bootstrap ClassLoader):這個類負責將 \lib 目錄中的類庫加載到內存中,啓動類加載器沒法被Java程序直接飲用。
  • 擴展類加載器(Extension ClassLoader):負責加載 \lib\ext 目錄中的類。開發者能夠直接使用擴展類加載器。
  • 應用程序類加載器(Application ClassLoader):這個類加載器是 ClassLoader 中 getSystemClassLoader() 方法的返回值,因此通常稱爲系統類加載器。若是沒有自定義過加載器,通常狀況下這個就是默認的類加載器。
  • 自定義類加載器(User ClassLoader):經過自定義類加載器能夠實現一些動態加載的功能,好比 SPI。

Java 內存模型與線程

概述

瞭解 JVM 的 Java 內存模型以及結構對於咱們在多線程開發時有很大幫助。瞭解線程安全的虛擬機底層運做原理以及虛擬機實現高效併發所採起的一些列鎖優化措施是咱們開發高效和安全代碼的基礎。

經過硬件類比 Java 內存模型

  • 硬件效率一致性

    計算機的存儲設備(內存,磁盤)和處理器的運算速度有着幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存(Cache)來做爲內存和處理器之間的緩衝。

    將運算所須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後在從緩存同步回內存中,這樣處理器就無需等待緩慢的內存讀寫了。

    這時會有緩存一致性問題,在多處理器系統中,每一個處理器都有本身的高速緩存,他們又共享同一主內存,會有可能致使各自的緩存數據不一致的問題。爲了解決這個問題,須要根據一些讀寫協議來操做,好比 MSI、MESI、MOSI、Synapse 等等。

在這裏插入圖片描述
在硬件系統中,爲了保證處理器內部的運算單元被充分利用,處理器可能會對輸入代碼進行亂序執行優化。Java 虛擬機即時編譯器也有相似的指令重排序優化。

  • Java 內存模型

    Java 虛擬機規範中試圖定義一種 Java 內存模型( Java Memory Model )來屏蔽掉各類硬件和操做系統的內存訪問差別,讓 Java 在各類平臺下都能達到一致的併發效果。

    Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。這裏的變量是指實例字段,靜態字段和構成數組對象的元素,可是不包括局部變量和方法參數,由於後者是線程私有的,不會被共享。

    在這裏插入圖片描述

    (圖片來源於網絡)

    Java 內存模型規定了全部變量都是存儲在主內存(Main Memory)中。每條線程還有本身的工做內存,工做內存中保存了被改線程使用到的變量的主內存副本拷貝,線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存中的變量。線程間的通訊要經過工做內存進行。工做內存中更改的變量會不定時刷新到主存中。

經過對比發現,兩者的變量更改、數據共享、內存刷新以及架構都很是類似。

volatile 與特殊規則

volatile 能夠說是 Java 虛擬機提供的最輕量級的同步機制,定義成 volatile 的字段能保證此變量對全部線程的可見性,修改後馬上刷新到主存,其餘線程讀取這個變量也要在主存中讀取。volatile 能夠禁止指令重排序優化。

經過上面的 Java 內存模型和 volatile 當即刷新和避免指令重排序的特性能夠發現 volatile 能夠保證數據的可見性。可是它不能保證原子性。

對於 64 位的數據類型,在模型中規定,它容許將沒有被 volatile 修飾的 64 位數據的讀寫劃分爲兩次的 32 位來操做,即不保證他的原子性。不過目前各類平臺的商用虛擬機機會都選擇把 64 位的數據讀寫做爲原子操做來對待,由於不須要專門爲 long 和 double 聲明 volatile。

Java 與線程

併發不必定要依賴多線程(PHP 常見的多進程併發),可是在 Java 裏面談論併發,大多數都與線程脫不開關係。

線程是比進程更輕量級的調度單位,線程能夠把一個進程的資源分配和調度執行分開,各個線程既能夠共享進程資源,又能夠獨立調度。

Java 中寫一個線程有三種方式,繼承 Thread 類,實現 Runnable 接口,實現 Callable 接口。對於 Sun JDK 來講,它的 Windows 與 Linux 版都是使用一對一的線程模型來實現的,一條 Java 線程就映射到一條輕量級進程之中。

狀態轉換

Java 定義了 5 種線程狀態,一個線程有且僅有其中一種狀態。

在這裏插入圖片描述

(圖片來源於網絡)

  • 新建(new):建立後還沒有啓動的線程就處於這種狀態。
  • 運行(Runnable):線程正在運行的狀態。
  • 就緒(Ready):就緒狀態,等待 CPU 分配時間片後就能夠運行。
  • 阻塞(Blocked):多是由於調用了 wait 方法進入阻塞(釋放鎖),也多是 sleep 方法進入阻塞(不釋放鎖)。
  • 結束(Terminated):以終止線程的線程狀態,線程已經結束執行。

線程安全

當多個線程訪問一個對象時,若是不用考慮這些線程在運行時環境下的調度和交替執行,也不須要額外的同步,或者在調用方法進行任何其餘協同操做,調用這個對象的行爲均可以得到正確的結果,那這個對象就是線程安全的。 按照線程安全由強至弱來排序,咱們能夠將 Java 語言中各類操做共享的數據分爲如下五類:

  • 不可變

    在 JDK 1.5 之後,不可變(Immutable)的對象必定是線程安全的,不管是對象的方法仍是方法的調用者,都不須要再進行任何的線程安全保證措施。對於基本數據類型,使用 final 關鍵字修飾它就能夠保證它是不可變的。

    若是共享數據是一個對象,那就須要保證對象的行爲不會對其狀態產生任何影響才行。好比 java.lang.String 類,它是典型的不可變對象,咱們調用它的 substring() ,replace() ,concat() 方法不會影響它原來的值,只會返回一個新構造的字符串對象。

  • 絕對線程安全

    絕對線程安全的定義是,一個類要達到無論運行時環境如何,調用者都不須要任何額外的同步措施。知足這個要求很難。好比 java.util.Vector 是一個線程安全的容器,它的 add()、get()、 size()等方法都被 synchronized 修飾。可是多線程對它同時操做時,它可能也不那麼安全。

  • 相對線程安全

    相對線程安全就是咱們一般意義上所講的線程安全,它須要保證對這個對象單獨的操做是線程安全的,可是對於一些特定順序的連續調用,就可能須要在調用端使用額外的同步手段來保證調用的正確性。Java 中大部分的線程安全類都屬於這個類型,好比 Vector,HashTable,Collections 的 synchronizedCollection() 方法包裝的集合等。

  • 線程兼容

    線程兼容指的是對象自己並非線程安全的,可是經過在調用端正確的同步手段來保證對象在併發環境中安全的使用。平時說一個類不是線程安全的,絕大多數指的都是這種狀況。好比 Vector 和 HashTable 對應的 ArrayList 和 HashMap 類。

  • 線程對立

    線程對立是指無論調用端是否採用了同步措施,都沒法在多線程環境中併發使用代碼。這種代碼多數是有害的,應當儘可能避免。好比 Thread 類的 suspend() 和 resume() 方法,若是有兩個線程同時持有一個對象,一個嘗試去中斷線程,一個嘗試去恢復線程,若是併發進行,目標線程是存在死鎖風險的,因此這兩個方法已經廢棄了。

線程安全的實現方法

  • 阻塞同步

    Java 中最基本的同步手段就是 synchronized 關鍵字,synchronzied 關鍵字在通過編譯後,會在代碼塊先後分別造成 monitorenter 和 monitorexit 這兩個字節碼指令。這兩個字節碼須要一個對象來指明要鎖定和解鎖的對象。若是 synchronized 明確指定了對象參數,那麼鎖的就是這個對象,若是 synchronized 修飾的是方法和類,那麼鎖的就是對象實例或 Class 對象做爲鎖對象。

    synchronized 同步快對於已經得到鎖的同一條線程來講是可重入的,不會出現鎖死本身的問題。另外,同步塊在已進入的線程執行完以前,會阻塞後面其餘線程的進入。

    因爲 Java 的線程是映射到操做系統的原生線程之上的,若是阻塞或喚醒一條線程,都須要操做系統來幫忙完成,這就須要從用戶態轉換到內核態,這個狀態轉換須要耗費不少的處理器時間。對於代碼簡單的同步快,狀態轉換消耗的時間可能比代碼執行時間還長。因此 synchronized 是一個重量級鎖。

    除了 synchronized 以外,還能夠是用 JUC 包中的重入鎖(ReentrantLock,Lock 接口的實現類)來實現同步。與 synchronized 相同的是,它具有線程重入的特性。ReentrantLock 表現爲 API 層面的互斥鎖,synchronized 是 JVM 底層實現的互斥鎖。Lock 接口的高級功能:

  • 等待可中斷 指的是當前持有鎖的線程長期不釋放的時候,正在等待的線程能夠選擇放棄等待,這對於處理時間很是長的同步塊有很大的幫助。

  • 公平鎖 指的是多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲取鎖。synchronized 是非公平的。

  • 綁定多個條件 指的是一個 Lock 對象能夠同時綁定多個 Condition 對象。

  • 非阻塞同步

    阻塞同步最主要的問題就是線程阻塞和喚醒所帶來的性能問題,屬於悲觀的併發策略。另外一種選擇就是基於衝突檢測(CAS)的樂觀併發策略,通俗的講就是,先進行操做,若是沒有其餘線程爭搶,那操做就成功了。

    樂觀的併發策略須要硬件指令集來完成的,用硬件來保證一個從語義上看起來須要屢次操做的行爲只經過一條處理器指令就能完成。

  • 測試並設置(Test-and-Set)

  • 獲取並增長(Fetch-and-Increment)

  • 交換(Swap)

  • 比較並交換(Compare and Swap,CAS)

    CAS 指令須要三個操做數,分別是內存位置 V,舊的預期值 A 和新值 B 。當且僅當 V 符合預期值 A 時,處理器用新值 B 更新 V 的值,不然就不更新。AtomicInteger 等類就是經過 CAS 實現的。

  • 無同步方案

    要保證線程安全,並非必定就要進行同步。若是一個方法原本就不涉及共享數據,那它天然就無需任何同步措施去保證正確性。

  • 可重入代碼:全部可重入代碼都是線程安全的,在代碼執行任什麼時候刻中斷它,轉而去執行另一段代碼,返回後也不會出現任何錯誤。簡單來講就是輸入了相同的數據,就能返回相同的結果。

  • 線程本地存儲:把共享數據的可見範圍限制在同一個線程以內,這樣,無需同步也能保證線程之間不出現數據爭用的問題。好比大部分的」生產者-消費者」模型,還有 Web 交互模型中的**一個請求對應一個服務器線程(Thread-per-Request)**的處理方式。

    Java 中有 ThreadLocl 類來實現線程本地存儲的功能。key 是線程,value 是線程的本地化值。

鎖優化

synchronized 鎖是一個重量級鎖,在 JDK 1.5 時對它進行了優化。

  • 自旋鎖與自適應自旋

    爲了不線程的掛起和恢復帶來的性能問題,可讓後面請求鎖的那個線程等一會,不放棄處理器的執行時間,看看持有鎖的線程是否很快就釋放鎖,讓等待線程執行一個忙循環(自旋)。

    自適應自旋意味着自旋時間不固定了,而是由前一次在同一個鎖上自旋時間以及鎖擁有者的狀態來決定,動態的肯定自旋時間。

  • 偏向鎖

    大多數時候是不存在鎖的競爭的,經常是一個線程屢次得到同一個鎖,爲了減小每次競爭鎖的代價,引入偏向鎖。

    當線程1訪問代碼塊並獲取鎖對象時,會在 Java 對象頭和棧幀中記錄偏向的鎖的 threadID。由於偏向鎖不會主動釋放,因此當線程1在此想獲取鎖的時候,返現 threadID 一致,則無需使用 CAS 來加鎖,解鎖。若是不一致,線程2須要競爭鎖,偏向鎖不會主動釋放裏面仍是存儲線程1的 threadID。若是線程1沒有存活,那麼鎖對象被重置爲無鎖狀態,其餘線程競爭並將其設置爲偏向鎖。若是線程1 還存活,那麼查看線程1是否須要持有鎖,若是須要,升級爲輕量級鎖。若是不須要設置爲無鎖狀態,從新偏向。

  • 輕量級鎖

    輕量級鎖考慮的是競爭鎖對象的線程很少,並且線程持有鎖的時間也不長的情景,爲了不阻塞線程讓 CPU 從用戶態轉到內核態和代價,乾脆不阻塞線程,直接讓它自旋等待鎖釋放。

    線程1獲取輕量級鎖時會先把鎖對象的對象頭複製到本身的鎖記錄空間,而後使用 CAS 替換對象頭的內容。

    若是線程1複製對象頭的同時,線程2也準備獲取鎖,可是線程2在 CAS 的時候失敗,自旋,等待線程1釋放鎖。

    若是自旋到了次數線程1尚未釋放鎖,或者線程1在執行,線程2在自旋等待,這時3有來競爭,這個輕量級鎖會膨脹爲重量級鎖,重量級鎖把全部擁有鎖的線程都阻塞,防止 CPU 空轉。

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

概述

對一個系統問題定位時,數據是依據,工具是運用知識處理數據的手段。這裏的數據包括:運行日誌,異常堆棧,GC 日誌,線程快照,堆轉儲快照等等。經過這些數據,咱們能夠快速定位 JVM 發生問題的位置,快速的解決它。

JDK 命令行工具

在 JDK 的 bin 目錄,除了 Java 和 Javac,還有一些比較好用的 JDK 工具幫咱們去定位系統問題。實際上他們都是對 tools.jar 類庫裏面的接口的簡單封裝,這些命令可讓你在應用程序中實現功能強大的監控分析功能。

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

    JVM Statistics Monitoring Tool 是用於監視虛擬機總運行狀態信息的命令行工具。它能夠顯示本地或者遠程虛擬機進程中的類裝載、內存、垃圾收集,JIT 編譯等運行數據。

    下面演示用 -gcutil 來監視堆內存情況。

    bin>jstat -gcutil 2764
    S0    S1    E     O      P      YGC    YGCT    FGC    FGCT    GCT
    0.00  0.00  6.20  41.42  47.20  16     0.105   3      0.472   0.577
    
    複製代碼

    結果代表:這臺服務器的 Eden 區(E,表示 Eden)使用了6.2%的空間,兩個 Survivor 區(S0,S1)裏面都是空的,老年代(O,表示 Old)和永久代(方法區,P表示 Permanent)則分別使用了41.42%和47.20%的空間。程序運行以來共發生 Minor GC 16次,Full GC 3次。

  • jmap:Java 內存映像工具

    Memory Map for Java 命令用於生成堆轉儲快照(通常稱爲 heapdump 或者 dump 文件)。jmap 的做用並不只僅是爲了獲取 dump 文件,它還能夠查詢 finalize 執行隊列,Java 堆和永久代的詳情。

    下面是用 jamp 生成一個正在運行的 Eclipse 的 dump 快照文件的例子,3500是經過 jps 命令查詢到的 LVMID。

    bin> jamp -dump:format=b,file=eclipse.bin 3500 Dumping heap to     
    複製代碼
  • jhat:虛擬機堆轉儲快照分析工具

    JVM Heap Analysis Tool 命令通常與 jamp 搭配使用,來分析 jamp 生成的堆轉儲快照。實際狀況中使用比較少,由於他的總體功能比較簡陋。有一些專業的分析工具比較好用,好比 VisualVM,Eclipse Memory Analyzer。

  • jstack:Java 堆棧跟蹤工具

    Stack Trace for Java 命令用於生成虛擬機當前線程的快照(通常爲 threaddump 或 javacore 文件)。線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合。目的是定位線程長時間停頓的緣由,好比線程死鎖,死循環,請求資源時間過長等。

經過這些封裝好的命令,徹底能夠本身實現一個虛擬機運行監控的小系統。大部分公司都有本身的 JVM 內存監控系統,實現原理也是調用這幾個命令。Github 上有許多比較好的實現,你們能夠參考參考。

因爲 markdown 編輯器註腳語法的 Bug 總也調很差,因此就把他們看成名詞解釋放在最後了。

HotSpot: 遵循 Java 虛擬機規範的商用虛擬機有不少,HotSpot 虛擬機是 Open JDK 中所使用的虛擬機,也是目前使用最普遍的。

Java Native Method: Java Natvie Method是 Java 調用一些本地方法的接口,所謂本地方法指的是用其餘語言實現的方法,好比 C 或者 C++。由於某些操做系統底層的操做 Java 確實不如 C 或者 C++ 作的好。

StopTheWorld:顧名思義就是中止全部操做,垃圾收集器在進行垃圾回收時,須要中止其餘全部工做線程,讓垃圾收集器回收死掉的對象。

SPI:SPI 全稱 Service Provider Interface,是Java提供的一套用來被第三方實現或者擴展的 API,它能夠用來啓用框架擴展和替換組件。

參考

《深刻理解Java虛擬機:JVM高級特性與最佳實踐》

最後的話

程序員這個職業須要咱們不斷進步,須要咱們不斷學習新的知識。

程序員具有了許多很是優秀的素質,愛學習,有責任感,能抗壓,花錢少。但願你們的這條路越走越寬,也走越順利。

若是經過上面的文章學到了一些東西的話,請幫忙點個贊,想獲取更多的學習資料或者視頻教程,能夠加入 QQ羣 725758660,免費分享學習資料。

相關文章
相關標籤/搜索