JVM學習第一篇思考:一個Java程式碼是怎麼運行起來的-上篇

  • 2021 年 6 月 24 日
  • 筆記

JVM學習第一篇思考:一個Java程式碼是怎麼運行起來的-上篇

作為一個使用Java語言開發的程式設計師,我們都知道,要想運行Java程式至少需要安裝JRE(安裝JDK也沒問題)。我們也知道我們Java程式設計師編寫的程式程式碼文件是*.java的,而JRE運行的是*.class的文件。所以,我們需要將java文件編譯成class文件然後才可以。那麼,你有沒有想過,一個java文件是怎麼運行起來的呢?中間都經歷了哪些環節呢?我們都知道JVM是Java虛擬機,那麼,有沒有思考過JVM的記憶體模型是什麼呢?我們new出來的對象,聲明不同類型的變數又是存放在JVM哪個位置呢?

本文是凱哥(凱哥Java:kaigejava)學習JVM系列教程第一篇。歡迎大家一起學習

本文目標:

通過本文學習後,希望大家對JVM類載入過程有個了解。

編輯

上面程式很簡單。那麼,有沒有想過上面程式碼怎麼運行的呢?

選中main方法,然後ruan as…,編譯後,運行輸出。這個流程我想大家都很熟悉的。那麼對應的流程應該是什麼樣的呢?如下圖:

編輯

在Run的時候,先將.java文件編譯成.class文件。然後,在通過類載入器,將class文件載入到JVM中,然後在運行。輸出結果。

那麼為什麼編譯好的AppTest.class可以載入到JVM中呢?可以被JVM識別呢?

一個java類的一生都會經歷哪些步驟呢?

如下圖:

編輯

在我們run的時候,AppTest.java類先經過編譯後,編譯成了AppTest.class文件。JVM把class文件載入到記憶體後需要經歷:載入-驗證-準備-解析-初始化-使用-卸載這七個階段。

第一個問題:JVM在什麼時候會載入一個類呢?起始也就是在什麼時候會載入.class位元組碼文件到JVM的記憶體中去呢?上面我們寫的,當我們run的時候,才執行的。所以答案就很明確了,就是在你程式碼中需要使用到這個類的時候,就去載入的。

具體每一步:

載入

載入階段是將class文件從磁碟或者jar等讀到JVM記憶體中,並為其創建一個Class對象。任何一個類被使用時候系統都會為其創建一個Class對象的。

載入的同時將載入的這些數據轉換成方法區中運行時數據(運行時候數據區:靜態變數、靜態程式碼塊、常量池等),作為方法區數據的訪問入口

這個很好理解的。我要想使用你,需要先得到你,是不是。結合上面我們自己寫的AppTest類。在此階段應該是:

編輯

擴展:

在類載入階段JVM都做了什麼?獲取class文件方式都有哪些?

1.1:在類載入的時候JVM完成了以下:

  • 根據類的全路徑(全限定名)來獲取到該類的二進位位元組流

(我們知道,在電腦的世界中,什麼都是二進位形式存在的)

  • 將載入的位元組流中所代表的靜態存儲結構轉換成方法區運行時數據結構

(這個話具體怎麼理解,有哪位能留言教教凱哥)

  • 將載入的對象在記憶體中生成一個代表了該類的jvaa.lang.Class對象。這個Class對象作為載入進來對象在方法區各種數據的訪問入口。

(要想在記憶體中訪問AppTest這個位元組碼類中的屬性或者方法的時候,可以在記憶體中方法區找到對應的Class對象。這個Class就是入口)

關於方法區在後面文章中,凱哥會詳細講講。

1.2:獲取class文件的方式

  • 可以直接從本地的磁碟文件獲取
  • 可以從忘了下載class文件
  • 可以從ZIP或者jar等文件中
  • Java源文件動態編譯的class文件

在一個類運行生命周期內,類載入(載入獲取類的二進位位元組流)階段,是可控性最強的階段。因為在這個階段,我們程式設計師可以使用系統提供的類載入去來載入完成,也可以使用自己自定義的類載入來完成.(類載入器在後面文章詳細講講)

1.3:類載入的具體時機,在文章最後,凱哥會列出來。

驗證

將上一步載入到記憶體中的Class對象進行校驗。確保載入的類的資訊符合JVM的規範。確保沒有安全方面的問題。

這個很好理解了,我要使用你,得到你好,我要檢查你是不是符合標準的。如果不合法,就沒法使用。

在此階段如下圖:

編輯

擴展:驗證都驗證哪些方面?

  • 文件給是驗證:驗證載入的位元組流是否 符合Class文件格式的規範。

例如:是否已咖啡babe開頭(0xCAFEBABE),主次版八號是否在當前JVM的處理範圍內等等

比如你在JDK1.8下編譯的class文件,放到JDK1.6版本的JVM中,有可能就運行不了的

  • 元數據驗證:對位元組碼描述的資訊進行語義分析。保證描述資訊符合Java語言規範。

例如:這個類如果有父類,是否實現了父類的抽象方法等.

  • 位元組碼驗證
  • 符號引用驗證:確保解析動作是正確的。

例如:通過符號引用能找到對應點的類和方法。比如com.kaigejava.Person.getAge()

在比如:符號引用中類、屬性、方法的訪問性是否能被當前類訪問等等。

準備

