Java虛擬機器體繫結構,java虛擬機器
JAVA虛擬機器的生命週期
一個運行時的Java虛擬機器執行個體的天職是:負責運行一個java程式。當啟動一個Java程式時,一個虛擬機器執行個體也就誕生了。當該程式關閉退出,這個虛擬機器執行個體也就隨之消亡。如果同一台電腦上同時運行三個Java程式,將得到三個Java虛擬機器執行個體。每個Java程式都運行於它自己的Java虛擬機器執行個體中。
Java虛擬機器執行個體通過調用某個初始類的main()方法來運行一個Java程式。而這個main()方法必須是共有的(public)、靜態(static)、傳回值為void,並且接受一個字串數組作為參數。任何擁有這樣一個main()方法的類都可以作為Java程式啟動並執行起點。
public class Test { public static void main(String[] args) { // TODO Auto-generated method stub System.out.println("Hello World"); }}
在上面的例子中,Java程式初始類中的main()方法,將作為該程式初始線程的起點,任何其他的線程都是由這個初始線程啟動的。
在Java虛擬機器內部有兩種線程:守護線程和非守護線程。守護線程通常是由虛擬機器自己使用的,比如執行垃圾收集任務的線程。但是,Java程式也可以把它建立的任何線程標記為守護線程。而Java程式中的初始線程——就是開始於main()的那個,是非守護線程。
只要還有任何非守護線程在運行,那麼這個Java程式也在繼續運行。當該程式中所有的非守護線程都終止時,虛擬機器執行個體將自動結束。假若安全管理器允許,程式本身也能夠通過調用Runtime類或者System類的exit()方法來退出。
JAVA虛擬機器的體繫結構
是JAVA虛擬機器的結構圖,每個Java虛擬機器都有一個類裝載子系統,它根據給定的全限定名來裝入類型(類或介面)。同樣,每個Java虛擬機器都有一個執行引擎,它負責執行那些包含在被裝載類的方法中的指令。
當JAVA虛擬機器運行一個程式時,它需要記憶體來儲存許多東西,例如:位元組碼、從已裝載的class檔案中得到的其他資訊、程式建立的對象、傳遞給方法的參數,傳回值、局部變數等等。Java虛擬機器把這些東西都組織到幾個“運行時資料區”中,以便於管理。
某些運行時資料區是由程式中所有線程共用的,還有一些則只能由一個線程擁有。每個Java虛擬機器執行個體都有一個方法區以及一個堆,它們是由該虛擬機器執行個體中所有的線程共用的。當虛擬機器裝載一個class檔案時,它會從這個class檔案包含的位元據中解析類型資訊。然後把這些類型資訊放到方法區中。當程式運行時,虛擬機器會把所有該程式在運行時建立的對象都放到堆中。
當每一個新線程被建立時,它都將得到它自己的PC寄存器(程式計數器)以及一個Java棧,如果線程正在執行的是一個Java方法(非本地方法),那麼PC寄存器的值將總是指向下一條將被執行的指令,而它的Java棧則總是儲存該線程中Java方法調用的狀態——包括它的局部變數,被調用時傳進來的參數、傳回值,以及運算的中間結果等等。而本地方法調用的狀態,則是以某種依賴於具體實現的方法儲存在本地方法棧中,也可能是在寄存器或者其他某些與特定實現相關的記憶體區中。
Java棧是由許多棧幀(stack frame)組成的,一個棧幀包含一個Java方法調用的狀態。當線程調用一個Java方法時,虛擬機器壓入一個新的棧幀到該線程的Java棧中,當該方法返回時,這個棧幀被從Java棧中彈出並拋棄。
Java虛擬機器沒有寄存器,其指令集使用Java棧來儲存中間資料。這樣設計的原因是為了保持Java虛擬機器的指令集盡量緊湊、同時也便於Java虛擬機器在那些只有很少通用寄存器的平台上實現。另外,Java虛擬機器這種基於棧的體繫結構,也有助於運行時某些虛擬機器實現的動態編譯器和即時編譯器的代碼最佳化。
描繪了Java虛擬機器為每一個線程建立的記憶體區,這些記憶體地區是私人的,任何線程都不能訪問另一個線程的PC寄存器或者Java棧。
展示了一個虛擬機器執行個體的快照,它有三個線程正在執行。線程1和線程2都正在執行Java方法,而線程3則正在執行一個本地方法。
Java棧都是向下生長的,而棧頂都顯示在圖的底部。當前正在執行的方法的棧幀則以淺色表示,對於一個正在運行Java方法的線程而言,它的PC寄存器總是指向下一條將被執行的指令。比如線程1和線程2都是以淺色顯示的,由於線程3當前正在執行一個本地方法,因此,它的PC寄存器——以深色顯示的那個,其值是不確定的。
資料類型
Java虛擬機器是通過某些資料類型來執行計算的,資料類型可以分為兩種:基本類型和參考型別,基本類型的變數持有原始值,而參考型別的變數持有引用值。
Java語言中的所有基本類型同樣也都是Java虛擬機器中的基本類型。但是boolean有點特別,雖然Java虛擬機器也把boolean看做基本類型,但是指令集對boolean只有很有限的支援,當編譯器把Java原始碼編譯為位元組碼時,它會用int或者byte來表示boolean。在Java虛擬機器中,false是由整數零來表示的,所有非零整數都表示true,涉及boolean值的操作則會使用int。另外,boolean數組是當做byte數組來訪問的,但是在“堆”區,它也可以被表示為位域。
Java虛擬機器還有一個只在內部使用的基本類型:returnAddress,Java程式員不能使用這個類型,這個基本類型被用來實現Java程式中的finally子句。該類型是jsr, ret以及jsr_w指令需要使用到的,它的值是JVM指令的作業碼的指標。returnAddress類型不是簡單意義上的數值,不屬於任何一種基本類型,並且它的值是不能被運行中的程式所修改的。
Java虛擬機器的參考型別被統稱為“引用(reference)”,有三種參考型別:類類型、介面類型、以及數群組類型,它們的值都是對動態建立對象的引用。類類型的值是對類執行個體的引用;數群組類型的值是對數組對象的引用,在Java虛擬機器中,數組是個真正的對象;而介面類型的值,則是對實現了該介面的某個類執行個體的引用。還有一種特殊的引用值是null,它表示該引用變數沒有引用任何對象。
JAVA中方法參數的引用傳遞
java中參數的傳遞有兩種,分別是按值傳遞和按引用傳遞。按值傳遞不必多說,下面就說一下按引用傳遞。
“當一個對象被當作參數傳遞到一個方法”,這就是所謂的按引用傳遞。
public class User { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }
public class Test { public void set(User user){ user.setName("hello world"); } public static void main(String[] args) { Test test = new Test(); User user = new User(); test.set(user); System.out.println(user.getName()); }}
上面代碼的輸出結果是“hello world”,這不必多說,那如果將set方法改為如下,結果會是多少呢?
public void set(User user){ user.setName("hello world"); user = new User(); user.setName("change"); }
答案依然是“hello world”,下面就讓我們來分析一下如上代碼。
首先
User user = new User();
是在堆中建立了一個對象,並在棧中建立了一個引用,此引用指向該對象,如:
test.set(user);
是將引用user作為參數傳遞到set方法,注意:這裡傳遞的並不是引用本身,而是一個引用的拷貝。也就是說這時有兩個引用(引用和引用的拷貝)同時指向堆中的對象,如:
user.setName("hello world");
在set()方法中,“user引用的拷貝”操作堆中的User對象,給name屬性設定字串"hello world"。如:
user = new User();
在set()方法中,又建立了一個User對象,並將“user引用的拷貝”指向這個在堆中新建立的對象,如:
user.setName("change");
在set()方法中,“user引用的拷貝”操作的是堆中新建立的User對象。
set()方法執行完畢,目光再回到mian()方法
System.out.println(user.getName());
因為之前,"user引用的拷貝"已經將堆中的User對象的name屬性設定為了"hello world",所以當main()方法中的user調用getName()時,列印的結果就是"hello world"。如:
類裝載子系統
在JAVA虛擬機器中,負責尋找並裝載類型的那部分被稱為類裝載子系統。
JAVA虛擬機器有兩種類裝載器:啟動類裝載器和使用者自訂類裝載器。前者是JAVA虛擬機器實現的一部分,後者則是Java程式的一部分。由不同的類裝載器裝載的類將被放在虛擬機器內部的不同命名空間中。
類裝載器子系統涉及Java虛擬機器的其他幾個組成部分,以及幾個來自java.lang庫的類。比如,使用者自訂的類裝載器是普通的Java對象,它的類必須派生自java.lang.ClassLoader類。ClassLoader中定義的方法為程式提供了訪問類裝載器機制的介面。此外,對於每一個被裝載的類型,JAVA虛擬機器都會為它建立一個java.lang.Class類的執行個體來代表該類型。和所有其他對象一樣,使用者自訂的類裝載器以及Class類的執行個體都放在記憶體中的堆區,而裝載的類型資訊則都位於方法區。
類裝載器子系統除了要定位和匯入二進位class檔案外,還必須負責驗證被匯入類的正確性,為類變數分配並初始化記憶體,以及協助解析符號引用。這些動作必須嚴格按以下順序進行:
(1)裝載——尋找並裝載類型的位元據。 (2)串連——指向驗證、準備、以及解析(可選)。
● 驗證 確保被匯入類型的正確性。
● 準備 為類變數分配記憶體,並將其初始化為預設值。
● 解析 把類型中的符號引用轉換為直接引用。
(3)初始化——把類變數初始化為正確初始值。
每個JAVA虛擬機器實現都必須有一個啟動類裝載器,它知道怎麼裝載受信任的類。
每個類裝載器都有自己的命名空間,其中維護著由它裝載的類型。所以一個Java程式可以多次裝載具有同一個全限定名的多個類型。這樣一個類型的全限定名就不足以確定在一個Java虛擬機器中的唯一性。因此,當多個類裝載器都裝載了同名的類型時,為了惟一地標識該類型,還要在類型名稱前加上裝載該類型(指出它所位於的命名空間)的類裝載器標識。
方法區
在Java虛擬機器中,關於被裝載類型的資訊儲存在一個邏輯上被稱為方法區的記憶體中。當虛擬機器裝載某個類型時,它使用類裝載器定位相應的class檔案,然後讀入這個class檔案——1個線性位元據流,然後它傳輸到虛擬機器中,緊接著虛擬機器提取其中的類型資訊,並將這些資訊儲存到方法區。該類型中的類(靜態)變數同樣也是儲存在方法區中。
JAVA虛擬機器在內部如何儲存類型資訊,這是由具體實現的設計者來決定的。
當虛擬機器運行Java程式時,它會尋找使用儲存在方法區中的類型資訊。由於所有線程都共用方法區,因此它們對方法區資料的訪問必須被設計為是安全執行緒的。比如,假設同時有兩個線程都企圖訪問一個名為Lava的類,而這個類還沒有被裝入虛擬機器,那麼,這時只應該有一個線程去裝載它,而另一個線程則只能等待。
對於每個裝載的類型,虛擬機器都會在方法區中儲存以下類型資訊:
● 這個類型的全限定名
● 這個類型的直接超類的全限定名(除非這個類型是java.lang.Object,它沒有超類)
● 這個類型是類類型還是介面類型
● 這個類型的存取修飾詞(public、abstract或final的某個子集)
● 任何直接超介面的全限定名的有序列表
除了上面列出的基本類型資訊外,虛擬機器還得為每個被裝載的類型儲存以下資訊:
● 該類型的常量池
● 欄位資訊
● 方法資訊
● 除了常量以外的所有類(靜態)變數
● 一個到類ClassLoader的引用
● 一個到Class類的引用
常量池
虛擬機器必須為每個被裝載的類型維護一個常量池。常量池就是該類型所用常量的一個有序集合,包括直接常量和對其他類型、欄位和方法的符號引用。池中的資料項目就像數組一樣是通過索引訪問的。因為常量池儲存了相應類型所用到的所有類型、欄位和方法的符號引用,所以它在Java程式的動態串連中起著核心的作用。
欄位資訊
對於類型中聲明的每一個欄位。方法區中必須儲存下面的資訊。除此之外,這些欄位在類或者介面中的聲明順序也必須儲存。
○ 欄位名
○ 欄位的類型
○ 欄位的修飾符(public、private、protected、static、final、volatile、transient的某個子集)
方法資訊
對於類型中聲明的每一個方法,方法區中必須儲存下面的資訊。和欄位一樣,這些方法在類或者介面中的聲明順序也必須儲存。
○ 方法名
○ 方法的傳回型別(或void)
○ 方法參數的數量和類型(按聲明順序)
○ 方法的修飾符(public、private、protected、static、final、synchronized、native、abstract的某個子集)
除了上面清單中列出的條目之外,如果某個方法不是抽象的和本地的,它還必須儲存下列資訊:
○ 方法的位元組碼(bytecodes)
○ 運算元棧和該方法的棧幀中的局部變數區的大小
○ 異常表
類(靜態)變數
類變數是由所有類執行個體共用的,但是即使沒有任何類執行個體,它也可以被訪問。這些變數只與類有關——而非類的執行個體,因此它們總是作為類型資訊的一部分而儲存在方法區。除了在類中聲明的編譯時間常量外,虛擬機器在使用某個類之前,必須在方法區中為這些類變數分配空間。
而編譯時間常量(就是那些用final聲明以及用編譯時間已知的值初始化的類變數)則和一般的類變數處理方式不同,每個使用編譯時間常量的類型都會複製它的所有常量到自己的常量池中,或嵌入到它的位元組碼流中。作為常量池或位元組碼流的一部分,編譯時間常量儲存在方法區中——就和一般的類變數一樣。但是當一般的類變數作為聲明它們的類型的一部分資料面儲存的時候,編譯時間常量作為使用它們的類型的一部分而儲存。
指向ClassLoader類的引用
每個類型被裝載的時候,虛擬機器必須跟蹤它是由啟動類裝載器還是由使用者自訂類裝載器裝載的。如果是使用者自訂類裝載器裝載的,那麼虛擬機器必須在類型資訊中儲存對該裝載器的引用。這是作為方法表中的類型資料的一部分儲存的。
虛擬機器會在動態串連期間使用這個資訊。當某個類型引用另一個類型的時候,虛擬機器會請求裝載發起參考型別的類裝載器來裝載被引用的類型。這個動態串連的過程,對於虛擬機器不連續命名空間的方式也是至關重要的。為了能夠正確地執行動態串連以及維護多個命名空間,虛擬機器需要在方法表中得知每個類都是由哪個類裝載器裝載的。
指向Class類的引用
對於每一個被裝載的類型(不管是類還是介面),虛擬機器都會相應地為它建立一個java.lang.Class類的執行個體,而且虛擬機器還必須以某種方式把這個執行個體和儲存在方法區中的類型資料關聯起來。
在Java程式中,你可以得到並使用指向Class對象的引用。Class類中的一個靜態方法可以讓使用者得到任何已裝載的類的Class執行個體的引用。
public static Class<?> forName(String className)
比如,如果調用forName("java.lang.Object"),那麼將得到一個代表java.lang.Object的Class對象的引用。可以使用forName()來得到代表任何包中任何類型的Class對象的引用,只要這個類型可以被(或者已經被)裝載到當前命名空間中。如果虛擬機器無法把請求的類型裝載到當前命名空間,那麼會拋出ClassNotFoundException異常。
另一個得到Class對象引用的方法是,可以調用任何對象引用的getClass()方法。這個方法被來自Object類本身的所有對象繼承:
public final native Class<?> getClass();
比如,如果你有一個到java.lang.Integer類的對象的引用,那麼你只需簡單地調用Integer對象引用的getClass()方法,就可以得到表示java.lang.Integer類的Class對象。
方法區使用執行個體
為了展示虛擬機器如何使用方法區中的資訊,下面來舉例說明:
class Lava { private int speed = 5; void flow(){ }}
public class Volcano { public static void main(String[] args){ Lava lava = new Lava(); lava.flow(); }}
不同的虛擬機器實現可能會用完全不同的方法來操作,下面描述的只是其中一種可能——但並不是僅有的一種。
要運行Volcano程式,首先得以某種“依賴於實現的”方式告訴虛擬機器“Volcano”這個名字。之後,虛擬機器將找到並讀入相應的class檔案“Volcano.class”,然後它會從匯入的class檔案裡的位元據中提取類型資訊並放到方法區中。通過執行儲存在方法區中的位元組碼,虛擬機器開始執行main()方法,在執行時,它會一直持有指向當前類(Volcano類)的常量池(方法區中的一個資料結構)的指標。
注意:虛擬機器開始執行Volcano類中main()方法的位元組碼的時候,儘管Lava類還沒被裝載,但是和大多數(也許所有)虛擬機器實現一樣,它不會等到把程式中用到的所有類都裝載後才開始運行。恰好相反,它只會需要時才裝載相應的類。
main()的第一條指令告知虛擬機器為列在常量池第一項的類分配足夠的記憶體。所以虛擬機器使用指向Volcano常量池的指標找到第一項,發現它是一個對Lava類的符號引用,然後它就檢查方法區,看Lava類是否已經被載入了。
這個符號引用僅僅是一個給出了類Lava的全限定名“Lava”的字串。為了能讓虛擬機器儘可能快地從一個名稱找到類,虛擬機器的設計者應當選擇最佳的資料結構和演算法。
當虛擬機器發現還沒有裝載過名為“Lava”的類時,它就開始尋找並裝載檔案“Lava.class”,並把從讀入的位元據中提取的類型資訊放在方法區中。
緊接著,虛擬機器以一個直接指向方法區Lava類資料的指標來替換常量池第一項(就是那個字串“Lava”),以後就可以用這個指標來快速地訪問Lava類了。這個替換過程稱為常量池解析,即把常量池中的符號引用替換為直接引用。
終於,虛擬機器準備為一個新的Lava對象分配記憶體。此時它又需要方法區中的資訊。還記得剛剛放到Volcano類常量池第一項的指標嗎?現在虛擬機器用它來訪問Lava類型資訊,找出其中記錄的這樣一條資訊:一個Lava對象需要分配多少堆空間。
JAVA虛擬機器總能夠通過儲存與方法區的類型資訊來確定一個對象需要多少記憶體,當JAVA虛擬機器確定了一個Lava對象的大小後,它就在堆上分配這麼大的空間,並把這個對象執行個體的變數speed初始化為預設初始值0。
當把新產生的Lava對象的引用壓到棧中,main()方法的第一條指令也完成了。接下來的指令通過這個引用調用Java代碼(該代碼把speed變數初始化為正確初始值5)。另一條指令將用這個引用調用Lava對象引用的flow()方法。
堆
Java程式在運行時建立的所有類執行個體或數組都放在同一個堆中。而一個JAVA虛擬機器執行個體中只存在一個堆空間,因此所有線程都將共用這個堆。又由於一個Java程式獨佔一個JAVA虛擬機器執行個體,因而每個Java程式都有它自己的堆空間——它們不會彼此幹擾。但是同一個Java程式的多個線程卻共用著同一個堆空間,在這種情況下,就得考慮多線程訪問對象(堆資料)的同步問題了。
JAVA虛擬機器有一條在堆中分配新對象的指令,卻沒有釋放記憶體的指令,正如你無法用Java代碼區明確釋放一個對象一樣。虛擬機器自己負責決定如何以及何時釋放不再被啟動並執行程式引用的對象所佔據的記憶體。通常,虛擬機器把這個任務交給垃圾收集器。
數組的內部表示
在Java中,數組是真正的對象。和其他對象一樣,數組總是儲存在堆中。同樣,數組也擁有一個與它們的類相關聯的Class執行個體,所有具有相同維度和類型的數組都是同一個類的執行個體,而不管數組的長度(多維陣列每一維的長度)是多少。例如一個包含3個int整數的數組和一個包含300個整數的數組擁有同一個類。數組的長度只與執行個體資料有關。
數組類的名稱由兩部分組成:每一維用一個方括弧“[”表示,用字元或字串表示元素類型。比如,元素類型為int整數的、一維數組的類名為“[I”,元素類型為byte的三維數組為“[[[B”,元素類型為Object的二維數組為“[[Ljava/lang/Object”。
多維陣列被表示為數組的數組。比如,int類型的二維數組,將表示為一個一維數組,其中的每一個元素是一個一維int數組的引用,如:
在堆中的每個數組對象還必須儲存的資料時數組的長度、數組資料,以及某些指向數組的類資料的引用。虛擬機器必須能夠通過一個數組對象的引用得到此數組的長度,通過索引訪問其元素(期間要檢查數組邊界是否越界),調用所有數組的直接超類Object聲明的方法等等。
程式計數器
對於一個運行中的Java程式而言,其中的每一個線程都有它自己的PC(程式計數器)寄存器,它是在該線程啟動時建立的,PC寄存器的大小是一個字長,因此它既能夠持有一個本地指標,也能夠持有一個returnAddress。當線程執行某個Java方法時,PC寄存器的內容總是下一條將被執行指令的“地址”,這裡的“地址”可以是一個本地指標,也可以是在方法位元組碼中相對於該方法起始指令的位移量。如果該線程正在執行一個本地方法,那麼此時PC寄存器的值是“undefined”。
Java棧
每當啟動一個新線程時,Java虛擬機器都會為它分配一個Java棧。Java棧以幀為單位儲存線程的運行狀態。虛擬機器只會直接對Java棧執行兩種操作:以幀為單位的壓棧和出棧。
某個線程正在執行的方法被稱為該線程的當前方法,當前方法使用的棧幀稱為當前幀,當前方法所屬的類稱為當前類,當前類的常量池稱為當前常量池。線上程執行一個方法時,它會跟蹤當前類和當前常量池。此外,當虛擬機器遇到棧內操作指令時,它對當前幀內資料執行操作。
每當線程調用一個Java方法時,虛擬機器都會在該線程的Java棧中壓入一個新幀。而這個新幀自然就成為了當前幀。在執行這個方法時,它使用這個幀來儲存參數、局部變數、中間運算結果等資料。
Java方法可以以兩種方式完成。一種通過return返回的,稱為正常返回;一種是通過拋出異常而異常終止的。不管以哪種方式返回,虛擬機器都會將當前幀彈出Java棧然後釋放掉,這樣上一個方法的幀就成為當前幀了。
Java幀上的所有資料都是此線程私人的。任何線程都不能訪問另一個線程的棧資料,因此我們不需要考慮多線程情況下棧資料的訪問同步問題。當一個線程調用一個方法時,方法的的局部變數儲存在調用線程Java棧的幀中。只有一個線程能總是訪問那些局部變數,即調用方法的線程。
本地方法棧
前面提到的所有運行時資料區都是Java虛擬機器規範中明確定義的,除此之外,對於一個運行中的Java程式而言,它還可能會用到一些跟本地方法相關的資料區。當某個線程調用一個本地方法時,它就進入了一個全新的並且不再受虛擬機器限制的世界。本地方法可以通過本地方法介面來訪問虛擬機器的運行時資料區,但不止如此,它還可以做任何它想做的事情。
本地方法本質上時依賴於實現的,虛擬機器實現的設計者們可以自由地決定使用怎樣的機制來讓Java程式調用本地方法。
任何本地方法介面都會使用某種本地方法棧。當線程調用Java方法時,虛擬機器會建立一個新的棧幀並壓入Java棧。然而當它調用的是本地方法時,虛擬機器會保持Java棧不變,不再線上程的Java棧中壓入新的幀,虛擬機器只是簡單地動態串連並直接調用指定的本地方法。
如果某個虛擬機器實現的本地方法介面是使用C串連模型的話,那麼它的本地方法棧就是C棧。當C程式調用一個C函數時,其棧操作都是確定的。傳遞給該函數的參數以某個確定的順序壓入棧,它的傳回值也以確定的方式傳回調用者。同樣,這就是虛擬機器實現中本地方法棧的行為。
很可能本地方法介面需要回調Java虛擬機器中的Java方法,在這種情況下,該線程會儲存本地方法棧的狀態並進入到另一個Java棧。
描繪了這樣一個情景,就是當一個線程調用一個本地方法時,本地方法又回調虛擬機器中的另一個Java方法。這幅圖展示了JAVA虛擬機器內部線程啟動並執行全景。一個線程可能在整個生命週期中都執行Java方法,操作它的Java棧;或者它可能毫無障礙地在Java棧和本地方法棧之間跳轉。
該線程首先調用了兩個Java方法,而第二個Java方法又調用了一個本地方法,這樣導致虛擬機器使用了一個本地方法棧。假設這是一個C語言棧,其間有兩個C函數,第一個C函數被第二個Java方法當做本地方法調用,而這個C函數又調用了第二個C函數。之後第二個C函數又通過本地方法介面回調了一個Java方法(第三個Java方法),最終這個Java方法又調用了一個Java方法(它成為圖中的當前方法)。
學習Java的同學注意了!!!
學習過程中遇到什麼問題或者想擷取學習資源的話,歡迎加入Java學習交流群:299541275 我們一起學Java!