JVM虛擬機上課總結

來源

運行時數據區域

JVM 內存主要分爲堆、程序計數器、方法區、虛擬機棧和本地方法棧。
在這裏插入圖片描述

程序計數器

較小的內存空間,當前線程執行的字節碼的行號指示器;各線程之間獨立存儲,互不影響。
程序計數器是一塊兒很小的內存空間,主要用來記錄各個線程執行的字節碼的地址,例如分支、循環、跳轉、異常、線程恢復等都依賴於計數器。
由於java是多線程語言,當執行的線程數量超過CPU核數時,線程之間會根據事件片輪詢爭奪CPU資源。如果一個線程的時間片用完了,或者是其它原因導致這個線程的CPU資源被提前搶奪,那麼這個退出的線程就需要單獨的一個程序計數器,來記錄下一條運行的指令。
程序計數器也是JVM中唯一不會OOM(outOfMemory)的內存區域

虛擬機棧

棧是什麼樣的數據結構?先進後出(FILO)的數據結構,
虛擬機棧在JVM運行過程中存儲當前線程運行方法所需的數據,指令、返回地址。
Java 虛擬機棧是基於線程的。哪怕你只有一個 main() 方法,也是以線程的方式運行的。在線程的生命週期中,參與計算的數據會頻繁地入棧和出棧,棧的生命週期是和線程一樣的。
棧裏的每條數據,就是棧幀。在每個 Java 方法被調用的時候,都會創建一個棧幀,併入棧。一旦完成相應的調用,則出棧。所有的棧幀都出棧後,線程也就結束了。
每個棧幀,都包含四個區域:(局部變量表、操作數棧、動態連接、返回地址)
棧的大小缺省爲1M,可用參數 –Xss調整大小,例如-Xss256k

局部變量表:顧名思義就是局部變量的表,用於存放我們的局部變量的。首先它是一個32位的長度,主要存放我們的Java的八大基礎數據類型,一般32位就可以存放下,如果是64位的就使用高低位佔用兩個也可以存放下,如果是局部的一些對象,比如我們的Object對象,我們只需要存放它的一個引用地址即可。
操作數據棧:存放我們方法執行的操作數的,它就是一個棧,先進後出的棧結構,操作數棧,就是用來操作的,操作的的元素可以是任意的java數據類型,所以我們知道一個方法剛剛開始的時候,這個方法的操作數棧就是空的,操作數棧運行方法就是JVM一直運行入棧/出棧的操作
動態連接:Java語言特性多態(需要類運行時才能確定具體的方法)。
返回地址:正常返回(調用程序計數器中的地址作爲返回)、異常的話(通過異常處理器表<非棧幀中的>來確定)棧幀執行對內存區域的影響
字節碼助記碼解釋地址:https://cloud.tencent.com/developer/article/1333540

在這裏插入圖片描述
在JVM中,基於解釋執行的這種方式是基於棧的引擎,這個說的棧,就是操作數棧。

線程共享的區域

