深入理解JVM02 - 垃圾收集器與內存分配策略

"??????This tutorial is experimental and unsupported."

上期課后作業解答

  • 問題: JVM內存區域劃分為哪些區域,以及哪些區域可能發生OutOfMemoryError?

參考回答:

  1. JVM內存區域分為下面幾個方面
  • 程序計數器(PC,Program Counter Register)
  • Java 虛擬機棧(Java Virtual Machine Stack)
  • 堆(Heap)
  • 方法區(Method Area)
  • 運行時常量池(Run-Time Constant Pool)
  • 本地方法棧(Native Method Stack)
  1. 會發生OOM的區域有:
  • 堆內存不足是最常見的 OOM 原因之一,拋出的錯誤信息是“java.lang.OutOfMemoryError:Java heap space”。
  • 對于 Java 虛擬機棧和本地方法棧,如果我們寫一段程序不斷的進行遞歸調用,而且沒有退出條件,就會導致不斷地進行壓棧。類似這種情況,JVM 實際會拋出 StackOverFlowError;當然,如果JVM 試圖去擴展棧空間的的時候失敗,則會拋出 OutOfMemoryError。
  • 方法區內存不足,出現 OOM,異常信息:“java.lang.OutOfMemoryError:Metaspace”。
  • 直接內存不足,也會導致 OOM。

----- 下面正文開始:

垃圾收集(Garbage Collection,GC)需要完成的3件事情:

  • 哪些內存需要回收?
  • 什么時候回收?
  • 如何回收?

JVM01介紹了Java內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧3個區域隨著線程而生,隨線程而滅;而Java堆和方法區則不一樣,只有在程序處于運行時才能知道會創建哪些對象,這部分內存的創建和回收都是動態的,垃圾收集器所關注的是這部分內存。

to be or not to be

確定對象”存活“還是”死去“。

引用計數算法

給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;

任何時刻計數器為0的對象就是不可能再被使用的,這時候變可通知GC收集器回收這些對象。

引用計數算法.png
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();
    }
}

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

但是Java虛擬機里面沒有選用引用計數算法來管理內存,其中最主要的原因是它很難解決對象之間相互循環引用的問題。

優點:簡單,高效,現在的objective-c用的就是這種算法。
缺點:很難處理循環引用,相互引用的兩個對象則無法釋放。(需要開發者自己處理)

可達性分析算法

Java虛擬機中,是通過可達性分析(Reachability Analysis)來判定對象是否存活的。

這個算法的基本思路是通過一系列稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈項鏈時,則證明此對象是不可用的。

可達性分析算法.png

GC Roots的對象:

  • 虛擬機棧(棧幀中的局部變量表)中的引用對象
  • 方法區中類靜態屬性(靜態對象)引用的對象
  • 方法區中常量(final 修飾的成員對象)引用的對象。
  • 本地方法棧中JNI(Native)引用的對象。

引用

判定對象是否存活(需要回收)都與“引用”有關。Java對引用的概念擴充,將引用分為強引用(Strong Reference),軟引用(Soft Reference),弱引用(Weak Reference),虛引用(Phantom Refernce)。

  • 強引用 : 類似Object obj=new Object()這類的引用,只要強引用還在,垃圾收集器永遠不會回收掉被引用的對象。
  • 軟引用 : 軟引用是用來描述一些有用但非必須的對象,在系統將要發生內存溢出(OOM)異常之前,將會把這些對象列進回收范圍之中進行二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出(OOM)異常。JDK1.2之后提供了SoftReference類來實現軟引用。
  • 弱引用 : 弱引用也是用來描述非必需對象的,但是它的引用比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。JDK1.2之后提供了SoftReference類來實現軟引用。
  • 虛引用 : 虛引用也成為幽靈引用或者幻影引用,它是最弱的一種引用關系。無法通過虛引用來取得一個對象的實例。為一個對象設置虛引用的唯一目的就是能夠在這個對象被回收的時候收到一個系統通知。

垃圾收集算法

標記清除算法

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

標記清除算法.png

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

  • 效率問題:標記和清除兩個過程的效率都不高;
  • 空間問題:標記-清除算法不需要進行對象的移動,并且僅對不存活的對象進行處理,因此標記清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
15479928257361.jpg

復制算法

復制算法將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。

這種算法適用于對象存活率低的場景,比如新生代。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。該算法示意圖如下所示:

15480247746757.jpg

事實上,現在商用的虛擬機都采用這種算法來回收新生代。因為研究發現,新生代中的對象每次回收都基本上只有10%左右的對象存活,所以需要復制的對象很少,效率還不錯。實踐中會將新生代內存分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的對象一次地復制到另外一塊Survivor空間上,最后清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用內存空間為整個新生代容量的90% ( 80%+10% ),只有10% 的內存會被“浪費”。

標記整理算法

復制收集算法在對象存活率較高時就要進行較多的復制操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。

標記整理算法的標記過程類似標記清除算法,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存,類似于磁盤整理的過程,該垃圾回收算法適用于對象存活率高的場景(老年代),其作用原理如下圖所示。

