《深入理解Java虛擬機-JVM高級特性與最佳實踐》學習總結(第三章)

首先針對垃圾收集提出的兩個問題?

  1. 什么時候回收?
  2. 怎么回收?

針對問題1,為了回答什么時候回收這個問題,就需要清楚處于怎樣狀態下的對象才需要回收。

處于怎樣狀態下的對象才需要回收?在強引用下,只有當對象失去所有引用的時候,才要對其進行回收。

那么如何判斷對象處于是否引用的狀態呢?

目前有兩種主流辦法:

  • 引用計數法

引用計數法是指每個對象都有一個引用計數器,每當該對象被引用,計數器加1,失去一個引用,計數器減1。如果該對象的計數器值為0時,則說明該對象無任何引用。

該方法的優勢:判定效率高

劣勢:解決不了"循環引用"問題

那么,何為循環引用呢?

舉個例子:

//jack所引用的對象引用計數加1,reference = 1
Student jack = new Student();

//lucy所引用的對象引用計數加1,reference = 1
Student lucy = new Student();

//jack.goodFriend所引用的對象(即為jack所引用的對象)引用計數加1,reference = 2
jack.goodFriend = lucy;

//jack.goodFriend所引用的對象(即為lucy所引用的對象)引用計數加1,reference = 2
lucy.goodFriend = jack;

//jack.goodFriend所引用的對象(即為jack所引用的對象)引用計數減1,reference = 1
jack = null;

//jack.goodFriend所引用的對象(即為lucy所引用的對象)引用計數減1,reference = 1
lucy = null;

** 在該例子中,雖然兩個對象還都有引用計數,但是經過 jack = nulllucy = null 之后,都無法再訪問這兩個對象了。所以由于這個劣勢,在java中并沒有使用到它,相反,使用的下面這種垃圾收集搜索算法 **

  • 根搜索算法

根搜索算法是指:通過一系列名為"GC Roots"的對象作為起始點,從這些節點起開始向下搜索,搜索所走過的路徑成為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連,即該對象到GC Roots不可達時,則此對象已經失去所有引用,已不可用了。

那么,哪些對象可以充當GC Roots對象呢?(為什么呢?)

  • 虛擬機棧(棧幀中的本地變量表)中的引用的對象
  • 方法區中的類靜態屬性引用的對象
  • 方法區中的常量引用的對象
  • 本地方法棧中JNI(Native方法)的引用的對象

擴展,"引用"這個詞語,在Java里面的意思可有很多呢,光我知道就至少有四個,它們分別是:

  • 強引用(Strong References)

對于強引用,則是我們經常在程序里面new一個對象,即 Boy boy = new Boy(),只要boy不置為null,則我們new的這個對象就會一直存在,垃圾收集器這家伙就不敢拿它怎么辦。

  • 軟引用(Soft References)

對于軟引用的聲明 SoftReference<T> softReference = new SoftReference<T>(Object obj);

具體例子: SoftReference<T> softReference = new SoftReference<T>(new Boy());

這個時候該Boy()實例就持有一個強引用boy和一個軟引用softReference。

那么該軟引用softReference有什么用呢?

boy = null 的時候,此時可以通過softReference.get()方法來重新獲得一個Boy的強引用。那么我就在想了,為什么要存在這個軟引用呢?我再用一個強引用指向Boy不也可以嗎?

先來看一下軟引用的特點:

該軟引用的確可以重新獲得一個該對象的強引用,而且該軟引用指向的對象也不會被垃圾收集器給收集,但是一旦JVM發現內存不夠,那接下來就要對這些軟引用開刀了---對它們所引用的對象進行收集,以獲得所需要的內存。而一旦垃圾收集結束,該softReference.get()方法返回的便是null了。所以,軟引用這種引用可以幫助我們再次獲得強引用,但是它也有可能會被清理掉。

那么這種特點的意義何在?

軟引用指向一個對象,一塊內存,但是該對象我們有可能會使用到它,所以我們需要一個get()方法來立即獲得該對象的一個強引用,但是也可能不會使用到它,可這樣的話,這個對象又占著一塊內存資源,所以我認為在這里JVM非常巧妙地采取了一種折中辦法,在內存不夠,OutOfMemory的時候,就要把這個對象給回收掉,空出多余的內存供系統正常使用。實在是妙呀!那么在什么應用場景下會使用到軟引用呢?通過對軟引用的了解,我認為在對數據、資源進行緩存的時候需要用到,有些非必須資源我們可以用一個軟引用持有,當還沒被回收掉的時候,可以提升應用程序的性能,而當內存不夠,需要回收的時候,那就給回收掉,也沒什么太大的損失。

  • 弱引用(Weak References)

弱引用是什么?

聲明一個弱引用WeakReference<T> weakWidget = new WeakReference<T>(Object obj)

舉個例子:WeakReference<T> weakReference = new WeakReference<T>(new Boy());
這個時候weakReference就是作為一個指向Boy對象的弱引用。

那么這個弱引用有什么特點呢?

在JVM中,如果一個對象被一個弱引用所指向,那么該對象首先會在第一次垃圾收集周期被標記(沒有任何條件,直接就會被標記),然后在第二次垃圾收集周期被回收掉。

不過在JAVA中提供了一個WeakHashMap()類,根據名字就可以得知這個類的一些基本用法了。WeakHashMap()類中的key為弱引用類型,value則為實例對象。當key所引用的對象被清理掉之后,該WeakHashMap()則會自動調用remove()方法來將對應的一組key-value給刪除掉。

那么弱引用的這種特點有什么作用嗎?

在學習這個弱引用的時候,查閱了許多的英文資料,不同的資料描述不一樣,但是基本上說的還都是同一個東西。于此同時又對比了一下軟引用,個人認為弱引用和它的功能比較類似,也是作為數據、資源緩存的一個很好的API,但之所以弱引用中有一個 "弱"字,就是因為該類型的引用不需要任何條件,直接就會被標記為垃圾,然后接下來一步就會被清理掉。所以在清理之前,我們可以通過 weakReference.get()來再次獲得一個該對象的引用,等到清理掉之后,返回的就是null。

4.虛引用(Phantom References)

虛引用可以理解為該引用指向了一個已經調用過一次finalize()方法的對象,那么再下一次垃圾收集的時候,就果斷將該對象給回收掉。虛引用是引用中最弱最弱的一種,以至于調用get()方法返回值始終都是null

虛引用的清除過程大概是怎樣的呢?

Java提供了一個ReferenceQueue類,即為引用隊列類,JVM會將該虛引用入隊到該ReferenceQueue,等到出隊的時候,也就是對象回收的時候,與此同時,也會給系統發送一個信號,表示該對象已被回收。

所以根據"對象被回收要接受到信號"這個特性,我們便先人一步知道了該對象被回收的時間,這個時候我們可以做一些后續的操作。

好了,總結完了對象何時會被回收之后,接下來要看看對象是如何被回收的。提到"如何"二字,如果用編程的思想來考慮的話,就是設計算法的問題了。

所以在"如何回收對象"這個問題上,JVM給我們提供了四種方法來解決。

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

簡述一下該算法:該算法分為兩個階段:標記清除 階段。
在標記階段,JVM所要做的事情有,給待回收的對象做上標記。
在清除階段,JVM則會命令垃圾收集器在一次垃圾回收的時候對已經被標記的對象進行大清理。

但是,該算法存在怎樣的問題呢?

會有內存碎片的產生

那么產生內存碎片有什么危害嗎?

內存碎片一旦產生,就意味著我們的一部分內存就被分割成一塊一塊較小的內存了,這樣每當有占有內存較大的對象要來分配的話,我們沒有足夠的內存來提供,但是這個對象又不可能不給人家分配內分對不對?所以JVM就不得不再次把垃圾收集器給叫過來,說:"看吧,都說了不建議你用這種 標記-清除方式 方法來干活,你就是不聽,看看現在麻煩來了吧?你趕緊再去收集一次垃圾吧,抓緊騰出一塊地方給剛剛那個新來的客人,人家是客,我們可惹不起"。垃圾收集器受到老大的這般訓斥后,就趕緊屁顛屁顛地跑過去干活了。

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

