JVM 知識體系漫談

劉正陽發表於2016-10-09

背景介紹

jvm已經是Java開發的必備技能了,jvm相當於Java的作業系統。JVM,java virtual machine, 即Java虛擬機器,是執行Java Class檔案的程式。

Java程式碼經過Java編譯器編譯,會編譯成class檔案,一種平臺無關的程式碼格式,class檔案按照jvm規範,包括了java程式碼執行所需的後設資料和程式碼等內容。jvm載入class檔案後,就可以執行java程式碼了。

JVM有不同的實現,有我們熟悉的Hotspot虛擬機器,JRockit等。在各個作業系統上,又回有各自的虛擬機器實現,從而形成了Java程式碼 > class檔案 > JVM規範 > JVM實現的層次。再加上其他語言如scala、groovy也能夠生成class檔案,這樣不僅實現了平臺無關性,也實現了語言無關性。

JVM體系,分為JVM記憶體結構,Class檔案結構,Java ByteCode,垃圾收集演算法和實現,調優和監控工具,以及Java記憶體模型(JMM)。

jvm-mindmap

JVM記憶體結構

JVM-runtime-area

通常,認為大概分為執行緒共享的區域和執行緒私有的區域。共享區域在JVM啟動時建立,私有區域伴隨這執行緒的啟動和結束。

私有區域

一個執行緒擁有的結構有

程式計數器(Program Counter, PC)

Java天生支援多執行緒,多執行緒會有執行緒切換的問題,當一個執行緒從可執行狀態得到CPU排程進入執行狀態,CPU需要知道從哪裡開始執行,並且Java是一種基於棧的執行架構(區別於基於暫存器的架構)。當執行一個Java方法時,PC會指向下一條指令的位置。執行native方法時,PC是未定義。操作指令可能會有0個或多個運算元。JVM的執行流程大概可以描述為:

while(true) {
opcode = code[pc];
oprand = getOperan(opcode);
pc = code[pc + len(oprand)];
execute(opcode, oprand);
}

Java虛擬機器棧(Java Virtual Machine Stack)

Java虛擬機器棧,或者叫方法棧,會伴隨這方法的呼叫和返回進行相應的入棧和出棧。棧的元素是棧幀(Stack Frame), 棧幀中的內容包括: 運算元棧,本地變數表,動態連結等資訊。當執行緒呼叫一個方法的時候,會組裝對應的棧幀入棧。

本地變數表(Local Variable Table)

本地變數表儲存方法的引數、方法內部建立的區域性變數。本地變數表的大小在編譯時就確定了。本地變數表會根據變數的作用範圍選擇重用一個位置。本地變數表會存放int,char,byte,float,double,long,address(例項引用)。其中除了double和long其他變數佔用一個slot,一個slot指一個抽象的位置,在32位虛擬機器中是32bit大小,double和long佔用兩個slot。

值得注意的是,如果一個方法是例項方法,Java編譯器會將this作為第一個引數傳入本地變數表。另外Java中物件導向,方法呼叫可以這樣理解

例項方法
obj.method(var1, var2, var3) => method invoke obj var1 var2 var3 

運算元棧

運算元棧用於方法內執行儲存中間結果,Java方法中的程式碼邏輯就是通過運算元棧來實現的。和本地方法表一樣,運算元棧也是在編譯時就確定最大大小了,即最大深度。運算元棧可以和本地變數表互動,進行資料的存放和讀取。下面用一個簡單的例子展示一下。

int add(int a, int b) {
return a + b;
} 

這個例項方法經過Java編譯器編譯後生成的位元組碼

本地變數表
slot0 this
slot1 a
slot2 b

方法位元組碼
iload_1 #讀位置是1的本地變數(本地變數表從0開始,位置0是this引用)
此時運算元棧是 a
iload_2 #讀位置是2的本地變數,即b
此時運算元棧是 a b
iadd #進行int型別的add操作,會取出棧頭的兩個元素取出進行相加並將結果入棧。
此時運算元棧是 c (相加的結果)
ireturn #ireturn指令會將棧頭元素返回給呼叫方法的棧幀 

執行緒共享區域

堆(Heap區)

