Java虚拟机(二)

Android知识总结

一、类加载机制

(一)、一个类生命周期

类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载7个阶段,其中验证、准备和解析这是三个部分统称为连接(linking)。

(二)、一个类载入过程

通过上面的内容我们知道,一个类的加载过程被分为5个阶段:加载连接初始化。其中连接分为三个步骤:验证准备解析。如下图

  • 1、加载阶段

1)通过类的全限定名来获取定义此类的二进制字节流
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3)在内存中生成一个代表这个类的java.lang.Class对象,作为这个类的各种数据的方位入口

第一步主要是获取一个类的二进制字节流,意思就是把类以流的形式加载进内存,类的来源没有说,可以是jar包,也可以是class文件或者是apk文件。这个特性是能够实现插件化技术的理论基础。

第二步就是在获取到这个字节流以后,虚拟机就会把类中的静态存储结果保存到方法区中,保存的过程会转化对应方法区中的数据结构,所以说静态的结构都保存在内存中的方法区中。

第三步是当类加载进内存以后,每个类都会生成一个对应的Class对象,当我们使用这个类的时候,都是通过此Class对象为入口来使用的,比如我们写程序的时候通过 new 关键字创建一个类的对象的时候,也是通过这个类的Class对象来创建的。

  • 2、验证阶段
    主要是对类中的语法结构是否合法进行验证,确认类型符合Java语言的语义。

  • 3、准备阶段
    这个阶段是给类中的类变量分配内存,设置默认初始值,比如一个静态的int变量初始值是0,布尔变量初始值是false。

  • 4、解析阶段
    在类型的常量池中寻找类,接口,字段和方法的符号引用,把这些符号引用替换成直接引用的过程。

  • 5、初始化阶段
    类初始化时类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>与<init>的区别

  • 这两个方法一个是虚拟机在装载一个类初始化的时候调用——<clinit>。另一个是在类实例化的时候调用的——<init>。
  • 所有的类变量初始化语句和类静态初始化语句都被Java编译器收集到了一起,放在一个特殊的方法中。这个方法就是——<clinit>
  • <init>方法时在一个类进行对象实例化时调用的。实例化一个类有四种途径:
    1)、调用new操作符;
    2)、调用Class或java.lang.reflect.Constructor对象的newInstance()方法;
    3)、调用任何现有对象的clone()方法;
    4)、通过java.io.ObjectInputStream类的getObject()进行反序列化

JVM的大致物理结构图

二、垃圾收集算法

(一)、复制算法

复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

  • 特点:
    1、实现简单、运行高效
    2、内存复制、没有内存碎片
    3、可用内存缩小为原来的一半,可用内存降低。

(二)、Appel式的复制回收算法

一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1和 Survivor2),按照8:1:1的关系分配。

专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1]。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有 10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

  • 特点
    1、Appel式回收
    2、提高空间利用率和空间分配担保

(三)、标记清除算法

标记-清楚算法分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象进行标记,玩标记完毕后,再扫描整个空间未被标记的对象进行回收

  • 特点
    1、效率问题,标记和清除过程的效率都不高;
    2、空间问题,标记清除后会产生大量不连续的内存碎片,导致提前GC。
    3、利用率百分之百
    4、可以做到线程不暂停

(四)、标记整理算法

标记整理算法的标记过程类似标记清楚算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,该垃圾回收算法适用于对象存活率高的场景(老年代),其原来如下:

  • 特点:
    1、对象移动
    2、引用更新
    3、用户线程暂停
    4、没有内存碎片

(四)、分代收集算法

