別動 我把知識裝你腦子裏 冷宮霸主JVM

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第11天,點擊查看活動詳情

前言

JVM 真的是學完忘。忘了學 因為很少去用 工作中很少接觸 但是又是一個必須瞭解的都東西 複習整理必不可少

JVM 架構

Java 源碼通過 javac 編譯為 Java 字節碼 ,Java 字節碼是 Java 虛擬機執行的一套代碼格式,其抽象了計算機的基本操作。大多數指令只有一個字節,而有些操作符需要參數,導致多使用了一些字節。

jvm_architecture

JVM 的基本架構如上圖所示,其主要包含三個大塊:

  • 類加載器:負責動態加載Java類到Java虛擬機的內存空間中。
  • 運行時數據區:存儲 JVM 運行時所有數據
  • 執行引擎:提供 JVM 在不同平台的運行能力

線程

在 JVM 中運行着許多線程,這裏面有一部分是應用程序創建來執行代碼邏輯的 應用線程,剩下的就是 JVM 創建來執行一些後台任務的 系統線程

主要的系統線程有:

  • Compile Threads:運行時將字節碼編譯為本地代碼所使用的線程
  • GC Threads:包含所有和 GC 有關操作
  • Periodic Task Thread:JVM 週期性任務調度的線程,主要包含 JVM 內部的採樣分析
  • Singal Dispatcher Thread:處理 OS 發來的信號
  • VM Thread:某些操作需要等待 JVM 到達 安全點(Safe Point) ,即堆區沒有變化。比如:GC 操作、線程 Dump、線程掛起 這些操作都在 VM Thread 中進行。

按照線程類型來分,在 JVM 內部有兩種線程:

  • 守護線程:通常是由虛擬機自己使用,比如 GC 線程。但是,Java程序也可以把它自己創建的任何線程標記為守護線程(public final void setDaemon(boolean on)來設置,但必須在start()方法之前調用)。
  • 非守護線程:main方法執行的線程,我們通常也稱為用户線程。

只要有任何的非守護線程在運行,Java程序也會繼續運行。當該程序中所有的非守護線程都終止時,虛擬機實例將自動退出(守護線程隨 JVM 一同結束工作)。

守護線程中不適合進行IO、計算等操作,因為守護線程是在所有的非守護線程退出後結束,這樣並不能判斷守護線程是否完成了相應的操作,如果非守護線程退出後,還有大量的數據沒來得及讀寫,這將造成很嚴重的後果。

類加載器

類加載器是 Java 運行時環境(Java Runtime Environment)的一部分,負責動態加載 Java 類到 Java 虛擬機的內存空間中。類通常是按需加載,即第一次使用該類時才加載。 由於有了類加載器,Java 運行時系統不需要知道文件與文件系統。每個 Java 類必須由某個類加載器裝入到內存。

jvm_classloader_architecture

類裝載器除了要定位和導入二進制 class 文件外,還必須負責驗證被導入類的正確性,為變量分配初始化內存,以及幫助解析符號引用。這些動作必須嚴格按一下順序完成:

  1. 裝載:查找並裝載類型的二進制數據。
  2. 鏈接:執行驗證、準備以及解析(可選) - 驗證:確保被導入類型的正確性 - 準備:為類變量分配內存,並將其初始化為默認值。 - 解析:把類型中的符號引用轉換為直接引用。
  3. 初始化:把類變量初始化為正確的初始值。

裝載

類加載器分類

在Java虛擬機中存在多個類裝載器,Java應用程序可以使用兩種類裝載器:

  • Bootstrap ClassLoader:此裝載器是 Java 虛擬機實現的一部分。由原生代碼(如C語言)編寫,不繼承自 java.lang.ClassLoader 。負責加載核心 Java 庫,啟動類裝載器通常使用某種默認的方式從本地磁盤中加載類,包括 Java API。
  • Extention Classloader:用來在<JAVA_HOME>/jre/lib/ext ,或 java.ext.dirs 中指明的目錄中加載 Java 的擴展庫。 Java 虛擬機的實現會提供一個擴展庫目錄。
  • Application Classloader:根據 Java應用程序的類路徑( java.class.pathCLASSPATH 環境變量)來加載 Java 類。一般來説,Java 應用的類都是由它來完成加載的。可以通過 ClassLoader.getSystemClassLoader() 來獲取它。
  • 自定義類加載器:可以通過繼承 java.lang.ClassLoader 類的方式實現自己的類加載器,以滿足一些特殊的需求而不需要完全瞭解 Java 虛擬機的類加載的細節。

全盤負責雙親委託機制

在一個 JVM 系統中,至少有 3 種類加載器,那麼這些類加載器如何配合工作?在 JVM 種類加載器通過 全盤負責雙親委託機制 來協調類加載器。

  • 全盤負責:指當一個 ClassLoader 裝載一個類的時,除非顯式地使用另一個 ClassLoader ,該類所依賴及引用的類也由這個 ClassLoader 載入。
  • 雙親委託機制:指先委託父裝載器尋找目標類,只有在找不到的情況下才從自己的類路徑中查找並裝載目標類。

全盤負責雙親委託機制只是 Java 推薦的機制,並不是強制的機制。實現自己的類加載器時,如果想保持雙親委派模型,就應該重寫 findClass(name) 方法;如果想破壞雙親委派模型,可以重寫 loadClass(name) 方法。

裝載入口

所有Java虛擬機實現必須在每個類或接口首次主動使用時初始化。以下六種情況符合主動使用的要求:

  • 當創建某個類的新實例時(new、反射、克隆、序列化)
  • 調用某個類的靜態方法
  • 使用某個類或接口的靜態字段,或對該字段賦值(用final修飾的靜態字段除外,它被初始化為一個編譯時常量表達式)
  • 當調用Java API的某些反射方法時。
  • 初始化某個類的子類時。
  • 當虛擬機啟動時被標明為啟動類的類。

除以上六種情況,所有其他使用Java類型的方式都是被動的,它們不會導致Java類型的初始化。

對於接口來説,只有在某個接口聲明的非常量字段被使用時,該接口才會初始化,而不會因為事先這個接口的子接口或類要初始化而被初始化。

父類需要在子類初始化之前被初始化。當實現了接口的類被初始化的時候,不需要初始化父接口。然而,當實現了父接口的子類(或者是擴展了父接口的子接口)被裝載時,父接口也要被裝載。(只是被裝載,沒有初始化)

驗證

確認裝載後的類型符合Java語言的語義,並且不會危及虛擬機的完整性。

  • 裝載時驗證:檢查二進制數據以確保數據全部是預期格式、確保除 Object 之外的每個類都有父類、確保該類的所有父類都已經被裝載。
  • 正式驗證階段:檢查 final 類不能有子類、確保 final 方法不被覆蓋、確保在類型和超類型之間沒有不兼容的方法聲明(比如擁有兩個名字相同的方法,參數在數量、順序、類型上都相同,但返回類型不同)。
  • 符號引用的驗證:當虛擬機搜尋一個被符號引用的元素(類型、字段或方法)時,必須首先確認該元素存在。如果虛擬機發現元素存在,則必須進一步檢查引用類型有訪問該元素的權限。

準備

在準備階段,Java虛擬機為類變量分配內存,設置默認初始值。但在到到初始化階段之前,類變量都沒有被初始化為真正的初始值。

| 類型 | 默認值 | | :-------- | :------- | | int | 0 | | long | 0L | | short | (short)0 | | char | ‘\u0000’ | | byte | (byte)0 | | blooean | false | | float | 0.0f | | double | 0.0d | | reference | null |

解析

解析的過程就是在類型的常量池總尋找類、接口、字段和方法的符號引用,把這些符號引用替換為直接引用的過程

  • 類或接口的解析:判斷所要轉化成的直接引用是數組類型,還是普通的對象類型的引用,從而進行不同的解析。
  • 字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果有,則查找結束;如果沒有,則會按照繼承關係從上往下遞歸搜索該類所實現的各個接口和它們的父接口,還沒有,則按照繼承關係從上往下遞歸搜索其父類,直至查找結束,

初始化

所有的類變量(即靜態量)初始化語句和類型的靜態初始化器都被Java編譯器收集在一起,放到一個特殊的方法中。 對於類來説,這個方法被稱作類初始化方法;對於接口來説,它被稱為接口初始化方法。在類和接口的 class 文件中,這個方法被稱為<clinit>

  1. 如果存在直接父類,且直接父類沒有被初始化,先初始化直接父類。
  2. 如果類存在一個類初始化方法,執行此方法。

這個步驟是遞歸執行的,即第一個初始化的類一定是Object

Java虛擬機必須確保初始化過程被正確地同步。 如果多個線程需要初始化一個類,僅僅允許一個線程來進行初始化,其他線程需等待。

這個特性可以用來寫單例模式。

Clinit 方法

  • 對於靜態變量和靜態初始化語句來説:執行的順序和它們在類或接口中出現的順序有關。
  • 並非所有的類都需要在它們的class文件中擁有<clinit>()方法, 如果類沒有聲明任何類變量,也沒有靜態初始化語句,那麼它就不會有<clinit>()方法。如果類聲明瞭類變量,但沒有明確的使用類變量初始化語句或者靜態代碼塊來初始化它們,也不會有<clinit>()方法。如果類僅包含靜態final常量的類變量初始化語句,而且這些類變量初始化語句採用編譯時常量表達式,類也不會有<clinit>()方法。只有那些需要執行Java代碼來賦值的類才會有<clinit>()
  • final常量:Java虛擬機在使用它們的任何類的常量池或字節碼中直接存放的是它們表示的常量值。

運行時數據區