方法區/永久代
很多開發者都習慣將方法區稱爲“永久代”,其實這兩者並不是等價的。
HotSpot 虛擬機使用永久代來實現方法區,但在其它虛擬機中,例如,Oracle 的 JRockit、IBM 的 J9 就不存在永久代一說。因此,方法區只是 JVM 中規範的一部分,可以說,在 HotSpot 虛擬機中,設計人員使用了永久代來實現了 JVM 規範的方法區。
方法區主要是用來存放已被虛擬機加載的類相關信息,包括類信息、靜態變量、常量、運行時常量池、字符串常量池。
JVM 在執行某個類的時候,必須先加載。在加載類(加載、驗證、準備、解析、初始化)的時候,JVM 會先加載 class 文件,而在 class 文件中除了有類的版本、字段、方法和接口等描述信息外,還有一項信息是常量池 (Constant Pool Table),用於存放編譯期間生成的各種字面量和符號引用。
字面量包括字符串(String a=“b”)、基本類型的常量(final 修飾的變量),符號引用則包括類和方法的全限定名(例如 String 這個類,它的全限定名就是 Java/lang/String)、字段的名稱和描述符以及方法的名稱和描述符。
而當類加載到內存中後,JVM 就會將 class 文件常量池中的內容存放到運行時的常量池中;在解析階段,JVM 會把符號引用替換爲直接引用(對象的索引值)。
例如,類中的一個字符串常量在 class 文件中時,存放在 class 文件常量池中的;在 JVM 加載完類之後,JVM 會將這個字符串常量放到運行時常量池中,並在解析階段,指定該字符串對象的索引值。運行時常量池是全局共享的,多個類共用一個運行時常量池,class 文件中常量池多個相同的字符串在運行時常量池只會存在一份。
方法區與堆空間類似,也是一個共享內存區,所以方法區是線程共享的。假如兩個線程都試圖訪問方法區中的同一個類信息,而這個類還沒有裝入 JVM,那麼此時就只允許一個線程去加載它,另一個線程必須等待。在 HotSpot 虛擬機、Java7 版本中已經將永久代的靜態變量和運行時常量池轉移到了堆中,其餘部分則存儲在 JVM 的非堆內存中,而 Java8 版本已經將方法區中實現的永久代去掉了,並用元空間(class metadata)代替了之前的永久代,並且元空間的存儲位置是本地
元空間大小參數:
jdk1.7及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize;
jdk1.8以後(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize
jdk1.8以後大小就只受本機總內存的限制(如果不設置參數的話)

JVM參數參考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

Java8 爲什麼使用元空間替代永久代,這樣做有什麼好處呢?
官方給出的解釋是:
移除永久代是爲了融合 HotSpot JVM 與 JRockit VM 而做出的努力,因爲 JRockit 沒有永久代,所以不需要配置永久代。
永久代內存經常不夠用或發生內存溢出,拋出異常 java.lang.OutOfMemoryError: PermGen。這是因爲在 JDK1.7 版本中,指定的 PermGen 區大小爲 8M,由於 PermGen 中類的元數據信息在每次 FullGC 的時候都可能被收集,回收率都偏低,成績很難令人滿意;還有,爲 PermGen 分配多大的空間很難確定,PermSize 的大小依賴於很多因素,比如,JVM 加載的 class 總數、常量池的大小和方法的大小等。

堆是 JVM 上最大的內存區域,我們申請的幾乎所有的對象,都是在這裏存儲的。我們常說的垃圾回收,操作的對象就是堆。
堆空間一般是程序啓動時,就申請了,但是並不一定會全部使用。
隨着對象的頻繁創建,堆空間佔用的越來越多,就需要不定期的對不再使用的對象進行回收。這個在 Java 中,就叫作 GC(Garbage Collection)。
那一個對象創建的時候,到底是在堆上分配,還是在棧上分配呢?這和兩個方面有關:對象的類型和在 Java 類中存在的位置。
Java 的對象可以分爲基本數據類型和普通對象。
對於普通對象來說,JVM 會首先在堆上創建對象,然後在其他地方使用的其實是它的引用。比如,把這個引用保存在虛擬機棧的局部變量表中。
對於基本數據類型來說(byte、short、int、long、float、double、char),有兩種情況。當你在方法體內聲明瞭基本數據類型的對象,它就會在棧上直接分配。其他情況,都是在堆上分配。

堆大小參數:
-Xms:堆的最小值;
-Xmx:堆的最大值;
-Xmn:新生代的大小;
-XX:NewSize;新生代最小值;
-XX:MaxNewSize:新生代最大值;
例如- Xmx256m

直接內存

不是虛擬機運行時數據區的一部分,也不是java虛擬機規範中定義的內存區域;如果使用了NIO,這塊區域會被頻繁使用,在java堆內可以用directByteBuffer對象直接引用並操作;
這塊內存不受java堆大小限制,但受本機總內存的限制,可以通過-XX:MaxDirectMemorySize來設置(默認與堆內存最大值一樣),所以也會出現OOM異常。

從底層深入理解運行時數據區、

開啓HSDB工具
Jdk1.8啓動JHSDB的時候必須將sawindbg.dll複製到對應目錄的jre下

在這裏插入圖片描述
C:\Program Files\Java\jdk1.8.0_101\lib
執行 java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB

在這裏插入圖片描述
當我們通過 Java 運行以上代碼時,JVM 的整個處理過程如下:

  1. JVM 向操作系統申請內存,JVM 第一步就是通過配置參數或者默認配置參數向操作系統申請內存空間。
  2. JVM 獲得內存空間後,會根據配置參數分配堆、棧以及方法區的內存大小。
  3. 完成上一個步驟後, JVM 首先會執行構造器,編譯器會在.java 文件被編譯成.class 文件時,收集所有類的初始化代碼,包括靜態變量賦值語句、靜態代碼塊、靜態方法,靜態變量和常量放入方法區
  4. 執行方法。啓動 main 線程,執行 main 方法,開始執行第一行代碼。此時堆內存中會創建一個 Teacher 對象,對象引用 student 就存放在棧中。
    執行其他方法時,具體的操作:棧幀執行對內存區域的影響。棧幀執行對內存區域的影響
    在這裏插入圖片描述

深入辨析堆和棧

  • 功能
    以棧幀的方式存儲方法調用的過程,並存儲方法調用過程中基本數據類型的變量(int、short、long、byte、float、double、boolean、char等)以及對象的引用變量,其內存分配在棧上,變量出了作用域就會自動釋放;
    而堆內存用來存儲Java中的對象。無論是成員變量,局部變量,還是類變量,它們指向的對象都存儲在堆內存中;
  • 線程獨享還是共享
    棧內存歸屬於單個線程,每個線程都會有一個棧內存,其存儲的變量只能在其所屬線程中可見,即棧內存可以理解成線程的私有內存。
    堆內存中的對象對所有線程可見。堆內存中的對象可以被所有線程訪問。
  • 空間大小
    棧的內存要遠遠小於堆內存

內存溢出

棧溢出

參數:-Xss1m, 具體默認值需要查看官網:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABHDABI
在這裏插入圖片描述
HotSpot版本中棧的大小是固定的,是不支持拓展的。
java.lang.StackOverflowError 一般的方法調用是很難出現的,如果出現了可能會是無限遞歸。
虛擬機棧帶給我們的啓示:方法的執行因爲要打包成棧楨,所以天生要比實現同樣功能的循環慢,所以樹的遍歷算法中:遞歸和非遞歸(循環來實現)都有存在的意義。遞歸代碼簡潔,非遞歸代碼複雜但是速度較快。
OutOfMemoryError:不斷建立線程,JVM申請棧內存,機器沒有足夠的內存。(一般演示不出,演示出來機器也死了)

堆溢出

內存溢出:申請內存空間,超出最大堆內存空間。
如果是內存溢出,則通過 調大 -Xms,-Xmx參數。
如果不是內存泄漏,就是說內存中的對象卻是都是必須存活的,那麼久應該檢查JVM的堆參數設置,與機器的內存對比,看是否還有可以調整的空間,再從代碼上檢查是否存在某些對象生命週期過長、持有狀態時間過長、存儲結構設計不合理等情況,儘量減少程序運行時的內存消耗。

方法區溢出

(1) 運行時常量池溢出
(2)方法區中保存的Class對象沒有被及時回收掉或者Class信息佔用的內存超過了我們配置。

注意Class要被回收,條件比較苛刻(僅僅是可以,不代表必然,因爲還有一些參數可以進行控制):
1、該類所有的實例都已經被回收,也就是堆中不存在該類的任何實例。
2、加載該類的ClassLoader已經被回收。
3、該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
在這裏插入圖片描述
代碼示例:
cglib是一個強大的,高性能,高質量的Code生成類庫,它可以在運行期擴展Java類與實現Java接口。
CGLIB包的底層是通過使用一個小而快的字節碼處理框架ASM,來轉換字節碼並生成新的類。除了CGLIB包,腳本語言例如Groovy和BeanShell,也是使用ASM來生成java的字節碼。當然不鼓勵直接使用ASM,因爲它要求你必須對JVM內部結構包括class文件的格式和指令集都很熟悉。

本機直接內存溢出

直接內存的容量可以通過MaxDirectMemorySize來設置(默認與堆內存最大值一樣),所以也會出現OOM異常;
由直接內存導致的內存溢出,一個比較明顯的特徵是在HeapDump文件中不會看見有什麼明顯的異常情況,如果發生了OOM,同時Dump文件很小,可以考慮重點排查下直接內存方面的原因。

虛擬機優化技術

編譯優化技術——方法內聯

方法內聯的優化行爲,就是把目標方法的代碼原封不動的“複製”到調用的方法中,避免真實的方法調用而已。

在這裏插入圖片描述

棧的優化技術——棧幀之間數據的共享

在一般的模型中,兩個不同的棧幀的內存區域是獨立的,但是大部分的JVM在實現中會進行一些優化,使得兩個棧幀出現一部分重疊。(主要體現在方法中有參數傳遞的情況),讓下面棧幀的操作數棧和上面棧幀的部分局部變量重疊在一起,這樣做不但節約了一部分空間,更加重要的是在進行方法調用時就可以直接公用一部分數據,無需進行額外的參數複製傳遞了。

在這裏插入圖片描述
使用HSDB工具查看棧空間一樣可以看到。
在這裏插入圖片描述

虛擬機中的對象

在這裏插入圖片描述

對象的分配

虛擬機遇到一條new指令時,首先檢查是否被類加載器加載,如果沒有,那必須先執行相應的類加載過程。
類加載就是把class加載到JVM的運行時數據區的過程(類加載後面有專門的專題講)。
1)檢查加載
首先檢查這個指令的參數是否能在常量池中定位到一個類的符號引用(符號引用 :符號引用以一組符號來描述所引用的目標),並且檢查類是否已經被加載、解析和初始化過。
2)分配內存
接下來虛擬機將爲新生對象分配內存。爲對象分配空間的任務等同於把一塊確定大小的內存從Java堆中劃分出來。
指針碰撞
如果Java堆中內存是絕對規整的,所有用過的內存都放在一邊,空閒的內存放在另一邊,中間放着一個指針作爲分界點的指示器,那所分配內存就僅僅是把那個指針向空閒空間那邊挪動一段與對象大小相等的距離,這種分配方式稱爲“指針碰撞”。