对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对昂也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况是不一样的),故而不同声明周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高JVM的执行效率。当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就采用标记清楚算法或者标记整理算法。Java堆内存一般可以分为新生代老年代永久带三个模块。栈中分配新生代占1/3,老年代占2/3.

  • 1、新生代(Young Generation)
    新生代的目标是尽可能快速收集掉那些生命周期短的对象,一般情况下,所有新生成的对象首先都是放在新生代的。新生代内存按照8:1:1的比例分成一个eden区、Form区和To区,大部分对象在Eden区中生成。

  • 2、老年代(Old Generation)
    老年代存放的都是一些生命周期长的对象,就像上面的所叙述的那样,在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。

  • 3、永久代(Permanent Generation)
    永久代主要用于存放静态文件,如Java类方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如使用反射、动态代理、GCLib等bytecode框架时,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。

三、判断对象的存活

在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去”(死去代表着不可能再被任何途径使用得对象了)。

(1)、引用计数算法:

判断对象的引用数量是否为0:
  Python在用,但主流虚拟机(Hotspot)没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理(补偿算法),这样做影响效率。
  每个对象实例都有一个引用计数器,被引用则+1,完成引用则-1;任何引用计数为0的对象实例可以被当作垃圾收集。

(2)、可达性分析算法:

该方法的基本思想是通过一系列的GC Roots对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。

作为GC Roots的对象包括下面几种:(通常来说我们只要知道虚拟机栈和静态引用就够了)

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    我们知道,每个方法执行的时候,jvm都会创建一个相应的栈帧(栈帧中包括操作数栈、局部变量表、运行时常量池的引用),栈帧中包含这在方法内部使用的所有对象的引用(当然还有其他的基本类型数据),当方法执行完后,该栈帧会从虚拟机栈中弹出,这样一来,临时创建的对象的引用也就不存在了,或者说没有任何gc roots指向这些临时对象,这些对象在下一次GC时便会被回收掉
  • 方法区中类静态属性引用的对象
    静态属性是该类型(class)的属性,不单独属于任何实例,因此该属性自然会作为gc roots。只要这个class存在,该引用指向的对象也会一直存在。class 也是会被回收的,在面后说明
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
  • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。
  • 所有被同步锁(synchronized关键)持有的对象。
  • JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
  • JVM实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代时)。

Class要被回收,条件比较苛刻,必须同时满足以下的条件

  • 1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  • 2、加载该类的classloader已经被回收
  • 3、该类的java.lang.Class对象没有在任何地方被引用,也就是说无法通过反射再带访问该类的信息

四、Finalize方法

即使通过可达性分析判断不可达的对象,也不是非死不可,它还会处于缓刑阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与GCRoots的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了finalize),我们可以在finalize中去拯救。

public class FinalizeGC {
    public static FinalizeGC instance = null;
    public void isAlive(){
        System.out.println("I am still alive!");
    }
    @Override
    protected void finalize() throws Throwable{
        super.finalize();
        System.out.println("finalize method executed");
        FinalizeGC.instance = this;
    }
    public static void main(String[] args) throws Throwable {
        instance = new FinalizeGC();
        //对象进行第1次GC
        instance =null;
        System.gc();
        Thread.sleep(1000);//Finalizer方法优先级很低,需要等待
        if(instance !=null){
            instance.isAlive();
        }else{
            System.out.println("I am dead!");
        }
        //对象进行第2次GC
        instance =null;
        System.gc();
        Thread.sleep(1000);
        if(instance !=null){
            instance.isAlive();
        }else{
            System.out.println("I am dead!");
        }
    }
}

运行结果:



可以看到,对象可以被拯救一次(finalize执行第一次,但是不会执行第二次)。

注意:finalize方法执行缓慢,当还没有挽救,还没有挽救垃圾回收就回收掉了。所以建议大家尽量不要使用finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,因为在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好

五、对象的分配策略

对象的分配原则

  • 1、对象优先在Eden区分配
    大多数情况下,对象在新生代Eden区中分配,如果启动了本地线程分配缓冲区,将线程优先在 (TLAB) 上分配。少数情况会直接分配在老年代中。当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。

虚拟机参数:
-Xms20m
-Xmx20m
-Xmn10m
-XX:+PrintGCDetails
-XX:+PrintGCDetails 打印垃圾回收日志,程序退出时输出当前内存的分配情况

注意:新生代初始时就有大小

  • 2、空间分配担保
    在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的,如果担保失败则会进行一次Full GC;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

  • 3、大对象直接进入老年代
    最典型的大对象是那种很长的字符串以及数组。这样做的目的:1.避免大量内存复制,2.避免提前进行垃圾回收,明明内存有空间进行分配。

参数设置:
-Xms20m
-Xmx20m
-Xmn10m
-XX:+PrintGCDetails
-XX:PretenureSizeThreshold=4m
-XX:+UseSerialGC
PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效。

  • 4、长期存活的对象进入老年代
    如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1,对象在Survivor区中每熬过一次 Minor GC,年龄就增加1,当它的年龄增加到一定程度(并发的垃圾回收器默认为15,底层四位二进制存储 1111),CMS是6时,就会被晋升到老年代中。
    -XX:MaxTenuringThreshold调整

  • 5、动态对象年龄判断
    为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

栈中分配对象

  • 逃逸分析
      逃逸分析:可以分为方法逃逸线程逃逸
      逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用,比如:调用参数传递到其他方法中,这种称之为方法逃逸,甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。
      如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。
      采用了逃逸分析后,满足逃逸的对象在栈上分配。没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢

堆中的优化技术

  • 本地线程分配缓冲(TLAB)
      另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。
      TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
      TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。

六、常量池和String

1)、常量池

在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字 字面量和符号引用

常量池有很多概念,包括运行时常量池class常量池字符串常量池。虚拟机规范只规定以上区域属于方法区,并没有规定虚拟机厂商的实现。

严格来说是静态常量池运行时常量池

  • 静态常量池是存放字符串字面量、符号引用以及类和方法的信息。(即:class 里面的一些信息。)

  • 运行时常量池存放的是运行时一些直接引用。(即:就是类加载是放到运行时数据区的方法区,把符号引用变成直接引用。)

运行时常量池是在类加载完成之后,将静态常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。

这两个常量池在JDK1.7版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。

字面量:
给基本类型变量赋值的方式就叫做字面量或者字面值
比如:String a=“b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。

符号引用:
符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA 在编译的时候一个每个 java 类都会被编译成一个 class文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段(后续 JVM 类加载会具体讲到)就是为了把这个符号引用转化成为真正的地址的阶段。
包括类和方法的全限定名(例如 String 这个类,它的全限定名就是 Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。
一个 java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因此只能使用符号引用(org.simple.Tool)来代替。而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址。

直接引用:
具体对象的索引值。

2)、String 对象是如何实现的

String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量:char 数组,hash 值。


了解了 String 对象的实现后,你有没有发现在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。我们知道类被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改。Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。

在 Java 中,通常有两种创建字符串对象的方式

  • 一种是通过字符串常量的方式创建,如 String str=“abc”

这种方式首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。


  • 另一种是字符串变量通过 new 形式的创建,如 String str = new String(“abc”)

这种方式,首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。

使用 new,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,同时这个堆中对象的成员变量会引用了常量池中的字符串对象。

public class Location {
private String city;
private String region;
}
  • String str2= "ab"+ "cd"+ "ef";
    编程过程中,字符串的拼接很常见。前面我讲过 String 对象是不可变的,如果我们使用 String 对象相加,拼接我们想要的字符串,是不是就会产生多个对象呢?例如以下代码:
    分析代码可知:首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象,从理论上来说,这段代码是低效的。
    编译器自动优化了这行代码, 编译后的代码,你会发现编译器自动优化了这行代码,如下
String str= "abcdef";
  • intern

String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。


  • 1、new Sting() 会在堆内存中创建一个 a 的 String 对象,king"将会在常量池中创建
  • 2、在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
  • 3、调用 new Sting() 会在堆内存中创建一个 b 的 String 对象。
  • 4、在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
    所以 a 和 b 引用的是同一个对象
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容