準備階段,就是給載入進來且驗證通過的Class類分配空間的。這裡是給類裡面的變數(也就是static修飾的變數)分配空間的,同時給變數一個默認的初始值。

如下圖:

編輯

在準備階段時候static int m 被分配了4個位元組的空間,且分配了默認初始值為0(注意默認初始值是0).

PS:int類型佔用4個位元組。int的默認值是0.如果是對象的話。默認為null

在此階段AppTest.class如下圖:

編輯

該階段需要注意:

  • 在此階段值只對static修飾的靜態變數進行記憶體分配,賦默認值的(比如0、0L、0D、null、false等);
  • 對於final修飾的靜態字面值常量直接賦初始值(注意:這裡的初始值並不是默認值。如果不是字面值靜態常量,那麼會和靜態變數一樣賦默認值)

比如:final int x = 1;這個在此階段就給賦值的就是1而不是0

解析

解析是將常量池中的符號引用替換為直接引用(記憶體地址)的過程。

在此階段AppTest類如下圖:

 

擴展:

符號引用:

就是一組符號來描述目標的。可以是任何字面量。這個屬於編譯原理方面的東西。

比如:可以是一個類的完整類名字(com.kaigejava.Person)、欄位的名稱和描述符、方法的名稱和描述等。

直接引用:

就是直接指向目標的指針、相對偏移量或者一個間接定位到目標的句柄。比如指向方法區中某一個類的一個指針。

例如:在AppTest這個類中,有個static的靜態變數p。這個靜態變數p又是一個自定義的類型(com.kaigejava.Person),那麼在經過解析階段後,這個靜態的p變數將是一個指針(比如0xddff1),這個指針指向該類在方法區的記憶體地址值。具體見凱哥後續文章,將會詳細講解。

編輯

初始化

到了此階段(初始化階段),JVM才開始真正的執行類中定義的Java程式碼。

當進行到初始化階段的時候,就是執行類的構造器<clinit>()方法的過程。

  • <clinit>()方法是由編譯器自動收集類中的所有類變數賦值動作和靜態語句。
  • <clinit>()方法與類的構造器不同。此方法不需要顯示的調用類的父構造器(如果類有父類的話),虛擬機會保證在子類的<clinit>()方法執行前,父類的<clinit>()方法已經執行完畢。因此JVM中第一個被執行的<clinit>()方法的類肯定是java.lang.Object(因為Java中所有類的父類是Object類)
  • 因為父類的<clinit>()方法先執行,所以也就意味著父類中定義的static語句塊要優先於子類的變數賦值操作
  • 如果一個類中沒有靜態變數或者是靜態的語句塊的時候,編譯器可以不為這個類創建<clinit>()方法的
  • 虛擬機會保證一個類的的<clinit>()方法在多執行緒環境中被正確的加鎖和同步。多執行緒訪問,一個訪問,其他在訪問的話會被阻塞。

使用

類實例化也初始化成功之後,這個類就是一個正常的類了。我們可以正常使用了。

卸載

當遇到以下幾種情況的時候,類會被卸載

  • 執行了System.exi()方法的時候
  • 程式正常執行結束
  • 程式在執行過程中遇到了異常或者是錯誤而異常終止
  • 由於作業系統出現錯誤導致Java虛擬機進程終止

今天問題:

現在我們知道了一個Java類是怎麼運行起來的了。那麼請看下面程式碼,運行後輸出的順序是什麼?

public class JvmDemo {

public static void main(String[] args) {

Son son = new Son();

FatherInterface fatherInterface = new SonInterFace();

fatherInterface.say(“凱哥Java”);

}

}

class Father{

static String st1 = “父類Father中的靜態變數”;

String str2 =”父類Father中的非靜態變數”;

static {

System.out.println(“當前執行了父類Father的靜態程式碼塊中的方法”);

}

{

System.out.println(“執行了父類Father類中的非靜態程式碼塊”);

}

public Father(){

System.out.println(“執行了父類Father中的構造方法了”);

}

}

class Son{

static String str1 = “子類Son中的靜態變數”;

String str2 = “子類Son中的非靜態變數”;

static{

System.out.println(“執行了子類son中的靜態程式碼塊”);

}

{

System.out.println(“執行了子類Son中的非靜態程式碼塊”);

}

public Son(){

System.out.println(“執行了子類son中的構造器方法”);

}

}

interface FatherInterface{

static String str1 = “介面父類FatherInterface中的靜態變數”;

void say(String say);

}

class SonInterFace implements FatherInterface{

static String str1 = “子類SonInterFace中的靜態變數”;

String str2 = “子類SonInterFace中的非靜態變數”;

static{

System.out.println(“執行了子類SonInterFace中的靜態程式碼塊”);

}

{

System.out.println(“執行了子類SonInterFace中的非靜態程式碼塊”);

}

public SonInterFace(){

System.out.println(“執行了子類SonInterFace中的構造器方法”);

}

@Override

public void say(String say) {

System.out.println(FatherInterface.str1+”–say:”+say);

}

}

編輯

編輯

運行後答案將在下一篇文章中揭曉。

下一篇預告:

因為這是第一篇,所以只是大致講解了下一個類怎麼載入過程。在下一篇文章中,咱們來講解在載入階段使用到類載入器、父類委派機制等、類在什麼時候會被初始化等?。歡迎繼續學習。