1. 程式人生 > >圖解Java 垃圾回收機制

圖解Java 垃圾回收機制

摘要:

  Java技術體系中所提倡的 自動記憶體管理 最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體 以及 回收分配給物件的記憶體,而且這兩個問題針對的記憶體區域就是Java記憶體模型中的 堆區。關於物件分配記憶體問題,筆者的博文《JVM 記憶體模型概述》已經闡述了 如何劃分可用空間及其涉及到的執行緒安全問題,本文將結合垃圾回收策略進一步給出記憶體分配規則。垃圾回收機制的引入可以有效的防止記憶體洩露、保證記憶體的有效使用,也大大解放了Java程式設計師的雙手,使得他們在編寫程式的時候不再需要考慮記憶體管理。本文著重介紹了判斷一個物件是否可以被回收的兩種經典演算法,並詳述了四種典型的垃圾回收演算法的基本思想及其直接應用——垃圾收集器,最後結合記憶體回收策略介紹了記憶體分配規則。

  本文涉及到的所有圖例均由筆者整理所得,其中部分由筆者親自繪製,部分借鑑於網際網路並在其基礎上修改而成。若涉及版權,請留言評論或直接聯絡筆者,聯絡方式見左側欄。

友情提示:

  為了更好地瞭解Java的垃圾回收機制,筆者建議讀者先要對JVM記憶體模型有一個整體的瞭解和把握。鑑於筆者在博文《JVM 記憶體模型概述》中已經深入介紹了JVM記憶體模型,此不贅述。

  本文內容是基於 JDK 1.6 的,不同版本虛擬機器之間也許會有些許差異,但不影響我們對JVM垃圾回收機制的整體把握和了解。

一、垃圾回收機制的意義

  在筆者的上一篇博文《JVM 記憶體模型概述》中提到,JVM 記憶體模型一共包括三個部分:堆 ( Java程式碼可及的 Java堆 和 JVM自身使用的方法區)、棧 ( 服務Java方法的虛擬機器棧 和 服務Native方法的本地方法棧 ) 和 保證程式在多執行緒環境下能夠連續執行的程式計數器。特別地,我們當時就提到Java堆是進行垃圾回收的主要區域,故其也被稱為GC堆;而方法區也有一個不太嚴謹的表述,就是永久代。總的來說,堆 (包括Java堆 和 方法區)是 垃圾回收的主要物件,特別是Java堆。

  實際上,Java技術體系中所提倡的 自動記憶體管理 最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體 以及回收分配給物件的記憶體,而且這兩個問題針對的記憶體區域就是Java記憶體模型中的堆區。關於物件分配記憶體問題,筆者的博文《JVM 記憶體模型概述》已經闡述了 如何劃分可用空間及其涉及到的執行緒安全問題,本文將結合垃圾回收策略進一步給出 記憶體分配規則。另外,我們知道垃圾回收機制是Java語言一個顯著的特點,其可以有效的防止記憶體洩露、保證記憶體的有效使用,從而使得Java程式設計師在編寫程式的時候不再需要考慮記憶體管理問題。Java 垃圾回收機制要考慮的問題很複雜,本文闡述了其三個核心問題,包括:

  • 那些記憶體需要回收?(物件是否可以被回收的兩種經典演算法: 引用計數法 和 可達性分析演算法)

  • 什麼時候回收? (堆的新生代、老年代、永久代的垃圾回收時機,MinorGC 和 FullGC)

  • 如何回收?(三種經典垃圾回收演算法(標記清除演算法、複製演算法、標記整理演算法)及分代收集演算法 和 七種垃圾收集器)

  在探討Java垃圾回收機制之前,我們首先應該記住一個單詞:Stop-the-World。Stop-the-world意味著 JVM由於要執行GC而停止了應用程式的執行,並且這種情形會在任何一種GC演算法中發生。當Stop-the-world發生時,除了GC所需的執行緒以外,所有執行緒都處於等待狀態直到GC任務完成。事實上,GC優化很多時候就是指減少Stop-the-world發生的時間,從而使系統具有 高吞吐低停頓 的特點。

  Ps: 記憶體洩露是指該記憶體空間使用完畢之後未回收,在不涉及複雜資料結構的一般情況下,Java 的記憶體洩露表現為一個記憶體物件的生命週期超出了程式需要它的時間長度。

二. 如何確定一個物件是否可以被回收?

1、 引用計數演算法:判斷物件的引用數量

  引用計數演算法是通過判斷物件的引用數量來決定物件是否可以被回收。

  引用計數演算法是垃圾收集器中的早期策略。在這種方法中,堆中的每個物件例項都有一個引用計數。當一個物件被建立時,且將該物件例項分配給一個引用變數,該物件例項的引用計數設定為 1。當任何其它變數被賦值為這個物件的引用時,物件例項的引用計數加 1(a = b,則b引用的物件例項的計數器加 1),但當一個物件例項的某個引用超過了生命週期或者被設定為一個新值時,物件例項的引用計數減 1。特別地,當一個物件例項被垃圾收集時,它引用的任何物件例項的引用計數器均減 1。任何引用計數為0的物件例項可以被當作垃圾收集。

  引用計數收集器可以很快的執行,並且交織在程式執行中,對程式需要不被長時間打斷的實時環境比較有利,但其很難解決物件之間相互迴圈引用的問題。如下面的程式和示意圖所示,物件objA和objB之間的引用計數永遠不可能為 0,那麼這兩個物件就永遠不能被回收。

            這裡寫圖片描述

  public class ReferenceCountingGC {
  
        public Object instance = null;

        public static void testGC(){

            ReferenceCountingGC objA = new ReferenceCountingGC ();
            ReferenceCountingGC objB = new ReferenceCountingGC ();

            // 物件之間相互迴圈引用,物件objA和objB之間的引用計數永遠不可能為 0
            objB.instance = objA;
            objA.instance = objB;

            objA = null;
            objB = null;

            System.gc();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
save_snippets.pngsave_snippets.png
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

  上述程式碼最後面兩句將objA和objB賦值為null,也就是說objA和objB指向的物件已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數器都不為 0,那麼垃圾收集器就永遠不會回收它們。

2、 可達性分析演算法:判斷物件的引用鏈是否可達

  可達性分析演算法是通過判斷物件的引用鏈是否可達來決定物件是否可以被回收。

  可達性分析演算法是從離散數學中的圖論引入的,程式把所有的引用關係看作一張圖,通過一系列的名為 “GC Roots” 的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain)。當一個物件到 GC Roots 沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個物件不可達)時,則證明此物件是不可用的,如下圖所示。在Java中,可作為 GC Root 的物件包括以下幾種:

  • 虛擬機器棧(棧幀中的區域性變量表)中引用的物件;

  • 方法區中類靜態屬性引用的物件;

  • 方法區中常量引用的物件;

  • 本地方法棧中Native方法引用的物件;

            可達性分析演算法示意圖.jpg-101.8kB

三. 垃圾收集演算法

1、標記清除演算法

  標記-清除演算法分為標記和清除兩個階段。該演算法首先從根集合進行掃描,對存活的物件物件標記,標記完畢後,再掃描整個空間中未被標記的物件並進行回收,如下圖所示。

            可達性分析演算法.jpg-26.7kB

  標記-清除演算法的主要不足有兩個:

  • 效率問題:標記和清除兩個過程的效率都不高;

  • 空間問題:標記-清除演算法不需要進行物件的移動,並且僅對不存活的物件進行處理,因此標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

                標記-清除演算法-10.6kB

2、複製演算法

  複製演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這種演算法適用於物件存活率低的場景,比如新生代。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。該演算法示意圖如下所示:

           複製演算法-5.7kB

  事實上,現在商用的虛擬機器都採用這種演算法來回收新生代。因為研究發現,新生代中的物件每次回收都基本上只有10%左右的物件存活,所以需要複製的物件很少,效率還不錯。正如在博文《JVM 記憶體模型概述》中介紹的那樣,實踐中會將新生代記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間 (如下圖所示),每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90% ( 80%+10% ),只有10% 的記憶體會被“浪費”。

                heap.bmp-174kB

3、標記整理演算法

  複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。標記整理演算法的標記過程類似標記清除演算法,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體,類似於磁碟整理的過程,該垃圾回收演算法適用於物件存活率高的場景(老年代),其作用原理如下圖所示。

            標記-整理演算法1.jpg-33.6kB

  標記整理演算法與標記清除演算法最顯著的區別是:標記清除演算法不進行物件的移動,並且僅對不存活的物件進行處理;而標記整理演算法會將所有的存活物件移動到一端,並對不存活物件進行處理,因此其不會產生記憶體碎片。標記整理演算法的作用示意圖如下:

            標記整理演算法示意圖.png-14.2kB

4、分代收集演算法

  對於一個大型的系統,當建立的物件和方法變數比較多時,堆記憶體中的物件也會比較多,如果逐一分析物件是否該回收,那麼勢必造成效率低下。分代收集演算法是基於這樣一個事實:不同的物件的生命週期(存活情況)是不一樣的,而不同生命週期的物件位於堆中不同的區域,因此對堆記憶體不同區域採用不同的策略進行回收可以提高 JVM 的執行效率。當代商用虛擬機器使用的都是分代收集演算法:新生代物件存活率低,就採用複製演算法;老年代存活率高,就用標記清除演算法或者標記整理演算法。Java堆記憶體一般可以分為新生代、老年代和永久代三個模組,如下圖所示:

               分代收集演算法總.jpg-35.5kB

1). 新生代(Young Generation)

  新生代的目標就是儘可能快速的收集掉那些生命週期短的物件,一般情況下,所有新生成的物件首先都是放在新生代的。新生代記憶體按照 8:1:1 的比例分為一個eden區和兩個survivor(survivor0,survivor1)區,大部分物件在Eden區中生成。在進行垃圾回收時,先將eden區存活物件複製到survivor0區,然後清空eden區,當這個survivor0區也滿了時,則將eden區和survivor0區存活物件複製到survivor1區,然後清空eden和這個survivor0區,此時survivor0區是空的,然後交換survivor0區和survivor1區的角色(即下次垃圾回收時會掃描Eden區和survivor1區),即保持survivor0區為空,如此往復。特別地,當survivor1區也不足以存放eden區和survivor0區的存活物件時,就將存活物件直接存放到老年代。如果老年代也滿了,就會觸發一次FullGC,也就是新生代、老年代都進行回收。注意,新生代發生的GC也叫做MinorGC,MinorGC發生頻率比較高,不一定等 Eden區滿了才觸發。

2). 老年代(Old Generation)

  老年代存放的都是一些生命週期較長的物件,就像上面所敘述的那樣,在新生代中經歷了N次垃圾回收後仍然存活的物件就會被放到老年代中。此外,老年代的記憶體也比新生代大很多(大概比例是1:2),當老年代滿時會觸發Major GC(Full GC),老年代物件存活時間比較長,因此FullGC發生的頻率比較低。

3). 永久代(Permanent Generation)

  永久代主要用於存放靜態檔案,如Java類、方法等。永久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者呼叫一些class,例如使用反射、動態代理、CGLib等bytecode框架時,在這種時候需要設定一個比較大的永久代空間來存放這些執行過程中新增的類。

