天天看點

深入Java虛拟機之 --- JVM的愛恨情仇

(本文參考深入了解JAVA虛拟機第二版第2章)
           

系列文章:

深入Java虛拟機之 — JVM的愛恨情仇

JAVA 垃圾回收機制(一) — 對象回收與算法初識

JAVA 垃圾回收機制(二) — GC回收具體實作

深入Java虛拟機之 – 類檔案結構(位元組碼)

一、認識Java環境

在講 JVM 之前,先講講 JDK、JRE和 JVM 的關系,如下面這張圖(圖檔來自百度圖檔):

深入Java虛拟機之 --- JVM的愛恨情仇

可以看到他們的包含關系是 JDK>JRE>JVM

  • JDK:jdk是支援 JAVA程式開發的最小環境,內建了JRE和一些工具包,如 javac,jar等;比如一個可運作jar,你就需要安裝了jdk,才能運作起來
  • JRE:是Java運作時的标準環境,除了JVM的環境還有一些基本的JAVA庫,比如界面的 swing、I/O等

JVM:熟稱Java虛拟機,也叫運作時資料區域,是保證跨平台的基本,因為 jvm 隻認識位元組碼,隻要linux、window、mac 有jvm 都是可以編譯執行的;

而這裡,我們就需要講解 JVM 這個 運作時資料區域的分布了,如下圖(圖檔來自百度圖檔,稍微修改了一點):

深入Java虛拟機之 --- JVM的愛恨情仇

上面解釋了一個java程式是怎麼運作的,其中 記憶體空間這裡,就是 JVM 了;

  • 線程共享區:即程式運作時,資料在各個線程之間是共享的,比如某個方法,某個類,還有一些運作時常量
  • 線程私有區:各個線程之間的資料是獨立的,比如多線程的資料

為了友善解釋,這裡的順序不會像上圖那裡的順序來;

二、線程私有區

2.1、程式計數器

首先先了解程式電腦,線程(UI線程)中程式語句的執行都離不開它,對它的解釋如下:

  1. 是一塊較小的記憶體存于,可以看做目前線程執行位元組碼時的行号訓示器
  2. 程式的運作,比如跳轉、循環等指令,就是通過改變計數器的數值,來選取下一條需要執行的位元組碼指令
  3. 多線程時,每個線程的程式電腦都是獨立的,互相不幹擾,獨立儲存;即記錄每次線程的位置,友善下次線程切換過來,知道上次線程的運作到哪了

    op

2.2 虛拟機棧

結合方法去中的一些變量和常量去了解會比較好
           

虛拟機棧也是線程私有的,與線程的生命周期相同;它對應着線程的記憶體模式,每個方法在執行的時候,都有一個棧幀用于存儲局部表,操作數棧、動态連結、方法出口等資訊;每個方法的執行,都對應着一個棧幀在虛拟機棧中的入棧和出棧,如下圖(圖檔參考深入Java虛拟機第二版)

深入Java虛拟機之 --- JVM的愛恨情仇

局部變量表:

  • 存儲了編譯器存放着各種基本資料類型(boolean、byte、char等)
  • 對象引用類型,這裡的對象不是對象本身,可能是對象的尋址指針,也可能是句柄或者相關位置
  • returnAddress 類型,指向了一條位元組碼指令的位址 (現在已經很少有虛拟機用了)

當進入一個方法時,這些變量在幀中配置設定的記憶體大小時固定的,在運作時不會改變局部變量表的大小。針對這個區域,規定了兩種異常情況

  • 如果虛拟機不支援動态擴充,當線程請求的棧大小大于虛拟機規定的大小時,抛出 StackOverflowError
  • 如果虛拟機棧可以動态擴充,如果擴充時,無法申請到足夠的記憶體,抛出 OutOfMemoryError

操作數棧:

操作數棧,也可以稱做操作棧,它可以是 Java 的任意類型,在資料提取時入棧和出棧,比如 int a = 1 + 2;它會先把 1,2 從棧中取出來,把它複制給 a 後,再把結果入棧。

動态連接配接:

這裡需要先了解靜态連接配接,比如類加載的解析步驟,是直接将符号引用轉換為直接引用,稱為靜态解析;而動态連接配接,則是運作期間,把符号引用轉換為直接引用。

可以這樣了解符号引用:

比如有目前運作類D,D類中還有其他類的申明,比如C,但虛拟機隻會加載目前類,是以C它會由一個符号引用來代替,當類在解析的時候,會把這個符号引用轉換為直接引用,直接引用就是類C的指針位址;關于類加載,可以到深入Java虛拟機之 – 類加載機制 細看。

方法傳回位址:

每個方法運作結束,隻有兩種方法可以退出方式;一是正常傳回資料,這種稱為正常完成出口;二是遇到異常,也會導緻退出,稱為異常完成退出。

無論哪種退出,都需要傳回到方法被調用的位置,程式才能繼續進行。

2.3 本地方法棧

本地方法棧與虛拟機棧的作用非常相似;隻不過虛拟機棧執行的是 java 的位元組碼服務,而本地方法棧執行的是 Native 方法服務;

本地方法棧同樣會穿件棧幀,如局部變量表、操作棧等資訊,同時也有 StackOverflowError 和 OutOfMemoryError 異常

三、線程共享區

3.1 Java 堆:

是Java虛拟機鎖管理的記憶體中最大的一塊,在虛拟機啟動建立時,此記憶體區域的唯一目的就是存放對象執行個體,幾乎所有的對象執行個體都是在這配置設定記憶體的;

Java 堆是記憶體回收的主要區域,也叫 GC 堆;根據規定,Java堆的實體位址可以是不連續的,隻要保證邏輯上是連續的即可。由于Java 堆基本采用分代手機算法,是以也可以分為:新生代和老年代;再細緻分,也可以分為 Eden空間,From Survivor 空間、To Surivivor 空間等涉及到的GC回收算法,具體可參考: JAVA 垃圾回收機制(一) — 對象回收與算法初識

JAVA 垃圾回收機制(二) — GC回收具體實作。

3.2 方法堆

方法堆也是線程共享的一個區域塊,它用于存儲虛拟機加載的類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料。雖然Java虛拟機規範把方法區規定為 Java 堆的一個邏輯子產品,但它還有一個方法,叫 Non-Heap (非堆) ,目的就是為了和 Java堆區分開來

3.2.1 運作時常量池

運作時常量池,其實算方法區的一部分。Class檔案中除了有 類的版本、字段、方法、接口等資訊外;還有一項資訊就是常量池,用于存放編譯期生成的字面量和字元引用,如下圖:

深入Java虛拟機之 --- JVM的愛恨情仇

四、直接記憶體

在JDK1.4中,新增加了一個 NIO(New Inout/Outinput)類,引入了一種基于通道(channel)與緩沖區(buffer)的I/O方式,它可以使用 Native 函數庫直接配置設定堆外記憶體,然後通過一個存儲在Java 堆中的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣在一些場景中能夠顯著提升技能,避免了資料再 Java 堆和 Native 堆中來回複制資料,常見的通道類型有:

  • FileChannel:從檔案中讀寫資料
  • DatagramChannel:從UDP中讀寫資料
  • SocketChannel:從TCP中讀寫資料
  • ServerSocketChannel:用來監聽 websocket 的連接配接

具體案例可以查找NIO的具體案例

直接記憶體,不是虛拟機運作時記憶體區的一部分,也不是Java規範中定義的記憶體區域。但既然是記憶體,如果 超過了 RAM 和 SWAP 尋址空間限制,還是會報OutOfMemoryError的。

五、對象建立過程

上面了解了 JVM 的一些知識之後,那麼一個對象的建立是怎麼樣的呢?對象的建立,可以分為以下幾個步驟

深入Java虛拟機之 --- JVM的愛恨情仇

類加載

當虛拟機遇到一個 new 指令的時候,會先去檢測這個指令的參數是否能定位到這個類的符号引用,并檢查這個類是否被加載、解析或初始化過。如果沒有,則執行類加載 (深入Java虛拟機之 – 類加載機制 )

記憶體配置設定

在類加載通過之後,虛拟機将為新生對象配置設定記憶體,對象所需記憶體的大小在類加載完成後便可完全确定,相當于從Java堆中抽取一塊記憶體出來;而根據記憶體的是否絕對規整,分為 指針碰撞 和 空閑清單 兩種配置設定方式:

  • 指針碰撞:假設Java堆中的記憶體隻絕對規整的,分為空閑和非空閑兩種,中間用一個指針當做劃分界限的訓示器;當一個新對象需要配置設定對象時,相當于把指針向空閑區域移動一段與對象大小相等的距離
  • 空閑清單:假設Java堆的記憶體不是絕對規整的,空閑和非空閑是互相交錯的,那就需要一個OopMap清單,用來記錄哪些記憶體塊是可以用的,在對象配置設定記憶體時,劃分一塊大小相等的區域給對象,并更新這個清單

從上面的解釋看,用哪種配置設定方式,是通過Java堆的記憶體塊是否絕對規整決定的。

但對象的建立是頻繁的,在并發的情況,多線程不一定是安全的,即存在A對象在配置設定記憶體,指針還未來得及修改,B對象也同時使用了原來的指針來配置設定對象。是以又衍生了兩種解決辦法,CAS+失敗重試 和 TLAB兩種方式

  • CAS+失敗重試:虛拟機采用CAS配上失敗重試的方式保證更新操作的原子性 (關于CAS鎖,是樂觀鎖的一種實作,解釋起來也比較麻煩,可以參考這裡:https://www.cnblogs.com/javalyy/p/8882172.html)
  • TLAB:本地線程配置設定緩沖,把記憶體配置設定的動作按照線程配置設定劃分在不同的空間中進行,即每個線程在Java堆中預先配置設定一小塊記憶體,哪個線程需要需要配置設定,先在 TLAB 中配置設定,用完了并重新配置設定新的TLAB時,才需要同步鎖定。

初始值為零

在記憶體配置設定完成之後,虛拟機需要将配置設定到的記憶體空間初始化為零值 (除對象頭外),這一步操作也保證了對象的執行個體字段在java代碼中可以不賦初始值就可以使用,因為程式能通路這些字段的資料類型所對應的零值。

設定對象頭

初始值設定之後,怎麼知道對象是哪個類的執行個體,如何才能找到類的中繼資料資訊、哈希碼、GC分代年齡等資訊呢?這就需要對對象頭進行一些必要的設定,才能定位到,詳細在5.2節介紹。

入棧,執行init指令:

從虛拟機來看,對象已經配置設定産生完成了,且入棧了;但 Java 程式來看,這才剛開始,是以,new 之後,則執行 init 方法,進行初始化。

5.2 對象的記憶體分布

上面講解了對象在 虛拟機的配置設定之後,再擴充一下,對象在記憶體中是怎麼配置設定的呢,對象在記憶體中的存儲布局可分為 3個部分:

深入Java虛拟機之 --- JVM的愛恨情仇

對象頭

其中,對象頭可以再細分為兩部分:

  • 存儲對象自身的運作時資料:如哈希碼、GC分代年齡、鎖狀态标志、線程持有的、偏向線程ID等資訊
  • 類型指針:即對象指向它的類中繼資料的指針,虛拟機通過這個來确定這個對象是哪個類的執行個體

執行個體資料

是對象真正儲存的有效資訊,比如程式中定義的各種類型的字段内容,無論父類和子類都會記錄下來;在配置設定時,相同寬度的字段會被配置設定到一起,這也是父類定義的變量會出現在子類之前的原因。

對齊填充

沒啥實際意義,隻是為了保證對象是8位元組的整數倍,沒對齊時,用來補全而已。

5.3 對象的通路定位

建立對象是為了使用對象,Java 程式需要通過棧上的 reference 資料來操作堆上的具體對象;但這些通路方式取決于虛拟機實作而定,目前主流有句柄和直接指針兩種:

  • 句柄:從Java 堆中劃分出一塊記憶體用來作為句柄池,reference 中存儲的就是對象的句柄位址,而句柄包含了對象執行個體資料與類型資料各自的具體位址資訊,如下圖(圖檔來自Java虛拟機第三版)
    深入Java虛拟機之 --- JVM的愛恨情仇
  • 直接指針:在直接指針中,reference 儲存的就是對象位址,是以,需要考慮的是如何防止通路類型資料的相關資訊(圖檔來自Java虛拟機第三版)
    深入Java虛拟機之 --- JVM的愛恨情仇

優點介紹:

句柄:使用句柄好處是,reference中存放的是文檔的句柄位址,對象被移動時,隻改變句柄的執行個體資料指針,而reference 本身不需要修改

直接指針:使用直接指針的最大好處就是速度更快,節省了指針定位的開銷;

擴充

為什麼字元串拼接的時候,不适合用 String ,而應該使用 StringBuilder 或者 StringBuffer ? 比如 String = “abc”; (可參考常量池來解釋喲)

深入Java虛拟機之 --- JVM的愛恨情仇