【朝花夕拾】Android性能篇之(三)Java内存回收

前言

       原文:【朝花夕拾】Android性能篇之(三)Java内存回收

       在上一篇日志(【朝花夕拾】Android性能篇之(二)Java内存分配)中有讲到,JVM内存由程序计数器、虚拟机栈、本地方法栈、GC堆,方法区五个部分组成。其中GC堆是一块多线程的共享区域,它存在的作用就是存放对象实例。本节中所要讲述的各种场景,就发生在这块区域,垃圾回收也主要发生在GC堆内存中。本章内容为高质量面试中几乎是必问的知识点,尤其是其中GC Root、分代算法、引用类型等方面的知识点,可以很好地体现程序员的内功。本文主要是在相关文章的基础上进行搜集和整理而成,也包含了自己的一些理解和总结,其中涉及到的代码,贴出运行结果的,都是自己亲自运行过的。另外,本文内容为笔者一字一句敲上去的,如果能对读者有帮助并被转载了,请注明一下,如果对本文中内容有异意的,也请不吝赐教。

       本章主要内容如下:


一、什么是垃圾回收

       垃圾回收,即GC:Garbage Collection。在Java中,当原先分配给某对象的内存不再被任何对象指向时,该内存便被废弃成为垃圾。这部分无用的内存空间需要在适当的时候被回收,以供新的对象实例使用。垃圾回收就是这种回收无用内存空间,并使其对未来实例可用的过程。


二、为什么要进行垃圾回收

       由于设备的内存空间是有限的,而程序运行时需要先加载到内存中,如果内存中垃圾过多,可用的空间过小,系统将会卡顿,甚至使得程序无法正常运行。为了能够充分利用内存空间,就需要对内存进行垃圾回收。 垃圾回收能够自动释放内存空间,减轻程序员的编程负担,JVM的一个系统级线程会自动释放该内存块,这就是我们平时所熟知的,JVM为程序员自动完成了内存的回收工作。垃圾回收将程序不再需要的对象的“无用信息”丢弃,以便将这些空间分配给新对象使用。除了清理废弃的对象,垃圾回收还会清除内存碎片,完成内存整理。


三、Java GC所用算法

       先上思维导图,下图总结了Java GC过程中所使用的相关算法。此处分为两类:(1)判断对象是否存活的算法;(2)GC不同阶段使用的算法


1、判断对象是否存活的算法

        GC堆内存中存放着几乎所有的对象实例,垃圾回收器在对该内存进行回收前,首先需要确定这些对象哪些是“活着”,哪些已经“死去”,其判断方法主要由如下两种:

      (1)引用计数法

          该算法由于无法处理对象之间相互循环引用的问题,在Java中并未采用该算法,在此不做深入探究,有兴趣的可自行学习。

      (2)根搜索算法(GC ROOT Tracing)

 Java中采用了该算法来判断对象是否是存活的。

         算法思想:通过一系列名为“GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论来说就是从GC Roots到这个对象不可达)时,则证明对象是不可用的,即该对象是“死去”的,同理,如果有引用链相连,则证明对象可以,是“活着”的。如下图所示:


          那么,哪些可以作为GC Roots的对象呢?Java 语言中包含了如下几种:

          1)虚拟机栈(栈帧中的本地变量表)中的引用的对象。

          2)方法区中的类静态属性引用的对象。

          3)方法区中的常量引用的对象。

          4)本地方法栈中JNI(即一般说的Native方法)的引用的对象。

拓展阅读:https://www.zhihu.com/question/50381439