運行時數據區用於保存 JVM 在運行過程中產生的數據,結構如圖所示:

Heap

Java 堆是可供各線程共享的運行時內存區域,是 Java 虛擬機所管理的內存區域中最大的一塊。此區域非常重要,幾乎所有的對象實例和數組實例都要在 Java 堆上分配,但隨着 JIT 編譯器及逃逸分析技術的發展,也可能會被優化為棧上分配

Heap 中除了作為對象分配使用,還包含字符串字面量 常量池(Internd Strings) 。 除此之外 Heap 中還包含一個 新生代(Yong Generation) 、一個 老年代(Old Generation)

新生代分三個區,一個Eden區,兩個Survivor區,大部分對象在Eden區中生成。Survivor 區總有一個是空的。

老年代中保存一些生命週期較長的對象,當一個對象經過多次的 GC 後還沒有被回收,那麼它將被移動到老年代。

Methoad Area

方法區的數據由所有線程共享,因此為安全的使用方法區的數據,需要注意線程安全問題。

方法區主要保存類級別的數據,包括:

  • ClassLoader Reference

  • Runtime Constant Pool

    • 數字常量
    • 類屬性引用
    • 方法引用
  • Field Data:每個類屬性的名稱、類型等

  • Methoad Data:每個方法的名稱、返回值類型、參數列表等

  • Methoad Code:每個方法的字節碼、本地變量表等

方法區的實現在不同的 JVM 版本有不同,在 JVM 1.8 之前,方法區的實現為 永久代(PermGen) ,但是由於永久代的大小限制, 經常會出現內存溢出。於是在 JVM 1.8 方法區的實現改為 元空間(Metaspace) ,元空間是在 Native 的一塊內存空間。

Stack

對於每個 JVM 線程,當線程啟動時,都會分配一個獨立的運行時棧,用以保存方法調用。每個方法調用,都會在棧頂增加一個棧幀(Stack Frame)。

每個棧幀都保存三個引用:本地變量表(Local Variable Array)操作數棧(Operand Stack)當前方法所屬類的運行時常量池(Runtime Constant Pool) 。由於本地變量表和操作數棧的大小都在編譯時確定,所以棧幀的大小是固定的。

當被調用的方法返回或拋出異常,棧幀會被彈出。在拋出異常時 printStackTrace() 打印的每一行就是一個棧幀。同時得益於棧幀的特點,棧幀內的數據是線程安全的。

棧的大小可以動態擴展,但是如果一個線程需要的棧大小超過了允許的大小,就會拋出 StackOverflowError

PC Register

對於每個 JVM 線程,當線程啟動時,都會有一個獨立的 PC(Program Counter) 計數器,用來保存當前執行的代碼地址(方法區中的內存地址)。如果當前方法是 Native 方法,PC 的值為 NULL。一旦執行完成,PC 計數器會被更新為下一個需要執行代碼的地址。

Native Method Stack

本地方法棧和 Java 虛擬機棧的作用相似,Java 虛擬機棧執行的是字節碼,而本地方法棧執行的是 native 方法。本地方法棧使用傳統的棧(C Stack)來支持 native 方法。

Direct Memory

在 JDK 1.4 中新加入了 NIO 類,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆裏的 DirectByteBuffer 對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為 避免了在 Java 堆和 Native 堆中來回複製數據

垃圾回收

對象存活檢測

Java堆中存放着大量的Java對象實例,在垃圾收集器回收內存前,第一件事情就是確定哪些對象是活着的,哪些是可以回收的。

引用計數算法

引用計數算法是判斷對象是否存活的基本算法:給每個對象添加一個引用計數器,沒當一個地方引用它的時候,計數器值加1;當引用失效後,計數器值減1。但是這種方法有一個致命的缺陷,當兩個對象相互引用時會導致這兩個都無法被回收

根搜索算法

引用計數是通過為堆中每個對象保存一個計數來區分活動對象和垃圾。根搜索算法實際上是追蹤從根結點開始的 引用圖

在根搜索算法追蹤的過程中,起點即 GC Root,GC Root 根據 JVM 實現不同而不同,但是總會包含以下幾個方面(堆外引用):

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象。
  • 方法區中的類靜態屬性引用的變量。
  • 方法區中的常量引用的變量。
  • 本地方法 JNI 的引用對象。

根搜索算法是從 GC Root 開始的引用圖,引用圖是一個有向圖,其中節點是各個對象,邊為引用類型。JVM 中的引用類型分為四種:強引用(StrongReference)軟引用(SoftReference)弱引用(WeakReference)虛引用(PhantomReference)

除強引用外,其他引用在Java 由 Reference 的子類封裝了指向其他對象的連接:被指向的對象稱為 引用目標

若一個對象的引用類型有多個,那到底如何判斷它的回收策略呢?其實規則如下:

  • 單條引用鏈以鏈上最弱的一個引用類型來決定;
  • 多條引用鏈以多個單條引用鏈中最強的一個引用類型來決定;