5、小結

           這裡寫圖片描述

  由於物件進行了分代處理,因此垃圾回收區域、時間也不一樣。垃圾回收有兩種型別,Minor GC 和 Full GC。

  • Minor GC:對新生代進行回收,不會影響到年老代。因為新生代的 Java 物件大多死亡頻繁,所以 Minor GC 非常頻繁,一般在這裡使用速度快、效率高的演算法,使垃圾回收能儘快完成。

  • Full GC:也叫 Major GC,對整個堆進行回收,包括新生代、老年代和永久代。由於Full GC需要對整個堆進行回收,所以比Minor GC要慢,因此應該儘可能減少Full GC的次數,導致Full GC的原因包括:老年代被寫滿、永久代(Perm)被寫滿和System.gc()被顯式呼叫等。

四. 垃圾收集器

  如果說垃圾收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。下圖展示了7種作用於不同分代的收集器,其中用於回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,還有用於回收整個Java堆的G1收集器。不同收集器之間的連線表示它們可以搭配使用。

              垃圾收集器.jpg-64.2kB

  • Serial收集器(複製演算法): 新生代單執行緒收集器,標記和清理都是單執行緒,優點是簡單高效;

  • Serial Old收集器 (標記-整理演算法): 老年代單執行緒收集器,Serial收集器的老年代版本;

  • ParNew收集器 (複製演算法): 新生代收並行集器,實際上是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現;

  • Parallel Scavenge收集器 (複製演算法): 新生代並行收集器追求高吞吐量,高效利用 CPU。吞吐量 = 使用者執行緒時間/(使用者執行緒時間+GC執行緒時間),高吞吐量可以高效率的利用CPU時間,儘快完成程式的運算任務,適合後臺應用等對互動相應要求不高的場景;

  • Parallel Old收集器 (標記-整理演算法) 老年代並行收集器吞吐量優先,Parallel Scavenge收集器的老年代版本;

  • CMS(Concurrent Mark Sweep)收集器(標記-清除演算法)老年代並行收集器以獲取最短回收停頓時間為目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。

  • G1(Garbage First)收集器 (標記-整理演算法)Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記-整理”演算法實現,也就是說不會產生記憶體碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。

五. 記憶體分配與回收策略

  Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體 以及 回收分配給物件的記憶體。一般而言,物件主要分配在新生代的Eden區上,如果啟動了本地執行緒分配快取(TLAB),將按執行緒優先在TLAB上分配。少數情況下也可能直接分配在老年代中。總的來說,記憶體分配規則並不是一層不變的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器中與記憶體相關的引數的設定。

  1) 物件優先在Eden分配,當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次MinorGC。現在的商業虛擬機器一般都採用複製演算法來回收新生代,將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。 當進行垃圾回收時,將Eden和Survivor中還存活的物件一次性地複製到另外一塊Survivor空間上,最後處理掉Eden和剛才的Survivor空間。(HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1)當Survivor空間不夠用時,需要依賴老年代進行分配擔保。

  2) 大物件直接進入老年代。所謂的大物件是指,需要大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。

  3) 長期存活的物件將進入老年代。當物件在新生代中經歷過一定次數(預設為15)的Minor GC後,就會被晉升到老年代中。

  4) 動態物件年齡判定。為了更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

  需要注意的是,Java的垃圾回收機制是Java虛擬機器提供的能力,用於在空閒時間以不定時的方式動態回收無任何引用的物件佔據的記憶體空間。也就是說,垃圾收集器回收的是無任何引用的物件佔據的記憶體空間而不是物件本身。

六. Java中的記憶體洩露問題

  雖然Java擁有垃圾回收機制,但同樣會出現記憶體洩露問題,比如下面提到的幾種情況:

  (1). 諸如 HashMap、Vector 等集合類的靜態使用最容易出現記憶體洩露,因為這些靜態變數的生命週期和應用程式一致,所有的物件Object也不能被釋放,因為他們也將一直被Vector等應用著。


private static Vector v = new Vector(); 