2、Java GC所用的算法

       在GC堆的不同区域,GC的不同阶段中,会选择不同的垃圾收集器来完成GC,当然,这些不同的垃圾回收器也采用了不同算法。

     (1)Tracing算法

                                   tracing算法示意图

        称标记-清除(mark-and-sweep)算法,顾名思义,就是标记存活的对象,清除死去的对象。如上示意图所示,该算法基于根搜索方法,从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于该算法直接回收不存活的对象,因此会造成内存碎片。其示意图如下:

    (2)Compacting算法

                                                                       compacting算法示意图

       该算法也被称为标记-整理(mark-and-compact)算法,顾名思义,就是标记存活的对象,整理回收后的空间。如上示意图所示,该算法和标记-清除算法一样先对存活的对象进行标记,然后清除掉没有标记的对象,即不存活的对象。但与标记-清除算法不同的是,该算法多了一个整理的过程,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针,因此解决了内存碎片的问题,当然,该算法多了一个整理的过程,进行了对象的移动,因此成本更高。在基于Compacting算法的收集器的实现中,一般增加了句柄和句柄表。

    (3)Copying算法

                                                                             copying算法示意图

       该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。如上示意图所示,它开始时把堆分成对象面(from space)和空闲面(to space),程序在对象面为实例对象分配空间,当对象满了,基于copying算法的垃圾收集器就从根基中扫描存活对象,并将每个存活对象复制到空闲面,使得存活的对象所占用的内存之间没有碎片。这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。一种典型的基于copying算法的垃圾回收是stop-and-copy算法,它将堆分成对象面和空闲区域面,在对象面与空闲区域面的切换过程中,程序暂停执行。该算法的优点是:不理会非存活的对象,copy数量仅仅取决于存活对象的数量,且在copy的同时,整理了heap空间,消除了内存碎片,空闲区的空间使用始终是连续的,内存使用效率得到提高。缺点是:划分了对象面和空闲面,内存的使用率为1/2。收集器必须复制所有的存活对象,这增加了程序等待时间。

    (4)Generation算法

                                                        generation算法示意图

          不同的对象的生命周期是不一样的,分代的垃圾回收策略正式基于这一点。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。该算法包含三个区域:年轻代(Young Generation)、年老代(Old Generation)、持久代(Permanent Generation)

      1)年轻代(Young Generation)

所有新生成的对象首先都是放在年轻代中。年轻代的目标就是尽可能快速地回收哪些生命周期短的对象。

        新生代内存按照8:1:1的比例分为一个Eden区和两个survivor(survivor0,survivor1)区。Eden区,字面意思翻译过来,就是伊甸区,人类生命开始的地方。当一个实例被创建了,首先会被存储在该区域内,大部分对象在Eden区中生成。Survivor区,幸存者区,字面理解就是用于存储幸存下来对象。回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存放满了后,则将Eden和Survivor0区中存活对象复制到另外一个survivor1区,然后清空Eden和这个Survivor0区,此时的Survivor0区就也是空的了。然后将Survivor0区和Survivor1区交换,即保持Servivor1为空,如此往复。

       当Survivor1区不足以存放Eden区和Survivor0的存活对象时,就将存活对象直接放到年老代。如果年老代也满了,就会触发一次Major GC(即Full GC),即新生代和年老代都进行回收。

       新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高,不一定等Eden区满了才会触发。

      2)年老代(Old Generation)

       在新生代中经历了多次GC后仍然存活的对象,就会被放入到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

       年老代比新生代内存大很多(大概比例2:1?),当年老代中存满时触发Major GC,即Full GC,Full GC发生频率比较低,年老代对象存活时间较长,存活率比较高。

       此处采用Compacting算法,由于该区域比较大,而且通常对象生命周期比较长,compaction需要一定的时间,所以这部分的GC时间比较长。

      3)持久代(Permanent Generation)

       持久代用于存放静态文件,如Java类、方法等,该区域比较稳定,对GC没有显著影响。这一部分也被称为运行时常量,有的版本说JDK1.7后该部分从方法区中移到GC堆中,有的版本却说,JDK1.7后该部分被移除,有待考证。

        持久代中的垃圾回收,请参考下文中的“方法区的垃圾回收”。

                                  Generaton算法结构思维导图

       现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。这种算法没什么特别的,就是将上述多种算法结合,根据对象的生命周期的不同将内存划分为几块,然后根据各快的特点采用最适合的收集算法。新生代对象存活率低,使用复制算法,在内部不同的区内进行复制,复制成本比较低;年老代对象存活率高,没有额外空间进行分配,采用标记-清理算法或者标记-整理算法。

    (4)参考资料

 https://www.cnblogs.com/andy-zcx/p/5522836.html

四、垃圾收集器

       垃圾收集器就是上一节中垃圾收集算法理论的具体实现。不同的虚拟机所提供的垃圾收集器可能会有很大差别,我们平时开发用的是HotSpot虚拟机,上图中就是该款虚拟机中所包含的所有收集器。如上一节所说,现代商业虚拟机中基本都采用了分代收集算法,上图中就展示了在内存的不同Generation中,各垃圾收集器的使用情况,在对象的不同生命周期中分别采用不同的收集器。没有最好的垃圾收集器,也没有万能的收集器,只能选择对具体应用最合适的收集器,这也是HotSpot为什么要实现这么多收集器的原因。

       上图展示的7款垃圾回收器,在某个生命周期阶段或单独使用,或配合(有连线的)使用,其实现原理也是按照上一节中描述的各种收集算法实现的。至于对每一款垃圾收集器的详细说明,本文展开讲解,有兴趣的可以自行研究。

扩展阅读https://blog.csdn.net/tjiyu/article/details/53983650


五、方法区的垃圾回收

       本文开头就说过,GC主要发生在GC堆内存中,但并不是只发生在该部分,方法区也需要进行垃圾回收。方法区和堆一样,都是现成共享的内存区域,被用于存储已被虚拟机加载的类信息、即时编译后的代码、静态变量和常量等数据。根据Java虚拟机规范,方法区无法满足内存分配需求时,也会抛出OutOfMemoryError异常,虽然规范规定可以不实现垃圾收集,因为和GC堆内存的垃圾回收相比,方法区的回收效率实在太低,但是该部分区域也是可以被回收的。

      方法区的垃圾回收主要由两种:废弃常量回收和无用类回收。

       1、当一个常量对象不在任何地方被引用的时候,则被标记为废弃常量,这个常量可以被回收。以字面量常量回收为例,如果一个字符串"abc"已经进入常量池,但是当前系统没有任何一个String对象引用了叫做“abc”的字面量,那么,如果发生GC并且有必要时,"abc"就会被系统移出常量池,常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

       2、方法区中的类需要同时满足如下三个条件才能被标记为无用的类:(1) Java堆中不存在该类的任何实例对象;(2) 加载该类的类加载器已经被回收;(3) 该类对应的java.lang.Class对象不在任何地方被引用,且无法在任何地方通过反射访问到该类的方法。当满足这三个条件的类才可以被回收,但并不是一定会被回收,需要参数进行控制,HotSpot虚拟机中提供了 -Xnoclassgc参数进行控制是否回收。

       在上一篇文章中讲到,运行时常量在jdk1.7之前是存在于方法区的,该部分也被称为永久代(Permanence Generation)。在前面章节Generaton算法中,也提到了该永久代。这里所说的方法区垃圾回收就是对Permanence Generation 的垃圾回收(这一句是笔者个人的理解,没有权威的资料声明这一点)。

参考资料:https://blog.csdn.net/hutongling/article/details/68946263


六、引用类型

       Java中提供了四种引用方式,强引用、软引用、弱引用、虚引用,这样做有两个目的:(1)可以让程序员通过代码的方式决定某些对象的生命周期;(2)有利于JVM进行垃圾回收。

1、强引用

     强引用是指创建一个对象,并把这个对象赋给一个引用变量。比如:

People people = new People();2 String str = "abc";

String str = "abc";

  当强引用有引用变量指向时,永远不会被JVM作为垃圾回收,系统内存紧张时,JVM宁愿抛出OutOfMemory异常,也不会回收强引用对象。比如:

public class ReferenceDemo {

    public static void main(String[] args) {

        ReferenceDemo demo = new ReferenceDemo();

        demo.test();

    }

    public void test() {

        People people = new People();

        People[] peopleArr = new People[1000];

    }

}

class People {

    public String name;

    public int age;

    public People() {

        this.name = "zhangsan";

        this.age = 20;

    }

    public People(String name, int age) {

        this.name = name;

        this.age = age;

    }

    @Override

    public String toString() {

        return "[name:" + name + ",age:" + age + "]";

    }

}

       当运行到People[] peopleArr = new People[1000];这句时, 如果内存不足,JVM会抛出OOM错误也不会回收object指向的对象。不过,要注意的是,当test运行完后,people和peopleArr都会不复存在,所以它们指向的对象都会被JVM回收。

如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null,这样,JVM在合适的时候就会回收该对象。比如Vector类的clear()方法中,就是通过将引用赋值为null来实现清理工作的。

2、软引用(SoftReference)

    (1)使用SoftReference实现软引用

如果一个对象具有软引用,内存空间足够,垃圾回收器就不会回收它。只有当内存空间不足了,才会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存,比如网页缓存、图片缓存等。使用软引用能够防止内存泄漏,增强程序的健壮性。

        SoftReference的特点是它的一个实例保存了对一个Java对象的软引用,该软引用的存在不妨碍垃圾收集线程对该Java对象的回收。一旦SoftReference保存了对一个Java对象的软引用后,在垃圾线程对这个Java对象回收前,SoftReference类所提供的get()方法返回Java对象的强引用。另外,一旦垃圾线程回收该Java对象之后,get()方法将返回null。

People people = new People();

SoftReference softRef = new SoftReference<>(people);

此时,对于这个People()对象,有两个引用路径,一个是来自mPeople的强引用,一个是来自SoftReference的软引用。所以这里的这个People()对象是强可及对象。随即,可以通过如下方式结束mPeople对这个People对象的强引用:

people = null;

此后,这个People()对象成为了软引用对象。如果垃圾收集线程进行内存垃圾收集,并不会因为有一个SoftReference对该对象的引用而始终保留该对象。

        JVM的垃圾收集线程对软可及对象和其他一般Java对象进行了区别对待:软可及对象的清理是由垃圾收集线程根据其特定算法按照内存需求决定的。垃圾收集线程会在虚拟机中抛出OutOfMemoryError之前回收软可及对象,而且虚拟机会尽可能优先回收长时间闲置不用的软可及对象,对那些刚刚构建的或刚刚使用过的“新”软可及对象会被JVM尽可能保留。在回收这些对象之前,我们可以通过:

People softRefPeople =  (People) softRef.get();

重新获得对该实例的强引用。如果软可及对象也被回收了,则mSoftRef.get()也只能得到null了。

如下代码演示了SoftReference的使用:

public class ReferenceDemo {

    public static void main(String[] args) {

        People people = new People();//来自people的强引用

        SoftReference softRef = new SoftReference<>(people);//来自SoftReference的软引用

        people = null;//结束people对People实例的强引用。

        People softRefPeople = (People) softRef.get();//通过get()重新获得对People的强引用。

        System.out.println(softRefPeople.toString());

    }

}

运行结果如下:

 [name:zhangsan,age:20]

    (2)使用ReferenceQueue清除失去了软引用对象的SoftReference

       SoftReference对象除了具有保存软引用的特殊性之外,它也是一个Java对象,也具有Java对象的一般特性。所以,当软可及对象被回收之后,这个SoftReference对象的get()方法返回null,已经不再具有存在的价值了,需要一个适当的清除机制,避免大量SoftReference对象带来的内存泄漏。在java.lang.ref包里还提供了ReferenceQueue。如果在创建SorftReference对象的时候,使用了一个ReferenceQueue对象作为参数提供给SoftReference的构造方法,如:

People people = new People();

ReferenceQueue queue = new ReferenceQueue<>();

