作為一個Java程式設計師,JVM的這些知識你懂了嗎?

程序猿凱撒 發佈 2022-07-05T07:14:28.710197+00:00

目錄 一.JVM內存區域劃分JVM為什麼要劃分出這些區域呢?JVM內存是從作業系統裡面申請過來的,而JVM就根據功能需求將這些劃分成了一些小的模塊,這樣一塊大的場地就可以劃分成一些小的模塊,然後每個模塊就負責自己的功能就可以了,那接下來看看這些區域的功能到底是什麼呢! 1.

目錄

 一.JVM內存區域劃分

JVM為什麼要劃分出這些區域呢?JVM內存是從作業系統裡面申請過來的,而JVM就根據功能需求將這些劃分成了一些小的模塊,這樣一塊大的場地就可以劃分成一些小的模塊,然後每個模塊就負責自己的功能就可以了,那接下來看看這些區域的功能到底是什麼呢!

 1.程序計數器

程序計數器是內存中最小的區域,這裡面主要保存了下一條要執行的指令的地址在哪裡(指令就是字節碼,一般程序要運行,JVM就需要把字節碼加載出來放到內存中,然後程序再把一條一條的指令從內存中取出來放到CPU上去執行,所以必須要記住當前執行到哪一條指令,以及下一條在哪裡,因為CPU不是只給一個進程提供服務的,是給所有的進程都提供服務,是並髮式的執行程序的,又因為作業系統是以線程為單位進行調度執行的,所以每個線程都要有自己的執行位置,也就是每一個線程都需要有一個程序計數器來記錄位置!)

棧裡面存放的主要是局部變量和方法調用信息,只要涉及到新方法的調用,就會有"入棧"的操作,每執行完成一個方法,就會有"出棧"的操作,而且棧也是每個線程都有一份的

因此對於遞歸來說,一定要控制好遞歸條件,否則很有可能會出現棧溢出(StackOverflowException)異常的!

堆是內存中空間最大的區域,而且堆是每個進程只有一份的,進程中的多個線程公用一個堆,裡面主要存放著new出來的對象以及對象的成員變量,例如String s = new String()如果在方法裡面這裡的s就是局部變量是在棧上的,如果這個s是成員變量,就是在堆上的,而後面new String()是對象的本體,對象是在堆上的,這是容易混淆的地方,另外堆還有一個重要的點就是關於垃圾回收問題,這個後面再詳細介紹!

 4.方法區

方法區中存放的是"類對象",平常所寫的.java代碼經過編譯器翻譯過後就會變成.class(二進位字節碼),然後.class就會被加載到內存中,也就被JVM構造成了類對象(加載的過程就是稱為"類加載"),而這些類對象就會存放到方法區中,這裡面就具體描述了類長啥樣(類的名字,類的成員及其成員名成員類型,類的方法及其方法名方法類型,以及一些指令…另外類對象裡面還存放了一個很重要的東西,就是靜態成員,一般被static修飾的成員就成為了類屬性,而普通的方法被稱為實例屬性,這是有很大差別的)!

上面所介紹的是JVM中比較常見的區域,而一些JVM的內存區域劃分不一定是符合實際情況的,JVM在實現的過程中區域的劃分是不盡相同的,不同的廠商不同版本的JVM都是有可能存在差異的,不過對於我們普通的程式設計師而講,只要不是去實現JVM,那麼就不需要了解那麼深刻,講上面的幾個常見的區域加以了解就可以了!

 二.JVM類加載機制

類加載其實是設計一個運行時環境的一個重要的功核心功能,這是非常重量級的,因此我這裡也就簡單介紹一下!

上述就是類加載的具體過程,最後面的Using和Unloading就是使用的過程就不介紹了,就介紹一下前面的三個大的步驟:

 1.Loading(加載)

在loading階段就會先找到對應的.class文件,然後打開並讀取(根據字節流).class文件,同時初步生成一個類對象,這個和完成的類加載(class Loading)是不相同的,不要弄混淆了!

class文件的具體格式(如果要實現一個Java編譯器就得按照這樣的格式來構造,實現JVM就得按照這個格式來進行加載!):

觀察這個格式就可以看到.class文件就把.Java文件中的核心信息都表述進去了,只不過組織格式上發生了轉變,所以loading環節就會把讀取到的信息,初步填寫到類對象中

 2.Linking(連接)

連接一般就是建立好多個實體之間的聯繫

 2.1.Verification(驗證)

Verification就是一個校驗的過程,主要就是驗證讀到的內容是不是和規範中規定的格式完全匹配,如果發現讀到的數據格式不符合規範,就會類加載失敗,並且拋出異常!

 2.2.Preparation(準備)

Preparation階段是正式為定義的變量(靜態變量,就是static修飾的變量)分配內存並設置類變量初始值的階段,就會給每個靜態變量分配內存,並且設置為0值!

 2.3.Resolution(解析)