在這裏插入圖片描述
空閒列表
如果Java堆中的內存並不是規整的,已使用的內存和空閒的內存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱爲“空閒列表”。

在這裏插入圖片描述
選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。
如果是Serial、ParNew等帶有壓縮的整理的垃圾回收器的話,系統採用的是指針碰撞,既簡單又高效。
如果是使用CMS這種不帶壓縮(整理)的垃圾回收器的話,理論上只能採用較複雜的空閒列表。

併發安全

除如何劃分可用空間之外,還有另外一個需要考慮的問題是對象創建在虛擬機中是非常頻繁的行爲,即使是僅僅修改一個指針所指向的位置,在併發情況下也並不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。

CAS機制

解決這個問題有兩種方案,一種是對分配內存空間的動作進行同步處理——實際上虛擬機採用CAS配上失敗重試的方式保證更新操作的原子性;

分配緩衝

另一種是把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊私有內存,也就是本地線程分配緩衝(Thread Local Allocation Buffer,TLAB),JVM在線程初始化時,同時也會申請一塊指定大小的內存,只給當前線程使用,這樣每個線程都單獨擁有一個Buffer,如果需要分配內存,就在自己的Buffer上分配,這樣就不存在競爭的情況,可以大大提升分配效率,當Buffer容量不夠的時候,再重新從Eden區域申請一塊繼續使用。
TLAB的目的是在爲新對象分配內存空間時,讓每個Java應用線程能在使用自己專屬的分配指針來分配空間,減少同步開銷。
TLAB只是讓每個線程有私有的分配指針,但底下存對象的內存空間還是給所有線程訪問的,只是其它線程無法在這個區域分配而已。當一個TLAB用滿(分配指針top撞上分配極限end了),就新申請一個TLAB。
參數:
-XX:+UseTLAB
允許在年輕代空間中使用線程本地分配塊(TLAB)。默認情況下啓用此選項。要禁用TLAB,請指定-XX:-UseTLAB。
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
在這裏插入圖片描述
3)內存空間初始化
(注意不是構造方法)內存分配完成後,虛擬機需要將分配到的內存空間都初始化爲零值(如int值爲0,boolean值爲false等等)。這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。
4)設置
接下來,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息(Java classes在Java hotspot VM內部表示爲類元數據)、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭之中。
5)對象初始化
在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了,但從Java程序的視角來看,對象創建纔剛剛開始,所有的字段都還爲零值。所以,一般來說,執行new指令之後會接着把對象按照程序員的意願進行初始化(構造方法),這樣一個真正可用的對象纔算完全產生出來。