簡述一下該算法:該算法與上一次算法的不同之處就在于,當每個待回收的對象被做上標記之后,垃圾收集器先不著急把它們一個個地給回收掉,而且先粗中有細地先把每個對象進行一個整理,怎么整理呢?將被標記的對象從第一個到最后一個依次有序地重新排列在內存的一端,然后再給一鍋端了,這樣做的好處與第一個算法相比,好處自然是大大的,為什么呢?因為不會產生內存碎片呀!

注意:該算法一般用在老年代內存區

  • 復制(Copying)算法
復制算法

簡述一下該算法:"復制"二字,我們可以大概猜測這種算法可能是要復制一塊內存吧?沒錯,準確地說,這種算法它會將內存分為均等的兩份cake1和cake2,每份一模一樣,不多也不少。然后在為對象分配內存的時候,會先在cake1上分配。最后當cake1上的內存被用光,要用到cake2內存的時候,就會先去cake1上檢測哪些對象是可回收的,哪些是不可回收的。對于暫時還不可回收的對象,我們就直接將其依次有序地復制到cake2上,對于那些可回收的對象,就果斷讓垃圾收集器過來把它們統統給趕走。這樣一來,我們的cake1就又完全變成一塊嶄新等待開發的內存了。這樣每當再次需要為對象分配內存的時候,就在cake2上進行,接下來的過程就像第一次一樣,循環交互,協同工作。

這種算法的優點:很明顯,這種算法也不會產生內存碎片(其實只要不是隨意地對對象進行回收,回收之前或者之后稍微做一些處理,都不會產生內存碎片的),而且實現簡單,運行高效。

不過上面的那一種算法只是剛誕生時候的設計,它將內存按照1:1的比例來分配,這樣有時候會造成50%的內存浪費,這對于程序員來說真得很讓人痛心,那么這種算法有沒有什么改進呢?

引用一段來自周志明先生所著的《深入理解Java虛擬機——JVM高級特性與最佳實踐(第2版)》的原話:

IBM的專門研究表明,新生代中的對象98%是朝生夕死的,所以并不需要按照1:1的比例來劃分內存空間。而是將內存分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次需要為對象分配內存的時候就現在Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中還存活著的對象一次性拷貝到另外一個Survivor空間上,最后清理掉Eden和剛才用過的Survivor的空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內存空間為整個新生代容量的90%,只有10%的內存是會被"浪費"的。

從這段話中我知道了原來該算法將內存劃分比例從1:1調整到了8:1,其中劃分了三個區域:Eden和兩個Survivor,然后內存分配首先在Eden和一塊Surivor上(也就是我的那個cake1),然后
當一次垃圾收集到來的時候,會根據上邊復制算法描述的那樣,該轉移的轉移,該清除的清除。不過轉移的內存是第二塊Survivor區域。

看完這本書里面的這段描述之后,覺得豁然開朗。但是隨著思維的慣性又思考下去,發現遇到了一個問題:如果第二塊Survivor的內存不夠存儲轉移的對象了該怎么辦?就不存儲內存了嗎?或者會發生內存溢出嗎?又接著看下去發現原來書中對我這個疑惑給予了一定的解釋,他是這樣說的

當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多余10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這里指老年代)進行分配擔保(Handle Promotion)。

有了這段話的解釋,就多多少少解決了一些我的疑惑。不過又發現這兩段引用中,有兩個詞不是太理解,一個叫"新生代",一個叫"老年代"。這兩個**代指的又是什么呢?

4.分代收集(Generational Collection)算法

簡述一下分代收集算法:"分代"是指根據對象的存活周期的不同把內存劃分為幾塊,一般是把java堆分為新生代和老年代。哈哈,這里終于提到了"新生代"和"老年代"啦!那么,它倆具體指什么呢?