在引用圖中,當一個節點沒有任何路徑可達時,我們認為它是可回收的對象。

StrongReference

強引用在Java中是普遍存在的,類似 Object o = new Object(); 。強引用和其他引用的區別在於:強引用禁止引用目標被垃圾收集器收集,而其他引用不禁止

SoftReference

對象可以從根節點通過一個或多個(未被清除的)軟引用對象觸及,垃圾收集器在要發生內存溢出前將這些對象列入回收範圍中進行回收,如果該軟引用對象和引用隊列相關聯,它會把該軟引用對象加入隊列。

JVM 的實現需要在拋出 OutOfMemoryError 之前清除 SoftReference,但在其他的情況下可以選擇清理的時間或者是否清除它們。

WeakReference

對象可以從 GC Root 開始通過一個或多個(未被清除的)弱引用對象觸及, 垃圾收集器在 GC 的時候會回收所有的 WeakReference,如果該弱引用對象和引用隊列相關聯,它會把該弱引用對象加入隊列。

PhantomReference

垃圾收集器在 GC 不會清除 PhantomReference,所有的虛引用都必須由程序明確的清除。同時也不能通過虛引用來取得一個對象的實例。

垃圾回收算法

複製回收算法

將可用內存分為大小相等的兩份,在同一時刻只使用其中的一份。當這一份內存使用完了,就將還存活的對象複製到另一份上,然後將這一份上的內存清空。複製算法能有效避免內存碎片,但是算法需要將內存一分為二,導致內存使用率大大降低。

標記清除算法

先暫停整個程序的全部運行線程,讓回收線程以單線程進行掃描標記,並進行直接清除回收,然後回收完成後,恢復運行線程。標記清除後會產生大量不連續的內存碎片,造成空間浪費。

標記整理算法

標記清除 相似,不同的是,回收期間同時會將保留的存儲對象搬運彙集到連續的內存空間,從而集成空閒空間。

增量回收

需要程序將所擁有的內存空間分成若干分區(Region)。程序運行所需的存儲對象會分佈在這些分區中,每次只對其中一個分區進行回收操作,從而避免程序全部運行線程暫停來進行回收,允許部分線程在不影響回收行為而保持運行,並且降低迴收時間,增加程序響應速度。

分代回收

在 JVM 中不同的對象擁有不同的生命週期,因此對於不同生命週期的對象也可以採用不同的垃圾回收算法,以提高效率,這就是分代回收算法的核心思想。

記憶集

上面有説到進行 GC 的時候,會從 GC Root 進行搜索,做一個引用圖。現有一個對象 C 在 Young Gen,其只被一個在 Old Gen 的對象 D 引用,其引用結構如下所示:

這個時候要進行 Young GC,要確定 C 是否被堆外引用,就需要遍歷 Old Gen,這樣的代價太大。所以 JVM 在進行對象引用的時候,會有個 記憶集(Remembered Set) 記錄從 Old Gen 到 Young Gen 的引用關係,並把記憶集裏的 Old Gen 作為 GC Root 來構建引用圖。這樣在進行 Young GC 時就不需要遍歷 Old Gen。

但是使用記憶集也會有缺點:C & D 其實都可以進行回收,但是由於記憶集的存在,不會將 C 回收。這裏其實有一點 空間換時間 的意思。不過無論如何,它依然確保了垃圾回收所遵循的原則:垃圾回收確保回收的對象必然是不可達對象,但是不確保所有的不可達對象都會被回收

垃圾回收觸發條件

堆內內存

針對 HotSpot VM 的實現,它裏面的 GC 其實準確分類只有兩大種:

  1. Partial GC:並不收集整個 GC 堆的模式

    1. Young GC(Minor GC) :只收集 Young Gen 的 GC
    2. Old GC:只收集 Old Gen 的 GC。只有 CMS的 Concurrent Collection 是這個模式
    3. Mixed GC:收集整個 Young Gen 以及部分 Old Gen 的 GC。只有 G1 有這個模式
  2. Full GC(Major GC) :收集整個堆,包括 Young Gen、Old Gen、Perm Gen(如果存在的話)等所有部分的 GC 模式。

最簡單的分代式GC策略,按 HotSpot VM 的 serial GC 的實現來看,觸發條件是

  • Young GC:當 Young Gen 中的 eden 區分配滿的時候觸發。把 Eden 區存活的對象將被複制到一個 Survivor 區,當這個 Survivor 區滿時,此區的存活對象將被複制到另外一個 Survivor 區。

  • Full GC

    • 當準備要觸發一次 Young GC 時,如果發現之前 Young GC 的平均晉升大小比目前 Old Gen剩餘的空間大,則不會觸發 Young GC 而是轉為觸發 Full GC

      除了 CMS 的 Concurrent Collection 之外,其它能收集 Old Gen 的GC都會同時收集整個 GC 堆,包括 Young Gen,所以不需要事先觸發一次單獨的Young GC

    • 如果有 Perm Gen 的話,要在 Perm Gen分配空間但已經沒有足夠空間時

    • System.gc()

    • Heap dump

併發 GC 的觸發條件就不太一樣。以 CMS GC 為例,它主要是定時去檢查 Old Gen 的使用量,當使用量超過了觸發比例就會啟動一次 GC,對 Old Gen做併發收集。

堆外內存

DirectByteBuffer 的引用是直接分配在堆得 Old 區的,因此其回收時機是在 FullGC 時。因此,需要避免頻繁的分配 DirectByteBuffer ,這樣很容易導致 Native Memory 溢出。

DirectByteBuffer 申請的直接內存,不再GC範圍之內,無法自動回收。JDK 提供了一種機制,可以為堆內存對象註冊一個鈎子函數(其實就是實現 Runnable 接口的子類),當堆內存對象被GC回收的時候,會回調run方法,我們可以在這個方法中執行釋放 DirectByteBuffer 引用的直接內存,即在run方法中調用 UnsafefreeMemory 方法。註冊是通過sun.misc.Cleaner 類來實現的。

垃圾收集器

垃圾收集器是內存回收的具體實現,下圖展示了 7 種用於不同分代的收集器,兩個收集器之間有連線表示可以搭配使用,每種收集器都有最適合的使用場景。

Serial 收集器

Serial 收集器是最基本的收集器,這是一個單線程收集器,它只用一個線程去完成垃圾收集工作。

雖然 Serial 收集器的缺點很明顯,但是它仍然是 JVM 在 Client 模式下的默認新生代收集器。它有着優於其他收集器的地方:簡單而高效(與其他收集器的單線程比較),Serial 收集器由於沒有線程交互的開銷,專心只做垃圾收集自然也獲得最高的效率。在用户桌面場景下,分配給 JVM 的內存不會太多,停頓時間完全可以在幾十到一百多毫秒之間,只要收集不頻繁,這是完全可以接受的。

ParNew 收集器

ParNew 是 Serial 的多線程版本,在回收算法、對象分配原則上都是一致的。ParNew 收集器是許多運行在Server 模式下的默認新生代垃圾收集器,其主要與 CMS 收集器配合工作。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一個新生代垃圾收集器,也是並行的多線程收集器。

Parallel Scavenge 收集器更關注可控制的吞吐量,吞吐量等於運行用户代碼的時間/(運行用户代碼的時間+垃圾收集時間)。

Serial Old收集器

Serial Old 收集器是 Serial 收集器的老年代版本,也是一個單線程收集器,採用“標記-整理算法”進行回收。

Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多線程進行垃圾回收,其通常與 Parallel Scavenge 收集器配合使用。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短停頓時間為目標的收集器, CMS 收集器採用 標記--清除 算法,運行在老年代。主要包含以下幾個步驟:

  • 初始標記(Stop the world)
  • 併發標記
  • 重新標記(Stop the world)
  • 併發清除

其中初始標記和重新標記仍然需要 Stop the world。初始標記僅僅標記 GC Root 能直接關聯的對象,併發標記就是進行 GC Root Tracing 過程,而重新標記則是為了修正併發標記期間,因用户程序繼續運行而導致標記變動的那部分對象的標記記錄。

由於整個過程中最耗時的併發標記和併發清除,收集線程和用户線程一起工作,所以總體上來説, CMS 收集器回收過程是與用户線程併發執行的。雖然 CMS 優點是併發收集、低停頓,很大程度上已經是一個不錯的垃圾收集器,但是還是有三個顯著的缺點:

  • CMS收集器對CPU資源很敏感:在併發階段,雖然它不會導致用户線程停頓,但是會因為佔用一部分線程(CPU資源)而導致應用程序變慢。
  • CMS收集器不能處理浮動垃圾:所謂的“浮動垃圾”,就是在併發標記階段,由於用户程序在運行,那麼自然就會有新的垃圾產生,這部分垃圾被標記過後,CMS 無法在當次集中處理它們,只好在下一次 GC 的時候處理,這部分未處理的垃圾就稱為“浮動垃圾”。
  • GC 後產生大量內存碎片:當內存碎片過多時,將會給分配大對象帶來困難,這是就會進行 Full GC。

正是由於在垃圾收集階段程序還需要運行,即還需要預留足夠的內存空間供用户使用,因此 CMS 收集器不能像其他收集器那樣等到老年代幾乎填滿才進行收集,需要預留一部分空間提供併發收集時程序運作使用。要是 CMS 預留的內存空間不能滿足程序的要求,這是 JVM 就會啟動預備方案:臨時啟動 Serial Old 收集器來收集老年代,這樣停頓的時間就會很長。

G1收集器

G1收集器與CMS相比有很大的改進:

  • 標記整理算法:G1 收集器採用標記整理算法實現
  • 增量回收模式:將 Heap 分割為多個 Region,並在後台維護一個優先列表,每次根據允許的時間,優先回收垃圾最多的區域

因此 G1 收集器可以實現在基本不犧牲吞吐量的情況下完成低停頓的內存回收,這是正是由於它極力的避免全區域的回收。

圖片.png

Java分派機制

在Java中,符合“編譯時可知,運行時不可變”這個要求的方法主要是靜態方法和私有方法。這兩種方法都不能通過繼承或別的方法重寫,因此它們適合在類加載時進行解析。

Java虛擬機中有四種方法調用指令:

  • invokestatic:調用靜態方法。
  • invokespecial:調用實例構造器方法,私有方法和super。
  • invokeinterface:調用接口方法。
  • invokevirtual:調用以上指令不能調用的方法(虛方法)。

只要能被invokestaticinvokespecial指令調用的方法,都可以在解析階段確定唯一的調用版本,符合這個條件的有:靜態方法、私有方法、實例構造器、父類方法,他們在類加載的時候就會把符號引用解析為改方法的直接引用。這些方法被稱為非虛方法,反之其他方法稱為虛方法(final方法除外)。

雖然final方法是使用invokevirtual指令來調用的,但是由於它無法被覆蓋,多態的選擇是唯一的,所以是一種非虛方法。

靜態分派

對於類字段的訪問也是採用靜態分派

People man = new Man()

靜態分派主要針對重載,方法調用時如何選擇。在上面的代碼中,People被稱為變量的引用類型,Man被稱為變量的實際類型。靜態類型是在編譯時可知的,而動態類型是在運行時可知的,編譯器不能知道一個變量的實際類型是什麼。

編譯器在重載時候通過參數的靜態類型而不是實際類型作為判斷依據。並且靜態類型在編譯時是可知的,所以編譯器根據重載的參數的靜態類型進行方法選擇。

在某些情況下有多個重載,那編譯器如何選擇呢? 編譯器會選擇"最合適"的函數版本,那麼怎麼判斷"最合適“呢?越接近傳入參數的類型,越容易被調用。

動態分派

動態分派主要針對重寫,使用invokevirtual指令調用。invokevirtual指令多態查找過程:

  • 找到操作數棧頂的第一個元素所指向的對象的實際類型,記為C。
  • 如果在類型C中找到與常量中的描述符合簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果權限校驗不通過,返回java.lang.IllegalAccessError異常。
  • 否則,按照繼承關係從下往上一次對C的各個父類進行第2步的搜索和驗證過程。
  • 如果始終沒有找到合適的方法,則拋出 java.lang.AbstractMethodError異常。

虛擬機動態分派的實現

由於動態分派是非常繁瑣的動作,而且動態分派的方法版本選擇需要考慮運行時在類的方法元數據中搜索合適的目標方法,因此在虛擬機的實現中基於性能的考慮,在方法區中建立一個虛方法表invokeinterface有接口方法表),來提高性能。

  • 虛方法表中存放各個方法的實際入口地址。如果某個方法在子類沒有重寫,那麼子類的虛方法表裏的入口和父類入口一致,如果子類重寫了這個方法那麼子類方法表中的地址會被替換為子類實現版本的入口地址。

String 常量池

JAVA 語言中有 8 中基本類型和一種比較特殊的類型 String 。這些類型為了使他們在運行過程中速度更快,更節省內存,都提供了一種常量池的概念。常量池就類似一個 JAVA 系統級別提供的緩存。

String 類型的常量池比較特殊。它的主要使用方法有兩種:

  • 直接使用雙引號聲明出來的 String 對象會直接存儲在常量池中
  • 如果不是用雙引號聲明的 String 對象,可以使用 String 提供的 intern 方法。 intern 方法會從字符串常量池中查詢當前字符串是否存在,若不存在就會將當前字符串放入常量池中

intern

/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class {@code String}. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this {@code String} object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this {@code String} object is added to the * pool and a reference to this {@code String} object is returned. * <p> * It follows that for any two strings {@code s} and {@code t}, * {@code s.intern() == t.intern()} is {@code true} * if and only if {@code s.equals(t)} is {@code true}. * <p> * All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * <cite>The Java&trade; Language Specification</cite>. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */ public native String intern();

JAVA 使用 jni 調用 c++ 實現的 StringTableintern 方法, StringTable 跟 Java 中的 HashMap 的實現是差不多的, 只是 不能自動擴容。默認大小是 1009

要注意的是, StringString Pool 是一個固定大小的 Hashtable ,默認值大小長度是 1009 ,如果放進 String PoolString 非常多,就會造成 Hash 衝突嚴重,從而導致鏈表會很長,而鏈表長了後直接會造成的影響就是當調用 String.intern 時性能會大幅下降。

在 JDK6 中 StringTable 是固定的,就是 1009 的長度,所以如果常量池中的字符串過多就會導致效率下降很快。在 jdk7 中, StringTable 的長度可以通過一個參數指定:

-XX:StringTableSize=99991

在 JDK6 以及以前的版本中,字符串的常量池是放在堆的 Perm 區。在 JDK7 的版本中,字符串常量池已經從 Perm 區移到正常的 Java Heap 區域

```java public static void main(String[] args) { String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2);

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);

} ```

上述代碼的執行結果:

  • JDK6: false false
  • JDK7: false true

```java public static void main(String[] args) { String s = new String("1"); String s2 = "1"; s.intern(); System.out.println(s == s2);

String s3 = new String("1") + new String("1");
String s4 = "11";
s3.intern();
System.out.println(s3 == s4);

} ```

上述代碼的執行結果:

  • JDK6: false false
  • JDK7: false false

由於 JDK7 將字符串常量池移動到 Heap 中,導致上述版本差異,下面具體來分析下。

JDK6

圖中綠色線條代表 string 對象的內容指向,黑色線條代表地址指向

jdk6 中上述的所有打印都是 false ,因為 jdk6 中的常量池是放在 Perm 區中的, Perm 區和正常的 JAVA Heap 區域是完全分開的。上面説過如果是使用引號聲明的字符串都是會直接在字符串常量池中生成,而 new 出來的 String 對象是放在 JAVA Heap 區域。所以拿一個 JAVA Heap 區域的對象地址和字符串常量池的對象地址進行比較肯定是不相同的,即使調用 String.intern 方法也是沒有任何關係的

JDK7

因為字符串常量池移動到 JAVA Heap 區域後,再來解釋為什麼會有上述的打印結果。

  • 在第一段代碼中,先看 s3s4 字符串。String s3 = new String("1") + new String("1");,這句代碼中現在生成了 2個 最終對象,是字符串常量池中的 “1”JAVA Heap 中的 s3 引用指向的對象。中間還有 2個 匿名的 new String("1") 我們不去討論它們。此時 s3 引用對象內容是 ”11” ,但此時常量池中是沒有 “11” 對象的。
  • 接下來 s3.intern(); 這一句代碼,是將 s3 中的 “11” 字符串放入 String 常量池中,因為此時常量池中不存在 “11” 字符串,因此常規做法是跟 jdk6 圖中表示的那樣,在常量池中生成一個 “11” 的對象,關鍵點是 jdk7 中常量池不在 Perm 區域了,這塊做了調整。常量池中不需要再存儲一份對象,可以直接存儲堆中的引用。這份引用指向 s3 引用的對象。 也就是説引用地址是相同的。
  • 最後 String s4 = "11"; 這句代碼中 ”11” 是顯示聲明的,因此會直接去常量池中創建,創建的時候發現已經有這個對象了,此時也就是指向 s3 引用對象的一個引用。所以 s4 引用就指向和 s3 一樣了。因此最後的比較 s3 == s4true
  • 再看 ss2 對象。 String s = new String("1"); 第一句代碼,生成了2個對象。常量池中的 “1”JAVA Heap 中的字符串對象。s.intern(); 這一句是 s 對象去常量池中尋找後發現 “1” 已經在常量池裏了。
  • 接下來 String s2 = "1"; 這句代碼是生成一個 s2 的引用指向常量池中的 “1” 對象。 結果就是 ss2 的引用地址明顯不同。

接下來是第二段代碼:

  • 第一段代碼和第二段代碼的改變就是 s3.intern(); 的順序是放在 String s4 = "11"; 後了。這樣,首先執行 String s4 = "11"; 聲明 s4 的時候常量池中是不存在 “11” 對象的,執行完畢後, “11“ 對象是 s4 聲明產生的新對象。然後再執行 s3.intern(); 時,常量池中 “11” 對象已經存在了,因此 s3s4 的引用是不同的。
  • 第二段代碼中的 ss2 代碼中,s.intern();,這一句往後放也不會有什麼影響了,因為對象池中在執行第一句代碼String s = new String("1"); 的時候已經生成 “1” 對象了。下邊的 s2 聲明都是直接從常量池中取地址引用的。 ss2 的引用地址是不會相等的。

小結

從上述的例子代碼可以看出 jdk7 版本對 intern 操作和常量池都做了一定的修改。主要包括2點:

  • String 常量池 從 Perm 區移動到了 Java Heap
  • String#intern 方法時,如果存在堆中的對象,會直接保存對象的引用,而不會重新創建對象。

使用範例

```java static final int MAX = 1000 * 10000; static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception { Integer[] DB_DATA = new Integer[10]; Random random = new Random(10 * 10000); for (int i = 0; i < DB_DATA.length; i++) { DB_DATA[i] = random.nextInt(); } long t = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])); arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern(); }

System.out.println((System.currentTimeMillis() - t) + "ms");
System.gc();

} ```