建立的物件(包括普通例項和陣列)都分配在Heap區(不考慮一些虛擬機器的棧上分配優化技術)。在細分的話,一般還分成年輕代和老年代。這是基於這樣一個類似28原理的統計,90%多的物件都是很快成為垃圾的物件。所以化為成兩個區域,分別使用不同的收集演算法,年輕代的收集頻率更高,所需空間也相對較小。記憶體分配時,多個執行緒會有併發問題,主要通過兩種方式解決:1.CAS加上失敗重試分配記憶體地址。2. TLAB, 即Thread Local Allocation Buffer, 為每個執行緒分配一塊緩衝區域進行物件分配。年輕代還可以分為兩個大小相等的Survivor和一個Eden區域。物件在幾種情況下會進入老年代:1. 大物件,超過Eden大小或者PretenureSizeThreshold. 2. 在年輕代的年齡(經歷的GC次數)超過設定的值的時候 3. To Survivor存放不下的物件

方法區

方法區存放載入的類資訊和執行時常量池等。

垃圾收集(Garbage Collect)

Java中不需要對記憶體進行手動釋放,JVM中的垃圾回收器幫助我們回收記憶體。

何時進行收集

一般來說,當某個區域記憶體不夠的時候就會進行垃圾收集。如當Eden區域分配不下物件時,就會進行年輕代的收集。還有其他的情況,如使用CMS收集器時配置CMSInitalize

如何判斷一塊記憶體是垃圾

即判斷一個物件不再使用,不再使用可以是沒有有效的引用。

一般來說,主要有兩種判斷方式

引用計數

當有物件引用自身時,就會計數器加1,刪除一個引用就減一,當計數為0時即可判斷為垃圾。python等語言使用引用計數。引用計數存在迴圈引用問題,如兩個落單的A和B互相引用,但是沒有其他物件指向它們這種情況。

可達性分析

通過一些根節點開始,分析引用鏈,沒有被引用的物件都可以被標記為垃圾物件。根節點是方法棧中的引用、常量等。

垃圾收集演算法

標記清除(Mark Sweep)

對非垃圾物件進行標記都,清除其他的物件。這種方式對對記憶體空間造成空隙,即記憶體碎片,最終導致有空餘空間,但沒有連續的足夠大小的空間分配記憶體。

標記整理(Mark Compact)

標記非垃圾物件後,將這些物件整理好,排列到記憶體的開始位置。這樣記憶體就是整齊的了。但是因為會造成物件移動,所以效率會有降低。

標記清除整理(Mark Sweep Compact)

即組合兩種方式,在若干次清除後進行一次整理。

複製(Copy)

劃分成兩個相同大小的區域,收集時,將第一個區域的活物件複製到另一個區域,這樣不會有記憶體碎片問題。但是最多隻能存放一半記憶體。

垃圾收集器

垃圾收集器就是垃圾收集演算法的相應實現。

Serial New

新生代單執行緒的收集器,是Client模式預設的垃圾收集器

Parallel New

Serial New的多執行緒版本。ParNew常和CMS拉配使用。這裡說明一些Parallel和Concurrent即並行和併發在垃圾收集這裡的表示的不同,並行表示有多個執行緒同時進行垃圾收集,併發是指垃圾收集執行緒和應用執行緒可以併發執行。

Parallel Scanvenge

PS收集器是注重吞吐量(ThroughPut)的收集器。

Serial Old。

老年代的單執行緒收集器

Parallel Old

Serial Old的多執行緒版本,由於Parallel Scavenge不能和CMS搭配使用,所以會是使用PS時的一種選擇。

CMS (Concurrent Mark Sweep)

注重延遲latency的收集器,在互動式應用中,如面向使用者的web應用,需要儘可能減少垃圾收集造成的停頓時間。在總的統計上,吞吐量可能沒有PS收集器高。

細分上,CMS還分為4個階段

  • 初始標記,標記GC Root可以直達的物件。STW
  • 併發標記,從第一步標記的物件開始,進行可達性分析遍歷,和應用執行緒併發執行。
  • 重新標記,SWT,修正上一階段併發執行造成的引用變化。
  • 併發清除,併發的清除垃圾
    CMS使用標記清除演算法,所以有記憶體碎片問題,可能設定引數在進行若干次不帶整理的收集後進行一次帶整理(compact)的收集。另外,因為垃圾收集是和應用執行緒併發執行的,在收集的同時可能還會有垃圾不斷產生,即產生了浮動垃圾。另外還需要預留出一定空間,到達這個值後進行收集,但是還會有收集速度趕不上生產的速度,這時就會出現Concurrent Mode Failure,CMS會退化成Serial Old進行GC。

    G1 (Garbage First)

    具有大記憶體收集和目標效率時間等控制能力,目標是代替CMS。G1通過將記憶體劃分成不同的區域(Region),並對不同區域計算分數,分析那個Region最具有收集價值。

一些JVM的GC引數

常用的引數設定有

  • -Xms=4g -Xmx=4g 設定Java堆的初始大小和最大大小均為4g,即避免了堆大小調整
  • -Xmn=1g 設定年輕代的總大小為1g
  • -SurvivorRatio=8, 設定Eden和一個Survivor的比例為8:1
  • -XX:+PringGCDetails

    堆外記憶體(Non Heap)

    Nio中的DirectByteBuffer就是堆外記憶體的一部分,這部分記憶體只能通過Full Gc進行清理。一些框架會通過System.gc呼叫手動觸發gc,但是在啟動引數中可能設定了禁止呼叫System.gc()。另外當設定堆過大時可能會造成堆外記憶體不夠導致OOM。

監控工具

監控工具幫助我們在執行時或問題發生後分析現場,分析記憶體分佈狀態,哪裡導致記憶體洩漏等(本該被釋放的物件仍然被引用)。

命令列工具

HotspotJVM的bin目錄下有很多可用的工具。

jps

jps
jps -l
jps -lv

即java版的ps,可以檢視當前使用者啟動了哪些java程式。

jstat

pid指jps命令檢視的java程式號

jstat -gcutil pid 1000 10 

jstat是一個多種用途的工具,更多需要man jstat或直接輸入jstat檢視提示。

jmap

jmap可以檢視記憶體狀況

jmap -histo:live pid
jmap -dump:file=dump.bin,format=b,live
jmap -dump:file=dump.bin,format=b
dump下來的記憶體檔案可以通過MAT進行分析,通過分析引用鏈等分析記憶體洩漏位置 

jstack

檢視Java執行緒狀況

jstack pid
jstack -F pid
可以檢視執行緒的狀態、名稱、程式碼位置 

javap (Java Printer)

javap 可以用可讀的方法檢視class檔案內容,在遇到線上class檔案問題,如NoSucheMethodError發生時,可以快速進行判斷分析。如分析一個A.class檔案,檢視它的私有方法和欄位。

javap -p -c -v A.class 

視覺化工具

JVisualVM

$JAVA_HOME/bin/jvisualvm

JMC

$JAVA_HOME/bin/jmc

JConsole

$JAVA_HOME/bin/jconsole

Class檔案結構

Java編譯器將Java程式碼編譯成class檔案格式。

其中步驟包括了我們熟悉的詞法分析將原始檔轉換成token流。語法分析將token流轉換成抽象語法樹(AST)。語義分析分析語義是否正確。原始碼優化。目的碼生成和目的碼優化等步驟。最終得到了class檔案。之後在虛擬機器中,class檔案可以通過直譯器解釋執行和通過即時編譯器(JIT-just in time)編譯成native程式碼執行兩種方式執行。

class檔案是有嚴格定義的。符合定義的class檔案才能夠被JVM載入、驗證、初始化、執行。

我們通過javap可以檢視一個class檔案的內容。

Class檔案可以分為以下幾個部分

  • Magic Number (0xCAFEBABY)
  • minor version, major version 如 0×0033 代表 00,51, 是java8版本
  • constant pool 常量池,常量池中包括了欄位、方法、類的名稱的符號引用,符號引用會在執行時經過連結轉換為直接引用。
  • access flags 類的private、public等修飾詞
  • this class 表明當前類的名稱
  • super class 父類
  • interfaces 實現的介面列表
  • fields class中定義的欄位,每個field又是一個結構體
  • methods 方法,包括MaxLocal, Max Stack,方法名,signature,access flags等。 程式碼儲存在方法的名稱為Code的屬性中。
  • attributes

下面以一個簡單的類

public class Inc {
public static void main() {
}

private int count;
public void inc() {
count++;
}
} 

看一下它的class檔案,通過vim開啟,在Normal模式下,按: 輸入%!xxd,即可轉換成16進製表示。然後可以通過%!xxd -r轉換回來