看一下這本書對其的簡單介紹:

在新生代中,每次垃圾收集時都會有大批對象死去,只有少量存活。那就選用復制算法,只需要付出少量存活對象的復制成本就可以完成收集。而老年代中因為對象存活率高,沒有額外空間對它進行分配擔保,就必須使用"標記-清理"或"標記-整理"算法來進行回收。

真好,不光介紹了這兩個代,而且把我剛剛學過的那些算法也用在相應了代上了。根據復制算法的特點我知道了何時選擇它,當大部分對象處于"朝生夕死",使用次數不多的時候,這種算法就突顯出了它的優勢:內存浪費少,不會產生內存碎片,并且實現簡單,運行高效。而當對象存活率高的時候,就用那兩個標記算法。

垃圾收集器

前邊學習了垃圾收集都有哪些算法,那么接下來就要來了解一下運行這些垃圾收集算法的東西,那就是垃圾收集器。

垃圾收集器的分類

HotSpot虛擬機的垃圾收集器

Serial收集器

Serial收集器是最基本、歷史最悠久的收集器,在JDK1.3.1之前是虛擬機新生代收集的唯一選擇。

特點:單線程,簡單高效(因為沒有線程交互所帶來的系統開銷),進行垃圾收集時需要暫停其他所有線程(stop the world),所以就會有一定的卡頓現象。

Serial垃圾收集器

應用:虛擬機在Clinet模式下的默認新生代收集器。

ParNew收集器

ParNew收集器是Serial收集器的多線程版本。

特點:多線程,速度相對較慢(因為有線程交互所帶來的系統開銷),進行垃圾收集時需要暫停其他所有進程

ParNew垃圾收集器

應用:虛擬機在Server模式下首選的新生代收集器,不過為什么呢?目前除了Serial收集器外,目前只有它能與CMS收集器配合工作。

Parallel Scavenge收集器

Parallel Scavenge收集器是一個多線程的新生代收集器,使用復制算法。

特點:多線程,吞吐量優先

所謂吞吐量是指:CPU運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)

Serial Old收集器

Serial Old收集器是Serial收集器的單線程老年代版本,使用"標記-整理"算法

特點:適用于老年代,單線程

Serial Old垃圾收集器

應用:
虛擬機在Client模式下使用該收集器;
在Server模式下,在JDK1.5及之前的版本中與Parallel Scavenge收集器搭配使用作為CMD收集器的后備預案,在并發收集發生Concurrent Mode Failure的時候使用。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和"標記-整理"算法。

特點:適用于老年代,多線程

Parallel Old垃圾收集器

應用:在注重吞吐量及CPU資源敏感的場合,優先考慮Parallel Scavenge收集器和Parallel Old收集器

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,基于"標記-清除"算法。

收集過程:
1.初始標記(CMS initial mark)

特點:單線程,stop the world

作用:僅僅是標記一下GC Roots能直接關聯到的對象,速度很快

2.并發標記(CMS concurrent mark)

特點:單線程,與其他線程并發運行

3.重新標記(CMS remark)

特點:多線程,stop the world

作用:修正并發標記期間,因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄。

4.并發清除(CMS concurrent sweep)

特點:單線程,與其他線程并發運行

CMS垃圾收集器

應用:服務端

很明顯的缺點:

1.對CPU資源敏感。CMS默認啟動的回收線程(CPU數量 + 3) / 4 ,當CPU >= 4的時候,并發回收時垃圾收集線程最多占用不超過25%的CPU資源,但是當CPU < 4 的時候,CMS對用戶程序的影響就可能變得很大。

2.CMS無法處理"浮動垃圾"(Floating Garbage),可能出現"Concurrent Mode Failure"失敗而導致另一次Full GC的產生。 什么是"浮動垃圾"呢?在CMS結束標記之后,有一部分對象也成可以被清理的垃圾了,可CMS無法在本次的垃圾處理過程中回收掉它們,所以又動態產生的這部分垃圾叫做"浮動垃圾"。在CMS運行的同時,也有用戶線程在運行,所以就需要預留夠足夠的內存空間給用戶線程,而當CMS不能保證這一點的時候,就會出現"Concurrent Mode Failure"這種錯誤。

3.CMS基于的"標記-清除"算法會產生內存碎片。(不過CMS較好地解決了這種問題,解決的辦法便是在經過一次的CMS垃圾處理過程服務之后,還會再送一個碎片整理服務)

G1收集器

G1收集器是當前收集器技術發展的最前沿成果,基于"標記-整理"算法,

特點:能夠精確地控制停頓,可以實現在基本不犧牲吞吐量的前提下完成低停頓的內存回收。

那么,為什么有以上優點呢?引用一段來自《深入理解Java虛擬機-JVM高級特性與最佳實踐》

G1收集器極力地避免全區域的垃圾收集,之前的收集器進行收集的范圍都是整個新生代或老年代,而G1將整個JAVA堆(包括新生代、老年代)劃分為多個大小固定的獨立區域(Region),并且跟蹤這些區域里面的垃圾堆積程度,在后臺維護一個優先列表,每次根據允許的收集時間,優先回收垃圾最多的區域(Garbage First名字的由來)。這樣一來,區域劃分以及有優先級的區域回收,保證了G1收集器在有限的時間內可以獲得最高的收集效率

內存分配與回收策略

1.對象優先在Eden分配

大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠的空間進行分配時,虛擬機將發起一次Minor Gc(新生代垃圾收集,復制算法)。

2.大對象直接進入老年代

1.什么是大對象?

需要大量連續內存空間的Java對象(很長的字符串和數組)

那么,這樣的大對象為什么要直接進入老年代呢?

因為經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來"安置"它們。所以,虛擬機提供了一個-XX:PretenureSizeThreshold參數,如果所需內存值超過該參數值,就直接在老年代中分配,這樣就直接避免了新生代區頻繁地進行GC操作了。

3.長期存活的對象將進入老年代

如何衡量一個對象的存活時間呢?

JVM為每個對象設置了一個對象年齡計數器,每一次進行分代收集之后,如果位于新生代的對象還沒有被收集的話,該年齡計數器加1,如果該值超過一個閥值(默認為15歲),則該對象會被調入到老年代中去享福咯!

4.動態對象年齡判定

這是另一種可以進入老年代的途徑:如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,而無須等待到當初設定的那個閥值。

5.空間分配擔保

JVM將內存分為新生代和老年代,在新生代又分為一個Eden區和兩個Survivor區域,在進行垃圾收集的時候,第二塊Survivor區域用于存儲還存活的對象,但是有可能會存在所有存活對象所占內存過多,導致Survivor區域不夠用,這個時候就要向老年代區域申請擔保,把多余的對象放在老年區。不過此時需要有一個對老年區是否也能存放得下這些對象的一個評估,那就是根據之前每次晉升到老年代的平均大小是否大于老年代的剩余空間大小,

如果大于的話,就意味著此次的對象有很大的可能性是晉升不到老年代區的,意思就是老年代內存有很大可能是不夠用的,那么該怎么做呢?只能把老年代區進行一次Full GC 來騰出一些空間了。

但是如果平均大小小于剩余空間的話,那就意味著有很大可能性是能夠晉升的,那么就趕緊把這些對象給放進老年代區嗎?等等!這里還有一個HandlePromotionFailure設置選項,該選項的意思是是否允許擔保失敗(這里是有可能失敗的)。如果允許,一旦老年代區放不下,那就立馬在新生代區執行MinorGC垃圾收集過程。如果不允許的話,一旦老年代放不下,那就要在老年代立馬進行一次Full GC垃圾收集了。這樣,無論哪種情況發生,我們要么在新生代進行MinorGC或者在老年代進行FullGC,這樣總能盡最大可能來為對象分配內存空間。

參考資料

英文資料一

英文資料二

書籍:《深入理解Java虛擬機-JVM高級特性與最佳實踐》周志強

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

推薦閱讀更多精彩內容