JVM相关

JVM相关

JVM - 内存结构

image.png

方法区和堆是所有线程共享的内存区域,java栈、本地方法栈和程序计数器是运行时线程私有的内存区域

  • Java堆(Heap):虚拟机启动时创建,所有线程共享,可以存放对象实例;
  • 方法区:所有线程共享,主要存储jvm加载的类信息、常量、静态变量和即时编译后的代码;
  • 程序计数器:用来存储当前线程所执行的字节码行号数据。
  • Java栈:线程私有的,用于存储局部变量,操作栈。动态链接、方法出口等数据;
  • 本地方法栈:与Java栈类似,主要服务本地Native方法。

Java对象分配规则

  • 如果JVM开启了逃逸分析,并且新分配的对象引用如果没有发生方法逃逸和线程逃逸,则优先尝试在线程栈内存进行分配;
  • 如果无法分配还可以在线程的TLAB区域进行分配,默认大小不超过64b;
  • 对象则分配在Eden区,如果eden区空间不足,则先执行一次young gc;
  • 大对象可以直接分配在老年代;可以避免在年轻代来回复制;
  • 长期存活的对象晋升到老年代,可以根据对象经历过的younggc次数计算对象的年龄,经历一次gc后仍然存活的对象复制到from区,每经历一次gc然后在复制到to区,两个区域来回复制,直到对象的年龄达到晋升阈值后复制到老年代;
  • 如果from或to区中相同年龄的对象大小的总和大于目标复制区域时,年龄大于或等于该年龄的对象可以直接复制到老年代;
  • 空间分配担保。每次进行young GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行young GC,如果false则进行Full GC。

JVM - 类的加载机制

什么是类的加载

类的加载指将类的.class文件二进制数据读入到内存中,将其放在运行时的方法区内,然后在堆内存创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。Class对象封装了类在方法区内的数据结构,并提供了各种访问接口。

类的加载过程

加载 --> 验证 --> 准备 --> 解析 --> 初始化 --> 使用 --> 卸载

  1. 加载:通过类加载器加载class文件,并在堆内存创建Class对象;
  2. 验证:验证文件格式、元数据、字节码和符号引用等是否符合JAVA规范,否则报错;
  3. 准备:初始化类的静态变量和默认值;
  4. 解析:将常量池内的符号引用替换为直接引用;
  5. 初始化:将类中静态变量赋予初始值;
  6. 使用:new出对象并使用;
  7. 卸载:执行垃圾回收。

类加载器

  • 启动类加载器:主要负责加载jre/lib目录下的类库;
  • 扩展类加载器:主要负责加载jre/lib/ext目录或者java.ext.dirs系统变量指定的目录下的类库;
  • 应用程序类加载器:主要负责加载classpath指定的目录下类库

类加载机制

  • 双亲委派:先让父类加载尝试加载类,父类加载器无法加载时才有当前类加载器进行加载;
  • 全面负责:一个类加载器加载某个class时,该class依赖的其他类也有该类加载器负责加载;
  • 缓存机制:所有加载过的class类都会被缓存,类加载器会先从缓存区查询,查询不到时才去加载。

类加载方式

  • 命令行启动应用时候由JVM初始化加载
  • 通过Class.forName()方法动态加载
  • 通过ClassLoader.loadClass()方法动态加载

Class.forName()和ClassLoader.loadClass()区别

  • Class.forName():将类的.class文件加载到jvm中,还会对类进行解释,执行类中的static块;
  • ClassLoader.loadClass():将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
  • Class.forName(name,initialize,loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。

JVM - Metaspace

JDK8 HotSpot JVM现在使用了本地内存来存储类元数据,被称为Metaspace,和Oracle JRockit以及IBM JVM类似。
它意味着java.lang.OutOfMemoryError:PermGen space问题会越来越少,也不再需要你去调整和监控内存空间。然而这种变化默认是可不见的,接下来我们给你展示的,是你仍然需要关注类元数据内存占用。请记住,这些新特点并不会很神奇的消除类和类加载器的内存泄露。你需要使用不同的方法和学习新的命名约定来找出问题的根源。

总结:

  1. 持久代场景
    • 这块内存区域被完全移除。
    • PermSize和MaxPermSize JVM 参数会被忽略,并且在启动的时候会给出警告信息。
  2. Metaspace 内存分配模型
    • 对于类元数据的大多数内存分配都不会发生在本地内存。
    • 被用于描述类元数据的类对象被移除。
  3. Metaspace 容量
    • 默认的,类元数据分配限制于可用的本地内存 (容量大小依赖于你用32位jvm或者64位jvm的操作系统可用虚拟内存)。
    • 新的标记已经可以使用 (MaxMetaspaceSize),它允许你限制用于类元数据的本地内存大小。如果你没有指定这个标记,Metaspace会根据运行时应用程序的需求来动态的控制大小。
  4. Metaspace 垃圾收集
    • 一旦类元数据的使用量达到了“MaxMetaspaceSize”指定的值,对于无用的类和类加载器,垃圾收集此时会触发。
    • 为了控制这种垃圾收集的频率和延迟,合适的监控和调整Metaspace非常有必要。过于频繁的Metaspace垃圾收集是类和类加载器发生内存泄露的征兆,同时也说明你的应用程序内存大小不合适,需要调整。
  5. Java 堆空间影响
    •一些杂项数据被移到了Java堆空间。这意味着当你更新到JDK8后会观察到Java堆空间的增长。
  6. Metaspace 监控
    • Metaspace 的使用可以通过HotSpot 1.8的详细的GC日志输出观察到。
    • 在基于b75上测试的时候Jstat 和 JVisualVM 还没有更新,旧的持久代空间引用依然存在。

JVM - GC算法

对象存活判断算法

  • 引用计数法:每个对象内置一个引用计数器,新增引用时,计数器加1,释放引用时,计数器减1,计数器为0时可以回收,但无法解决对象相互循环引用问题。
  • 可达性分析算法:从GC Roots开始向下搜索,搜索所有走过的路径称为引用链,当一个对象在GC roots的引用链上没有相连时,表示该对象是不可达对象。
  • GC roots主要包括:
    1. 线程栈空间中引用的对象
    2. 方法区中静态属性引用的对象
    3. 方法区中常量引用的对象
    4. 本地方法栈中JNI中引用对象

GC算法

最基础的GC算法有三种:标记-清除算法、复制算法、标记压缩算法,常用的垃圾回收器一般都采用分代收集算法。

  • 标记清除算法:分为标记和清除两个阶段,首先标记所有需要回收的对象,标记完成后统一回收掉所有被标记的对象。
  • 复制算法:将内存按照容量划分为大小相等的两款,每次只使用其中一块,当这块的内存用完了,将还活着的对象复制到另一块上面,然后把已使用的内存空间清理掉。
  • 标记-压缩算法:首先标记所有需要回收的对象,然后让所有存活的对象都向一端移动,然后在清理掉端边界以外的内存。
  • 分代收集算法:把java堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法。

垃圾回收器

  • Serial收集器:最古老,最稳定和高效率的收集器,可能会产生较长的停顿,只使用一个线程回收。
  • ParNew收集器:多线程版本的Serial收集器。
  • Parallel收集器:并行收集器类似ParNew收集器,更关注系统吞吐量。
  • Parallel Old收集器:并行收集器老年代版本,使用多线程和标记-整理算法。
  • CMS收集器:是一种以获取最短回收停顿时间为目标的并发标记清除收集器。
  • G1收集器:是面向服务器的垃圾收集器,主要针对多核处理器和大内存的机器,可以预设GC停顿时间的并发标记清除收集器。

JVM - 垃圾回收器

Young GC

  • 查找GC Roots,拷贝所引用的对象到to区
    GC ROOTS内存区域主要包括
    1. 线程栈空间中引用的对象
    2. 方法区中静态属性引用的对象
    3. 方法区中常量引用的对象
    4. 本地方法栈中JNI中引用对象
  • 递归遍历步骤1中对象,并拷贝器所引用对象到to区,可能会存在自然晋升,或因为to区空间不足引起提前晋升。
    GC年龄存在java对象头的markword字段,只占用4个bit,所以最大年龄是15。
    默认到达15就会晋升到老年代!

CMS垃圾回收器(默认75%触发)

CMS回收器在一次GC过程中会有两次STW,一次是初始标记阶段,一次是重新标记阶段

  • 初始标记(STW initial mark)- 用来标记从root直接可达的和从年轻代中对象直接可达的对象
  • 并发标记 (Concurrent marking) - 遍历初始标记的存活对象,然后继续递归标记这些对象可达的对象
  • 并发预清理(Concurrent precleaning) - 并发预清理用于减少remark的工作量,降低STW时间
  • 重新标记(STW remark) - 用来标记在并发标记阶段遗漏的对象
  • 并发清理(Concurrent sweeping) - 并发清理垃圾对象
  • 并发重置 (Concurrent reset) - 并发清除内部状态,为下次回收做准备

G1垃圾回收器

G1堆内存结构
默认将JVM切分为2048份固定大小区域,最小1M,最大32M,2的幂次方;区域大小通过-XX:G1HeapRegionSize参数指定
G1堆内存分配
每块区域被标记了E、S、O和H,分别映射Eden、Survivor、老年代和巨型对象区

G1提供三种垃圾回收模式 young gc 、mixed gc 和full gc

  • young gc - 年轻代gc算法,所有Eden region被耗尽无法申请内存时触发,执行完一次young gc,活跃对象拷贝到S区或晋升到old region,空闲region放入空闲列表,等待使用
参数 含义
-XX:MaxGCPauseMillis 设置G1收集过程目标时间,默认值200ms
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%
  • mixed gc - 当越来越多对象晋升到old region时,为了避免内存耗尽,会触发mixed gc;回收整个young region 和部分old region,从而控制回收耗时时间,可以通过
    -XX:InitiatingHeapOccupancyPercent 指定old region使用率百分比阈值,超过该阈值则直接触发,默认45%

    1. 初始标记(STW initial mark)- 应用线程暂停,然后标记从root直接可达的对象
    2. 并发标记 - 该阶段耗时较长,但是可以与应用线程并发执行,遍历初始标记对象,继续递归标记这些对象可达的对象,采用STAB算法保证新分配对象不会漏标。
    3. 重新标记(STW) - 标记那些在并发标记阶段遗漏的对象
    4. 筛选回收 - 按照区域回收价值和成本进行排序,根据用户配置的GC停顿时间制定回收计划,开始回收部分区域
  • full gc - 对象内存分配速度过快,来不及进行mixed gc导致old region填满时触发;
    该算法是单线程执行serial old gc,需要长时间STW,所以要尽可能避免full gc。

JVM - 调优命令

jps

显示指定系统内所有jvm进程信息
命令格式:jps [options] [hostid]
option参数

  • -l:输出主类全名或jar路径
  • -q:只输出进程ID
  • -m:输出启动JVM时传递给main函数的参数
  • -v:输出JVM启动时显示指定的JVM参数

jstat

用户监视JVM运行时状态信息的命令,可以显示出JVM的类加载、内存、垃圾收集、JIT编译等运行数据。
命令格式:jstat [option] 进程ID [interval] [count]
参数描述

  • [option] : 操作参数
  • [interval] : 连续输出的时间间隔
  • [count] : 连续输出的次数
    option 参数
  • -class:监视类装载、卸载数量、总空间以及耗费的时间
  • -compiler:输出JIT编译过的方法数量耗时等
  • -gc:垃圾回收堆的行为统计; jstat -gc 1262 2000 20 ;每隔2000ms输出1262的gc情况,一共输出20次。
S0C : survivor0区的总容量
S1C : survivor1区的总容量
S0U : survivor0区已使用的容量
S1C : survivor1区已使用的容量
EC : Eden区的总容量
EU : Eden区已使用的容量
OC : Old区的总容量
OU : Old区已使用的容量
PC 当前perm的容量 (KB)
PU perm的使用 (KB)
YGC : 新生代垃圾回收次数
YGCT : 新生代垃圾回收时间
FGC : 老年代垃圾回收次数
FGCT : 老年代垃圾回收时间
GCT : 垃圾回收总消耗时间
  • -gccapacity:同-gc,不过还会输出Java堆各区域使用到的最大、最小空间

  • -gcutil:同-gc,不过输出的是使用空间占总空间的百分比

  • -gccause:同-gcutil,附加最近两次垃圾回收事件的原因
    LGCC:最近垃圾回收的原因
    GCC:当前垃圾回收的原因

  • -gcnew:统计新生代的行为
    TT:提升阈值,MTT:最大提升阈值,DSS:survivor区域大小

  • -gcnewcapacity:新生代与其相应的内存空间的统计

  • -gcold:统计老年代的行为

  • -gcoldcapacity:统计老年代的大小和空间

  • -gcpermcaoacuty:统计持久代的行为

  • -printcomplation jit编译方法统计
    Compiled:被执行的编译任务的数量
    Size:方法字节码的字节数
    Type:编译类型
    Method:编译方法的类名和方法名。类名使用"/" 代替 "." 作为空间分隔符. 方法名是 给出类的方法名. 格式是一致于HotSpot - XX:+PrintComplation 选项

    jmap

    jmap(JVM Memory Map)命令用于生成heap dump文件,如果不使用这个命令,还阔以使用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机出现OOM的时候·自动生成dump文件。 jmap不仅能生成dump文件,还阔以查询finalize执行队列、Java堆和永久代的详细信息,如当前使用率、当前使用的是哪种收集器等。
    命令格式:jmap [option] 进程ID
    option参数
    dump : 生成堆转储快照
    finalizerinfo : 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
    heap : 显示Java堆概要信息,GC使用的算法,heap的配置及wise heap的使用情况
    histo : 打印堆的对象统计,包括对象数、内存大小等等 (因为在dump:live前会进行full gc,如果带上live则只统计活对象,因此不加live的堆大小要大于加live堆的大小 )
    permstat : 打印Java堆内存的永久保存区域的类加载器的智能统计信息。对于每个类加载器而言,它的名称、活跃度、地址、父类加载器、它所加载的类的数量和大小都会被打印。此外,包含的字符串数量和大小也会被打印
    F : 当-dump没有响应时,强制生成dump快照

jhat

jhat(JVM Heap Analysis Tool)命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析。
命令格式:jhat [dumpfile]

jstack

jstack用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。 如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。另外,jstack工具还可以附属到正在运行的java程序中,看到当时运行的java程序的java stack和native stack的信息, 如果现在运行的java程序呈现hung的状态,jstack是非常有用的。
命令格式:jstack [option] 进程ID
option参数
-F : 当正常输出请求不被响应时,强制输出线程堆栈
-l : 除堆栈外,显示关于锁的附加信息
-m : 如果调用到本地方法的话,可以显示C/C++的堆栈

jinfo

jinfo(JVM Configuration info)这个命令作用是实时查看和调整虚拟机运行参数。 之前的jps -v口令只能查看到显示指定的参数,如果想要查看未被显示指定的参数的值就要使用jinfo口令
命令格式:jinfo [option] [args] 进程ID
option参数
-flag : 输出指定args参数的值
-flags : 不需要args参数,输出所有JVM参数的值
-sysprops : 输出系统属性,等同于System.getProperties()

JVM - 调优工具

jconsole

Jconsole(Java Monitoring and Management Console)是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控,是一个基于JMX(java management extensions)的GUI性能监测工具。
jconsole使用jvm的扩展机制获取并展示虚拟机中运行的应用程序的性能和资源消耗等信息。

VisualVM

VisualVM 是一个工具,它提供了一个可视界面,用于查看 Java 虚拟机 (Java Virtual Machine, JVM) 上运行的基于 Java 技术的应用程序(Java 应用程序)的详细信息。VisualVM 对 Java Development Kit (JDK) 工具所检索的 JVM 软件相关数据进行组织,并通过一种使您可以快速查看有关多个 Java 应用程序的数据的方式提供该信息。您可以查看本地应用程序以及远程主机上运行的应用程序的相关数据。此外,还可以捕获有关 JVM 软件实例的数据,并将该数据保存到本地系统,以供后期查看或与其他用户共享。

MAT

MAT(Memory Analyzer Tool),一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗。使用内存分析工具从众多的对象中进行分析,快速的计算出在内存中对象的占用大小,看看是谁阻止了垃圾收集器的回收工作,并可以通过报表直观的查看到可能造成这种结果的对象。

GChisto

GChisto是一款专业分析gc日志的工具,可以通过gc日志来分析:Minor GC、full gc的时间、频率等等,通过列表、报表、图表等不同的形式来反应gc的情况。虽然界面略显粗糙,但是功能还是不错的。

gcviewer

GCViewer也是一款分析小工具,用于可视化查看由Sun / Oracle, IBM, HP 和 BEA Java 虚拟机产生的垃圾收集器的日志,gcviewer个人感觉显示 的界面比较乱没有GChisto更专业一些。

GC Easy

这是一个web工具,在线使用非常方便.
地址: http://gceasy.io
进入官网,讲打包好的zip或者gz为后缀的压缩包上传,过一会就会拿到分析结果。

什么是压缩指针:

通常64位JVM消耗的内存会比32位的最多会多用1.5倍,这是因为对象指针在64位架构下,对象指针长度会翻倍。 对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用,这是开发者不愿意看到的。 幸运的是,从JDK 1.6 update14开始,64 bit JVM正式支持了 -XX:+UseCompressedOops (需要jdk1.6.0_14) 这个可以压缩指针,起到节约内存占用的新参数。

什么是OOP?

OOP = "ordinary object pointer" 普通对象指针。启用CompressOops后,会压缩的对象:

  • 每个Class的属性指针(静态成员变量)
  • 每个对象的属性指针
  • 普通对象数组的每个元素指针
    当然,压缩也不是万能的,针对一些特殊类型的指针,JVM是不会优化的。 比如指向PermGen的Class对象指针,本地变量,堆栈元素,入参,返回值,NULL指针不会被压缩。

CompressedOops的原理:

32位内最多可以表示4GB,64位地址分为堆的基地址+偏移量,当堆内存<32GB时候,在压缩过程中,把偏移量/8后保存到32位地址。在解压再把32位地址放大8倍,所以启用CompressedOops的条件是堆内存要在4GB*8=32GB以内。所以压缩指针之所以能改善性能,是因为它通过对齐(Alignment),还有偏移量(Offset)将64位指针压缩成32位。换言之,性能提高是因为使用了更小更节省空间的压缩指针而不是完整长度的64位指针,CPU缓存使用率得到改善,应用程序也能执行得更快。

零基压缩优化(Zero Based Compressd Oops):

零基压缩是针对压解压动作的进一步优化。 它通过改变正常指针的随机地址分配特性,强制堆地址从零开始分配(需要OS支持),进一步提高了压解压效率。要启用零基压缩,你分配给JVM的内存大小必须控制在4G以上,32G以下。如果GC堆大小在4G以下,直接砍掉高32位,避免了编码解码过程 如果GC堆大小在4G以上32G以下,则启用UseCompressedOop 如果GC堆大小大于32G,压指失效,使用原来的64位(所以说服务器内存太大不好......)。

适用场景:

CompressedOops,可以让跑在64位平台下的JVM,不需要因为更宽的寻址,而付出Heap容量损失的代价。 不过,它的实现方式是在机器码中植入压缩与解压指令,可能会给JVM增加额外的开销。

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