public void test(Vector v){

    for (int i = 1; i<100; i++) { 
        Object o = new Object(); 
        v.add(o); 
        o = null; 
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
save_snippets.pngsave_snippets.png
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

  在這個例子中,虛擬機器棧中儲存者 Vector 物件的引用 v 和 Object 物件的引用 o 。在 for 迴圈中,我們不斷的生成新的物件,然後將其新增到 Vector 物件中,之後將 o 引用置空。問題是雖然我們將 o 引用置空,但當發生垃圾回收時,我們建立的 Object 物件也不能夠被回收。因為垃圾回收在跟蹤程式碼棧中的引用時會發現 v 引用,而繼續往下跟蹤就會發現 v 引用指向的記憶體空間中又存在指向 Object 物件的引用。也就是說,儘管o 引用已經被置空,但是 Object 物件仍然存在其他的引用,是可以被訪問到的,所以 GC 無法將其釋放掉。如果在此迴圈之後, Object 物件對程式已經沒有任何作用,那麼我們就認為此 Java 程式發生了記憶體洩漏。

  (2). 各種資源連線包括資料庫連線、網路連線、IO連線等沒有顯式呼叫close關閉,不被GC回收導致記憶體洩露。

  (3). 監聽器的使用,在釋放物件的同時沒有相應刪除監聽器的時候也可能導致記憶體洩露。

七. 知識點補充

1、引用

 1). 引用概述

  無論是通過引用計數演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件的引用鏈是否可達,判定物件是否存活都與“引用”有關。在JDK 1.2之前,Java中的引用的定義很傳統:如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個物件在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的物件就顯得無能為力。我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。很多系統的快取功能都符合這樣的應用場景。

  為此,在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種,這四種引用強度依次逐漸減弱。

 2). 引用的種類及其定義
  
  強引用就是指在程式程式碼之中普遍存在的,類似“Object obj = new Object()”這類引用。 只要強引用還存在,垃圾收集器就永遠不會回收掉被引用的物件。

  軟引用用來描述一些還有用,但並非必需的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中並進行第二次回收。如果這次回收還是沒有足夠的記憶體,才會丟擲記憶體溢位異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。

  弱引用也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。在JDK 1.2之後,提供了WeakReference類來實現弱引用。

  虛引用是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是希望能在這個物件被收集器回收時收到一個系統通知。在JDK 1.2之後,提供了PhantomReference類來實現虛引用。

2、方法區的回收

  方法區的記憶體回收目標主要是針對 常量池的回收 對型別的解除安裝。回收廢棄常量與回收Java堆中的物件非常類似。以常量池中字面量的回收為例,假如一個字串“abc”已經進入了常量池中,但是當前系統沒有任何一個String物件是叫做“abc”的,換句話說是沒有任何String物件引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果在這時候發生記憶體回收,而且必要的話,這個“abc”常量就會被系統“請”出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

  判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:

  • 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項;

  • 載入該類的ClassLoader已經被回收;

  • 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

      虛擬機器可以對滿足上述3個條件的無用類進行回收(解除安裝),這裡說的僅僅是“可以”,而不是和物件一樣,不使用了就必然會回收。特別地,在大量使用反射、動態代理、CGLib等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

八. 更多

  更多關於JVM記憶體模型的結構、Java物件在虛擬機器中的建立、定位過程、記憶體異常分析等相關知識的介紹,請各位看官移步我的博文請移步我的博文《JVM 記憶體模型概述》

  更多關於 Java SE 進階 方面的內容,請關注我的專欄 《Java SE 進階之路》。本專欄主要研究 JVM基礎、Java原始碼和設計模式等Java進階知識,從初級到高階不斷總結、剖析各知識點的內在邏輯,貫穿、覆蓋整個Java知識面,在一步步完善、提高把自己的同時,把對Java的所學所思分享給大家。萬丈高樓平地起,基礎決定你的上限,讓我們攜手一起勇攀Java之巔…

引用: