##《java虛擬機》匯總所有關鍵要點

一 、java虛擬機底層結構詳解

我們知道,一個JVM實例的行為不光是它自己的事,還涉及到它的子系統、存儲區域、數據類型和指令這些部分,它們描述了JVM的一個抽象的內部體系結構,其目的不光規定實現JVM時它內部的體系結構,更重要的是提供了一種方式,用于嚴格定義實現時的外部行為。每個JVM都有兩種機制,一個是裝載具有合適名稱的類(類或是接口),叫做類裝載子系統;另外的一個負責執行包含在已裝載的類或接口中的指令,叫做運行引擎。每個JVM又包括方法區、堆、Java棧、程序計數器和本地方法棧這五個部分,這幾個部分和類裝載機制與運行引擎機制一起組成的體系結構圖為: JVM的每個實例都有一個它自己的方法域和一個堆,運行于JVM內的所有的線程都共享這些區域;當虛擬機裝載類文件的時候,它解析其中的二進制數據所包含的類信息,并把它們放到方法域中;當程序運行的時候,JVM把程序初始化的所有對象置于堆上;而每個線程創建的時候,都會擁有自己的程序計數器和Java棧,其中程序計數器中的值指向下一條即將被執行的指令,線程的Java棧則存儲為該線程調用Java方法的狀態;本地方法調用的狀態被存儲在本地方法棧,該方法棧依賴于具體的實現。
下面分別對這幾個部分進行說明。
執行引擎處于JVM的核心位置,在Java虛擬機規范中,它的行為是由指令集所決定的。盡管對于每條指令,規范很詳細地說明了當JVM執行字節碼遇到指令時,它的實現應該做什么,但對于怎么做卻言之甚少。Java虛擬機支持大約248個字節碼。每個字節碼執行一種基本的CPU運算,例如,把一個整數加到寄存器,子程序轉移等。Java指令集相當于Java程序的匯編語言。
Java指令集中的指令包含一個單字節的操作符,用于指定要執行的操作,還有0個或多個操作數,提供操作所需的參數或數據。許多指令沒有操作數,僅由一個單字節的操作符構成。

[圖片上傳中。。。(2)]

JVM寄存器
pc: Java程序計數器;
optop: 指向操作數棧頂端的指針;
frame: 指向當前執行方法的執行環境的指針;。
vars: 指向當前執行方法的局部變量區第一個變量的指針。

JVM棧結構
局部變量區
每個Java方法使用一個固定大小的局部變量集。它們按照與vars寄存器的字偏移量來尋址。局部變量都是32位的。長整數和雙精度浮點數占據了兩個局部變量的空間,卻按照第一個局部變量的索引來尋址。(例如,一個具有索引n的局部變量,如果是一個雙精度浮點數,那么它實際占據了索引n和n+1所代表的存儲空間)虛擬機規范并不要求在局部變量中的64位的值是64位對齊的。虛擬機提供了把局部變量中的值裝載到操作數棧的指令,也提供了把操作數棧中的值寫入局部變量的指令。

運行環境區

在運行環境中包含的信息用于動態鏈接,正常的方法返回以及異常捕捉。

操作數棧區

機器指令只從操作數棧中取操作數,對它們進行操作,并把結果返回到棧中

例子展示

上面對虛擬機的各個部分進行了比較詳細的說明,下面通過一個具體的例子來分析它的運行過程。
虛擬機通過調用某個指定類的方法main啟動,傳遞給main一個字符串數組參數,使指定的類被裝載,同時鏈接該類所使用的其它的類型,并且初始化它們。例如對于程序:

class HelloApp

{ public static void main(String[] args) { System.out.println("Hello World!"); for (int i = 0; i < args.length; i++ ) { System.out.println(args[i]); } }}

編譯后在命令行模式下鍵入: Java HelloApp run virtual machine
將通過調用HelloApp的方法main來啟動java虛擬機,傳遞給main一個包含三個字符串"run"、"virtual"、"machine"的數組?,F在我們略述虛擬機在執行HelloApp時可能采取的步驟。
開始試圖執行類HelloApp的main方法,發現該類并沒有被裝載,也就是說虛擬機當前不包含該類的二進制代表,于是虛擬機使用ClassLoader試圖尋找這樣的二進制代表。如果這個進程失敗,則拋出一個異常。類被裝載后同時在main方法被調用之前,必須對類HelloApp與其它類型進行鏈接然后初始化。鏈接包含三個階段:檢驗,準備和解析。檢驗檢查被裝載的主類的符號和語義,準備則創建類或接口的靜態域以及把這些域初始化為標準的默認值,解析負責檢查主類對其它類或接口的符號引用,在這一步它是可選的。類的初始化是對類中聲明的靜態初始化函數和靜態域的初始化構造方法的執行。一個類在初始化之前它的父類必須被初始化。整個過程如下:
[圖片上傳中。。。(3)]
二、類的生命周期(上)類的加載和連接

類加載器,顧名思義,類加載器(class loader)用來加載 Java 類到 Java 虛擬機中。一般來說,Java 虛擬機使用 Java 類的方式如下:Java 源程序(.java 文件)在經過 Java 編譯器編譯之后就被轉換成 Java 字節代碼(.class 文件)。類加載器負責讀取 Java 字節代碼,并轉換成 java.lang.Class類的一個實例。每個這樣的實例用來表示一個 Java 類。通過此實例的 newInstance()方法就可以創建出該類的一個對象。實際的情況可能更加復雜,比如 Java 字節代碼可能是通過工具動態生成的,也可能是通過網絡下載的。基本上所有的類加載器都是 java.lang.ClassLoader類的一個實例。其實我們研究類加載器主要研究的就是類的生命周期
首先來了解一下jvm(java虛擬機)中的幾個比較重要的內存區域,這幾個區域在java類的生命周期中扮演著比較重要的角色:
方法區:在java的虛擬機中有一塊專門用來存放已經加載的類信息、常量、靜態變量以及方法代碼的內存區域,叫做方法區。

常量池:常量池是方法區的一部分,主要用來存放常量和類中的符號引用等信息。
堆區: 用于存放類的對象實例。
棧區: 也叫java虛擬機棧,是由一個一個的棧幀組成的后進先出的棧式結構,棧楨中存放方法運行時產生的局部變量、方法出口等信息。當調用一個方法時,虛擬機棧中就會創建一個棧幀存放這些數據,當方法調用完成時,棧幀消失,如果方法中調用了其他方法,則繼續在棧頂創建新的棧楨。類的生命周期
當我們編寫一個java的源文件后,經過編譯會生成一個后綴名為class的文件,這種文件叫做字節碼文件,只有這種字節碼文件才能夠在java虛擬機中運行,java類的生命周期就是指一個class文件從加載到卸載的全過程。一個java類的完整的生命周期會經歷加載、連接、初始化、使用、和卸載五個階段,當然也有在加載或者連接之后沒有被初始化就直接被使用的情況,這里我們主要來研究類加載器所執行的部分,也就是加載,鏈接和初始化。如圖所示:

[圖片上傳中。。。(5)][圖片上傳中。。。(6)]下面我先簡單看一下類加載器所執行的三部分的簡單介紹1、加載:查找并加載類的二進制數據
2、連接
–驗證:確保被加載的類的正確性
–準備:為類的靜態變量分配內存,并將其初始化為默認值
–解析:把類中的符號引用轉換為直接引用
3、初始化:為類的靜態變量賦予正確的初始值
從上邊我們可以看出類的靜態變量賦了兩回值。這是為什么呢?原因是,在連接過程中時為靜態變量賦值為默認值,也就是說,只要是你定義了靜態變量,不管你開始給沒給它設置,我系統都為他初始化一個默認值。到了初始化過程,系統就檢查是否用戶定義靜態變量時有沒有給設置初始化值,如果有就把靜態變量設置為用戶自己設置的初始化值,如果沒有還是讓靜態變量為初始化值
類的加載、連接和初始化

[圖片上傳中。。。(8)]
類的加載
類的加載指的是將類的.class文件中的二進制數據讀入到內存中,將其放在運行時數據區的方法區內,然后在堆區創建一個java.lang.Class對象,用來封裝類在方法區內的數據結構 。這里的class對象其實就像一面鏡子一樣,外面是類的源程序,里面是class對象,它實時的反應了類的數據結構和信息。
加載.class文件的方式
1、從本地系統中直接加載
2、通過網絡下載.class文件
3、從zip,jar等歸檔文件中加載.class文件
4、從專有數據庫中提取.class文件
5、將Java源文件動態編譯為.class文件
類的加載過程
[圖片上傳中。。。(9)]

結論:
1、類的加載的最終產品是位于堆區中的Class對象
2、Class對象封裝了類在方法區內的數據結構,并且向Java程序員提供了訪問方法區內的數據結構的接口
Java虛擬機給我們提供了兩種類加載器:
1、Java虛擬機自帶的加載器
1)根類加載器(使用C++編寫,程序員無法在Java代碼中獲得該類)
2)擴展加載器,使用Java代碼實現
3)系統加載器(應用加載器),使用Java代碼實現
2、用戶自定義的類加載器
java.lang.ClassLoader的子類
用戶可以定制類的加載方式

我們看一下API對ClassLoader的介紹:
類加載器是負責加載類的對象。ClassLoader 類是一個抽象類。如果給定類的二進制名稱,那么類加載器會試圖查找或生成構成類定義的數據。一般策略是將名稱轉換為某個文件名,然后從文件系統讀取該名稱的“類文件”。每個class對象都包含一個對定義它的 ClassLoader 的引用。
我們再來看一下Class類的一個方法getClassLoader
public ClassLoader getClassLoader()
返回該類的類加載器。有些實現可能使用 null 來表示根類加載器。如果該類由根類加載器加載,則此方法在這類實現中將返回 null。

下面我們來看一個小例子來驗證一下:


[圖片上傳中。。。(12)]

看一下打印結果,一目了然:


[圖片上傳中。。。(14)]
從上面打印結果可以看出,第一個為null,也就是它用根類加載器加載的,第二個是我們自己寫的類,也就是說,我們自己寫的那個類用sun.misc.Launcher$AppClassLoader@1372a1a加載器加載的,我們可以看到APP,也就是應用類加載器,也就是系統加載器

還記得我們以前用過的動態代理吧,InvocationHandler,當我們利用proxy對象調用newProxyInstance建立一個代理類時,我們要給他傳一個ClassLoader,也就是類加載器,如下:
[圖片上傳中。。。(15)]

