複習JVM

JVM概述

簡介

JVM全稱Java Virtual Machine,也就是Java虛擬機,目前大多數的Java虛擬機都是HotSpot。JVM它就包含在JRE(Java Runtime Environment)中。
說到這就不免想起JDK(Java Development Kit),那JRE裏有什麼,JDK裏除了JRE還有什麼?

  • JRE中的都是Java程序必須的組件,如JVM,Java核心類庫等
  • JDK中它不僅有Java程序的必要組件,也有一些診斷工具,如JConsole,Jstack等等

工作過程

那JVM是怎麼進行工作的呢?
它把一個.class的字節碼文件先加載到Java運行時數據區中,使用PC(Program Counter)的自加來自動執行程序,在它執行程序前會將每一個方法以棧幀的形式放入虛擬機棧裏(無論正常結束或異常結束都會彈出當前的棧幀),把一些對象還有靜態變量等放入堆區,然後執行。

編譯方式

衆所周知,.class的字節碼形式計算機是不認識它的,計算機只認識二進制,因此這就需要將它編譯成機器碼然後執行,在HostSpot中存在有以下兩種編譯方式:

  • 解釋執行:也就是逐條邊解釋邊執行
  • 即時編譯(Just-In-Time Compliation,JIT):也就是先將字節碼編譯爲機器代碼,然後執行
    在這裏插入圖片描述

對比:

  1. 從啓動效率來說:解釋執行是優於編譯執行的,編譯執行需要進行編譯,而解釋無需等待編譯
  2. 從執行效率來說:編譯執行是優於解釋執行的,因爲編譯執行只需要編譯一次,而解釋執行每次都要重新邊解釋邊執行
  3. 從內存使用來說:解釋執行不產生中間代碼,相對於產生中間代碼的編譯執行是佔優的
  4. 從優化方面來說:解釋執行用於更多的動態信息,可以根據程序當前狀態來調整優化,而編譯執行在優化上就有很大的侷限性

那編譯執行和解釋執行各有優缺點,HostSpot中是根據什麼原則來進行協調的呢?
採用計算機領域中常用的2 8原則(比如在彙編指令集中RISC和CISC),8是一些不常用代碼,2則是一些熱點代碼,在HotSpot中採用的是對熱點代碼使用JIT,對非熱點代碼使用解釋執行。

HotSpot內置的編譯器

那麼在HotSpot中有幾種內置的編譯器呢?那它們之間又是怎麼相互協同的呢?
在HostSpot中的編譯器大致可以分爲三種:

  • C1,又叫做Client編譯器,面向的是對啓動性能有要求的客戶端GUI程序,優化手段簡單,因此編譯時間較短
  • C2,又叫做Server編譯器,面向對峯值性能有要求的服務端程序,優化手段較複雜,因此編譯時間長,但代碼執行效率高
  • Graal,是Java10引入的實驗性的編譯器

從JDK7開始的HotSpot默認採用的是分層:熱點代碼首先會讓C1去編譯然後熱點代碼中的熱點代碼會交給C2進一步處理,並且HotSpot即時編譯器線程是在應用正常運行的工作線程外,根據CPU數量設置編譯線程數量,並按1:2的比例分給C1和C2.

內存模型和硬件內存架構和運行時數據區

Java內存模型(JavaMemoryModel,JMM)

JMM模型:它是一個抽象的模型,它在內存和用戶線程間會有一個雙向通道

  • 主存:裏面基本都是一些共享信息
  • 工作內存:裏面基本都是線程私有的,如私有信息,基本數據類型分配在工作內存,對象的引用地址放在工作內存,而真實的對象放在堆中(主存)
    在這裏插入圖片描述

從JMM看多線程併發(併發編程中的三個重要特性:原子性,有序性,可見性)

  • 原子性
  • 可見性
  • 有序性

名詞解釋
as if serial:核心思想就是無論怎麼從排序都不影響程序執行結果,爲了遵守as if seria語義,一般都會把相依賴的數據不會進行指令重排序
happends-before:核心思想就是前一個操作需要對後一個操作保持可見性

硬件內存架構

在硬件內存架構中主要是解決CPU和Memory之間的速度不匹配關係和併發處理,CPU和內存的關係如下圖,可以看到使用緩存來環節CPU和內存的速度不匹配問題,但是這又會造成一個新的問題,那就是在併發處理中會出現緩存數據的不一致
在這裏插入圖片描述
那怎麼去解決併發導致的數據不一致性呢?

  1. 可以採用總線加鎖,但是這樣又會導致CPU的吞吐量急劇下降
  2. 採用MESI緩存一致性協議,主要使用的是Cache Line進行數據通報(我理解的是0/1信號),如下圖所示

在這裏插入圖片描述
可以看到它有四個狀態MESI

  • Modify:因修改數據而不一致了
  • Exclusive:獨享的,只能被緩存在CPU
  • Shared:共享的
  • Invalid:禁止使用的

它就可以對應到如下四種操作:

  1. local read:讀本地緩存
  2. local write:寫本地緩存
  3. remote read:讀內存
  4. remote write:寫內存

狀態之間的轉換

M:

當數據被寫入內存的時候,爲了讓其他核共享數據,狀態轉變爲S(remote read)

當寫入到內存的時候,如果其他核修改了數據,就變爲I(作廢)(remote write)

3.要是在緩存中讀取(local read)或修改(local write)不變化

S:

如果localread那就不變

如果修改數據(loacl write)的話,那就轉爲M(修改),在其他核裏變爲I(作廢)。

如果是存到本地頁不會改變狀態

如果是在本地修改數據(loacal write),那所有關於此數據的Cache Line不能使用了,狀態就變爲I(作廢)。

E:

從CacheLine中讀取數據就不改變

別人需要讀取,並寫入內存並變爲S(remote read)。

如果修改(local write)的話變爲M

在內存中被修改的話(reomte write)就變爲I

I:

1.(laocal read) 如果其他核中沒有這個數據的話就從內存中取,並且狀態變爲E,如果在其他Cache中有這個數據的話(也就是這個數據的狀態爲S或E),那就從內存中讀取,並且將這些緩存行的數據變爲S。如果這個數據的狀態是M,那就把它寫到內存再進行讀取,這兩個緩存行的狀態就變爲S。

從內存中取數據並且在Cache中修改,狀態變爲M。如果其他Cache中有這份數據並且狀態爲M,那就要將數據更新到內存中。如果其他數據有這份數據並且狀態不爲M,那就將這些轉換爲I。

既然是作廢的狀態那就remote操作就不能執行了。

核心思想就是某個線程會在修改的時候將S狀態轉爲M,並且將所有與S相關的都改爲I,然後修改完成後remote write寫入內存,通過cache Line保證了數據的一致性。

運行時數據區

在這裏插入圖片描述
但是在JDK1.8後移除掉了方法區,將原本的方法區一分爲二,類的原信息數據分到了元數據去,另一部分分到了堆中,如一些靜態成員,變量等等

棧幀的結構如下
在這裏插入圖片描述

三者的聯繫

硬件結構和JMM的聯繫,如下圖所示,它們的關係是相互交叉的,工作內存和內存都可以存在於硬件結構中的CPU(寄存器),Cache和內存中。在此就更能體會到JMM實際上是一個抽象的模型。
在這裏插入圖片描述
JMM和J運行時數據區的聯繫
JMM模型中對應了內存結構中的線程私有(PC,虛擬機棧,本地方法棧)和線程間共享(堆,方法區),在我看來運行時數據區就是對JMM的進一步實現。

類加載

類加載簡單的來說就是講.class交給JVM去處理,因爲一個類中會存在一些靜態變量還有外部庫函數等等,因此它需要一些流程來講.class類一步步的加載入內存中。
類加載大致可以分爲三個階段:

  • 加載:主要就是藉助ClassLoader查找字節流並創建一個類(數組類沒有對應的字節流)
  • 鏈接:主要是做類的合成工作
    • 驗證:主要驗證類是正確性,確保類能滿足JVM的約束條件
    • 準備:主要是給靜態變量分配內存空間,並賦默認值
    • 解析:主要是將符號引用轉換爲實際引用,如果符號引用指向一個未加載的一個類或方法,那就會觸發這個類的加載,但是未必會觸發類的鏈接和初始化
  • 初始化:主要是對靜態變量賦初始值。Java 虛擬機會通過加鎖來確保類的 < clinit > 方法僅被執行一次

初始化觸發的條件:簡單來說就是首次主動使用到它(除此之外就是被動使用),如下

  1. 當虛擬機啓動時,初始化用戶指定的主類
  2. 當遇到用以新建目標類實例的 new 指令時,初始化 new 指令的目標類
  3. 當遇到調用靜態方法的指令時,初始化該靜態方法所在的類
  4. 當遇到訪問靜態字段的指令時,初始化該靜態字段所在的類
  5. 子類的初始化會觸發父類的初始化
  6. 如果一個接口定義了 default 方法,那麼直接實現或者間接實現該接口的類的初始化,會觸發
    該接口的初始化
  7. 使用反射 API 對某個類進行反射調用時,初始化這個類
  8. 當初次調用 MethodHandle 實例時,初始化該 MethodHandle 指向的方法所在的類

類加載器的分類

啓動類加載器:用來加載最基本的也是最重要的類,主要加載JRE的lib下jar的類(C++寫的,parent爲null)
擴展類加載器:用來加載非核心,但也常用的類,主要加載JRE的lib下的ext目錄下的類
應用類加載器:也叫上下文加載器,用於加載classpath下的類,一般應用程序中的類就由此類加載
自定義類加載器:自己定義加載類的方式

類加載器還擁有類似於命名空間的作用,使用不同類加載器加載的類會被看做是不同的類

雙親委派模型
在這裏插入圖片描述
委派機制的必要性是爲了解決類混亂問題,採用了分層模式,從上到下都有明確的負責區域,以防止自定義或外部的類和內部類產生混淆,也防止對內部的類的篡改。

是不是所有類的加載都採用的是雙親委派?如果不是,那爲什麼要對雙親委派進行破壞呢?
並不是這樣的,比如Driver接口和DriverManager都是在啓動類加載器上,但是這些當DriverManager想要去管理那些Driver的實現的時候,啓動類加載器並不能加載的到(因爲啓動類加載器的範圍侷限,在環境路徑(JAVA_HOME)下的lib),這些類只能由應用類加載器來加載,從而破壞了雙親委派,但我認爲這並不完全是破壞,也可以看做是擴展。

SPI(Service Provided Interface):由JDK提供接口,不同的廠商提供服務。上述情況就是一個SPI的典型例子

JVM中的方法識別與調用

在JVM中是通過什麼來進行方法識別的呢?它通過類名,方法名,和方法描述來進行方法識別,這就爲什麼不能在同一個類中出現相同的方法名並且方法描述也相同的方法。
我們常常說的重載就是類名,方法名相同的但是方法描述不同的一些方法,如果描述也相同就會報錯。
不止是重載,方法的重寫也是根據方法描述來判斷的(就是子類的描述和父類的非私有,非靜態的方法同名且描述相同,這樣才能被判斷爲方法的重寫)
在這裏插入圖片描述

因爲在編譯器已經完成了重載方法的區分,所以將它換爲JVM上的概念就可以對應爲靜態綁定

  • 重載就對應的是靜態綁定,但是這樣並不是完全正確的,因爲一個類的子類也有可能對它的重載方法進行了重寫
  • 重寫就對應的是動態綁定

一個類方法查找和調用的流程(符號引用轉換爲實際引用的過程)
在這裏插入圖片描述
一個接口方法查找和調用過程
在這裏插入圖片描述
然後找到直接引用後,根據方法表(符號表)內容指示去執行!
方法的調用會通過一些列的字節碼指令來完成如:

  1. invokestatic:用於調用靜態方法
  2. invokespecial:用於調用私有實例方法、構造器,以及使用 super 關鍵字調用父類的實例方法
    或構造器,和所實現接口的默認方法
  3. invokevirtual:用於調用非私有實例方法
  4. invokeinterface:用於調用接口方法
  5. invokedynamic:用於調用動態方法

類文件結構

這部分主要就是對類的結構進行翻譯,可以根據一串串的0 1代碼分析出這個類的所有信息。可以照着書上的解釋把0 1 代碼轉換爲一些指令,並根據指令的語義翻譯出相應的代碼。
比如OxCAFEBABE就是是魔數(magic)佔用頭四個字節,唯一作用就是爲了讓JVM能識別這是一個可以接受的class文件。
多餘的就不細說了,在深入理解JVM裏每一條都說的很詳細!

垃圾回收機制

堆內存結構

在這裏插入圖片描述

垃圾回收

垃圾回收的主要區域就是在堆裏,堆裏面有大量的對象,有些對象很快就消亡了,有些對象會一直存活着,那麼要思考的問題是怎麼去判斷對象的死活?怎麼去進行對象的迭代?怎麼去對合適的地方使用合適的垃圾回收算法?

判斷對象的死活

通常用於判斷對象的死活有下面兩種方法

  • 引用計數法:效率高,但是不能解決循環引用問題,並且需要很大空間存儲對象計數情況
  • GC Root根可達性分析法:以GCRoots爲起點,走過的路就是引用鏈。可能性能稍差與引用計數,但是它較穩定
    在Java中採用的是GCRoot根可達性分析法,在Redis中就使用的是引用計數法(追求高性能)。
    可以作爲GC Root的一些對象:在我看來能作爲GC Root的對象對象的可以分爲靜態屬性和正在運行期間的被引用到了的對象
  • 虛擬機棧中引用的對象
  • 方法區中的類靜態屬性引用的對象
  • 方法區中的常量引用的對象
  • 本地方法棧中JNI(Native方法)的引用的對象

雖然GCRoot可達性分析看起來簡明,但是它在多線程併發的情況下有可能出現誤報(已經訪問過的對象,將引用設置爲null)或者是漏報(已經訪問過的對象,將引用設爲從未訪問過,因爲在多線程狀態下頻繁的狀態更新),GC可能當前在回收的其實是一個存活的對象,一旦這個對象被回收,JVM再次訪問這個已回收的對象就可能會導致JVM的崩潰

那怎麼去預防這件事情的發生了?
在JVM中採用了Stop-the-world機制停止所有非垃圾回收的工作,直到垃圾回收結束。它的原理是採用的安全點,JVM收到stop-the-world,只有所有線程到達安全點,才允許stop-the-world獨佔工作

找到安全點的目的就是讓線程的狀態穩定下來!一般安全點都在方法調時,方法返回時,基本都是一些臨界區域

爲什麼不採取很多的安全點?
因爲安全點太多開銷太大,而且它存的東西也比較多佔內存空間,因此,一般安全點的設立不會太多

對象怎麼去進行迭代

上一步可以根據GC Root可達性分析法判斷對象的死活,下來就要想怎麼去進行垃圾對象清理。在JVM中對一個新生對象的迭代過程如下:
在這裏插入圖片描述
由於老年代對象都是歷經考驗存活下來的,因此一般採用標記整理/清除,而新生代存活下的對象少,使用複製算法效率高!

TALB(Thread Local Allocation Buffer)

但是,如果是併發條件下,多個線程new對象,這有可能會導致內存衝突,多個線程進行使用一塊區域?那怎麼去解決這個問題呢?
這就要說到TALB(ThreadLocalAllocationBuffer,本地線程緩衝區分配),給那些線程預先分配一段很長的區域(因此在這個劃分的時候是需要進行同步的),然後每個線程在自己的區域內通過指針加法(bump the pointer)移動指針給新對象分配空間,如果空間不夠就繼續申請這時如果空間不夠會產生MirrorGC

卡表(Card Table)

新生代採用複製算法很高效,因爲存活下的對象很少,但是這樣也會出現一些問題,比如老年代對象引用了新生代的對象,那在GC時需要判斷對象存活是否,那豈不是還有做一次老年代的全表掃描?
答案是不用的,在HostSpot中給出的一項方案是卡表(Card Table),該技術將堆劃分爲一個個512M的空間,如果此空間有對象引用新生代(設立一個標誌位),那麼就認爲這個這個卡是髒的,因此在Mirror的時候並不需要去全表掃描,只需要對髒卡進行掃描,大大提高了效率。

垃圾回收器

選擇垃圾回收器也是比較重要的,針對不同場景選擇不同的垃圾回收器。

  • 按串並行分:有串行的有並行的
  • 按算法分:有複製的,有標記清除的,有標記整理的
  • 按清掃範圍:新生代收集器,老年代收集器

新生代的垃圾回收器有:Serial Parallel Scavenge ParallelNew 這三個都是採用的標記複製算法
老年代的垃圾回收器有:Serial Old Parallel Old CMS(除少數操作需要Stop-the-world,JDK9被棄用)
通用收集器:G1,分區,如果某個區的死亡對象比較多,會優先回收

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