運行的參數是:-Xmx2g -Xms2g -Xmn1500M 上述代碼是一個演示代碼,其中有兩條語句不一樣,一條是使用 intern,一條是未使用 intern。

通過上述結果,我們發現不使用 intern 的代碼生成了 1000w 個字符串,佔用了大約 640m 空間。 使用了 intern 的代碼生成了 1345 個字符串,佔用總空間 133k 左右。其實通過觀察程序中只是用到了 10 個字符串,所以準確計算後應該是正好相差 100w 倍。雖然例子有些極端,但確實能準確反應出 intern 使用後產生的巨大空間節省。

細心的同學會發現使用了 intern 方法後時間上有了一些增長。這是因為程序中每次都是用了 new String 後,然後又進行 intern 操作的耗時時間,這一點如果在內存空間充足的情況下確實是無法避免的,但我們平時使用時,內存空間肯定不是無限大的,不使用 intern 佔用空間導致 jvm 垃圾回收的時間是要遠遠大於這點時間的。 畢竟這裏使用了 1000wintern 才多出來1秒鐘多的時間。

不當使用

fastjson 中對所有的 jsonkey 使用了 intern 方法,緩存到了字符串常量池中,這樣每次讀取的時候就會非常快,大大減少時間和空間。而且 jsonkey 通常都是不變的。這個地方沒有考慮到大量的 json key 如果是變化的,那就會給字符串常量池帶來很大的負擔。

這個問題 fastjson1.1.24版本中已經將這個漏洞修復了。程序加入了一個最大的緩存大小,超過這個大小後就不會再往字符串常量池中放了。

對象的生命週期

一旦一個類被裝載、連接和初始化,它就隨時可以被使用。程序可以訪問它的靜態字段,調用它的靜態方法,或者創建它的實例。作為Java程序員有必要了解Java對象的生命週期。

類實例化

在Java程序中,類可以被明確或隱含地實例化。明確的實例化類有四種途徑:

  • 明確調用new
  • 調用Class或者java.lang.reflect.Constructor對象的newInstance方法。
  • 調用任何現有對象的clone
  • 通過java.io.ObjectInputStream.getObject()反序列化。

隱含的實例化:

  • 可能是保存命令行參數的String對象。
  • 對於Java虛擬機裝載的每個類,都會暗中實例化一個Class對象來代表這個類型
  • 當Java虛擬機裝載了在常量池中包含CONSTANT_String_info入口的類的時候,它會創建新的String對象來表示這些常量字符串。
  • 執行包含字符串連接操作符的表達式會產生新的對象。

Java編譯器為它編譯的每個類至少生成一個實例初始化方法。在Java class文件中,這個方法被稱為<init>。針對源代碼中每個類的構造方法,Java編譯器都會產生一個<init>()方法。如果類沒有明確的聲明任何構造方法,編譯器會默認產生一個無參數的構造方法,它僅僅調用父類的無參構造方法。

一個<init>()中可能包含三種代碼:調用另一個<init>()、實現對任何實例變量的初始化、構造方法體的代碼。

如果構造方法明確的調用了同一個類中的另一個構造方法(this()),那麼它對應的<init>()由兩部分組成:

  • 一個同類的<init>()的調用。
  • 實現了對應構造方法的方法體的字節碼。

在它對應的<init>()方法中不會有父類的<init>(),但不代表不會調用父類的<init>(),因為this()中也會調用父類<init>()

如果構造方法不是通過一個this()調用開始的,而且這個對象不是Object<init>()則有三部分組成:

  • 一個父類的<init>()調用。如果這個類是Object,則沒有這個部分
  • 任意實例變量初始化方法的字節碼。
  • 實現了對應構造方法的方法體的字節碼。

如果構造方法明確的調用父類的構造方法super()開始,它的<init>()會調用對應父類的<init>()。比如,如果一個構造方法明確的調用super(int,String)開始,對應的<init>()會從調用父類的<init>(int,String)方法開始。如果構造方法沒有明確地從this()super()開始,對應的<init>()默認會調用父類的無參<init>()

垃圾收集和對象的終結

程序可以明確或隱含的為對象分配內存,但不能明確的釋放內存。一個對象不再為程序引用,虛擬機必須回收那部分內存。

卸載類

在很多方面,Java虛擬機中類的生命週期和對象的生命週期很相似。當程序不再使用某個類的時候,可以選擇卸載它們。

類的垃圾收集和卸載值所以在Java虛擬機中很重要,是因為Java程序可以在運行時通過用户自定義的類裝載器裝載類型來動態的擴展程序。所有被裝載的類型都在方法區佔據內存空間。

Java虛擬機通過判斷類是否在被引用來進行垃圾收集。判斷動態裝載的類的Class實例在正常的垃圾收集過程中是否可觸及有兩種方式:

  • 如果程序保持非Class實例的明確引用。
  • 如果在堆中還存在一個可觸及的對象,在方法區中它的類型數據指向一個Class實例。