不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化

cxyYIDd 2021-09-20 01:17:58 阅读数:943

不是吧 工作 都不 不知道 不知

在面向對象編程中,會頻繁的使用到動態分派,如果每次動態分派都要重新在類的方法元數據中搜索合適的目標有可能會影響到執行效率。為了提高性能,JVM 采用在類的方法區建立一個虛方法錶(virtual method table),使用索引錶來代替查找。非虛方法不會出現在錶中。

每個類中都有一個虛方法錶,錶中存放著各個方法的實際入口。

虛方法錶會在類加載的連接階段被創建並開始初始化,類的變量初始值准備完成之後,JVM 會把該類的方法錶也初始化完畢。

2.4.4. 方法返回地址(return address)

用來存放調用該方法的 PC 寄存器的值。

一個方法的結束,有兩種方式

  • 正常執行完成
  • 出現未處理的异常,非正常退出

無論通過哪種方式退出,在方法退出後都返回到該方法被調用的比特置。方法正常退出時,調用者的 PC 計數器的值作為返回地址,即調用該方法的指令的下一條指令的地址。而通過异常退出的,返回地址是要通過异常錶來確定的,棧幀中一般不會保存這部分信息。

當一個方法開始執行後,只有兩種方式可以退出這個方法:

  1. 執行引擎遇到任意一個方法返回的字節碼指令,會有返回值傳遞給上層的方法調用者,簡稱正常完成出口一個方法的正常調用完成之後究竟需要使用哪一個返回指令還需要根據方法返回值的實際數據類型而定在字節碼指令中,返回指令包含 ireturn(當返回值是boolean、byte、char、short和int類型時使用)、lreturn、freturn、dreturn以及areturn,另外還有一個 return 指令供聲明為 void 的方法、實例初始化方法、類和接口的初始化方法使用。
  2. 在方法執行的過程中遇到了异常,並且這個异常沒有在方法內進行處理,也就是只要在本方法的异常錶中沒有搜索到匹配的异常處理器,就會導致方法退出。簡稱异常完成出口方法執行過程中拋出异常時的异常處理,存儲在一個异常處理錶,方便在發生异常的時候找到處理异常的代碼。

本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢複上層方法的局部變量錶、操作數棧、將返回值壓入調用者棧幀的操作數棧、設置PC寄存器值等,讓調用者方法繼續執行下去。

正常完成出口和异常完成出口的區別在於:通過异常完成出口退出的不會給他的上層調用者產生任何的返回值

2.4.5. 附加信息

棧幀中還允許攜帶與 Java 虛擬機實現相關的一些附加信息。例如,對程序調試提供支持的信息,但這些信息取决於具體的虛擬機實現。


三、本地方法棧

3.1 本地方法接口

簡單的講,一個 Native Method 就是一個 Java 調用非 Java 代碼的接口。我們知道的 Unsafe 類就有很多本地方法。

為什麼要使用本地方法(Native Method)?

Java 使用起來非常方便,然而有些層次的任務用 Java 實現起來也不容易,或者我們對程序的效率很在意時,問題就來了

  • 與 Java 環境外交互:有時 Java 應用需要與 Java 外面的環境交互,這就是本地方法存在的原因。
  • 與操作系統交互:JVM 支持 Java 語言本身和運行時庫,但是有時仍需要依賴一些底層系統的支持。通過本地方法,我們可以實現用 Java 與實現了 jre 的底層系統交互, JVM 的一些部分就是 C 語言寫的。
  • Sun’s Java:Sun的解釋器就是C實現的,這使得它能像一些普通的C一樣與外部交互。jre大部分都是用 Java 實現的,它也通過一些本地方法與外界交互。比如,類java.lang.Thread 的 setPriority() 的方法是用Java 實現的,但它實現調用的是該類的本地方法 setPrioruty(),該方法是C實現的,並被植入 JVM 內部。

3.2 本地方法棧(Native Method Stack)

  • Java 虛擬機棧用於管理 Java 方法的調用,而本地方法棧用於管理本地方法的調用

  • 本地方法棧也是線程私有的

  • 允許線程固定或者可動態擴展的內存大小

  • 如果線程請求分配的棧容量超過本地方法棧允許的最大容量,Java 虛擬機將會拋出一個 StackOverflowError 异常

  • 如果本地方法棧可以動態擴展,並且在嘗試擴展的時候無法申請到足够的內存,或者在創建新的線程時沒有足够的內存去創建對應的本地方法棧,那麼 Java虛擬機將會拋出一個OutofMemoryError异常

  • 本地方法是使用C語言實現的

  • 它的具體做法是 Mative Method Stack 中登記native方法,在 Execution Engine 執行時加載本地方法庫當某個線程調用一個本地方法時,它就進入了一個全新的並且不再受虛擬機限制的世界。它和虛擬機擁有同樣的權限。

  • 本地方法可以通過本地方法接口來訪問虛擬機內部的運行時數據區,它甚至可以直接使用本地處理器中的寄存器,直接從本地內存的堆中分配任意數量的內存

  • 並不是所有 JVM 都支持本地方法。因為 Java 虛擬機規範並沒有明確要求本地方法棧的使用語言、具體實現方式、數據結構等。如果 JVM 產品不打算支持 native 方法,也可以無需實現本地方法棧

  • 在 Hotspot JVM 中,直接將本地方棧和虛擬機棧合二為一


棧是運行時的單比特,而堆是存儲的單比特

棧解决程序的運行問題,即程序如何執行,或者說如何處理數據。堆解决的是數據存儲的問題,即數據怎麼放、放在哪。

四、堆內存

4.1 內存劃分

對於大多數應用,Java 堆是 Java 虛擬機管理的內存中最大的一塊,被所有線程共享。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數據都在這裏分配內存。

為了進行高效的垃圾回收,虛擬機把堆內存邏輯上劃分成三塊區域(分代的唯一理由就是優化 GC 性能):

  • 新生帶(年輕代):新對象和沒達到一定年齡的對象都在新生代
  • 老年代(養老區):被長時間使用的對象,老年代的內存空間應該要比年輕代更大
  • 元空間(JDK1.8之前叫永久代):像一些方法中的操作臨時對象等,JDK1.8之前是占用JVM內存,JDK1.8之後直接使用物理內存

不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化_程序員

JDK7

Java 虛擬機規範規定,Java 堆可以是處於物理上不連續的內存空間中,只要邏輯上是連續的即可,像磁盤空間一樣。實現時,既可以是固定大小,也可以是可擴展的,主流虛擬機都是可擴展的(通過 -Xmx 和 -Xms 控制),如果堆中沒有完成實例分配,並且堆無法再擴展時,就會拋出 OutOfMemoryError 异常。

年輕代 (Young Generation)

年輕代是所有新對象創建的地方。當填充年輕代時,執行垃圾收集。這種垃圾收集稱為Minor GC。年輕一代被分為三個部分——伊甸園(Eden Memory)和兩個幸存區(Survivor Memory,被稱為from/to或s0/s1),默認比例是8:1:1

  • 大多數新創建的對象都比特於 Eden 內存空間中
  • 當 Eden 空間被對象填充時,執行Minor GC,並將所有幸存者對象移動到一個幸存者空間中
  • Minor GC 檢查幸存者對象,並將它們移動到另一個幸存者空間。所以每次,一個幸存者空間總是空的
  • 經過多次 GC 循環後存活下來的對象被移動到老年代。通常,這是通過設置年輕一代對象的年齡閾值來實現的,然後他們才有資格提昇到老一代

老年代(Old Generation)

舊的一代內存包含那些經過許多輪小型 GC 後仍然存活的對象。通常,垃圾收集是在老年代內存滿時執行的。老年代垃圾收集稱為主GC,通常需要更長的時間。

大對象直接進入老年代(大對象是指需要大量連續內存空間的對象)。這樣做的目的是避免在Eden區和兩個Survivor區之間發生大量的內存拷貝

不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化_程序員_02

元空間

不管是 JDK8 之前的永久代,還是 JDK8 及以後的元空間,都可以看作是 Java 虛擬機規範中方法區的實現。

雖然 Java 虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆),目的應該是與 Java 堆區分開。

所以元空間放在後邊的方法區再說。

4.2 設置堆內存大小和 OOM

Java 堆用於存儲 Java 對象實例,那麼堆的大小在 JVM 啟動的時候就確定了,我們可以通過 -Xmx 和 -Xms 來設定

  • -Xms 用來錶示堆的起始內存,等價於 -XX:InitialHeapSize
  • -Xmx 用來錶示堆的最大內存,等價於 -XX:MaxHeapSize

如果堆的內存大小超過 -Xmx 設定的最大內存, 就會拋出 OutOfMemoryError 异常。

我們通常會將 -Xmx 和 -Xms 兩個參數配置為相同的值,其目的是為了能够在垃圾回收機制清理完堆區後不再需要重新分隔計算堆的大小,從而提高性能

  • 默認情况下,初始堆內存大小為:電腦內存大小/64
  • 默認情况下,最大堆內存大小為:電腦內存大小/4

可以通過代碼獲取到我們的設置值,當然也可以模擬 OOM:

public static void main(String[] args) {
//返回 JVM 堆大小
long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
//返回 JVM 堆的最大內存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
System.out.println("-Xms : "+initalMemory + "M");
System.out.println("-Xmx : "+maxMemory + "M");
System.out.println("系統內存大小:" + initalMemory * 64 / 1024 + "G");
System.out.println("系統內存大小:" + maxMemory * 4 / 1024 + "G");
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

查看 JVM 堆內存分配

  1. 在默認不配置 JVM 堆內存大小的情况下,JVM 根據默認值來配置當前內存大小
  2. 默認情况下新生代和老年代的比例是 1:2,可以通過 –XX:NewRatio 來配置
  3. 新生代中的?Eden:From Survivor:To Survivor?的比例是?8:1:1,可以通過-XX:SurvivorRatio來配置
  4. 若在JDK 7中開啟了 -XX:+UseAdaptiveSizePolicy,JVM 會動態調整 JVM 堆中各個區域的大小以及進入老年代的年齡此時 –XX:NewRatio 和 -XX:SurvivorRatio 將會失效,而 JDK 8 是默認開啟-XX:+UseAdaptiveSizePolicy在 JDK 8中,不要隨意關閉-XX:+UseAdaptiveSizePolicy,除非對堆內存的劃分有明確的規劃

每次 GC 後都會重新計算 Eden、From Survivor、To Survivor 的大小

計算依據是GC過程中統計的GC時間吞吐量內存占用量

java -XX:+PrintFlagsFinal -version | grep HeapSize
uintx ErgoHeapSizeLimit = 0 {product}
uintx HeapSizePerGCThread = 87241520 {product}
uintx InitialHeapSize := 134217728 {product}
uintx LargePageHeapSizeThreshold = 134217728 {product}
uintx MaxHeapSize := 2147483648 {product}
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
$ jmap -heap 進程號

  • 1.

4.3 對象在堆中的生命周期

  1. 在 JVM 內存模型的堆中,堆被劃分為新生代和老年代
  2. 新生代又被進一步劃分為Eden區Survivor區,Survivor區由From SurvivorTo Survivor組成
  3. 當創建一個對象時,對象會被優先分配到新生代的Eden區
  4. 此時 JVM 會給對象定義一個對象年輕計數器(-XX:MaxTenuringThreshold)
  5. 當 Eden 空間不足時,JVM 將執行新生代的垃圾回收(Minor GC)
  6. JVM 會把存活的對象轉移到 Survivor 中,並且對象年齡 +1
  7. 對象在 Survivor 中同樣也會經曆 Minor GC,每經曆一次 Minor GC,對象年齡都會+1
  8. 如果分配的對象超過了-XX:PetenureSizeThreshold,對象會直接被分配到老年代

4.4 對象的分配過程

為對象分配內存是一件非常嚴謹和複雜的任務,JVM 的設計者們不僅需要考慮內存如何分配、在哪裏分配等問題,並且由於內存分配算法和內存回收算法密切相關,所以還需要考慮 GC 執行完內存回收後是否會在內存空間中產生內存碎片。

  1. new 的對象先放在伊甸園區,此區有大小限制
  2. 當伊甸園的空間填滿時,程序又需要創建對象,JVM 的垃圾回收器將對伊甸園區進行垃圾回收(Minor GC),將伊甸園區中的不再被其他對象所引用的對象進行銷毀。再加載新的對象放到伊甸園區
  3. 然後將伊甸園中的剩餘對象移動到幸存者 0 區
  4. 如果再次觸發垃圾回收,此時上次幸存下來的放到幸存者 0 區,如果沒有回收,就會放到幸存者 1 區
  5. 如果再次經曆垃圾回收,此時會重新放回幸存者 0 區,接著再去幸存者 1 區
  6. 什麼時候才會去養老區呢? 默認是 15 次回收標記
  7. 在養老區,相對悠閑。當養老區內存不足時,再次觸發 Major GC,進行養老區的內存清理
  8. 若養老區執行了 Major GC 之後發現依然無法進行對象的保存,就會產生 OOM 异常

4.5 GC 垃圾回收簡介

Minor GC、Major GC、Full GC

JVM 在進行 GC 時,並非每次都對堆內存(新生代、老年代;方法區)區域一起回收的,大部分時候回收的都是指新生代。

針對 HotSpot VM 的實現,它裏面的 GC 按照回收區域又分為兩大類:部分收集(Partial GC),整堆收集(Full GC)

  • 部分收集:不是完整收集整個 Java 堆的垃圾收集。其中又分為:

  • 目前只有 G1 GC 會有這種行為

  • 目前,只有 CMS GC 會有單獨收集老年代的行為

  • 很多時候 Major GC 會和 Full GC 混合使用,需要具體分辨是老年代回收還是整堆回收

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集

  • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集

  • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集

  • 整堆收集(Full GC):收集整個 Java 堆和方法區的垃圾

4.6 TLAB

什麼是 TLAB (Thread Local Allocation Buffer)?

  • 從內存模型而不是垃圾回收的角度,對 Eden 區域繼續進行劃分,JVM 為每個線程分配了一個私有緩存區域,它包含在 Eden 空間內
  • 多線程同時分配內存時,使用 TLAB 可以避免一系列的非線程安全問題,同時還能提昇內存分配的吞吐量,因此我們可以將這種內存分配方式稱為快速分配策略
  • OpenJDK 衍生出來的 JVM 大都提供了 TLAB 設計

為什麼要有 TLAB ?

  • 堆區是線程共享的,任何線程都可以訪問到堆區中的共享數據
  • 由於對象實例的創建在 JVM 中非常頻繁,因此在並發環境下從堆區中劃分內存空間是線程不安全的
  • 為避免多個線程操作同一地址,需要使用加鎖等機制,進而影響分配速度

盡管不是所有的對象實例都能够在 TLAB 中成功分配內存,但 JVM 確實是將 TLAB 作為內存分配的首選。

在程序中,可以通過 -XX:UseTLAB 設置是否開啟 TLAB 空間。

默認情况下,TLAB 空間的內存非常小,僅占有整個 Eden 空間的 1%,我們可以通過 -XX:TLABWasteTargetPercent 設置 TLAB 空間所占用 Eden 空間的百分比大小。

一旦對象在 TLAB 空間分配內存失敗時,JVM 就會嘗試著通過使用加鎖機制確保數據操作的原子性,從而直接在 Eden 空間中分配內存。

4.7 堆是分配對象存儲的唯一選擇嗎

隨著 JIT 編譯期的發展和逃逸分析技術的逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那麼“絕對”了。 ——《深入理解 Java 虛擬機》

逃逸分析

逃逸分析(Escape Analysis)是目前 Java 虛擬機中比較前沿的優化技術。這是一種可以有效减少 Java 程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。通過逃逸分析,Java Hotspot 編譯器能够分析出一個新的對象的引用的使用範圍從而决定是否要將這個對象分配到堆上。

逃逸分析的基本行為就是分析對象動態作用域:

  • 當一個對象在方法中被定義後,對象只在方法內部使用,則認為沒有發生逃逸。
  • 當一個對象在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為調用參數傳遞到其他地方中,稱為方法逃逸。

例如:

public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

StringBuffer sb是一個方法內部變量,上述代碼中直接將sb返回,這樣這個 StringBuffer 有可能被其他方法所改變,這樣它的作用域就不只是在方法內部,雖然它是一個局部變量,稱其逃逸到了方法外部。甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱為線程逃逸。

上述代碼如果想要 StringBuffer sb不逃出方法,可以這樣寫:

public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

不直接返回 StringBuffer,那麼 StringBuffer 將不會逃逸出方法。

參數設置:

  • 在 JDK 6u23版本之後,HotSpot 中默認就已經開啟了逃逸分析
  • 如果使用較早版本,可以通過-XX"+DoEscapeAnalysis顯式開啟

開發中使用局部變量,就不要在方法外定義。

使用逃逸分析,編譯器可以對代碼做優化:

  • 棧上分配:將堆分配轉化為棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配
  • 同步省略:如果一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操作可以不考慮同步
  • 分離對象或標量替換:有的對象可能不需要作為一個連續的內存結構存在也可以被訪問到,那麼對象的部分(或全部)可以不存儲在內存,而存儲在 CPU 寄存器

JIT 編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成後,繼續在調用棧內執行,最後線程結束,棧空間被回收,局部變量對象也被回收。這樣就無需進行垃圾回收了。

常見棧上分配的場景:成員變量賦值、方法返回值、實例引用傳遞

代碼優化之同步省略(消除)
  • 線程同步的代價是相當高的,同步的後果是降低並發性和性能
  • 在動態編譯同步塊的時候,JIT 編譯器可以借助逃逸分析來判斷同步塊所使用的鎖對象是否能够被一個線程訪問而沒有被發布到其他線程。如果沒有,那麼 JIT 編譯器在編譯這個同步塊的時候就會取消對這個代碼的同步。這樣就能大大提高並發性和性能。這個取消同步的過程就叫做同步省略,也叫鎖消除。
public void keep() {
Object keeper = new Object();
synchronized(keeper) {
System.out.println(keeper);
}
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

如上代碼,代碼中對 keeper 這個對象進行加鎖,但是 keeper 對象的生命周期只在 keep()方法中,並不會被其他線程所訪問到,所以在 JIT編譯階段就會被優化掉。優化成:

public void keep() {
Object keeper = new Object();
System.out.println(keeper);
}

  • 1.
  • 2.
  • 3.
  • 4.
代碼優化之標量替換

標量(Scalar)是指一個無法再分解成更小的數據的數據。Java 中的原始數據類型就是標量。

相對的,那些的還可以分解的數據叫做聚合量(Aggregate),Java 中的對象就是聚合量,因為其還可以分解成其他聚合量和標量。

在 JIT 階段,通過逃逸分析確定該對象不會被外部訪問,並且對象可以被進一步分解時,JVM不會創建該對象,而會將該對象成員變量分解若幹個被這個方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上分配空間。這個過程就是標量替換

通過 -XX:+EliminateAllocations 可以開啟標量替換,-XX:+PrintEliminateAllocations 查看標量替換情况。

public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

以上代碼中,point 對象並沒有逃逸出alloc()方法,並且 point 對象是可以拆解成標量的。那麼,JIT 就不會直接創建 Point 對象,而是直接使用兩個標量 int x ,int y 來替代 Point 對象。

private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
代碼優化之棧上分配

我們通過 JVM 內存分配可以知道 JAVA 中的對象都是在堆上進行分配,當對象沒有被引用的時候,需要依靠 GC 進行回收內存,如果對象數量較多的時候,會給 GC 帶來較大壓力,也間接影響了應用的性能。為了减少臨時對象在堆內分配的數量,JVM 通過逃逸分析確定該對象不會被外部訪問。那就通過標量替換將該對象分解在棧上分配內存,這樣該對象所占用的內存空間就可以隨棧幀出棧而銷毀,就减輕了垃圾回收的壓力。

總結:

關於逃逸分析的論文在1999年就已經發錶了,但直到JDK 1.6才有實現,而且這項技術到如今也並不是十分成熟的。

其根本原因就是無法保證逃逸分析的性能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。

一個極端的例子,就是經過逃逸分析之後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。

雖然這項技術並不十分成熟,但是他也是即時編譯器優化技術中一個十分重要的手段。


五、方法區

最後總結

ActiveMQ+Kafka+RabbitMQ學習筆記PDF

不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化_程序員_03

  • RabbitMQ實戰指南

不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化_Java_04

  • 手寫RocketMQ筆記

不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化_程序員_05

  • 手寫“Kafka筆記”

不是吧工作3年你都不知道這份超詳細JVM內存結構,怎麼漲薪,MySQL數據庫優化_Java_06

關於分布式,限流+緩存+緩存,這三大技術(包含:ZooKeeper+Nginx+MongoDB+memcached+Redis+ActiveMQ+Kafka+RabbitMQ)等等。這些相關的面試也好,還有手寫以及學習的筆記PDF,都是啃透分布式技術必不可少的寶藏。以上的每一個專題每一個小分類都有相關的介紹,並且小編也已經將其整理成PDF啦

 CodeChina開源項目:【一線大廠Java面試題解析+核心總結學習筆記+最新講解視頻】

版权声明:本文为[cxyYIDd]所创,转载请带上原文链接,感谢。 https://gsmany.com/2021/09/20210920011758098R.html