Resolution階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程,也就是初始化常量的過程,.class文件中常量是集中放置的,每個常量會有一個編號,而在.class文件中的結構體裡初始情況就只是記錄的編號,然後就可以根據這個編號找到對應的內容,再填充到類對象中!

 3.Initialization(初始化)

Initialization階段就是真正的對類對象進行初始化(根據寫的代碼),尤其是針對靜態成員

 4.典型的面試題

class A {
 
    public A(){
 
        System.out.println("A的構造方法");
    }
    {
 
        System.out.println("A的構造代碼塊");
    }
    static {
 
        System.out.println("A的靜態代碼塊");
    }
}
class B extends A{
 
    public B(){
 
        System.out.println("B的構造方法");
    }
    {
 
        System.out.println("B的構造代碼塊");
    }
    static {
 
        System.out.println("B的靜態代碼塊");
    }
}
public class Test extends B{
 
    public static void main(String[] args) {
 
        new Test();
        new Test();
    }
}

可以自己先嘗試寫一下輸出的結果

做這樣的題就需要把握幾個大的原則:

  1. 類加載階段就會進行靜態代碼塊的執行,要想創建實例,勢必要先進行類加載
  2. 靜態代碼塊只是類加載階段執行一次,其他階段都不會再執行
  3. 構造方法和構造代碼塊每次實例化都會執行,而且構造代碼塊會在構造方法前面執行~~
  4. 父類執行在前,子類執行在後!
  5. 程序是從main開始執行的,main的Test的方法,因此要執行main就需要先加載Test類
  6. 只有涉及到這個類了,類裡面的東西才會被加載
輸出結果:
A的靜態代碼塊
B的靜態代碼塊
A的構造代碼塊
A的構造方法
B的構造代碼塊
B的構造方法
A的構造代碼塊
A的構造方法
B的構造代碼塊
B的構造方法

 5.雙親委派模型

這個東西是類加載中的一個環節,處於Loading階段(比較靠前的部分),雙親委派模型描述的就是JVM中的類加載器,如何根據類的全限定名(java.lang.String)找到.class文件的過程。這裡的類加載器是JVM專門提供的對象,主要負責進行類加載,所以找文件的過程也是由類加載器來負責的,.class文件可能放置的位置有很多,有的要放到JDK目錄裡面,有的放到項目目錄裡面,還有的在其他特定的位置裡面,因此JVM提供了多個類加載器,每個類加載器負責一個片區,而默認的類加載器主要有3個:

  1. BootStrapClassLoader:負責加載標準庫中的類(String,ArrayList,Random,Scanner…)
  2. ExtensionClassLoader:負責加載JDK擴展的類(現在很少用到)
  3. ApplicationClassLoader:負責加載當前項目目錄中的類
  4. 另外程式設計師還可以自定義類加載器,來加載其他目錄中的類,Tomcat就自定義了類加載器,用來專門加載webapps裡面的.class

雙親委派模型就描述了這個找目錄的過程,也就是上述類加載器是如何配合的

考慮找一下java.lang.String:

  1. 程序啟動,就會先進入ApplicationClassLoader類加載器
  2. ApplicationClassLoader類加載器就會檢查下,它的父加載器是否已經加載過了,如果沒有,就調用父 類加載器ExtensionClassLoader
  3. ExtensionClassLoader類加載器就會檢查下,它的父加載器是否已經加載過了,如果沒有,就調用父 類加載器BootStrapClassLoader
  4. BootStrapClassLoader類加載器也會檢查下,它的父加載器是否已經加載過了,然後發現沒有父親,於是就掃描自己負責的目錄
  5. 然後java.lang.String這個類就在標準庫中能找到,然後後續就由BootStrapClassLoader加載器負責後續的加載過程,查找環節就結束了!

考慮找一下自己寫的Test類:

  1. 程序啟動,就會先進入ApplicationClassLoader類加載器
  2. ApplicationClassLoader類加載器就會檢查下,它的父加載器是否已經加載過了,如果沒有,就調用父 類加載器ExtensionClassLoader
  3. ExtensionClassLoader類加載器就會檢查下,它的父加載器是否已經加載過了,如果沒有,就調用父 類加載器BootStrapClassLoader
  4. BootStrapClassLoader類加載器也會檢查下,它的父加載器是否已經加載過了,然後發現沒有父親,於是就掃描自己負責的目錄,沒掃描到,就會回到子加載器中繼續掃描
  5. ExtensionClassLoader掃描自己負責的目錄,也沒有掃描到,再回到子加載器中繼續掃描
  6. ApplicationClassLoader也掃描自己負責的目錄,自己寫的類就在自己的項目目錄下,因此就能找到,然後後續的類加載就由ApplicationClassLoad完成,此時查找目錄的環節就結束了~~(另外如果ApplicationClassLoader也沒有找到們就會拋出ClassNotFoundException異常)

這一套查找規則就稱為雙親委派模型,那為啥JVM要這樣設計呢,理由就是一旦程式設計師自己寫的類和全限定類名重複了,也能夠成功加載標準庫中的類,而不是自己寫的類!!!

另外如果是自定義的類加載器,要不要遵守這個雙親委派模型呢?

答案是可以遵守也可以不遵守,主要看需求,例如Tomcat加載webapp中的類,就沒有遵守,因為遵守了上面的類加載器也是不可能找到的!

 三.JVM的垃圾回收

JVM中的垃圾回收機制(GC),一般在寫代碼的時候,經常就會涉及到申請內存,例如創建一個變量,new一個對象,調用一個方法,加載類…而申請內存的時機一般是明確的(需要保存某個或某些數據就需要申請內存),但是釋放內存的時機,卻是不那麼清楚的,釋放的早了也不行(如果還是要使用的,結果已經被釋放了這就讓其無內存可用了,就讓這些數據"無處可去"),釋放的晚了也不行(釋放晚了,大量的囤積很有可能讓可用內存逐漸變少,很有可能會出現內存泄漏問題,就是無內存可以使用),因此內存的釋放要恰到好處才好!

而垃圾回收的本職是靠運行時環境額外做了很多的工作來完成釋放內存操作的,這讓程式設計師的心智負擔大大降低了,但是垃圾回收也是有劣勢的:①消耗額外的開銷(消耗資源耕更多了);②可能會影響程序的流暢運行(垃圾回收會經常引入STW問題(Stop The World))

垃圾回收的內存有哪些呢,是全部都要回收嘛?

當然不是了,就用上面的四個區域來說一下:

  • 程序計數器:這個內存是固定大小的,不涉及到釋放,也就不需要GC了;
  • 棧:當函數調用完畢,對應的棧幀也就自動釋放了,也是不需要GC的;
  • 堆:這是最需要GC的內存,一般代碼中的大量的內存都在堆上;
    而這三個區域到底哪些是需要釋放的,對於這種一部分在使用,一部分不再使用的對象,整體來說就是不釋放的,只有等到這個對象完全不再使用,才真正的進行釋放,因此在GC中就不會出現半個對象的情況,因此垃圾回收的基本單位就是對象,而不是字節!
  • 方法區:類對象,類加載的,而只有進行到類卸載的時候才需要進行釋放內存,而卸載操作是非常低頻的,因此幾乎就不涉及到GC!

下面就具體來看一下是怎麼回收的:

 1.找垃圾/判定垃圾

而當下有兩個主流的方案:

 1.1.基於引用計數

這不是Java中採取的方案,這是Python及其他語言的方案,因此這裡就簡單介紹一下,就不過多介紹了~

而引用計數的具體思路就是針對每個對象,都會額外引入一小塊內存,來保存這個對象有多少個引用指向它

而這樣的引用計數存在兩個缺陷:

  • 空間利用率比較低!!!,每個new的對象都需要搭配一個計數器,假設一個計數器4個字節,如果對象本身比較大(幾百個字節),那麼這個計數器就無所謂,而一旦這個對象本身就比較小(4個字節),那麼再多出來4個字節,就相當於空間利用率就浪費了一倍,因此空間利用率會比較低~
  • 有循環引用的問題
    因此使用引用計數也是會有大量的問題出現的,而想Python,PHP之類的語言也不是只使用引用計數器就完成GC的,也是配合了一些其他的機制來完成的!

 1.2.基於可達性分析

可達性分析是Java所採取的方案,可達性分析是通過一些額外的線程,定期針對整個內存空間的對象進行掃描,有一些起始位置(GCRoots),然後就類似於深度優先遍歷一樣(可以想像成是一棵樹),把可以訪問到的對象都標記一邊(帶有標記的對象就是可達的對象),而沒有被標記的對象,就是不可達的對象,也就是垃圾,應該被釋放掉!

這裡的GCRoots(從這些位置開始遍歷):

  • 棧上的局部變量;
  • 常量池中的引用指向的對象;
  • 方法區中的靜態成員指向的對象;

因此可達性分析的優點就是解決了引用計數的缺點:空間利用率低,循環引用;而可達性分析的缺點也很明顯:系統開銷大,遍歷一次可能比較慢~

因此找垃圾也是很簡單的,核心就是確認這個對象未來是否還會使用,看還有沒有引用指向它,應不應該釋放掉!

 2.釋放垃圾

既然已經明確了什麼是垃圾,接下來就要回收垃圾了,而回收垃圾有三種基本策略,下面來看一下!

 2.1.標記-請除