當時我們學習的時候,只知道這里的loader隨便給他設置一個類的類加載器就可以。現在我們來想想為什么這里需要一個類加載器呢?我們知道這個newProxyInstance是動態的給我們生成一個代理類,然后根據這個代理類生成一個代理對象。動態生成這個代理類之后我們不得把他加載到內存里嗎,加載到內存里我們才可以用他。用什么加載到內存里,只有類加載器,所以我們要給他指定一個類加載器。

  類加載器并不需要等到某個類被“首次主動使用”時再加載它 。JVM規范允許類加載器在預料某個類將要被使用時就預先加載它,如果在預先加載的過程中遇到了.class文件缺失或存在錯誤,類加載器必須在程序首次主動使用該類時才報告錯誤(LinkageError錯誤) 如果這個類一直沒有被程序主動使用,那么類加載器就不會報告錯誤 。大家在做web開發的時候有可能會出現這種問題,比如我們在做[測試](http://lib.csdn.net/base/softwaretest)的時候是用的jdk1.6,而我們在部署的時候我們用的是jdk1.5.這時候就很可能匯報LinkageError錯誤,版本不兼容。

類的連接
類被加載后,就進入連接階段。連接就是將已經讀入到內存的類的二進制數據合并到虛擬機的運行時環境中去。
驗證:當一個類被加載之后,必須要驗證一下這個類是否合法,比如這個類是不是符合字節碼的格式、變量與方法是不是有重復、數據類型是不是有效、繼承與實現是否合乎標準等等。總之,這個階段的目的就是保證加載的類是能夠被jvm所運行。很多人都感覺,既然這個類都通過編譯加載到內存里了,那肯定就是合法的了,為什么還要驗證呢,這是因為這里的驗證時為了避免有人惡意編寫class文件,也就是說并不是通過編譯得到的class文件。所以這里驗證其實是檢查的class文件的內部結構是否符合字節碼的要求
準備:準備階段的工作就是為類的靜態變量分配內存并設為jvm默認的初值,對于非靜態的變量,則不會為它們分配內存。有一點需要注意,這時候,靜態變量的初值為jvm默認的初值,而不是我們在程序中設定的初值。jvm默認的初值是這樣的:基本類型(int、long、short、char、byte、boolean、float、double)的默認值為0。引用類型的默認值為null。常量的默認值為我們程序中設定的值,比如我們在程序中定義final static int a = 100,則準備階段中a的初值就是100。
解析:這一階段的任務就是把常量池中的符號引用轉換為直接引用。那么什么是符號引用,什么又是直接引用呢?我們來舉個例子:我們要找一個人,我們現有的信息是這個人的身份證號是1234567890。只有這個信息我們顯然找不到這個人,但是通過公安局的身份系統,我們輸入1234567890這個號之后,就會得到它的全部信息:比如山東省濱州市濱城區18號張三,通過這個信息我們就能找到這個人了。這里,123456790就好比是一個符號引用,而山東省濱州市濱城區18號張三就是直接引用。在內存中也是一樣,比如我們要在內存中找一個類里面的一個叫做show的方法,顯然是找不到。但是在解析階段,jvm就會把show這個名字轉換為指向方法區的的一塊內存地址,比如c17164,通過c17164就可以找到show這個方法具體分配在內存的哪一個區域了。這里show就是符號引用,而c17164就是直接引用。在解析階段,jvm會將所有的類或接口名、字段名、方法名轉換為具體的內存地址。

類的初始化:在類的生命周期執行完加載和連接之后就開始了類的初始化。在類的初始化階段,java虛擬機執行類的初始化語句,為類的靜態變量賦值,在程序中,類的初始化有兩種途徑:(1)在變量的聲明處賦值。(2)在靜態代碼塊處賦值,比如下面的代碼,a就是第一種初始化,b就是第二種初始化


[圖片上傳中。。。(18)]

  靜態變量的聲明和靜態代碼塊的初始化都可以看做靜態變量的初始化,類的靜態變量的初始化是有順序的。順序為類文件從上到下進行初始化,想到這,想起來一個很無恥的面試題,分享給大家看一下:

[圖片上傳中。。。(19)]

大家先看看這里的程序會輸出什么?
不知道大家的答案是什么,如果不介意的話可以把你的答案寫到評論上,看看有多少人的答案和你一樣的。我先說說我剛開始的答案吧。我認為會輸出:
counter1 = 1
Counter2 = 1
不知道大家的答案是不是這個,反正我的是。下面我們來看一下正確答案:



[圖片上傳中。。。(22)]
不知道你做對沒有,反正我剛開始做錯了。好,現在我來解釋一下為什么會是這個答案。在給出解釋之前,我們先來看一個概念:
Java程序對類的使用方式可分為兩種
主動使用
被動使用
?所有的Java虛擬機實現必須在每個類或接口被Java程序“首次主動使用”時才初始化他們
主動使用(六種)
–創建類的實例
–訪問某個類或接口的靜態變量,或者對該靜態變量賦值
–調用類的靜態方法
–反射(如Class.forName(“com.bzu.csh.Test”))
–初始化一個類的子類
–Java虛擬機啟動時被標明為啟動類的類(Java Test)
OK,我們開始解釋一下上面的答案,程序開始運行,首先執行main方法,執行main方法第一條語句,調用Singleton類的靜態方法,這里調用Singleton類的靜態方法就是主動使用Singleton類。所以開始加載Singleton類。在加載Singleton類的過程中,首先對靜態變量賦值為默認值,
Singleton=null
counter1 = 0
Counter2 = 0
給他們賦值完默認值值之后,要進行的就是對靜態變量初始化,對聲明時已經賦值的變量進行初始化。我們上面提到過,初始化是從類文件從上到下賦值的。所以首先給Singleton賦值,給它賦值,就要執行它的構造方法,然后執行counter1++;counter2++;所以這里的counter1 = 1;counter2 = 1;執行完這個初始化之后,然后執行counter2的初始化,我們聲明的時候給他初始化為0 了,所以counter2 的值又變為了0.初始化完之后執行輸出。所以這是的
counter1 = 1
counter2 = 0
類初始化步驟
(1)假如一個類還沒有被加載或者連接,那就先加載和連接這個類
(2)假如類存在直接的父類,并且這個父類還沒有被初始化,那就先初始化直接的父類
(3)假如類中存在初始化語句,那就直接按順序執行這些初始化語句
在上邊我們我們說了java虛擬機實現必須在每個類或接口被Java程序“首次主動使用”時才初始化他們,上面也舉出了六種主動使用的說明。除了上述六種情形,其他使用Java類的方式都被看作是被動使用,不會導致類的初始化。程序中對子類的“主動使用”會導致父類被初始化;但對父類的“主動”使用并不會導致子類初始化(不可能說生成一個Object類的對象就導致系統中所有的子類都會被初始化)

注:調用ClassLoader類的loadClass方法加載一個類,并不是對類的主動使用,不會導致類的初始化。
當java虛擬機初始化一個類時,要求它的所有的父類都已經被初始化,但這條規則并不適用于接口。
在初始化一個類時,并不會先初始化它所實現的接口
在初始化一個接口時,并不會先初始化它的父接口
因此,一個父接口并不會因為它的子接口或者實現類的初始化而初始化。只有當程序首次使用特定接口的靜態變量時,才會導致該接口的初始化。只有當程序訪問的靜態變量或靜態方法確實在當前類或當前接口中定義時,才可以認為是對類或接口的主動使用 。如果是調用的子類的父類屬性,那么子類不會被初始化。

三、java虛擬機的垃圾回收機制

一、Java垃圾回收機制
Java 的垃圾回收器要負責完成3 件任務:
1.分配內存
2.確保被引用的對象的內存不被錯誤回收
3.回收不再被引用的對象的內存空間。
垃圾回收是一個復雜而且耗時的操作。如果JVM 花費過多的時間在垃圾回收上,則勢必會影響應用的運行性能。一般情況下,當垃圾回收器在進行回收操作的時候,整個應用的執行是被暫時中止(stop-the-world)的。這是因為垃圾回收器需要更新應用中所有對象引用的實際內存地址。不同的硬件平臺所能支持的垃圾回收方式也不同。比如在多CPU 的平臺上,就可以通過并行的方式來回收垃圾。而單CPU 平臺則只能串行進行。不同的應用所期望的垃圾回收方式也會有所不同。服務器端應用可能希望在應用的整個運行時間中,花在垃圾回收上的時間總數越小越好。而對于與用戶交互的應用來說,則可能希望所垃圾回收所帶來的應用停頓的時間間隔越小越好。對于這種情況,JVM 中提供了多種垃圾回收方法以及對應的性能調優參數,應用可以根據需要來進行定制。
二、判斷對象是否該被回收算法
1.引用計數算法
給對象添加一個引用計數器,每當有一個地方引用它時,計數器值就加1,當引用失效時,計數器值就減1;任何時刻計數器值都為0時對象就表示它不可能被使用了。這個算法實現簡單,但很難解決對象之間循環引用的問題,因此Java并沒有用這種算法!這是很多人都誤解了的地方。
2.根搜索算法
通過一系列名為“GC ROOT”的對象作為起始點,從這些結點開始向下搜索,搜索所走過的路徑稱為引用鏈,當一個對象到GC ROOT沒有任何引用鏈相連時,則證明這個對象是不可用的。如果對象在進行根搜索后發現沒有與GC ROOT相連接的引用鏈,則會被第一次第標記,并看此對象是否需要執行finalize()方法(忘記finalize()這個方法吧,它可以被try-finally或其他方式代替的),當第二次被標記時,對象就會被回收。
三、Java虛擬機基本垃圾回收算法:
1.標記-清除(Mark-Sweep)


此算法執行分兩階段。第一階段從引用根節點開始標記所有被引用的對象,第二階段遍歷整個堆,把未標記的對象清除。它停止所有工作,收集器從根開始訪問每一個活躍的節點,標記它所訪問的每一個節點。走過所有引用后,收集就完成了,然后就對堆進行清除(即對堆中的每一個對象進行檢查),所有沒有標記的對象都作為垃圾回收并返回空閑列表。下圖 展示了垃圾收集之前的堆,陰影塊是垃圾,因為用戶程序不能到達它們:
可到達和不可到達的對象
[圖片上傳中。。。(24)]
標記-清除實現起來很簡單,可以容易地回收循環的結構,并且不像引用計數那樣增加編譯器或者賦值函數的負擔。但是它也有不足 ―― 收集暫??赡軙荛L,在清除階段整個堆都是可訪問的,這對于可能有頁面交換的堆的虛擬內存系統有非常負面的性能影響。
標記-清除的最大問題是,每一個活躍的(即已分配的)對象,不管是不是可到達的,在清除階段都是可以訪問的。因為很多對象都可能成為垃圾,這意思著收集器花費大量精力去檢查并處理垃圾。標記-清除收集器還容易使堆產生碎片,這會產生區域性問題并可以造成分配失敗,即使看來有足夠的自由內存可用。此算法需要暫停整個應用,同時,會產生內存碎片。
[圖片上傳中。。。(25)]

2.復制(Copying)

此算法把內存空間劃為兩個相等的區域,每次只使用其中一個區域。垃圾回收時,遍歷當前使用區域,把正在使用中的對象復制到另外一個區域中。次算法每次只處理正在使用中的對象,因此復制成本比較小,同時復制過去以后還能進行相應的內存整理,不過出現“碎片”問題。當然,此算法的缺點也是很明顯的,就是需要兩倍內存空間。


[圖片上傳中。。。(28)]

3.標記-整理(Mark-Compact)

此算法結合了“標記-清除”和“復制”兩個算法的優點。也是分兩階段,第一階段從根節點開始標記所有被引用對象,第二階段遍歷整個堆,把清除未標記對象并且把存活對象“壓縮”到堆的其中一塊,按順序排放。此算法避免了“標記-清除”的碎片問題,同時也避免了“復制”算法的空間問題。


[圖片上傳中。。。(30)]

4.增量收集(Incremental Collecting)
實施垃圾回收算法,即:在應用進行的同時進行垃圾回收。不知道什么原因JDK5.0中的收集器沒有使用這種算法的。

5.堆內存的分代回收

[圖片上傳中。。。(31)]

[圖片上傳中。。。(33)]
新生代(Young)

新生代包括兩個區:Eden區和Survivor區,其中Survivor區一般也分成兩塊,簡稱Survivor1 Space 和 Survivor2 Space (或者From Space 和 To Space)。新生代通常存活時間較短,因此基于標記清除復制算法來進行回收,掃描出存活的對象,并復制到一塊新的完全未使用的空間中,對應于新生代,就是在Eden和From或To之間copy。新生代采用空閑指針的方式來控制GC觸發,指針保持最后一個分配的對象在新生代區間的位置,當有新的對象要分配內存時,用于檢查空間是否足夠,不夠就觸發GC。當連續分配對象時,對象會逐漸從eden到Survior,最后到舊生代。

可以采用串行處理和并行處理器

老年代(Old)

在垃圾回收多次,如果對象仍然存活,并且新生代的空間不夠,則對象會存放在老年代。
在老年代采用的是 標記清除壓縮算法。因為老年代的對象一般存活時間比較長,每次標記清除之后,會有很多的零碎空間,這個就是所謂的浮動垃圾。當老年代的零碎空間不足以分配一個大的對象的時候,就會采用壓縮算法。在壓縮的時候,應用需要暫停。
可以采用串行處理,并行處理器,以及并發處理器。

持久代(Permanent)

這部分空間主要存放Java方法區的數據以及啟動類加載器加載的對象。這一部分對象通常不會被回收。所以持久代空間在默認的情況下是不會被垃圾回收的。

  由于把內存空間分為三塊,一般把新生代的GC稱為minor GC ,把老年代的GC成為 full GC,所謂full gc 會先出發一次minor gc,然后在進行老年代的GC。

具體的過程如下:
首先想eden區申請分配空間,如果空間夠,就直接進行分配,否則進行一次Minor GC。minor GC 首先會對Eden區的對象進行標記,標記出來存活的對象。然后把存活的對象copy到From空間。如果From空間足夠,則回收eden區可回收的對象。如果from內存空間不夠,則把From空間存活的對象復制到To區,如果TO區的內存空間也不夠的話,則把To區存活的對象復制到老年代。如果老年代空間也不夠(或者達到觸發老年年垃圾回收條件的話)則觸發一次full GC。

四、探秘Java虛擬機 gc的監控

[圖片上傳中。。。(34)]

2、常用的內存區域調節參數
-Xms:初始堆大小,默認為物理內存的1/64(<1GB);默認(MinHeapFreeRatio參數可以調整)空余堆內存小于40%時,JVM就會增大堆直到-Xmx的最大限制
-Xmx:最大堆大小,默認(MaxHeapFreeRatio參數可以調整)空余堆內存大于70%時,JVM會減少堆直到 -Xms的最小限制
-Xmn:新生代的內存空間大小,注意:此處的大小是(eden+ 2 survivor space)。與jmap -heap中顯示的New gen是不同的。整個堆大小=新生代大小 + 老生代大小 + 永久代大小。 在保證堆大小不變的情況下,增大新生代后,將會減小老生代大小。此值對系統性能影響較大,Sun官方推薦配置為整個堆的3/8。
-XX:SurvivorRatio:新生代中Eden區域與Survivor區域的容量比值,默認值為8。兩個Survivor區與一個Eden區的比值為2:8,一個Survivor區占整個年輕代的1/10。
-Xss:每個線程的堆棧大小。JDK5.0以后每個線程堆棧大小為1M,以前每個線程堆棧大小為256K。應根據應 用的線程所需內存大小進行適當調整。在相同物理內存下,減小這個值能生成更多的線程。但是操作系統對一個進程內的線程數還是有限制的,不能無限生成,經驗 值在3000~5000左右。一般小的應用, 如果棧不是很深, 應該是128k夠用的,大的應用建議使用256k。這個選項對性能影響比較大,需要嚴格的測試。和threadstacksize選項解釋很類似,官方文檔似乎沒有解釋,在論壇中有這樣一句話:"-Xss is translated in a VM flag named ThreadStackSize”一般設置這個值就可以了。
-XX:PermSize:設置永久代(perm gen)初始值。默認值為物理內存的1/64。
-XX:MaxPermSize:設置持久代最大值。物理內存的1/4。
3、內存分配方法
1)堆上分配 2)棧上分配 3)堆外分配(DirectByteBuffer或直接使用Unsafe.allocateMemory,但不推薦這種方式)
4、監控方法
1)系統程序運行時可通過jstat –gcutil來查看堆中各個內存區域的變化以及GC的工作狀態; 2)啟動時可添加-XX:+PrintGCDetails –Xloggc:<file>輸出到日志文件來查看GC的狀況; 3)jmap –heap可用于查看各個內存空間的大??;
5)斷代法可用GC匯總


一、新生代可用GC
1)串行GC(Serial Copying):client模式下默認GC方式,也可通過-XX:+UseSerialGC來強制指定;默認情況下 eden、s0、s1的大小通過-XX:SurvivorRatio來控制,默認為8,含義 為eden:s0的比例,啟動后可通過jmap –heap [pid]來查看。
默認情況下,僅在TLAB或eden上分配,只有兩種情況下會在老生代分配: 1、需要分配的內存大小超過eden space大小; 2、在配置了PretenureSizeThreshold的情況下,對象大小大于此值。
默認情況下,觸發Minor GC時: 之前Minor GC晉級到old的平均大小 < 老生代的剩余空間 < eden+from Survivor的使用空間。當HandlePromotionFailure為true,則僅觸發minor gc;如為false,則觸發full GC。
默認情況下,新生代對象晉升到老生代的規則:
1、經歷多次minor gc仍存活的對象,可通過以下參數來控制:以MaxTenuringThreshold值為準,默認為15。 2、to space放不下的,直接放入老生代;
2)并行GC(ParNew):CMS GC時默認采用,也可采用-XX:+UseParNewGC強制指定;垃圾回收的時候采用多線程的方式。
3)并行回收GC(Parallel Scavenge):server模式下默認的GC方式,也可采用-XX:+UseParallelGC強制指定;eden、s0、s1的大小可通過-XX:SurvivorRatio來控制,但默認情況下 以-XX:InitialSurivivorRatio為準,此值默認為8,代表的為新生代大小 : s0,這點要特別注意。
默認情況下,當TLAB、eden上分配都失敗時,判斷需要分配的內存大小是否 >= eden space的一半大小,如是就直接在老生代上分配;
默認情況下的垃圾回收規則:
1、在回收前PS GC會先檢測之前每次PS GC時,晉升到老生代的平均大小是否大于老生代的剩余空間,如大于則直接觸發full GC; 2、在回收后,也會按照上面的規則進行檢測。
默認情況下的新生代對象晉升到老生代的規則: 1、經歷多次minor gc仍存活的對象,可通過以下參數來控制:AlwaysTenure,默認false,表示只要minor GC時存活,就晉升到老生代;NeverTenure,默認false,表示永不晉升到老生代;上面兩個都沒設置的情冴下,如 UseAdaptiveSizePolicy,啟動時以InitialTenuringThreshold值作為存活次數的閾值,在每次ps gc后會動態調整,如不使用UseAdaptiveSizePolicy,則以MaxTenuringThreshold為準。 2、to space放不下的,直接放入老生代。
在回收后,如UseAdaptiveSizePolicy,PS GC會根據運行狀態動態調整eden、to以及TenuringThreshold的大小。如果不希望動態調整可設置 -XX:-UseAdaptiveSizePolicy。如希望跟蹤每次的變化情況,可在啟勱參數上增加: PrintAdaptiveSizePolicy。
二、老生代可用GC
1、串行GC(Serial Copying):client方式下默認GC方式,可通過-XX:+UseSerialGC強制指定。
觸發機制匯總: 1)old gen空間不足; 2)perm gen空間不足; 3)minor gc時的悲觀策略; 4)minor GC后在eden上分配內存仍然失?。? 5)執行heap dump時; 6)外部調用System.gc,可通過-XX:+DisableExplicitGC來禁止。
2、并行回收GC(Parallel Scavenge): server模式下默認GC方式,可通過-XX:+UseParallelGC強制指定; 并行的線程數為當cpu core<=8 ? cpu core : 3+(cpu core5)/8或通過-XX:ParallelGCThreads=x來強制指定。如ScavengeBeforeFullGC為true(默認 值),則先執行minor GC。
3、并行Compacting:可通過-XX:+UseParallelOldGC強制指定。
4、并發CMS:可通過-XX:+UseConcMarkSweepGC來強制指定。并發的線程數默認為:( 并行GC線程數+3)/4,也可通過ParallelCMSThreads指定。
觸發機制: 1、當老生代空間的使用到達一定比率時觸發;
Hotspot V 1.6中默認為65%,可通過PrintCMSInitiationStatistics(此參數在V 1.5中不能用)來查看這個值到底是多少;可通過CMSInitiatingOccupancyFraction來強制指定,默認值并不是賦值在了這個值 上,是根據如下公式計算出來的: ((100 - MinHeapFreeRatio) +(double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)/ 100.0; 其中,MinHeapFreeRatio默認值: 40 CMSTriggerRatio默認值: 80。
2、當perm gen采用CMS收集且空間使用到一定比率時觸發;
perm gen采用CMS收集需設置:-XX:+CMSClassUnloadingEnabled Hotspot V 1.6中默認為65%;可通過CMSInitiatingPermOccupancyFraction來強制指定,同樣,它是根據如下公式計算出來的: ((100 - MinHeapFreeRatio) +(double)(CMSTriggerPermRatio
MinHeapFreeRatio) / 100.0)/ 100.0; 其中,MinHeapFreeRatio默認值: 40 CMSTriggerPermRatio默認值: 80。
3、Hotspot根據成本計算決定是否需要執行CMS GC;可通過-XX:+UseCMSInitiatingOccupancyOnly來去掉這個動態執行的策略。 4、外部調用了System.gc,且設置了ExplicitGCInvokesConcurrent;需要注意,在hotspot 6中,在這種情況下如應用同時使用了NIO,可能會出現bug。
6、GC組合
1)默認GC組合

2)可選的GC組合

7、GC監測
1)jstat –gcutil [pid] [intervel] [count] 2)-verbose:gc // 可以輔助輸出一些詳細的GC信息;-XX:+PrintGCDetails // 輸出GC詳細信息;-XX:+PrintGCApplicationStoppedTime // 輸出GC造成應用暫停的時間 -XX:+PrintGCDateStamps // GC發生的時間信息;-XX:+PrintHeapAtGC // 在GC前后輸出堆中各個區域的大??;-Xloggc:[file] // 將GC信息輸出到單獨的文件中,建議都加上,這個消耗不大,而且對查問題和調優有很大的幫助。gc的日志拿下來后可使用GCLogViewer或 gchisto進行分析。 3)圖形化的情況下可直接用jvisualvm進行分析。
4)查看內存的消耗狀況
(1)長期消耗,可以直接dump,然后MAT(內存分析工具)查看即可
(2)短期消耗,圖形界面情況下,可使用jvisualvm的memory profiler或jprofiler。
8、系統調優方法
步驟:1、評估現狀 2、設定目標 3、嘗試調優 4、衡量調優 5、細微調整
設定目標:
1)降低Full GC的執行頻率? 2)降低Full GC的消耗時間? 3)降低Full GC所造成的應用停頓時間? 4)降低Minor GC執行頻率? 5)降低Minor GC消耗時間? 例如某系統的GC調優目標:降低Full GC執行頻率的同時,盡可能降低minor GC的執行頻率、消耗時間以及GC對應用造成的停頓時間。
衡量調優:
1、衡量工具 1)打印GC日志信息:-XX:+PrintGCDetails –XX:+PrintGCApplicationStoppedTime -Xloggc: {文件名} -XX:+PrintGCTimeStamps 2)jmap:(由于每個版本jvm的默認值可能會有改變,建議還是用jmap首先觀察下目前每個代的內存大小、GC方式) ? 3)運行狀況監測工具:jstat、jvisualvm、sar 、gclogviewer
2、應收集的信息 1)minor gc的執行頻率;full gc的執行頻率,每次GC耗時多少? 2)高峰期什么狀況? 3)minor gc回收的效果如何?survivor的消耗狀況如何,每次有多少對象會進入老生代? 4)full gc回收的效果如何?(簡單的memory leak判斷方法) 5)系統的load、cpu消耗、qps or tps、響應時間
QPS每秒查詢率:是對一個特定的查詢服務器在規定時間內所處理流量多少的衡量標準。在因特網上,作為域名服務器的機器性能經常用每秒查詢率來衡量。對應fetches/sec,即每秒的響應請求數,也即是最大吞吐能力。 TPS(Transaction Per Second):每秒鐘系統能夠處理的交易或事務的數量。
嘗試調優:
注意Java RMI的定時GC觸發機制,可通過:-XX:+DisableExplicitGC來禁止或通過 -Dsun.rmi.dgc.server.gcInterval=3600000來控制觸發的時間。
1)降低Full GC執行頻率 – 通常瓶頸 老生代本身占用的內存空間就一直偏高,所以只要稍微放點對象到老生代,就full GC了; 通常原因:系統緩存的東西太多; 例如:使用Oracle 10g驅動時preparedstatement cache太大; 查找辦法:現執行Dump然后再進行MAT分析;
(1)Minor GC后總是有對象不斷的進入老生代,導致老生代不斷的滿 通常原因:Survivor太小了 系統表現:系統響應太慢、請求量太大、每次請求分配的內存太多、分配的對象太大... 查找辦法:分析兩次minor GC之間到底哪些地方分配了內存; 利用jstat觀察Survivor的消耗狀況,-XX:PrintHeapAtGC,輸出GC前后的詳細信息; 對于系統響應慢可以采用系統優化,不是GC優化的內容;
(2)老生代的內存占用一直偏高 調優方法:① 擴大老生代的大?。p少新生代的大小或調大heap的 大?。?; 減少new注意對minor gc的影響并且同時有可能造成full gc還是嚴重; 調大heap注意full gc的時間的延長,cpu夠強悍嘛,os是32 bit的嗎? ② 程序優化(去掉一些不必要的緩存)
(3)Minor GC后總是有對象不斷的進入老生代 前提:這些進入老生代的對象在full GC時大部分都會被回收 調優方法: ① 降低Minor GC的執行頻率; ② 讓對象盡量在Minor GC中就被回收掉:增大Eden區、增大survivor、增大TenuringThreshold;注意這些可能會造成minor gc執行頻繁; ③ 切換成CMS GC:老生代還沒有滿就回收掉,從而降低Full GC觸發的可能性; ④ 程序優化:提升響應速度、降低每次請求分配的內存、
(4)降低單次Full GC的執行時間 通常原因:老生代太大了... 調優方法:1)是并行GC嗎? 2)升級CPU 3)減小Heap或老生代
(5)降低Minor GC執行頻率 通常原因:每次請求分配的內存多、請求量大 通常辦法:1)擴大heap、擴大新生代、擴大eden。注意點:降低每次請求分配的內存;橫向增加機器的數量分擔請求的數量。
(6)降低Minor GC執行時間 通常原因:新生代太大了,響應速度太慢了,導致每次Minor GC時存活的對象多 通常辦法:1)減小點新生代吧;2)增加CPU的數量、升級CPU的配置;加快系統的響應速度
細微調整:
首先需要了解以下情況:
① 當響應速度下降到多少或請求量上漲到多少時,系統會宕掉?
② 參數調整后系統多久會執行一次Minor GC,多久會執行一次Full GC,高峰期會如何?
需要計算的量:
①每次請求平均需要分配多少內存?系統的平均響應時間是多少呢?請求量是多少、多常時間執行一次Minor GC、Full GC?
②現有參數下,應該是多久一次Minor GC、Full GC,對比真實狀況,做一定的調整;
必殺技:提升響應速度、降低每次請求分配的內存?
9、系統調優舉例
現象:1、系統響應速度大概為100ms;2、當系統QPS增長到40時,機器每隔5秒就執行一次minor gc,每隔3分鐘就執行一次full gc,并且很快就一直full GC了;4、每次Full gc后舊生代大概會消耗400M,有點多了。
解決方案:解決Full GC次數過多的問題
(1)降低響應時間或請求次數,這個需要重構,比較麻煩;——這個是終極方法,往往能夠順利的解決問題,因為大部分的問題均是由程序自身造成的。
(2)減少老生代內存的消耗,比較靠譜;——可以通過分析Dump文件(jmap dump),并利用MAT查找內存消耗的原因,從而發現程序中造成老生代內存消耗的原因。
(3)減少每次請求的內存的消耗,貌似比較靠譜;——這個是海市蜃樓,沒有太好的辦法。
(4)降低GC造成的應用暫停的時間——可以采用CMS GS垃圾回收器。參數設置如下:
-Xms1536m -Xmx1536m -Xmn700m -XX:SurvivorRatio=7 -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection
-XX:CMSMaxAbortablePrecleanTime=1000 -XX:+CMSClassUnloadingEnabled -XX:+UseCMSInitiatingOccupancyOnly -XX:+DisableExplicitGC
(5)減少每次minor gc晉升到old的對象??蛇x方法:1) 調大新生代。2)調大Survivor。3)調大TenuringThreshold。
調大Survivor:當前采用PS GC,Survivor space會被動態調整。由于調整幅度很小,導致了經常有對象直接轉移到了老生代;于是禁止Survivor區的動態調整 了,-XX:-UseAdaptiveSizePolicy,并計算Survivor Space需要的大小,于是繼續觀察,并做微調…。最終將Full GC推遲到2小時1次。
10、垃圾回收的實現原理
內存回收的實現方法:1)引用計數:不適合復雜對象的引用關系,尤其是循環依賴的場景。2)有向圖Tracing:適合于復雜對象的引用關系場景,Hotspot采用這種。常用算法:Copying、Mark-Sweep、Mark-Compact。
Hotspot從root set開始掃描有引用的對象并對Reference類型的對象進行特殊處理。 以下是Root Set的列表:1)當前正在執行的線程;2)全局/靜態變量;3)JVM Handles;4)JNI 【 Java Native Interface 】Handles;
另外:minor GC只掃描新生代,當老生代的對象引用了新生代的對象時,會采用如下的處理方式:在給對象賦引用時,會經過一個write barrier的過程,以便檢查是否有老生代引用新生代對象的情況,如有則記錄到remember set中。并在minor gc時,remember set指向的新生代對象也作為root set。
新生代串行GC(Serial Copying):
新生代串行GC(Serial Copying)完整內存的分配策略:
1)首先在TLAB(本地線程分配緩沖區)上嘗試分配; 2)檢查是否需要在新生代上分配,如需要分配的大小小于PretenureSizeThreshold,則在eden區上進行分配,分配成功則返回;分配失敗則繼續; 3)檢查是否需要嘗試在老生代上分配,如需要,則遍歷所有代并檢查是否可在該代上分配,如可以則進行分配;如不需要在老生代上嘗試分配,則繼續; 4)根據策略決定執行新生代GC或Full GC,執行full gc時不清除soft Ref; 5)如需要分配的大小大于PretenureSizeThreshold,嘗試在老生代上分配,否則嘗試在新生代上分配; 6)嘗試擴大堆并分配; 7)執行full gc,并清除所有soft Ref,按步驟5繼續嘗試分配。
新生代串行GC(Serial Copying)完整內存回收策略 1)檢查to是否為空,不為空返回false; 2)檢查老生代剩余空間是否大于當前eden+from已用的大小,如大于則返回true,如小于且HandlePromotionFailure為 true,則檢查剩余空間是否大于之前每次minor gc晉級到老生代的平均大小,如大于返回true,如小于返回false。 3)如上面的結果為false,則執行full gc;如上面的結果為true,執行下面的步驟; 4)掃描引用關系,將活的對象copy到to space,如對象在minor gc中的存活次數超過tenuring_threshold或分配失敗,則往老生代復制,如仍然復制失敗,則取決于 HandlePromotionFailure,如不需要處理,直接拋出OOM,并退出vm,如需處理,則保持這些新生代對象不動;
新生代可用GC-PS
完整內存分配策略 1)先在TLAB上分配,分配失敗則直接在eden上分配; 2)當eden上分配失敗時,檢查需要分配的大小是否 >= eden space的一半,如是,則直接在老生代分配; 3)如分配仍然失敗,且gc已超過頻率,則拋出OOM; 4)進入基本分配策略失敗的模式; 5)執行PS GC,在eden上分配; 6)執行非最大壓縮的full gc,在eden上分配; 7)在舊生代上分配; 8)執行最大壓縮full gc,在eden上分配; 9)在舊生代上分配; 10)如還失敗,回到2。
最悲慘的情況,分配觸發多次PS GC和多次Full GC,直到OOM。
完整內存回收策略 1)如gc所執行的時間超過,直接結束; 2)先調用invoke_nopolicy 2.1 先檢查是不是要嘗試scavenge; 2.1.1 to space必須為空,如不為空,則返回false; 2.1.2 獲取之前所有minor gc晉級到old的平均大小,并對比目前eden+from已使用的大小,取更小的一個值,如老生代剩余空間小于此值,則返回false,如大于則返回true; 2.2 如不需要嘗試scavenge,則返回false,否則繼續; 2.3 多線程掃描活的對象,并基亍copying算法回收,回收時相應的晉升對象到舊生代; 2.4 如UseAdaptiveSizePolicy,那么重新計算to space和tenuringThreshold的值,并調整。 3)如invoke_nopolicy返回的是false,或之前所有minor gc晉級到老生代的平均大小 > 舊生代的剩余空間,那么繼續下面的步驟,否則結束; 4)如UseParallelOldGC,則執行PSParallelCompact,如不是UseParallelOldGC,則執行PSMarkSweep。
老生代并行CMS GC:
優缺點:
1) 大部分時候和應用并發進行,因此只會造成很短的暫停時間; 2)浮動垃圾,沒辦法,所以內存空間要稍微大一點; 3)內存碎片,-XX:+UseCMSCompactAtFullCollection 來解決; 4) 爭搶CPU,這GC方式就這樣; 5)多次remark,所以總的gc時間會比并行的長; 6)內存分配,free list方式,so性能稍差,對minor GC會有一點影響; 7)和應用并發,有可能分配和回收同時,產生競爭,引入了鎖,JVM分配優先。
11、TLAB的解釋
堆內的對象數據是各個線程所共享的,所以當在堆內創建新的對象時,就需要進行鎖操作。鎖操作是比較耗時,因此JVM為每個線在堆上分配了一塊“自留地” ——TLAB(全稱是Thread Local Allocation Buffer),位于堆內存的新生代,也就是Eden區。每個線程在創建新的對象時,會首先嘗試在自己的TLAB里進行分配,如果成功就返回,失敗了再到 共享的Eden區里去申請空間。在線程自己的TLAB區域創建對象失敗一般有兩個原因:一是對象太大,二是自己的TLAB區剩余空間不夠。通常默認的 TLAB區域大小是Eden區域的1%,當然也可以手工進行調整,對應的JVM參數是-XX:TLABWasteTargetPercent?!秊ava虛擬機》匯總所有關鍵要點 - 小草君技術專欄 - 博客頻道 - CSDN.NET http://blog.csdn.net/ldds_520/article/details/51932125

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,646評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,595評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,560評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,035評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,814評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,224評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,301評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,444評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,988評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,804評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,998評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,544評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,237評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,665評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,927評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,706評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,993評論 2 374

推薦閱讀更多精彩內容