對象的內存佈局

在這裏插入圖片描述
在HotSpot虛擬機中,對象在內存中存儲的佈局可以分爲3塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
對象頭包括兩部分信息,第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等。
對象頭的另外一部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
如果對象是一個java數組,那麼在對象頭中還有一塊用於記錄數組長度的數據。
第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起着佔位符的作用。由於HotSpot VM的自動內存管理系統要求對對象的大小必須是8字節的整數倍。當對象其他數據部分沒有對齊時,就需要通過對齊填充來補全。

在這裏插入圖片描述

對象的訪問定位

建立對象是爲了使用對象,我們的Java程序需要通過棧上的reference數據來操作堆上的具體對象。目前主流的訪問方式有使用句柄和直接指針兩種。

句柄

如果使用句柄訪問的話,那麼Java堆中將會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。

直接指針

如果使用直接指針訪問, reference中存儲的直接就是對象地址。
這兩種對象訪問方式各有優勢,使用句柄來訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不需要修改。
使用直接指針訪問方式的最大好處就是速度更快,它節省了一次指針定位的時間開銷,由於對象的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本。
對Sun HotSpot而言,它是使用直接指針訪問方式進行對象訪問的。

判斷對象的存活

在堆裏面存放着幾乎所有的對象實例,垃圾回收器在對對進行回收前,要做的事情就是確定這些對象中哪些還是“存活”着,哪些已經“死去”(死去代表着不可能再被任何途徑使用得對象了)

引用計數法

在對象中添加一個引用計數器,每當有一個地方引用它,計數器就加1,當引用失效時,計數器減1.
Python在用,但主流虛擬機沒有使用,因爲存在對象相互引用的情況,這個時候需要引入額外的機制來處理,這樣做影響效率,

在這裏插入圖片描述
在這裏插入圖片描述
在代碼中看到,只保留相互引用的對象還是被回收掉了,說明JVM中採用的不是引用計數法。

可達性分析

(面試時重要的知識點,牢記)
來判定對象是否存活的。這個算法的基本思路就是通過一系列的稱爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的。
作爲GC Roots的對象包括下面幾種:
虛擬機棧(棧幀中的本地變量表)中引用的對象。
方法區中類靜態屬性引用的對象。
方法區中常量引用的對象。
本地方法棧中JNI(即一般說的Native方法)引用的對象。
JVM的內部引用(class對象、異常對象NullPointException、OutofMemoryError,系統類加載器)。
所有被同步鎖(synchronized關鍵)持有的對象。
JVM內部的JMXBean、JVMTI中註冊的回調、本地代碼緩存等
JVM實現中的“臨時性”對象,跨代引用的對象(在使用分代模型回收只回收部分代時)

以上的回收都是對象,類的回收條件:
注意Class要被回收,條件比較苛刻,必須同時滿足以下的條件(僅僅是可以,不代表必然,因爲還有一些參數可以進行控制):
1、該類所有的實例都已經被回收,也就是堆中不存在該類的任何實例。
2、加載該類的ClassLoader已經被回收。
3、該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
4、參數控制:
在這裏插入圖片描述
還有一個廢棄的常量,這個是對象的回收非常相似,比如:假如有一個字符串“king”進入常量池。

Finalize方法

即使通過可達性分析判斷不可達的對象,也不是“非死不可”,它還會處於“緩刑”階段,真正要宣告一個對象死亡,需要經過兩次標記過程,一次是沒有找到與GCRoots的引用鏈,它將被第一次標記。隨後進行一次篩選(如果對象覆蓋了finalize),我們可以在finalize中去拯救。
代碼演示:
在這裏插入圖片描述
運行結果
在這裏插入圖片描述
可以看到,對象可以被拯救一次(finalize執行第一次,但是不會執行第二次)
代碼改一下,再來一次。

在這裏插入圖片描述
運行結果
在這裏插入圖片描述
對象沒有被拯救,這個就是finalize方法執行緩慢,還沒有完成拯救,垃圾回收器就已經回收掉了。
所以建議大家儘量不要使用finalize,因爲這個方法太不可靠。在生產中你很難控制方法的執行或者對象的調用順序,建議大家忘了finalize方法!因爲在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好

各種引用

強引用

一般的Object obj = new Object() ,就屬於強引用。在任何情況下,只有有強引用關聯(與根可達)還在,垃圾回收器就永遠不會回收掉被引用的對象。

軟引用 SoftReference

一些有用但是並非必需,用軟引用關聯的對象,系統將要發生內存溢出(OuyOfMemory)之前,這些對象就會被回收(如果這次回收後還是沒有足夠的空間,纔會拋出內存溢出)。參見代碼:
VM參數 -Xms10m -Xmx10m -XX:+PrintGC
在這裏插入圖片描述
運行結果
在這裏插入圖片描述
例如,一個程序用來處理用戶提供的圖片。如果將所有圖片讀入內存,這樣雖然可以很快的打開圖片,但內存空間使用巨大,一些使用較少的圖片浪費內存空間,需要手動從內存中移除。如果每次打開圖片都從磁盤文件中讀取到內存再顯示出來,雖然內存佔用較少,但一些經常使用的圖片每次打開都要訪問磁盤,代價巨大。這個時候就可以用軟引用構建緩存。

弱引用 WeakReference

一些有用(程度比軟引用更低)但是並非必需,用弱引用關聯的對象,只能生存到下一次垃圾回收之前,GC發生時,不管內存夠不夠,都會被回收。
參看代碼:
在這裏插入圖片描述
在這裏插入圖片描述
注意:軟引用 SoftReference和弱引用 WeakReference,可以用在內存資源緊張的情況下以及創建不是很重要的數據緩存。當系統內存不足的時候,緩存中的內容是可以被釋放的。
實際運用(WeakHashMap、ThreadLocal)

虛引用 PhantomReference

幽靈引用,最弱(隨時會被回收掉)
垃圾回收的時候收到一個通知,就是爲了監控垃圾回收器是否正常工作。

學習垃圾回收的意義

Java與C++等語言最大的技術區別:自動化的垃圾回收機制(GC)
爲什麼要了解GC和內存分配策略
1、面試需要
2、GC對應用的性能是有影響的;
3、寫代碼有好處
棧:棧中的生命週期是跟隨線程,所以一般不需要關注
堆:堆中的對象是垃圾回收的重點
方法區/元空間:這一塊也會發生垃圾回收,不過這塊的效率比較低,一般不是我們關注的重點

對象的分配策略

棧上分配

沒有逃逸
即方法中的對象沒有發生逃逸。
即方法中的對象沒有發生逃逸。
逃逸分析的原理:分析對象動態作用域,當一個對象在方法中定義後,它可能被外部方法所引用,比如:調用參數傳遞到其他方法中,這種稱之爲方法逃逸,甚至還有可能被外部線程訪問到,例如:賦值給其他線程中訪問的變量,這個稱之爲線程逃逸。
從不逃逸到方法逃逸到線程逃逸,稱之爲對象由低到高的不同逃逸程度。

如果確定一個對象不會逃逸出線程之外,那麼讓對象在棧上分配內存可以提高JVM的效率。

逃逸分析代碼

public class EscapeAnalysisTest {
   public static void main(String[] args) throws Exception {
       long start = System.currentTimeMillis();
       for (int i = 0; i < 50000000; i++) {
           allocate();
      }
       System.out.println((System.currentTimeMillis() - start) + " ms");
       Thread.sleep(600000);
  }static void allocate() {
       MyObject myObject = new MyObject(2020, 2020.6);
  }static class MyObject {
       int a;
       double b;MyObject(int a, double b) {
           this.a = a;
           this.b = b;
      }
  }
}

這段代碼在調用的過程中 myboject這個對象屬於全局逃逸,JVM可以做棧上分配
然後通過開啓和關閉DoEscapeAnalysis開關觀察不同。
開啓逃逸分析(JVM默認開啓)

在這裏插入圖片描述
查看執行速度
在這裏插入圖片描述
關閉逃逸分析
在這裏插入圖片描述
測試結果可見,開啓逃逸分析對代碼的執行性能有很大的影響!那爲什麼有這個影響?

逃逸分析

如果是逃逸分析出來的對象可以在棧上分配的話,那麼該對象的生命週期就跟隨線程了,就不需要垃圾回收,如果是頻繁的調用此方法則可以得到很大的性能提高。
採用了逃逸分析後,滿足逃逸的對象在棧上分配

在這裏插入圖片描述

沒有開啓逃逸分析,對象都在堆上分配,會頻繁觸發垃圾回收(垃圾回收會影響系統性能),導致代碼運行慢

在這裏插入圖片描述
代碼驗證
開啓GC打印日誌
-XX:+PrintGC

在這裏插入圖片描述
可以看到沒有GC日誌
關閉逃逸分析

在這裏插入圖片描述

可以看到關閉了逃逸分析,JVM在頻繁的進行垃圾回收(GC),正是這一塊的操作導致性能有較大的差別。

對象優先在Eden區分配

虛擬機參數:
-Xms20m
-Xmx20m
-Xmn10m
-XX:+PrintGCDetails

-XX:+PrintGCDetails 打印垃圾回收日誌,程序退出時輸出當前內存的分配情況
注意:新生代初始時就有大小
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間分配時,虛擬機將發起一次Minor GC。

大對象直接進入老年代

-Xms20m
-Xmx20m
-Xmn10m
-XX:+PrintGCDetails
-XX:PretenureSizeThreshold=4m
-XX:+UseSerialGC

PretenureSizeThreshold參數只對Serial和ParNew兩款收集器有效。
最典型的大對象是那種很長的字符串以及數組。這樣做的目的:1.避免大量內存複製,2.避免提前進行垃圾回收,明明內存有空間進行分配。

長期存活對象進入老年區

如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並將對象年齡設爲1,對象在Survivor區中每熬過一次 Minor GC,年齡就增加1,當它的年齡增加到一定程度(併發的垃圾回收器默認爲15),CMS是6時,就會被晉升到老年代中。
-XX:MaxTenuringThreshold調整

對象年齡動態判定

爲了能更好地適應不同程序的內存狀況,虛擬機並不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡

空間分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次Minor GC,儘管這次Minor GC是有風險的,如果擔保失敗則會進行一次Full GC;如果小於,或者HandlePromotionFailure設置不允許冒險,那這時也要改爲進行一次Full GC。

本地線程分配緩衝(TLAB)

具體見章節分配緩衝

垃圾回收算法

垃圾回收算法的實現設計到大量的程序細節,並且每一個平臺的虛擬機操作內存的方式都有不同,所以不需要去了解算法的實現,我們重點講解分代收集理論和3種算法的思想。

分代收集理論

當前商業虛擬機的垃圾收集器,大多遵循“分代收集”的理論來進行設計,這個理論大體上是這麼描述的:
1、絕大部分的對象都是朝生夕死
2、熬過多次垃圾回收的對象就越難回收。
根據以上兩個理論,朝生夕死的對象放一個區域,難回收的對象放另外一個區域,這個就構成了新生代和老年代。

GC種類

市面上發生垃圾回收的叫法很多,我大體整理了一下:
1、新生代回收(Minor GC/Young GC):指只是進行新生代的回收。
2、老年代回收(Major GC/Old GC):指只是進行老年代的回收。目前只有CMS垃圾回收器會有這個單獨的收集老年代的行爲。(Major GC定義是比較混亂,有說指是老年代,有的說是做整個堆的收集,這個需要你根據別人的場景來定,沒有固定的說法)
3、整堆收集(Full GC):收集整個Java堆和方法區(注意包含方法區)

在這裏插入圖片描述

在這裏插入圖片描述

複製算法(Copying)

將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲了原來的一半。
注意:內存移動是必須實打實的移動(複製),不能使用指針玩。
複製回收算法適合於新生代,因爲大部分對象朝生夕死,那麼複製過去的對象比較少,效率自然就高,另外一半的一次性清理是很快的。

Appel式回收

一種更加優化的複製回收分代策略:具體做法是分配一塊較大的Eden區和兩塊較小的Survivor空間(你可以叫做From或者To,也可以叫做Survivor1和Survivor2)
專門研究表明,新生代中的對象98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor[1]。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。
HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內存空間爲整個新生代容量的90%(80%+10%),只有10%的內存會被“浪費”。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這裏指老年代)進行分配擔保(Handle Promotion)

標記-清除算法(Mark-Sweep)

算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。
回收效率不穩定,如果大部分對象是朝生夕死,那麼回收效率降低,因爲需要大量標記對象和回收對象,對比複製回收效率很低。
它的主要不足空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
回收的時候如果需要回收的對象越多,需要做的標記和清除的工作越多,所以標記清除算法適用於老年代。複製回收算法適用於新生代。

標記-整理算法(Mark-Compact)

首先標記出所有需要回收的對象,在標記完成後,後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。標記整理算法雖然沒有內存碎片,但是效率偏低。
我們看到標記整理與標記清除算法的區別主要在於對象的移動。對象移動不單單會加重系統負擔,同時需要全程暫停用戶線程才能進行,同時所有引用對象的地方都需要更新。
所以看到,老年代採用的標記整理算法與標記清除算法,各有優點,各有缺點。

JVM中常見的垃圾收集器

分代收集的思想

在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。
而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記—清理”或者“標記—整理”算法來進行回收。
請記住下圖的垃圾收集器和之間的連線關係。
具體看官網JVM參數:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

在這裏插入圖片描述
在這裏插入圖片描述

並行:垃圾收集的多線程的同時進行。
併發:垃圾收集的多線程和應用的多線程同時進行。

注:吞吐量=運行用戶代碼時間/(運行用戶代碼時間+ 垃圾收集時間)
垃圾收集時間= 垃圾回收頻率 * 單次垃圾回收時間

垃圾回收器工作示意圖

Serial/Serial Old

最古老的,單線程,獨佔式,成熟,適合單CPU 服務器
-XX:+UseSerialGC 新生代和老年代都用串行收集器
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old
-XX:+UseParallelGC 新生代使用ParallerGC,老年代使用Serial Old

ParNew

和Serial基本沒區別,唯一的區別:多線程,多CPU的,停頓時間比Serial少
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old

Parallel Scavenge(ParallerGC)/Parallel Old

關注吞吐量的垃圾收集器,高吞吐量則可以高效率地利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。
所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

Concurrent Mark Sweep (CMS)

在這裏插入圖片描述

收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS收集器就非常符合這類應用的需求。
從名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基於“標記—清除”算法實現的,它的運作過程相對於前面幾種收集器來說更復雜一些,整個過程分爲4個步驟,包括:
初始標記-短暫,僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快。
併發標記-和用戶的應用程序同時進行,進行GC Roots追蹤的過程,標記從GCRoots開始關聯的所有對象開始遍歷整個可達分析路徑的對象。這個時間比較長,所以採用併發處理(垃圾回收器線程和用戶線程同時工作)
重新標記-短暫,爲了修正併發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。
併發清除
由於整個過程中耗時最長的併發標記和併發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS收集器的內存回收過程是與用戶線程一起併發執行的。
-XX:+UseConcMarkSweepGC ,表示新生代使用ParNew,老年代的用CMS
CPU敏感:CMS對處理器資源敏感,畢竟採用了併發的收集、當處理核心數不足4個時,CMS對用戶的影響較大。
浮動垃圾:由於CMS併發清理階段用戶線程還在運行着,伴隨程序運行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就稱爲“浮動垃圾”。
由於浮動垃圾的存在,因此需要預留出一部分內存,意味着 CMS 收集不能像其它收集器那樣等待老年代快滿的時候再回收。
在1.6的版本中老年代空間使用率閾值(92%)
如果預留的內存不夠存放浮動垃圾,就會出現 Concurrent Mode Failure,這時虛擬機將臨時啓用 Serial Old 來替代 CMS。
會產生空間碎片:標記 - 清除算法會導致產生不連續的空間碎片

總體來說,CMS是JVM推出了第一款併發垃圾收集器,所以還是非常有代表性。
但是最大的問題是CMS採用了標記清除算法,所以會有內存碎片,當碎片較多時,給大對象的分配帶來很大的麻煩,爲了解決這個問題,CMS提供一個參數:-XX:+UseCMSCompactAtFullCollection,一般是開啓的,如果分配不了大對象,就進行內存碎片的整理過程。
這個地方一般會使用Serial Old ,因爲Serial Old是一個單線程,所以如果內存空間很大、且對象較多時,CMS發生這樣情況會很卡。

在這裏插入圖片描述

Stop The World現象

任何的GC收集器都會進行業務線程的暫停,這個就是STW,Stop The World,所以我們GC調優的目標就是儘可能的減少STW的時間和次數。

G1

在這裏插入圖片描述

-XX:+UseG1GC
內存佈局:在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的內存佈局就與其他收集器有很大差別,它將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。每一個區域可以通過參數-XX:G1HeapRegionSize=size 來設置。

在這裏插入圖片描述

Region中還有一塊特殊區域Humongous區域,專門用於存儲大對象,一般只要認爲一個對象超過了Region容量的一般可認爲是大對象,如果對象超級大,那麼使用連續的N個Humongous區域來存儲。
並行與併發:G1能充分利用多CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程序繼續執行。
分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新創建的對象和已經存活了一段時間、熬過多次GC的舊對象以獲取更好的收集效果。
空間整合:與CMS的“標記—清理”算法不同,G1從整體來看是基於“標記—整理”算法實現的收集器,從局部(兩個Region之間)上來看是基於“複製”算法實現的,但無論如何,這兩種算法都意味着G1運作期間不會產生內存空間碎片,收集後能提供規整的可用內存。這種特性有利於程序長時間運行,分配大對象時不會因爲無法找到連續內存空間而提前觸發下一次GC。
追求停頓時間:
-XX:MaxGCPauseMillis 指定目標的最大停頓時間,G1嘗試調整新生代和老年代的比例,堆大小,晉升年齡來達到這個目標時間。
-XX:ParallerGCThreads:設置GC的工作線程數量。

一般在G1和CMS中間選擇的話平衡點在6~8G,只有內存比較大G1才能發揮優勢。

常量池與String

常量池有很多概念,包括運行時常量池、class常量池、字符串常量池。
虛擬機規範只規定以上區域屬於方法區,並沒有規定虛擬機廠商的實現。
嚴格來說是靜態常量池和運行時常量池,靜態常量池是存放字符串字面量、符號引用以及類和方法的信息,而運行時常量池存放的是運行時一些直接引用。
運行時常量池是在類加載完成之後,將靜態常量池中的符號引用值轉存到運行時常量池中,類在解析之後,將符號引用替換成直接引用。
這兩個常量池在JDK1.7版本之後,就移到堆內存中了,這裏指的是物理空間,而邏輯上還是屬於方法區(方法區是邏輯分區)。

字面量:
給基本類型變量賦值的方式就叫做字面量或者字面值
比如:int i=120; long j=10L;
符號引用:包括類和方法的全限定名(例如 String 這個類,它的全限定名就是 Java/lang/String)、字段的名稱和描述符以及方法的名稱和描述符。
直接引用:具體對象的索引值。

String 對象是如何實現的?

瞭解了 String 對象的實現後,你有沒有發現在實現代碼中 String 類被 final 關鍵字修飾了,而且變量 char 數組也被 final 修飾了。我們知道類被 final 修飾代表該類不可繼承,而 char[]被 final+private 修飾,代表了 String 對象不可被更改。Java 實現的這個特性叫作 String 對象的不可變性,即 String 對象一旦創建成功,就不能再對它進行改變。
在 Java 中,通常有兩種創建字符串對象的方式,
一種是通過字符串常量的方式創建,如 String str=“abc”;
這種方式首先會檢查該對象是否在字符串常量池中,如果在,就返回該對象引用,否則新的字符串將在常量池中被創建。這種方式可以減少同一個值的字符串對象的重複創建,節約內存。
另一種是字符串變量通過 new 形式的創建,如 String str = new String(“abc”)。
這種方式,首先在編譯類文件時,"abc"常量字符串將會放入到常量結構中,在類加載時,“abc"將會在常量池中創建;其次,在調用 new 時,JVM 命令將會調用 String 的構造函數,同時引用常量池中的"abc” 字符串,在堆內存中創建一個 String 對象;最後,str 將引用 String 對象。
如果調用 intern 方法,會去查看字符串常量池中是否有等於該對象的字符串的引用,如果沒有會把首次遇到的字符串的引用添加到常量池中;如果有,就返回常量池中的字符串引用。(這個版本都是基於JDK1.7及以後版本)