這裡的標記就是可達性分析的過程,而清除就是釋放內存,假設上面是一塊內存,而打鉤的區域代表是垃圾,此時如果直接釋放掉,雖然內存是還給系統了,但是釋放掉的內存是離散的,不是連續的,而這樣帶來的問題就是"內存碎片",空閒的內存可能會有很多,假設加起來一共是1G,而此時想要申請500MB的空間,按理是可以申請到的,但在這裡是有可能申請失敗的(因為要申請的500MB是連續的內存,每次申請的內存都是連續的內存空間,而這裡的1G可能是多個碎片加起來的),因此這樣的問題其實是非常影響程序運行的

 2.2.複製算法

由於上面的標記-清除策略可能會帶來內存碎片的問題,因此引入了複製算法來解決這一問題

上面是一塊內存,複製算法的策略就是內存使用一半,丟一半,不全部使用,在使用的一般裡面把不是垃圾的拷貝到另一半(這個拷貝是JVM內部處理好的,不用糾結),然後把前面使用的全部內存都釋放掉,這樣內存碎片的問題就迎刃而解了!

所以複製算法就有兩個很大的問題:

  • 內存空間利用率低(只使用了一般的內存);
  • 如果要保留的對象多,要釋放的對象少,那麼複製的開銷就很大;

 2.3.標記-整理

這又是針對複製算法,再進一步做出改進!

標記整理的策略就是將不是垃圾的內存整理到一起,然後釋放掉後面的全部內存,就類似於順序表刪除中間元素的操作一樣,有一個搬運的過程!

這個方案空間利用率是高了,但是仍然沒有辦法解決複製/搬運元素開銷大的問題!

上述的三種方案,雖然能夠解決問題,但是都有各自的缺陷,因此實際上JVM中的實現,會把多種方案結合起來使用,也就是"分代回收"!!!

 2.4分代回收

這裡的分代就是針對對象來進行分類(根據對象的"年齡"進行分類,而這裡的年齡表示一個對象熬過一輪GC的掃描,就稱"長了一歲"),而針對不同年齡的對象,就採取不同的方案!!!

這就是整個分代回收的過程!

 3.垃圾回收器

上面的找垃圾和釋放垃圾都只是算法的思想,並不是真正的落地實現的過程,而真正實現上述算法模塊的是"垃圾回收器",下面來介紹一些具體的垃圾回收器:

 3.1.Serial收集器和Serial Old收集器

Serial收集器是給新生代提供的垃圾回收器,Serial Old收集器是給老年代提供的垃圾回收器,這兩個收集器是串行收集的,而且在進行垃圾的掃描和釋放的時候,業務線程要停止工作,所以這樣的方式掃描的滿,釋放的也慢,而且也能產生嚴重的STW!

 3.2.ParNew收集器,Parallel Scavenge收集器和Parallel Old收集器

ParNew收集器,Parallel Scavenge收集器都是提供給新生代的,Parallel Scavenge收集器比起ParNew收集器加了一些參數,可以控制STW的時間,就是多了一些更強的功能,Parallel Old收集器是提供給老年代的,這三個收集器都是並行收集的,就是引入了多線程的方式來解決掃描垃圾和釋放垃圾的功能!

上面的這幾個回收器都是歷史遺留下來的,也就是比較老的垃圾回收方式,另外再介紹兩個更新的垃圾回收器!

 3.3.CMS收集器

CMS收集器設計的比較巧妙,其設計的初衷是儘可能讓STW時間短,Java8使用的正是CMS收集器,下面簡單介紹一下CMS收集器的過程:

  1. 初始標記:速度很快,會引起短暫的STW(只是找到GCRoots);
  2. 並發標記:速度很快,但是可以和業務線程並發執行,不會產生STW;
  3. 重新標記:在2業務代碼可能會影響並發標記的結果(業務線程在執行,就有可能產生新的垃圾),因此這一步就是針對2的結果進行微調,雖然會引起STW,但只是微調,速度很快;
    上面三步都是基於可達性分析!
  4. 回收內存:也是和業務線程並發執行,不會產生STW,這是基於標記整理;

 3.4.G1收集器

G1收集器是唯一一款全區域的垃圾回收器,從Java11開始使用的就是G1收集器,這個收集器是把整個內存,分成了很多小的區域Region,給這些Region進行了不同的標記,有的Region放新生代對象,有的Region放老年代對象,然後掃描的時候,就一次掃描若干個Region(不追求一輪GC就掃描完,需要分多次掃描),這樣對於業務代碼的影響也是最小的,

這兩個新的收集器的核心思想就是化整為零,G1當下可以優化到讓STW停頓時間小於1ms,這是完全可以接收的!上面就是關於JVM的一些學習了,這裡的收集器主要還是了解為主,主要還是上面的垃圾回收思想很重要!!!

關鍵字: