Android 内存管理

  1. 概述
  2. 虚拟内存
    2.1 分页
    2.2 内存映射
  3. 内存不足时的处理
    3.1 kswapd
    3.2 LMK
  4. 虚拟机
    4.1 堆空间划分
    4.2 回收算法

在看这篇文章之前,需要Linux内存管理基础,推荐Linux 内存管理

对于这篇文章的结构我也是思虑再三,为什么先讲LMK,后讲虚拟机回收,主要是因为,分页、内存映射、LMK是直接影响物理内存的,而虚拟机对应的更多的是硬件无关的。此外,本篇不涉及具体代码实现,详细实现以后再补。

概述

Android 使用的是Linux内核,但是这个Linux内核是根据Android所需,在文件系统、内存管理、进程间通信机制和电源管理方面等方面进行了修改了的,继承了Linux内核的诸多优点,保留了Linux内核的主题框架,同时能够更好的工作在移动设备上。

Android同样使用分页内存映射来构建虚拟内存,同时使用垃圾回收器回收内存,使用LowMemoryKiller(LMK)在低内存的时候来杀死进程释放更多内存。

值得注意的是,应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在 RAM 中,并且不会换出到磁盘。

每一个应用都是一个独立的虚拟机,以一个Linux进程的形式存在,在Android上,4.4之前都是Dalvik虚拟机,5.0之后默认都是ART虚拟机了,对于垃圾回收机制,Android ART虚拟机默认使用CMS来清理回收对象。

对于回收进程资源这回事,Linux在进程退出的时候,就会释放当前Linux的内存,但是Android为了提高的切换进程时的启动速度,会将这些进程都保存在内存中,知道系统需要更多的内存为止。LMK每个一段时间都会检查一次,当内存值较低的时候,LMK就会根据不同的剩余内存档位来来选择杀不同优先级的进程。其实在Linux也有类似的管理策略,即OOM killer,全称(Out Of Memory Killer), OOM的策略更多的是用于分配内存不足时触发,得分最高的进程杀掉。当需要OOM killer处理的时候,系统可能已经处于异常状态,而Android更像是在未雨绸缪。

虚拟内存

分页

分页的Linux很多具体实现我们已经聊过,既然Android是从Linux开始改的,那么很多其实都是一样的,这里着重介绍一下他不同的地方。

首先,Android的内存形式被分为三种:RAM、zRAM 和存储器。

  • RAM 就是我们常说的物理内存,是最快的内存类型,但其大小通常有限。
  • zRAM 是从RAM开辟出来的一块区域,是用于交换空间的 RAM 分区。所有数据在放入 zRAM 时都会进行压缩,然后在从 zRAM 向外复制时进行解压缩。这部分 RAM 会随着页面进出 zRAM 而增大或缩小。设备制造商可以设置 zRAM 大小上限。
  • 存储器,这一部分就是我们常说的磁盘,存储器中包含所有持久性数据(例如文件系统等),以及为所有应用、库和平台添加的对象代码,存储器比另外两种内存的容量大得多。

在 Android 上,存储器不像在其他 Linux 实现上那样用于交换空间,因为频繁写入会导致这种内存出现损坏,并缩短存储媒介的使用寿命,而是所有内存都是一直驻留RAM中。只是在实现分页的时候,需要一块辅村来做担保,Linux选择了磁盘,Android选择了压缩+RAM。这部分跟Linux大同小异,所以就不展开叙述,Linux参考这里.

对于RAM与zRAM,Android 使用了跟 Linux 相似的管理手法,分页。RAM 分为多个“页”。通常,每个页面为 4KB 的内存。系统会将页面分为“可用”或“已使用”。对于已使用的内存可以分为以下类别:

  • 缓存页:在储存器中有对应文件的内存,例如,代码或内存映射文件。缓存页也分为两种:
    • 干净页(clean):存储器中未经修改的文件副本,可由 kswapd删除以增加可用内存
    • 脏页(dirty):存储器中已经被修改的文件副本,可由 kswapd 移动到 zRAM 中进行压缩储存
  • 共享页:由多个进程共享使用的页面
    • 干净页:存储器中未经修改的文件副本,可由 kswapd 删除以增加可用内存
    • 脏页:存储器中已经被修改的文件副本;允许通过 kswapd 或者通过明确使用 msync() 或 munmap() 将更改写回存储器中的文件,然后就变成干净页,可删除
  • 匿名页:存储器中没有对应文件的内存(例如,由设置了 MAP_ANONYMOUS 标记的 mmap() 进行分配)
    • 脏页:可由 kswapd 移动到 zRAM/在 zRAM 中进行压缩以增加可用内存

匿名页中,由于不可以回写到存储器,无论是在RAM或者zRAM中,一直都在内存中,所以只能是脏页。同时,干净页可以删除,因为始终可以使用存储器中的数据重新生成它们。

内存映射

内存映射(mmap)是一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和应用程序进程虚拟地址空间中一段虚拟地址的一一映射关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上。应用程序处理映射部分如同访问主存。

分页和内存映射的区别:

在于分页文件操作在进程访存时是需要先查询页面缓存(page cache)的,若发生缺页中断,需要通过inode定位文件磁盘地址,先把缺失文件复制到page cache,再从page cache复制到内存中,才能进行访问。这样访存需要经过两次文件复制,写操作也是一样。总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。但mmap的优势在于,把磁盘文件与进程虚拟地址做了映射,这样可以跳过page cache,只使用一次数据拷贝

内存不足时的处理

Android 有两种处理内存不足的进程:内核交换守护进程(kswapd)和低内存终止守护进程(LowMemoryKiller)。对应着两种内存不足时候的处理方式。

kswapd

kswapd, 是 Linux 内核的一部分,用于将已使用内存转换为可用内存。当设备上的可用内存不足时,该守护进程将变为活动状态。Linux 内核设有可用内存上下限阈值。当可用内存降至下限阈值以下时,kswapd 开始回收内存页。当可用内存达到上限阈值时,kswapd 停止回收内存页。

kswapd 可以对干净页进行删除,当进程需要该页面的时候,只需要从存储器中读回。同时也可以将脏页移入zRAM中压缩存储,压缩率越高,相对释放的内存也就越多。如果进程需要该页,解压调出即可,跟Linux中的交换分区处理逻辑相同。

LMK

系统会定时检查内存可用值,当低于特定的阈值,LMK就会开始杀进程,直到可用内存值高于阈值才会停止杀内存。在介绍LMK之前,我们需要了解LMK的三个关键参数:

  • oom_adj:在 Framework 层使用,代表进程的优先级,数值越高,优先级越低,越容易被杀死。
  • oom_adj threshold:在 Framework 层使用,代表 oom_adj 的内存阈值。Android Kernel 会定时检测当前剩余内存是否低于这个阀值,若低于这个阈值,则会根据 oom_score_adj 参数的值杀进程,数值越大越先杀。
  • oom_score_adj: 在 Kernel 层使用,由 oom_adj 换算而来,是杀死进程时实际使用的参数。

在Android 6.0(API23)及之前版本 oom_adj 的取值范围为 [-17, 16],在这里给出 oom_adj 和 oom_score_adj 转换关系:

  • 当oom_adj = 15, 则oom_score_adj=1000;
  • 当oom_adj < 15, 则oom_score_adj= oom_adj * 1000/17;

在简述LMK流程之前,需要对于这三个参数有一定的认识哦。下面就是 adj 值的列表,值越大越容易被杀:

常量定义 常量取值 含义
NATIVE_ADJ -1000 native进程(不被系统管理)
SYSTEM_ADJ -900 系统进程
PERSISTENT_PROC_ADJ -800 系统persistent进程,比如telephony
PERSISTENT_SERVICE_ADJ -700 关联着系统或persistent进程
FOREGROUND_APP_ADJ 0 前台进程
VISIBLE_APP_ADJ 100 可见进程
PERCEPTIBLE_APP_ADJ 200 可感知进程,比如后台音乐播放
BACKUP_APP_ADJ 300 备份进程
HEAVY_WEIGHT_APP_ADJ 400 后台的重量级进程,system/rootdir/init.rc文件中设置
SERVICE_ADJ 500 服务进程
HOME_APP_ADJ 600 Home进程
PREVIOUS_APP_ADJ 700 上一个App的进程
SERVICE_B_ADJ 800 B List中的Service(较老的、使用可能性更小)
CACHED_APP_MIN_ADJ 900 不可见进程的adj最小值
CACHED_APP_MAX_ADJ 906 不可见进程的adj最大值
UNKNOWN_ADJ 1001 一般指将要会缓存进程,无法获取确定值

对此,官网也给出了一个杀进程的顺序图:


lmkd 是由 init进程通过解析 init.rc 文件来启动的守护进程,可监控运行中的 Android 系统的内存状态,并通过终止最不必要的进程来解决内存压力,使系统以可接受的水平运行。lmkd 需要跟 framework 层交互,因为framework层知道各个进程当前的状态(比如是否在前台等),lmkd 进程会创建名为 lmkd 的 socket,节点位于 /dev/socket/lmkd,用于与framework交互。

lmkd 启动后,便会进入循环等待状态,接受来自 ProcessList 的三个命令:

命令 功能 方法
LMK_TARGET 初始化 oom_adj ProcessList::setOomAdj()
LMK_PROCPRIO 更新 oom_adj ProcessList::updateOomLevels()
LMK_PROCREMOVE 移除进程(暂时无用) ProcessList::remove()

Android 四大组件直接影响了oom_adj值,ActivityManagerService 会根据当前应用进程托管组件(即四大组件)生命周期的变化,及时的调用 applyOomAdjLocked(),更新进程状态及该状态对应的 oom_adj。

虚拟机

为什么需要 Java 虚拟机?为什么 Java 说 write once, run everywhere ?

在各个平台都有对应的虚拟机,他们负责将符合JVM对加载编译文件格式要求的语言进行解释执行。JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码字节码,就可以在多种平台上不加修改地运行。这也意味,JVM 有自己完善的硬体架构,如处理器堆栈寄存器等,还具有相应的指令系统,与具体的操作系统无关。

而在 Android 上,同JVM一样,Dalvik 或者 ART 负责将字节码解释成 Android 可以执行的指令,而每一个Dalvik 或者 ART 提供了对象生命周期管理、堆栈管理、线程管理、安全和异常管理以及垃圾回收等重要功能,各自拥有一套完整的指令系统。但是,JVM其核心目的,是为了构建一个真正跨OS平台,跨指令集的程序运行环境(VM),DVM的目的更像是为了将android OS的本地资源和环境,以一种统一的界面提供给应用程序开发

堆空间划分

网上大部分都是堆Dalvik和ART主要讲解的都是堆内存及回收,想来其他区域的原理与Java虚拟机规范原理差不多。话不多说,我们先看 Dalvik 和 ART 虚拟机对运行时堆的空间划分:

Dalvik 堆

在 Dalvik 中,默认使用 标记-清除(Mark-Sweep)算法 来进行垃圾回收,在运行时,堆被分成2个Space和多个辅助数据结构。

  • Zygote Space
    主要是用来管理Zygote进程在启动过程中预加载和创建的各种对象,在Zygote Space中不会出发GC,同时在Zygote进程和应用程序之间共享Zygote Space。

  • Allocation Space
    在Zygote Space进程fork第一个子进程之前,会把Zygote Space分为2个部分,原来的已经被使用的那部分堆仍旧是Zygote Space,而未使用的那部分堆就叫做Allocation Space,以后的对象都会在Allocation Space上进行分配和释放。Allocation Space不是进程间共享的,每个进程中都独立拥有一份。

DVM中数据结构如下:

  • Card Table
    用于DVM的并发 GC,当第一次进行垃圾标记后,记录垃圾信息。

  • Heap Bitmap
    有2个堆位图,一个用来记录上次GC存活的对象,另一个用来记录这次GC存活的对象。

  • Mark Stack
    DVM的运行时进行GC时用来进行标记的数据结构

Android系统的第一个Dalvik虚拟机是由Zygote进程创建的,而其他的应用进程是由 Zygote 进程 fork 出来的,为了尽量地避免拷贝,应用程序进程使用了一种写时拷贝技术(COW)来复制了Zygote进程的地址空间。

Zygote进程在启动过程中创建Dalvik虚拟机的时候,其实只有一个堆,但是当Zygote进程在fork第一个应用程序进程之前,会将已经使用了的那部分堆内存划分为一部分,还没有使用的堆内存划分为另外一部分,前面那部分就是 Zygote Space。

当应用程序或者Zygote 进程想要new一个新的对象的时候就会在 Allocation Space 上分配。每一个应用进程都会共享一个 Zygote Space,当Zygote进程或者应用程序进程对该堆进行写操作时,内核就会执行真正的拷贝一份提供给进程进行写操作。

ART 堆

ART运行时堆划分为四个空间,分别是Image Space、Zygote Space、Allocation Space和Large Object Space。其中前三个Space是连续的(Continuous Space),Large Object Space是一些离散地址的集合(Discontinuous Space),用于分配一些大对象。

在Image Space和Zygote Space之间,隔着一段用来映射system@framework@boot.art@classes.oat 文件的内存,system@framework@boot.art@classes.oat 是一个OAT文件,它是由在系统启动类路径中的所有DEX文件翻译得到的。

其中,Zygote Space、Allocation Space 跟 Dalvik 中的空间生成方式和含义相同。

  • Image Space
    包含了那些需要预加载的系统类对象。这意味着需要预加载的类对象是在生成system@framework@boot.art@classes.oat这个OAT文件的时候创建并且保存在文件system@framework@boot.art@classes.dex中,以后只要系统启动类路径中的DEX文件不发生变化(即不发生更新升级),那么以后每次系统启动只需要将文件system@framework@boot.art@classes.dex直接映射到内存即可,省去了创建各个类对象的时间。之前使用Dalvik虚拟机作为应用程序运行时时,每次系统启动时,都需要为那些预加载的类创建类对象。因此,虽然ART运行时第一次启动时会比较慢,但是以后启动实际上会更快。

  • Large Object Space

    • 其中一种实现和Continuous Space的实现类似,预先分配好一块大的内存空间,然后再在上面为对象分配内存块。不过这种方式实现的Large Object Space不像Continuous Space通过C库的内块管理接口来分配和释放内存,而是自己维护一个Free List。每次为对象分配内存时,都是从这个Free List找到合适的空闲的内存块来分配。释放内存的时候,也是将要释放的内存添加到该Free List去。

    • 另外一种Large Object Space实现是每次为对象分配内存时,都单独为其映射一新的内存。也就是说,为每一个对象分配的内存块都是相互独立的。这种实现方式相比上面介绍的Free List实现方式,也更简单一些。在Android 4.4中,ART运行时使用的是后一种实现方式。

对应的数据结构:

  • Mod Union Table
    Mod Union Table 是与Card Table配合使用的,用来记录在一次GC过程中,记录不会被回收的Space的对象对会被回收的Space的引用。例如,Image Space的对象对Zygote Space和Allocation Space的对象的引用,以及Zygote Space的对象对Allocation Space的对象的引用。

回收算法

Dalvik

Dalvik 虚拟机默认采用 Mark-Sweep 算法,不会进行整理,所以长时间运行会产生碎片问题。但是值得注意的是,在某些情况下,Dalvik 也会采用并发Gc。


Mark-Sweep 算法步骤

(a)GC 前的状态。示例中有一个 GC Root,所有对象都未被标记。
(b)GC 标记后的状态。在标记阶段,所有活动对象(Active Objects)都会被标记。
(c)GC 清除后的状态。所有垃圾已被回收,并且所有活动对象的标记状态都被重置为 false。

垃圾回收原因可以在Dalvik 日志消息中看到:

  • GC_CONCURRENT
    在您的堆开始占用内存时可以释放内存的并发垃圾回收。
  • GC_FOR_MALLOC
    堆已满而系统不得不停止您的应用并回收内存时,您的应用尝试分配内存而引起的垃圾回收。
  • GC_HPROF_DUMP_HEAP
    当您请求创建 HPROF 文件来分析堆时出现的垃圾回收。
  • GC_EXPLICIT
    显式垃圾回收,例如当您调用 gc() 时(您应避免调用,而应信任垃圾回收会根据需要运行
  • GC_EXTERNAL_ALLOC
    这仅适用于 API 级别 10 及更低级别(更新版本会在 Dalvik 堆中分配任何内存)。外部分配内存的垃圾回收(例如存储在原生内存或 NIO 字节缓冲区中的像素数据)。

ART

ART 具有可以运行的多种不同的垃圾回收,整个堆回收器会释放和回收 Image Space 以外的所有其他空间。Art 在GC上不像Dalvik仅有一种回收算法,Art在不同的情况下会选择不同的回收算法,比如Alloc内存不够的时候会采用非并发GC,而在Alloc后发现内存达到一定阀值的时候又会触发并发GC。

Dalvik 和 ART gc 流程对比

Dalvik 垃圾回收 (GC) 可能有损于应用性能,从而导致显示不稳定、界面响应速度缓慢以及其他问题。ART 通过以下几种方式对垃圾回收做了优化:

  • 只有一次(而非两次)GC 暂停
  • 在 GC 保持暂停状态期间并行处理
  • 在清理最近分配的短时对象这种特殊情况中,回收器的总 GC 时间更短
  • 优化了垃圾回收的工效,能够更加及时地进行并行垃圾回收,这使得 GC_FOR_ALLOC 事件在典型用例中极为罕见
  • 压缩 GC 以减少后台内存使用和碎片

从谷歌提供的数据来看,Art相对Dalvik内存分配的效率提高了10倍,GC的效率提高了2-3倍。

GC 类型:

  • Concurrent
    不会暂停应用线程的并发垃圾回收。此垃圾回收在后台线程中运行,而且不会阻止分配。
  • Alloc
    您的应用在堆已满时尝试分配内存引起的垃圾回收。在这种情况下,分配线程中发生了垃圾回收。可能造成卡顿。
  • Explicit
    由应用明确请求的垃圾回收,例如,通过调用 System#gc()Runtime#gc()。与 Dalvik 相同。不建议使用显式垃圾回收,因为它们会阻止分配线程并不必要地浪费 CPU 周期。如果显式垃圾回收导致其他线程被抢占,那么它们也可能会导致卡顿(应用中出现间断、抖动或暂停)。
  • NativeAlloc
    Native 分配(如位图或 RenderScript 分配对象)导致出现原生内存压力,进而引起的回收。
  • CollectorTransition
    由堆转换引起的回收;此回收由运行时切换垃圾回收引起。回收器转换包括将所有对象从空闲列表空间复制到碰撞指针空间(反之亦然)。当前,回收器转换仅在以下情况下出现:在 RAM 较小的设备上,应用将进程状态从可察觉的暂停状态变更为可察觉的非暂停状态(反之亦然)。
  • HomogeneousSpaceCompact
    齐性空间压缩是空闲列表空间到空闲列表空间压缩,通常在应用进入到可察觉的暂停进程状态时发生。这样做的主要原因是减少 RAM 使用量并对堆进行碎片整理。
  • DisableMovingGc
    这不是真正的垃圾回收原因,但请注意,发生并发堆压缩时,由于使用了 GetPrimitiveArrayCritical,回收遭到阻止。一般情况下,强烈建议不要使用 GetPrimitiveArrayCritical,因为它在移动回收器方面具有限制。
  • HeapTrim
    这不是垃圾回收原因,但请注意,堆修剪完成之前回收会一直受到阻止。

参考文章:
官方文档-内存管理概览
官方文档-管理应用内存
官方文档-进程间的内存分配
官方文档-平台架构
官方文档-调查 RAM 使用情况
官方文档-Android Runtime (ART) 和 Dalvik
浅谈内存映射
android底层之什么是Zram?
官方文档-Android Runtime (ART) 和 Dalvik
分页-维基百科
內存映射文件-维基百科
Java中什么是JVM及其工作原理?
Android和Linux的关系
Android LowMemoryKiller原理分析
Android LowMemoryKiller 简介
Android内存管理之LMK和OOM
Java虚拟机
Android内存管理分析总结
JVM、DVM(Dalvik VM)和ART虚拟机对比
阿里巴巴 说说 Android 虚拟机Dalvik与ART区别在哪里?
Dalvik虚拟机垃圾收集机制简要介绍和学习计划
Dalvik 虚拟机和 Sun JVM 在架构和执行方面有什么本质区别?
ART运行时垃圾收集机制简要介绍和学习计划
Android 虚拟机 Vs Java 虚拟机
Android GC原理探究
JVM、DVM(Dalvik VM)和ART虚拟机对比

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