面試題

JVM內存結構說一下!

開放式題目,具體可見章節 運行時數據區域
一般從兩個維度出發:線程私有和線程共享。到每一個內存區域的細節點。

Java 虛擬機棧是基於線程的。哪怕你只有一個 main() 方法,也是以線程的方式運行的。在線程的生命週期中,參與計算的數據會頻繁地入棧和出棧,棧的生命週期是和線程一樣的。
棧裏的每條數據,就是棧幀。在每個 Java 方法被調用的時候,都會創建一個棧幀,併入棧。一旦完成相應的調用,則出棧。所有的棧幀都出棧後,線程也就結束了。每個棧幀,都包含四個區域:
局部變量表
操作數棧
動態連接
返回地址
本地方法棧是和虛擬機棧非常相似的一個區域,它服務的對象是 native 方法。
程序計數器是一塊較小的內存空間,它的作用可以看作是當前線程所執行的字節碼的行號指示器。
堆是 JVM 上最大的內存區域,我們申請的幾乎所有的對象,都是在這裏存儲的。我們常說的垃圾回收,操作的對象就是堆。
方法區,這個區域存儲的內容,包括:類的信息、常量池、方法數據、方法代碼就可以了。
什麼情況下內存棧溢出?
java.lang.StackOverflowError 如果出現了可能會是無限遞歸。
OutOfMemoryError:不斷建立線程,JVM申請棧內存,機器沒有足夠的內存。

描述new一個對象的流程!

在這裏插入圖片描述
具體見章節對象的分配
Java對象會不會分配在棧中?
可以,如果這個對象不滿足逃逸分析,那麼虛擬機在特定的情況下會走棧上分配。
如果判斷一個對象是否被回收,有哪些算法,實際虛擬機使用得最多的是什麼?
引用計數法和根可達性分析兩種,用得最多是根可達性分析。
GC收集算法有哪些?他們的特點是什麼?
複製、標記清除、標記整理。複製速度快,但是要浪費空間,不會內存碎片。標記清除空間利用率高,但是有內存碎片。標記整理算法沒有內存碎片,但是要移動對象,性能較低。三種算法各有所長,各有所短。
JVM中一次完整的GC流程是怎樣的?對象如何晉級到老年代?
對象優先在新生代區中分配,若沒有足夠空間,Minor GC;
大對象(需要大量連續內存空間)直接進入老年態;長期存活的對象進入老年態。
如果對象在新生代出生並經過第一次MGC後仍然存活,年齡+1,若年齡超過一定限制(15),則被晉升到老年態。
Java中的幾種引用關係,他們的區別是什麼?
強引用
一般的Object obj = new Object() ,就屬於強引用。在任何情況下,只有有強引用關聯(與根可達)還在,垃圾回收器就永遠不會回收掉被引用的對象。
軟引用 SoftReference
一些有用但是並非必需,用軟引用關聯的對象,系統將要發生內存溢出(OuyOfMemory)之前,這些對象就會被回收(如果這次回收後還是沒有足夠的空間,纔會拋出內存溢出)。
弱引用 WeakReference
一些有用(程度比軟引用更低)但是並非必需,用弱引用關聯的對象,只能生存到下一次垃圾回收之前,GC發生時,不管內存夠不夠,都會被回收。
虛引用 PhantomReference
幽靈引用,最弱(隨時會被回收掉)
垃圾回收的時候收到一個通知,就是爲了監控垃圾回收器是否正常工作。
final、finally、finalize的區別?
在java中,final可以用來修飾類,方法和變量(成員變量或局部變量)
當用final修飾類的時,表明該類不能被其他類所繼承。當我們需要讓一個類永遠不被繼承,此時就可以用final修飾,但要注意:
final類中所有的成員方法都會隱式的定義爲final方法。
使用final方法的原因主要有兩個:
  (1) 把方法鎖定,以防止繼承類對其進行更改。
  (2) 效率,在早期的java版本中,會將final方法轉爲內嵌調用。但若方法過於龐大,可能在性能上不會有多大提升。因此在最近版本中,不需要final方法進行這些優化了。
final成員變量表示常量,只能被賦值一次,賦值後其值不再改變。
finally作爲異常處理的一部分,它只能用在try/catch語句中,並且附帶一個語句塊,表示這段語句最終一定會被執行(不管有沒有拋出異常),經常被用在需要釋放資源的情況下
Object中的Finalize方法
即使通過可達性分析判斷不可達的對象,也不是“非死不可”,它還會處於“緩刑”階段,真正要宣告一個對象死亡,需要經過兩次標記過程,一次是沒有找到與GCRoots的引用鏈,它將被第一次標記。隨後進行一次篩選(如果對象覆蓋了finalize),我們可以在finalize中去拯救。
所以建議大家儘量不要使用finalize,因爲這個方法太不可靠。在生產中你很難控制方法的執行或者對象的調用順序,建議大家忘了finalize方法!因爲在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好
String s = new String(“xxx”);創建了幾個對象?
2個,
1、在一開始字符串"xxx"會在加載類時,在常量池中創建一個字符串對象。
2、調用 new時 會在堆內存中創建一個 String 對象,String 對象中的 char 數組將會引用常量池中字符串。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章