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

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

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

概述

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

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

引出問題

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

運行時數據區域

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

  • 程序計數器

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

    這時唯一一個沒有規定任何 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 (元空間)的東西。

ze-adjust:none;-webkit-font-smoothing:antialiased;box-sizing:border-box;margin:0px 0px 1.1em;outline:0px;">這樣更改的好處:

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

內存溢出

雖然有 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 空間不夠用時,需要依賴老年代進行分配擔保。

    比較適合需要清除的對象比較多的情況。

(圖片來源於網絡)

  • 標記-整理

    老年代一般不選複製算法,而選擇標記-清除或者標記-整理算法。

    (圖片來源於網絡)

內存分配與回收策略

首先需要了解兩個名詞:

Minor GC:新生代 GC,指的是發生在新生代的垃圾回收動作,因爲 Java 對象大多都具有朝生夕滅的特性,所以 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 收集器

    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 內存模型與線程

通過硬件類比 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 裏面談論併發,大多數都與線nt-smoothing:antialiased;box-sizing:border-box;margin:0px 0px 1.1em;outline:0px;">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.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。

    
    

jamp -dump:format=b,file=eclipse.bin 3500 Dumping heap to C:\Users\A\eclipse.bin … Heap dump file created


* 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高級特性與最佳實踐》

最後的話

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

程序員具備了許多非常優秀的素質,愛學習,有責任感,能抗壓,花錢少。希望大家的這條路越走越寬,也走越順利。


歡迎關注我的公衆號,回覆關鍵字“Java” ,將會有大禮相送!!! 祝各位面試成功!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章