SoftReference softRef2 = new SoftReference(people,queue);

        当这个SoftReference所软引用的people被垃圾收集器回收的同时,softRef2所强引用的SoftReference对象被列入ReferenceQueue。也就是说,ReferenceQueue中保存的对象是Reference对象,而且是已经失去了它所软引用对象的Reference对象。另外,从ReferenceQueue这个名字可以看出,它是一个队列,当我们调用它的poll()方法的时候,如果这个队列中不是空队列,那么将返回队列中第一个Reference对象。在任何时候,我们都可以调用ReferenceQueue的poll()方法来检查是否有它所关心的非强可及对象被回收。如果队列为空,将返回一个null,否则该方法返回队列中第一个Reference对象。利用这个方法,我们可以检查哪个SoftReference所软引用的对象已经被回收。于是我们可以把这些失去所软引用对象的SoftReference对象清除掉。

SoftReference softRef3 = null;

while ((softRef3 = (SoftReference)queue.poll())!=null) {

    //清除ref

}

3、弱引用(WeakReference)

       弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在Java中,用java.lang.ref.WeakReference类来表示。使用方法如下:

public class ReferenceDemo {

    public static void main(String[] args) {

        WeakReference<People> weakRef = new WeakReference<People>(new People());

        System.out.println(weakRef.get());//获取到弱引用保护的对象。

        System.gc();//通知JVM进行垃圾回收

        System.out.println(weakRef.get());

    }

}

      运行结果如下:

[name:zhangsan,age:20]

null

第二个结果为null,说明只要JVM进行垃圾回收,被弱引用关联的对象必定会被回收掉。不过要注意的是,这里所说的被弱引用关联的对象,是指只有弱引用与之关联。如果存在强引用同时与之关联,则进行垃圾回收时也不会回收该对象(软引用也是如此)。比如,将代码做一点小改动:

public class ReferenceDemo {

    public static void main(String[] args) {

        People people = new People();

        WeakReference<People> weakRef = new WeakReference<People>(people);

        System.out.println(weakRef.get());

        System.gc();

        System.out.println(weakRef.get());

    }

}

      运行结果如下:第二行结果有了很大的变化,不再是null,说明没有被回收。

[name:zhangsan,age:20]

[name:zhangsan,age:20]

       弱引用也可以和引用队列ReferenceQueue联合使用,如果弱引用所引用的对象被JVM回收,这个弱引用就会被加入到与之关联的引用队列中,其使用方法同软引用中的使用。

       在使用软引用和弱引用的时候,我们可以显示地通过System.gc()来通知JVM进行垃圾回收,但是要注意的是,虽然发出了通知,JVM不一定会立刻执行,也就是说,这句代码是无法确保此时JVM一定会进行垃圾回收的,可能会在发出通知后,在某个合适的时间进行回收。

       另外,在Android开发中,常常与Handler联合使用,来避免内存泄漏的发生。

4、虚引用(PhantomReference)

       在Java中,用java.lang.PhantamReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。需要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中(这里和前面的软引用和弱引用有所不同,这两者加入队列是在对象被回收之时)。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

public class ReferenceDemo {

    public static void main(String[] args) {

        ReferenceQueue<People> queue = new ReferenceQueue<>();

        PhantomReference<People> phanRef = new PhantomReference<People>(new People(), queue);

        System.out.println(phanRef.get());

    }

}

      运行结果如下:

 null

该结果验证了前面所说的,“如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收”。

5、小结

6、参考资料

https://www.cnblogs.com/huajiezh/p/5835618.html


当前系列内容已完成如下篇章:

【朝花夕拾】Android性能篇之(一)序言及JVM篇

【朝花夕拾】Android性能篇之(二)Java内存分配

【朝花夕拾】Android性能篇之(三)Java内存回收

【朝花夕拾】Android性能篇之(四)Apk打包

【朝花夕拾】Android性能篇之(五)Android虚拟机

【朝花夕拾】Android性能篇之(六)Android进程管理机制

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容