0000000: cafe babe 0000 0034 0013 0a00 0400 0f09 .......4........
0000010: 0003 0010 0700 1107 0012 0100 0563 6f75 .............cou
0000020: 6e74 0100 0149 0100 063c 696e 6974 3e01 nt...I...<init>.
0000030: 0003 2829 5601 0004 436f 6465 0100 0f4c ..()V...Code...L
0000040: 696e 654e 756d 6265 7254 6162 6c65 0100 ineNumberTable..
0000050: 046d 6169 6e01 0003 696e 6301 000a 536f .main...inc...So
0000060: 7572 6365 4669 6c65 0100 0849 6e63 2e6a urceFile...Inc.j
0000070: 6176 610c 0007 0008 0c00 0500 0601 0003 ava.............
0000080: 496e 6301 0010 6a61 7661 2f6c 616e 672f Inc...java/lang/
0000090: 4f62 6a65 6374 0021 0003 0004 0000 0001 Object.!........
00000a0: 0002 0005 0006 0000 0003 0001 0007 0008 ................
00000b0: 0001 0009 0000 001d 0001 0001 0000 0005 ................
00000c0: 2ab7 0001 b100 0000 0100 0a00 0000 0600 *...............
00000d0: 0100 0000 0100 0900 0b00 0800 0100 0900 ................
00000e0: 0000 1900 0000 0000 0000 01b1 0000 0001 ................
00000f0: 000a 0000 0006 0001 0000 0003 0001 000c ................
0000100: 0008 0001 0009 0000 0027 0003 0001 0000 .........'......
0000110: 000b 2a59 b400 0204 60b5 0002 b100 0000 ..*Y....`.......
0000120: 0100 0a00 0000 0a00 0200 0000 0700 0a00 ................
0000130: 0800 0100 0d00 0000 0200 0e0a ............ 

通過javap來看一下它的結構

javap -v -p -c -s -l Inc
Classfile /Users/liuzhengyang/study/java/Inc.class
Last modified Oct 6, 2016; size 315 bytes
MD5 checksum 770dcaa972162765744184ffc14bc3c6
Compiled from "Inc.java"
public class Inc
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // Inc.count:I
#3 = Class #17 // Inc
#4 = Class #18 // java/lang/Object
#5 = Utf8 count
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 inc
#13 = Utf8 SourceFile
#14 = Utf8 Inc.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // count:I
#17 = Utf8 Inc
#18 = Utf8 java/lang/Object
{
private int count;
descriptor: I
flags: ACC_PRIVATE

public Inc();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 3: 0

public void inc();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return
LineNumberTable:
line 7: 0
line 8: 10
}
SourceFile: "Inc.java" 

位元組碼指令集

bytecode儲存在class檔案的方法的Code屬性中。用一個byte表示操作指令,所以最多有256個指令。一個指令可能會有多個運算元。

操作指令可以分為以下幾類:

  • 數學運算,如iadd,i2c,imul,idiv
  • 條件分支, 如ifeq,if_icompeq, if_icmplt
  • 運算元棧和本地變數表的操作,如iload_0, iconst_0, ldc i bipush 100, astore_1,
    iinc, dup, swap, dup_x1,put_field, get_field, get_static, put_static等。
  • class操作,如new, checkcast, instanceof
  • 方法呼叫:1.invokespecial:呼叫構造器、私有方法和父類方法;2.invokestatic:呼叫靜態方法;3.invokevirtual:呼叫虛方法,一般的例項方法都是invokevirtual呼叫;4.invokeinterface:呼叫介面類的方法;5.invokedynamic,java中對動態語言的支援。
    invokevirtual和invokeinterface通過第一個引數查詢方法,動態分派,從而實現多型。

JMM (Java記憶體模型)

現代計算機的一本基本思想是分層模型,例如網路上的分層。在儲存上,為解決CPU和記憶體磁碟的速度有指數級差別的問題加入了很多快取,利用區域性性原理加快速度,從CPU暫存器到L1Cache、L2Cache、記憶體、磁碟,各個層的速度依次降低、空間增大、單位bit造價降低。最近CPU的處理能力的垂直增加似乎遇到瓶頸,轉而向多核方向發展,多個cpu核可能各自快取自己的內容,又出現了快取一致性問題。CPU有一些快取一致性協議,MESI等。CPU還可能會對機器指令進行亂序執行。JVM為了遮蔽底層的這些差異,提出了Java記憶體模型,即JMM(Java Memory Model),來保證Write One Run Anywhere。開發者面向JMM程式設計,通過JMM提供的一致性保證和工具,就能保證一致性問題。

JMM模型中,每個執行緒會有一個私有的記憶體區域用於快取讀和寫,各個執行緒共享一個主記憶體。

一個重要的概念是happen-before原則。

happen-before用來描述兩個操作的偏序關係,如果Ahappen-beforeB,那個A的操作的結果、產生的影響能夠被B看到。

最後總結

以上知識是通過閱讀書籍、官方文件、規範得來的,會有過時、不準確的情況。

還需通過檢視原始碼、自身探索,真像就在那程式碼中。每個知識點又能夠引出一篇筆記分析。

參考

  • JLS
  • JVMS

相關文章