標記整理算法.png

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

15480252982828.jpg

分代收集算法

對于一個大型的系統,當創建的對象和方法變量比較多時,堆內存中的對象也會比較多,如果逐一分析對象是否該回收,那么勢必造成效率低下。

分代收集算法是基于這樣一個事實:不同的對象的生命周期(存活情況)是不一樣的,而不同生命周期的對象位于堆中不同的區域,因此對堆內存不同區域采用不同的策略進行回收可以提高 JVM 的執行效率。

當代商用虛擬機使用的都是分代收集算法:新生代對象存活率低,就采用復制算法;老年代存活率高,就用標記清除算法或者標記整理算法。Java堆內存一般可以分為新生代、老年代和永久代三個模塊,如下圖所示:

15480256054339.jpg

GC算法小結

GC算法小結.png

由于對象進行了分代處理,因此垃圾回收區域、時間也不一樣。垃圾回收有兩種類型,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收集器。不同收集器之間的連線表示它們可以搭配使用。

15481131132194.jpg
  • Serial收集器(復制算法): 新生代單線程收集器,標記和清理都是單線程,優點是簡單高效;
  • ParNew收集器 (復制算法): 新生代收并行集器,實際上是Serial收集器的多線程版本,在多核CPU環境下有著比Serial更好的表現;
  • Parallel Scavenge收集器 (復制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用戶線程時間/(用戶線程時間+GC線程時間),高吞吐量可以高效率的利用CPU時間,盡快完成程序的運算任務,適合后臺應用等對交互相應要求不高的場景;
  • Serial Old收集器 (標記-整理算法): 老年代單線程收集器,Serial收集器的老年代版本;
  • Parallel Old收集器 (標記-整理算法): 老年代并行收集器,吞吐量優先,Parallel Scavenge收集器的老年代版本;
  • CMS(Concurrent Mark Sweep)收集器(標記-清除算法): 老年代并行收集器,以獲取最短回收停頓時間為目標的收集器,具有高并發、低停頓的特點,追求最短GC回收停頓時間。
  • G1(Garbage First)收集器 (標記-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基于“標記-整理”算法實現,也就是說不會產生內存碎片。此外,G1收集器不同于之前的收集器的一個重要特點是:G1回收的范圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的范圍僅限于新生代或老年代。

GC日志解讀

33.125:[GC [DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
100.667:[Full GC [Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
  • 最前面的“33.125:”和“100.667:" 代表了GC發生時間(從java虛擬機啟動以來經過的秒數)。
  • 日志開頭“[GC ”和“[Full GC”說明了這次垃圾收集的停頓類型。有"Full"說明這次GC是發生了Stop-The-World的。一般因為出現了分配擔保失敗之類的問題才會導致STW。如果調用System.gc()方法所觸發的收集,那么這里將顯示“[Full GC(System)”。
  • “ [DefNew”、“[Tenured”、“[Perm”表示GC發生區域,這里顯示區域名稱與使用的GC收集器密切相關,,例如上面樣例所使用的Serial收集器中的新生代名為“Default New Generation”,所以顯示的是“[DefNew”。如果是ParNew收集器,新生代名稱就會變為“[ParNew”,意為“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代稱為“PSYoungGen”,老年代和永久代同理,名稱也是由收集器決定的。
  • 后面方括號內部的“3324K->152K(3712K)”含義是“GC前該內存區域已使用容量->GC后該內存區域已使用容量(該內存區域總容量)”。而在方括號之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆總容量)”。
  • 再往后,“0.0025925 secs”表示該內存區域GC所占用的時間,單位是秒。
  • 有的收集器會給出更具體的時間數據,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,這里面的user、sys和real與Linux的time命令所輸出的時間含義一致,分別代表用戶態消耗的CPU時間、內核態消耗的CPU時間和操作從開始到結束所經過的墻鐘時間(Wall Clock Time)。
  • CPU時間與墻鐘時間的區別是,墻鐘時間包括各種非運算的等待耗時,例如等待磁盤I/O、等待線程阻塞,而CPU時間不包括這些耗時,但當系統有多CPU或者多核的話,多線程操作會疊加這些CPU時間,所以讀者看到user或sys時間超過real時間是完全正常的。
15481139383800.jpg

新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略里就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。

3.5.9 垃圾收集器參數

15481142198157.jpg
15481142322830.jpg

內存分配與回收策略

新生代Minor GC

回顧下垃圾回收算法,通常新生代按照8:1:1(eden space + survivor from space + survivor to space)進行內存劃分,新生產的對象會被放到eden space,當eden內存不足時,就會將存活對象移動到survivor區域,如果survivor空間也不夠時,就需要從老年代中進行分配擔保,將存活的對象移動老年代,這就是一次Minor GC的過程。

15481148676972.jpg
/**
 * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 * -XX:SurvivorRatio=8 -XX:+UseSerialGC
 */

public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;

    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];
    }

    public static void main(String[] agrs) {
        testAllocation();
    }
}

代碼清單的testAllocation()方法中,嘗試分配3個2MB大小和1個4MB大小的對象,在運行時通過-Xms20M、-Xmx20M、-Xmn10M這3個參數限制了Java堆大小為20MB,不可擴展,其中10MB分配給新生代,剩下的10MB分配給老年代。-XX:SurvivorRatio=8決定了新生代中Eden區與一個Survivor區的空間比例是8:1,從輸出的結果也可以清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代總可用空間為9216KB(Eden區+1個Survivor區的總容量)。

before MinorGC:

15481150728891.jpg

after MinorGC:

15481150979566.jpg

GC日志:

15482534095722.jpg

解讀: [GC (Allocation Failure) [DefNew: 6815K->290K(9216K), 0.0054224 secs] 6815K->6434K(19456K), 0.0054619 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

  • GC 表明進行了一次垃圾回收,前面沒有Full修飾,表明這是一次Minor GC ,注意它不表示只GC新生代
  • Allocation Failure: 表明本次引起GC的原因是因為在年輕代中沒有足夠的空間能夠存儲新的數據了。
  • DefNew: 使用的是Serial收集器,它的新生代名為“Default New Generation”。
  • 6815K->290K(9216K): GC前該內存區域(這里是年輕代)使用容量,GC后該內存區域使用容量,該內存區域總容量。
  • 0.0054224 secs: 該內存區域GC耗時,單位是秒
  • 6815K->6434K(19456K): 三個參數分別為:堆區垃圾回收前的大小,堆區垃圾回收后的大小,堆區總大小。
  • 0.0054619 secs: 該內存區域GC耗時,單位是秒
  • Times: user=0.00 sys=0.00, real=0.01 secs: 分別表示用戶態耗時,內核態耗時和總耗時

結論:

  • eden space 8192K, 51% used ==> 新生代的Eden區總共大小為8MB,使用掉的4MB是用來存放allocation4對象
  • tenured generation total 10240K, used 6144K ==> 老年代大小為10MB,使用掉的6MB是用來存放allocation1、allocation2和allocation3這3個對象

大對象直接進入老年代

創建了一個數組對象allocation,大小為4MB,已經超出PretenureSizeThreshold設置的范圍,該對象將直接被分配到老年代中。

/**
 * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 * -XX:SurvivorRatio=8 -XX:+UseSerialGC      
 * -XX:PretenureSizeThreshold=3145728
 */

public class TestClass2 {
    private static final int _1MB = 1024 * 1024;
    
    public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4 * _1MB];
    }
    /**
     * @param args
     */
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        testPretenureSizeThreshold();
    }

}

VM參數說明:
-XX:PretenureSizeThreshold=3145728 表示 所占用內存大于該值的對象直接分配到老年代,3145728為3MB

15482543601661.jpg

解讀: 上述log中未發生GC垃圾回收,同時tenured generation total 10240K, used 4096K,說明老年代大小為10MB,用掉的4MB用來存放allocation對象,即大對象直接進入老年代。

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

創建了3個數組對象,當執行到"allocation3 = new byte[4 * _1MB]; "時,Eden已經被占用了256KB + 4MB,而創建allocation3需要4MB,已經超過Eden的大小8MB,需要先發生一次MinorGC,才能保證有空間存放allocation3。

/**
 * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 * -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1
 */

public class TestClass3 {
    private static final int _1MB = 1024 * 1024;

    public static void testTenuringThreshold() {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }

    public static void main(String[] agrs) {
        testTenuringThreshold();
    }
}

VM參數說明:
-XX:MaxTenuringThreshold=1 表示 對象晉升為老年代的年齡閥值為1

15482546980342.jpg

說明:

該段代碼創建了3個數組對象,當執行到"allocation3 = new byte[4 * _1MB]; "時,Eden已經被占用了256KB + 4MB,而創建allocation3需要4MB,已經超過Eden的大小8MB,需要先發生一次MinorGC,才能保證有空間存放allocation3

解讀:

  • 由GC日志開頭的兩句"[GC [DefNew"可知,該段代碼一共發生了2次GC,第一次是"allocation3 = new byte[4 * _1MB]; ",第二次是執行allocation3 = null時
  • allocation1在經過第一次GC時,對象年齡變成了1,由于設置的MaxTenuringThreshold=1,當發生第二次GC時,allocation1的年齡已經超出了設置的閥值,allocation1進入到老年代,因此,新生代的from space使用空間為0,對應GC語句為from space 1024K, 0% used

若將MaxTenuringThreshold改成15(注: 設置下-XX:TargetSurvivorRatio=90),GC log為:

15482572739275.jpg

即 新生代的from space使用空間不為0,對應GC語句為from space 1024K, 52% used

課后作業

經典面試問題: Java 常見的垃圾收集器有哪些?

參考

深入理解Java虛擬機:JVM高級特性與最佳實踐(第2版)

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

推薦閱讀更多精彩內容