JVM

1、从编码到执行


JAVA从编码到执行.png
  • 解释执行和编译执行是可以混合的,执行次数多的代码,会进行 JIT 的编译,交由操作系统直接执行。
  • JVM 和 JAVA 无关,只要可以编译为 CLASS,都可在 JVM 上运行。
  • 常见 JVM:
    • Hotspot - Oracle 官方,最常用的 JVM。
    • Jrockit - BEA,曾经号称最快的 JVM。被 Oracle 收购,合并于 Hotspot。
    • J9 - IBM。
    • Microsoft VM。
    • TaobaoVM - Hotspot 深度定制版。
    • LiquidVM - 直接针对硬件。
    • Azul Zing - 最新垃圾回收的夜间标杆。官网:https://www.azul.com/

JDK、JRE、JVM

  • JVM:Java Virtual Machine
  • JRE:JVM + Core lib
  • JDK:JRE + Development kit

2、Class文件结构


  1. class 文件:是二进制字节流。

  2. 查看 class 文件:javap -v X.class

CLASS文件结构 - HEX.png

3、类加载过程


类加载器

  • JVM 是按需动态加载,采用双亲委派机制。

    主要是基于安全考虑:如果自定义 class 都可以 load 到内存,客户可以创建 java.lang.String 类,通过 CustomClassLoader 覆盖掉 Bootstrap 内的类文件。

    次要是防止资源浪费:如果已经加载过,查找使用即可,无需重复加载。

类加载器.png
  • 打破双亲委派:

    1. 重写 loadClass() 方法。
    2. ThreadContextClassLoader 可以实现基础类调用实现类代码,通过 thread.setContextClassLoader 指定。
    3. 场景 - 热启动,热部署:osgi tomcat 都有自己的模块(web application)指定 classloader,可以加载同一类库的不同版本。
  • 查看 ClassLoader 加载路径:

    // BootstrapClassLoader 加载路径 
    System.getProperty("sun.boot.class.path");
    // ExtensionClassLoader 加载路径
    System.getProperty("java.ext.dirs");
    // AppClassLoader 加载路径
    System.getProperty("java.class.path");
    

类加载过程

  1. Loading:class 文件 load 到内存。
  2. Linking:链接。
    • Verification:校验 class 是否符合 JVM 规范。
    • Preparation:静态成员变量赋默认值,不是初值。
    • Resolution:将常量池中类、方法、属性等符号引用解析为指针、偏移量等内存地址的直接引用。
  3. Initializing:静态变量赋初始值,调用静态代码块,调用类初始化代码。
类加载过程.png

CompilerAPI

可以手动直接在内存中编译代码,无需生成到磁盘。

LazyLoading

严格讲应该叫做 LazyInitializing

JVM 规范并没有规定何时加载,但是严格规定了什么时候必须初始化。

  1. newgetstaticputstaticinvokestatic 指令,访问 final 变量除外。

    getstatic:读取静态变量。

    putstatic:设置静态变量。

    invokestatic:执行静态方法

  2. java.lang.reflect 对类进行反射调用时。

  3. 初始化子类时,父类首先初始化。

  4. 虚拟机启动时,被执行的主类必须初始化。

    包含 main 方法的类。

  5. 动态语言支持 java.lang.invoke.MethodHandler 解析的结果为 REF_getstaticREF_putstaticREF_invokestatic 的方法句柄时,该类必须初始化。

Java代码执行模式

Java 默认是解释执行,jvm 发现某段代码执行频率很高,则将其编译为本地代码。

  • 解释器 - bytecode intepreter

  • JIT - Just In-Time compiler

  • 混合模式:混合使用解释器 + 热点代码编译。起始阶段采用 解释执行。

    热点代码检测:

    1. 多次被调用的方法(方法计数器:检测方法执行频率)

    2. 多次被调用的循环(循环计数器:检测循环执行频率)

可以通过参数指定运行模式:

  • -Xmixed 默认为混合模式,开始解释执行,启动速度较快,对热点代码实行检测和编译。
  • -Xint 使用解释模式,启动很快,执行稍慢。
  • -Xcomp 使用纯编译模式,执行很快,启动很慢。
  • -XX:CompileThreshold=10000 检测热点代码阈值。

4、JMM


  • Java Memory Model:Java 内存模型

硬件层数据一致性

现代 CPU 数据一致性实现通过缓存锁总线锁实现。

CPU 缓存一致性协议:MSI、MESI、MOSI、Synapse、Firefly、Dragon。Intel CPU 使用的是 MESI 协议。

MESI 是缓存锁实现方式之一,有些无法被缓存的数据,或者跨越多个缓存行的数据,依然必须使用总线锁。

读取缓存以 cache line 为基本单位,目前 64bytes。

CPU 每个 cache line 标记四种状态(额外2位):

  • Modified:对缓存数据进行了更改。
  • Exclusive:对缓存数据独享。
  • Shared:对缓存数据读共享。
  • Invalid:缓存数据被其他 CPU 进行了更改。

伪共享

位于同一缓存航的两个不同数据,被两个不同 CPU 锁定,产生互相影响。

解决方案:缓存行对齐,能够提高效率,但会浪费一些空间。

// disruptor 多线程对指针游标使用特别频繁。
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

CPU 乱序执行

CPU 乱序执行根源:为了提高指令执行效率,读等待同时指令执行。读指令等待的同时,可以同时执行不影响其他指令。而写的同时可以进行合并写。这样 CPU 的执行就是乱序的。

必须使用 Memory Barrier 来做好指令排序。volatile 的底层就是这样实现的(Windows 是 lock 指令)。防止乱序执行,可以使用内存屏障。

内存屏障

Intel CPU 级别内存屏障(不同 CPU,内存屏障实现不同):

  • sfence:在 sfence 指令前的写操作当必须在 sfence 指令后的写操作前完成。
  • lfence:在 lfence 指令前的读操作当必须在 lfence 指令后的读操作前完成。
  • mfence:在 mfence 指令前的读写操作当必须在 mfence 指令后的读写操作前完成。

JVM 级别内存屏障规范(JSR33):

  • LoadLoad 屏障:对于这样的语句 Loadl:LoadLoad:Load2,在 Load2 及后读读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
  • StoreStore 屏障:对于这样的语句 Storel:StoreStore:Store2,在 Store2 及后续写入操怍执行前,保证 Store1 的写入操怍对其它处理器可见。
  • LoddStore 屏障:对于这样的语句 Loadl:LoadStore:Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。
  • StoreLoad 屏障:对于这样的语句 Store1:StoreLoad:Load2,茌 Load2 及后续所有读取操作执行前,保证 Storel 的写入对所有处理器可见。

Volatile 实现细节:

  • 字节码层面:class 文件增加了 ACC_VOLITILE 标识。

  • JVM 层面:volatile 内存区的读写,都加屏障。

    StoreStoreBarrier
    volatile 写操作
    StoreLoadBarrier
    
    LoadLoadBarrier
    volatile 写操作
    LoadStoreBarrier
    
  • OS 和硬件层面:HSDIS - HotSpot Dis Assembler;Windows - lock 指令实现。

Synchronized 实现细节:

  • 字节码层面:ACC_SYNCHRONIZED、monitorenter、monitorexit。
  • JVM层面:调用了操作系统提供的同步机制。
  • OS 和硬件层面:X86 - lock comxchg / xxxx。

Hanppens-Before原则:]VM规定重排序必须遵守的规则

  • 程序次序规则:同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
  • 管程锁定规则:一个 unlock 操作先行发生于后面(时间上)对同一个锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面(时间上)对这个变量的读操作。
  • 线程启动规则:Threadstart() 方法先行发生于这个线程的每一个操作。
  • 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测线程的终止。
  • 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.isInterrupted() 方法检测线程是否中断。
  • 对象终结规则:一个对象的初始化完成先行于发生它的 finalize() 方法的开始。
  • 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

AS IF SERIAL

不管如何重排序,单线程执行结果不会改变。

对象的内存布局

观察虚拟机配置:java -XX:+PrintCommandLineFlags -version

普通对象:

  1. 对象头:markword

    • 32位 - 4字节
markword-32bit.png
  • 64位 - 8字节
markword-64bit.png
  1. ClassPointer 指针:开启 -XX:+UseCompressedClassPointers 为4字节,不开启为8字节。默认开启。

    可以使用 -XX:-UseCompressedClassPointers 关闭。

  2. 实例数据:普通对象指针。开启 -XX:+UseCompressedOops 为4字节,不开启为8字节。默认开启。

    Oops:Ordinary Object Pointers。

  3. Padding 对齐:8的倍数。

数组对象:在普通对象基础上,多了一个数组长度,4字节。

HashCode 和 偏向锁

  • 当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;
  • 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
  • 重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。或者简单说就是重量锁可以存下identity hash code。

请一定要注意,这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。
Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。

对象定位

  1. 句柄池
  2. 直接指针:Hotspot 使用的是直接指针。

GC 回收时,定位方式有影响,三色标记算法,对句柄池算法效率比较高,对直接指针算法效率比较低。

对象分配过程

  • 栈上分配:线程私有小对象,无逃逸(对象仅在方法内有引用),支持标量替换。默认开启。

  • 线程本地分配:TLAB(Thread Local Allocation Buffer)。小对象,占用 Eden 区,默认 1%。多线程情况下不用竞争 Eden 就可以申请空间,提高效率。

  • 老年代:大对象。

  • 栈上分配 和 线程本地分配一般不需要调整参数。

  • 相关 JVM 启动参数:

    -XX:-EscapeAnalysis :关闭逃逸分析。

    -XX:-EliminateAllocations:关闭标量替换。

    -XX:-UseTLAB:关闭TLAB。

对象分配过程.png

5、运行时数据区


Run-time Data Areas

Run-time data areas.png
线程共享区域.png
  • Program Counter:程序计数器 - 存放指令位置。每个线程有自己的 PC。

  • JVM stacks:每个线程有独有的栈,线程栈内装载的是栈帧,每个方法调用对应一个栈帧。

  • native method stacks:native 方法栈。

  • Direct Memory:JVM 直接访问内核空间内存(OS 管理的内存),省略了内存拷贝的过程。
    NIO,提交效率,实现 zero copy。

  • Method area:方法区。装载 class、常量池。方法区在所有 JVM 线程间共享。方法区是逻辑概念,PermSpace 和 MetaSpace 是具体实现。

    1.8 版本前:Perm Space,FGC 不回收。字符串常量位于 PermSpace。启动时指定,不可变。

    1.8 版本后:Meta Space,字符串常量位于堆,会触发 FGC 清理。如果不设定,最大是物理内存。

  • Heap:在所有 JVM 线程间共享。堆是运行时数据区,为所有类实例和数组分配内存。

  • run-time constant pool:常量池数据,装载在运行时常量池内。

堆内逻辑分区

内存模型:除 Epsilon、ZGC、shenandoah 之外,都是使用逻辑分代,其中 G1 是逻辑分代,物理不分代,除此之外,不仅逻辑分代,物理也分代。内存分区不适用不分代垃圾收集器,例如 ZGC(jdk11)、Shenandoah(jdk12)。

内存布局:新生代、老年代、方法区(MethodArea)

  1. 方法区是逻辑概念,1.7版本永久代(Perm Generation)实现,1.8版本元数据区(Metaspace)实现。
  2. 永久代需要启动时指定大小,且不可更改。元数据区可以设置,也可以不设置,受限于物理内存。
  3. 永久代/元数据区 存放:Class 元信息、方法编译后信息、代码编译后信息、JIT 编译信息、字节码等。
  4. 字符串常量:1.7 在永久代,1.8 在堆。
  5. 1.8 版本 新生代/老年代 内存比例默认 1 : 2。可通过参数 -XX:NewRatio=2 调整。
堆内存逻辑分区.png
  • MinorGC / YGC:新生代空间耗尽时触发。
  • MajorGC / FullGC:老年代满了,无法继续分配空间时触发,新生代老年代同事进行回收。
  • 新生代大量死去,少量存活,采用复制算法。
  • 老年代存活率高,回收较少,采用标记清除或标记压缩算法。

Eden 区经过回收之后,进入 Survivor 区。在 Survivor 区达到 -XX:MaxTenuringThreshold 参数阈值后(最大15),进入 Tunured 区。

动态年龄:S0 向 S1 复制时,超过 50%,把年龄最大的放入老年代。

空间担保/分配担保:YGC 期间,Survivor 区空间不够了,直接进入老年代。

栈帧

框架用于存储数据和中间结果,以及执行动态链接、方法返回值和异常。

  • Local Variables Table:局部变量表。

  • Operand Stacks:操作数栈。

  • Dynamic Linking:指向运行时常量池的符号链接。

    A() -> B(),B 方法要到常量池找,B 方法调用在栈帧上就是一个 Dynamic Linking。

  • Return Address:A() -> B(),B 方法的返回值应存放的位置。可以理解为方法出口。

指令集

指令集设计有两种类型:

  • 基于栈的指令集:JVM 基于栈的指令集设计。
  • 基于寄存器的指令集。

Hotspot 的 Local variable table 类似于寄存器。

6、GC基本算法


垃圾定义:没有任何引用指向的对象。

查找算法

  • Reference Count:引用计数。不能解决循环引用问题。

  • Root Searching:根可达算法。可作为 root 的对象包括:

    1. JVM stack:线程栈变量
    2. native method stack:JNI 指针
    3. run-time constant pool:常量池
    4. static references in method area:静态变量 - 方法区内部静态引用
    5. class

回收算法

  • Mark-Sweep:标记清除 - 存活对象比较多的情况下,效率比较高,不适合 Eden 区。需要两次扫描,第一次标记,第二次清除,效率偏低。容易产生碎片。
Mark-Sweep.png
  • Copying:拷贝 - 适用于存活对象较少的情况,例如 Eden 区。只需要扫描一次,效率提高,没有碎片。缺点是空间浪费,需要移动和复制对象,指向对象的引用需要调整。
Copying.png
  • Mark-Compact:标记压缩 - 空间连续,没有碎片,不会产生内存浪费。需要扫描两次,并移动数据,效率偏低。
Mark-Compact.png

7、垃圾回收器


GC.png

常见垃圾回收器:1.8 版本默认 PS + ParallelOld。

  1. Serial:年轻代,串行回收,单线程设计。单 CPU 效率最高。

  2. SerialOld:老年代,串行回收,单线程设计,使用 mark-sweep-compact 算法。

  3. ParallelScavenge:年轻代,串行回收,多线程设计。

  4. ParallelOld:老年代,串行回收,多线程设计,使用 mark-compact 算法。

  5. ParNew:年轻代,配合 CMS 的并行回收。基于 PS 做了增强,例如 CMS 某些特定阶段,ParNew 会同时运行。

  6. CMS:ConcurrentMarkSweep,老年代,并发的。垃圾回收和应用程序同时运行,降低 STW 时间(200ms)。

    CMS 使用标记清除,一定会产生碎片,碎片达到一定程度,使用 SerialOld 进行老年代回收。

    1.4 版本开始支持,CMS 问题较多,目前没有任何版本默认支持,需要手动开启。

  7. G1:算法 - f + SATB。

  8. ZGC:算法 - ColloredPointers + 写屏障。

  9. Shenandoah:算法 - ColloredPointers + 读屏障。

  10. Eplison:一般调试时使用。

GC 和内存大小的关系:

  1. Serial:100Mb 以内
  2. PS:100Mb - 几个Gb
  3. CMS:20Gb
  4. G1:上百Gb
  5. ZGC:4Tb

CMS

CMS线程角度.png
  • 运行阶段(实际6个,另外两个不重要)

    • 初始标记:单线程。直接找到最根上的对象,会产生 STW,但运行很快。
    • 并发标记:最浪费时间的阶段,和应用线程同时运行。
    • 重新标记:多线程。会产生 STW,多数垃圾在并发标记过程中,标记完成之后产生的新垃圾,进行重新标记。新垃圾不多,停顿时间很短。
    • 并发清理:清理过程中会产生浮动垃圾,需要等待下次 CMS 运行,再进行清理。
  • 缺点:

    1. Memory Fragmentation:内存碎片化。 -XX:+UseCMSCampactAtFullCollection -XX:CMSFullGCsBeforeCompaction 可优化此问题。
    2. Floating Garbage:浮动垃圾。解决方案:降低触发 CMS 的阈值。-XX:CMSInitiatingOccupancyFraction 92%,可以降低阈值,保持老年代有足够空间。
    3. CMS 的设计并不是应对大内存,由于内存碎片化,无法分配大对象,会启动 SerialOld。
  • 日志分析

    [GC (CMS Initial Mark) [1 CMS-initial-mark: 8511K(13696K)] 9866K(19840K), 0.0040321 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
      //8511 (13696) : 老年代使用(最大)
      //9866 (19840) : 整个堆使用(最大)
    [CMS-concurrent-mark-start]
    [CMS-concurrent-mark: 0.018/0.018 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] 
      //这里的时间意义不大,因为是并发执行
    [CMS-concurrent-preclean-start]
    [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
      //标记Card为Dirty,也称为Card Marking
    [GC (CMS Final Remark) [YG occupancy: 1597 K (6144 K)][Rescan (parallel) , 0.0008396 secs][weak refs processing, 0.0000138 secs][class unloading, 0.0005404 secs][scrub symbol table, 0.0006169 secs][scrub string table, 0.0004903 secs][1 CMS-remark: 8511K(13696K)] 10108K(19840K), 0.0039567 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
      //STW阶段,YG occupancy:年轻代占用及容量
      //[Rescan (parallel):STW下的存活对象标记
      //weak refs processing: 弱引用处理
      //class unloading: 卸载用不到的class
      //scrub symbol(string) table: 
          //cleaning up symbol and string tables which hold class-level metadata and 
          //internalized string respectively
      //CMS-remark: 8511K(13696K): 阶段过后的老年代占用及容量
      //10108K(19840K): 阶段过后的堆占用及容量
    [CMS-concurrent-sweep-start]
    [CMS-concurrent-sweep: 0.005/0.005 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
      //标记已经完成,进行并发清理
    [CMS-concurrent-reset-start]
    [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
      //重置内部结构,为下次GC做准备
    

G1

  • G1 GC:Garbage First Garbage Collector - 主要运行在server端的,目标是在多核、大内存服务器上,通过并发和并行的手段,达到暂停时间比较短,维持不错的吞吐量。当开始 GC 过程时,优先收集垃圾最多的 Regions。G1 还是一种带压缩的收集器,在回收老年代分区时,将存活对象从一个分区,拷贝到另一个分区,实现了局部压缩。

    G1 新生代、老年代比例是动态的,一般不要手工指定,因为这是 G1 预测停顿时间的基准。

  • 特点

    • 并发收集 - 并发标记、并发回收。
    • 压缩空闲空间不会延长 GC 的暂停时间。
    • 更易预测的 GC 暂停时间。
  • 使用不需要实现很高的吞吐量的场景。

  • 触发

    • YGC:Eden 空间不足,多线程并行执行。

    • MixedGC:相当于 CMS,YGC之后 ,堆内存空间超过阈值,就会启动。阈值通过 -XX:InitiatingHeapOccupancyPercent=45 设置,默认 45%。

    • FullGC:Old 空间不足,或手动调用System.GC()

      优化方案:扩内存;提高CPU性能(产生对象的速度固定,GC 越快,内存空间越大);降低 MixedGC 触发的阈值,让 MixedGC 提早发生(默认45%)。

      JDK10 之前使用的是 Serial,串行回收,调优目标是尽量不要产生 FGC。

  • MixedGC 过程

    • 初始标记:STW
    • 并发标记
    • 最终标记:STW(重新标记)
    • 筛选回收:STW(并行)
  • Region:G1 内存模型逻辑分代,物理不分代。每个分区从 1M 到 32M 不等,但都是 2 的幂次方。基础分区大小可通过参数配置。

G1-Regions.png
  • CSset:Collection Set,一组可被回收的分区的集合,可理解为待回收的 Region 集合。在CSet中存活的数据会在 GC 过程中被移动到另一个可用分区,CSet 中的分区可以来自 Eden 空间、Survivor 空间、或者老年代。CSet 会占用不到整个堆空间的 1% 大小。

  • RSet:Remembered Set,记录了其他 Region 中的对象到本 Region 的引用,RSet 的价值在于使得 GC 不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。
    由于 RSet 的存在,每次给对象赋值引用时,需要在 RSet 中做一些额外的记录,在 GC 中被称为写屏障(不是 JVM 的内存屏障)。

  • CardTable:卡表。由于 YGC 时,Y区对象可能由 OLD 区对象引用,因此需要扫描整个 OLD 区,效率非常低,所以 JVM 设计了 CardTable。如果一个 OLD 区 CardTable 中有对象指向 Y 区,就将它设为 Dirty,下次扫描时,只需要扫描 Dirty Card。Card Table 使用 BitMap 实现。堆划分为相等大小的一个个区域,这个小的区域(一般 size 在128-512字节)被当做 Card,而 Card Table 维护着所有的 Card。

  • 三色标记

    • 白色:未被标记的对象。
    • 灰色:自身被标记,成员变量未被标记。
    • 黑色:自身和成员变量均已标记完成。

    漏标问题:在 Remark 过程中,黑色指向了白色,且灰色指向白色的引用消失了,如果不对黑色重新扫描,则会漏标,会把白色对象当做没有新引用指向,从而回收掉。

三色标记-漏标.png

解决漏标问题有两种方案:

  1. Incremental Update - 增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性。CMS 使用此方案。

  2. SATB - Snapshot at the beginning - 关注引用的删除,当灰色对象指向白色对象引用消失时,要把这个引用推到 GC 的堆栈,保证白色对象还能被 GC 扫描到。

    GC 栈中存放的是灰色对象指向白色对象的引用。

G1 使用的是 SATB,只需要把改变过的引用重新扫描即可。当再次扫描白色对象时,仅需判断白色对象所在 Region 的 RSet 是否有引用指向该对象,不需要扫描整个堆,即可标记该对象是否为垃圾。SATB 配合 RSet 使用,浑然天成。

  • 日志分析

    [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0015790 secs]
    //young -> 年轻代 Evacuation-> 复制存活对象 
    //initial-mark 混合回收的阶段,这里是YGC混合老年代回收
       [Parallel Time: 1.5 ms, GC Workers: 1] //一个GC线程
          [GC Worker Start (ms):  92635.7]
          [Ext Root Scanning (ms):  1.1]
          [Update RS (ms):  0.0]
             [Processed Buffers:  1]
          [Scan RS (ms):  0.0]
          [Code Root Scanning (ms):  0.0]
          [Object Copy (ms):  0.1]
          [Termination (ms):  0.0]
             [Termination Attempts:  1]
          [GC Worker Other (ms):  0.0]
          [GC Worker Total (ms):  1.2]
          [GC Worker End (ms):  92636.9]
       [Code Root Fixup: 0.0 ms]
       [Code Root Purge: 0.0 ms]
       [Clear CT: 0.0 ms]
       [Other: 0.1 ms]
          [Choose CSet: 0.0 ms]
          [Ref Proc: 0.0 ms]
          [Ref Enq: 0.0 ms]
          [Redirty Cards: 0.0 ms]
          [Humongous Register: 0.0 ms]
          [Humongous Reclaim: 0.0 ms]
          [Free CSet: 0.0 ms]
       [Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 18.8M(20.0M)->18.8M(20.0M)]
     [Times: user=0.00 sys=0.00, real=0.00 secs] 
    //以下是混合回收其他阶段
    [GC concurrent-root-region-scan-start]
    [GC concurrent-root-region-scan-end, 0.0000078 secs]
    [GC concurrent-mark-start]
    //无法evacuation,进行FGC
    [Full GC (Allocation Failure)  18M->18M(20M), 0.0719656 secs]
       [Eden: 0.0B(1024.0K)->0.0B(1024.0K) Survivors: 0.0B->0.0B Heap: 18.8M(20.0M)->18.8M(20.0M)], [Metaspace: 38
    76K->3876K(1056768K)] [Times: user=0.07 sys=0.00, real=0.07 secs]
    

ZGC

ZGC的核心是 Colored Pointer + Load Barrier,不支持32位,不支持指针压缩。

Collored Pointer:颜色指针,GC信息记录在指针上,不记录在头部,Immediate memory use。

JDK12及以前,使用42位指针,寻址空间4T;JDK13及以后,扩展为 2^{44},16T,因为CPU地址总线最大支持48位。

Marked0、Marked1、Remapped、Finalizable 是互斥的,同时只能有一位为1,普通对象全部为0。

ZGC-Object-Pointer.png

Load Barrier 根据指针颜色决定是否做一些事情。

ZGC 可以做到 NUMA(Non Uniform Memory Access) Aware。

8、JVM调优


确定调优之前,应该确定是吞吐量优先(计算型任务),还是响应时间优先(响应型任务),还是在满足一定响应时间的情况下,要求达到多大的吞吐量。

  • 吞吐量:用户代码执行时间 / (用户代码执行时间 + 垃圾回收执行时间)

    例如科学计算、数据挖掘。GC 一般选择 PS + PO。

  • 响应时间:用户线程停顿的时间短

    STW 越短,响应时间越好。例如网站、API服务。GC 一般选择 G1。

调优思路

  1. 根据需求进行 JVM 规划和预调优。
  2. 优化运行 JVM 运行环境。
  3. 解决 JVM 运行过程中出现的各种问题。

从规划开始

  • 调优,从业务场景开始,没有业务场景的调优都是耍流氓。

  • 无监控(压力测试,能看到结果),不调优。

  • 步骤:

    1. 熟悉业务场景(没有最好的垃圾回收器,只有最合适的垃圾回收器)。

      确定追求吞吐量,还是响应时间。

    2. 选择回收器组合。

    3. 计算内存需求(很难计算,经验值,或经过测试和监控确定)。

    4. 选定 CPU(越高越好)。

    5. 设定年代大小、升代年龄。

    6. 设定日志参数:

      -Xloggc:/opt/xxx-xxx-gc-%t.log -XX:+useGCLogFileRotation -XX:NumberOfGCLogFiles=5

      -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCause

    7. 观察日志情况。

百万并发

淘宝 2019 年双 11,最大支撑 TPS 54万。12306 号称 百万并发。

9、OOM问题


排查过程中,jconsole、jvisualvm 等图形化界面工具,仅仅适用于开发及测试,不适合排查生产问题,因为JMX连接到服务后,对服务器影响很高。一般图形化界面只适用于系统上线前压测监控。

如果生产在线定位,生产一般做了高可用,停掉一台服务器,对其他服务器无影响,因此先进行隔离,停止该服务器对外提供服务,流量降为0之后,基于此服务器在线定位,进行分析。

OOM类型

  1. 堆溢出:java.lang.OutOfMemoryError: Java heap space
  2. 栈溢出:-Xss 设定太小,java.lang.StackOverflowError
  3. 方法区溢出:java.lang.OutOfMemoryError: Compressed class space
  4. 直接内存溢出:使用Unsafe分配直接内存,或者使用NIO的问题。

排查思路

  1. top 命令观察到问题:内存不断增长,CPU占用率居高不下。

  2. top -Hp pid 命令观察进程中的线程,哪个线程CPU和内存占比高。

  3. printf %x pid 将10进制PID转换为16进制。

  4. jstack -l pid 查看进程内线程状态。

    • nid:16进制线程ID。
    • waiting on <0x0000000088拆310> (a java.lang.Object) :要找到哪个线程持有此锁。
  5. jps 定位具体java进程。

  6. jinfo pid 查看进程JVM详细信息。

  7. jstat -gc pid 500 每500毫秒打印 GC 情况,动态观察 GC 情况,阅读 GC 日志发现频繁GC。

    响应信息很不直观,所以不常用,可通过 arthas 、jconsole 等工具观察。

  8. jmap -histo pid | head -20 查找有多少对象产生。此命令对在线系统影响不是很高。

    此步骤很关键,数量很多的对象,往往是造成 Heap OOM 的问题所在。

    Arthas 目前未提供此功能。

  9. jmap -dump:format=b,file=xxx pid / jmap -histo 无论进程是否卡顿了,只要进程在,就可以导出。

    执行此命令,对在线系统的影响特别高。

  10. java -Xms20M -Xmx20M -XX:+UseParallelGC -XX:+HeapDumpOnOutOfMemoryError -jar xxx.jar Heap OOM 时,自动产生堆转储文件。

  11. 使用 MAT / jhat 进行 dump 文件分析。

    jhat -J-Xmx512M heap.hprof 默认开启 http 7000 端口,可在浏览器查看分析结果。

  12. 找到代码的问题。

jconsole 远程连接

  1. 程序启动加入参数

    java -Djava.rmi.server.hostname=192.168.17.11 \
         -Dcom.sun.management.jmxremote \
         -Dcom.sun.management.jmxremote.port=11111 \
         -Dcom.sun.management.jmxremote.authenticate=false \
         -Dcom.sun.management.jmxremote.ssl=false -jar Test.jar
    
  2. 如果遇到 Local host name unknown: XXX 的错误,修改 /etc/hosts 文件,把 XXX 加入进去

    192.168.17.11 basic localhost localhost.localdomain localhost4 1ocalhost4.1ocaldomain4
    ::1 localhost 1ocalhost.1ocaldomain localhost6 1ocalhost6.1ocaldomain6
    

jvisualvm 远程连接

可以连接 JMX,进行实时监控。

可以进行 Heap dump 文件分析。

抽样器:内存监控 - 观察此界面哪些对象很多,且不断增长,GC回收不掉,一定是相关类代码出了问题。

线程状态说明:

  • 运行:线程运行中,对应 JSTACK 中 RUNNABLE。
  • 休眠:对应 sleep 操作。
  • 等待:对应 wait 操作。
  • 驻留:对应线程池里的空闲线程。
  • 监视:对应的 synchronized 阻塞。

jprofiler

号称是最好用的,但是收费。

10、JSTACK线程状态


  • RUNNABLE:线程运行中或I/O等待。

    public static void runnable() {
        long i = 0;
        while (true) {
            i++;
        }
    }
    
  • BLOCKED:等待互斥量或锁的释放。线程在等待monitor锁(synchronized关键字)。

    public static void blocked() {
        final Object lock = new Object();
        new Thread() {
            public void run() {
                synchronized (lock) {
                    System.out.println("i got lock, but don't release");
                    try {
                        Thread.sleep(1000L * 1000);
                    } catch (InterruptedException e) {
                    }
                }
            }
        }.start();
    
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
    
        synchronized (lock) {
            try {
                Thread.sleep(30 * 1000);
            } catch (InterruptedException e) {
            }
        }
    }
    
  • TIMED_WAITING:线程在等待唤醒,但设置了时限。Lock.tryLock(30000) 也会触发此状态。

    public static void timedWaiting() {
        final Object lock = new Object();
        synchronized (lock) {
            try {
                lock.wait(30 * 1000);
            } catch (InterruptedException e) {
            }
        }
    }
    
  • WAITING:线程在无限等待唤醒。

    public static void waiting() {
        final Object lock = new Object();
        synchronized (lock) {
            try {
                lock.wait();
            } catch (InterruptedException e) {
            }
        }
    }
    

11、Arthas


  • 启动:java -jar arthas-boot.jar,之后选择进程:1
  • 查看JVM详细配置情况: jvm,类似 JAVA 中jinfo 命令。
  • 实时数据面板:dashboard
  • 查看线程栈:thread 1。支持管道,thread 1 | rep 'main(' 可以查找到 main class
  • 导出堆文件:heapdump [filepath] 生产环境慎用,影响很大。
  • 查找类:sc -d *MathGame
  • 反编译:jad demo.Test
  • 监控函数参数/返回值/异常信息:watch demo.MathGame primeFactors "{params,target,returnObj}" [-x 2] [-b] [-s] [-n 2]
  • 退出:临时退出(可再次连接):exitquit;彻底退出:stop
  • 热替换:redefine /path/Test.class 目前有限制条件:只能改方法实现,不能改方法名,不能改属性。

12、常用参数


参数分类

  • 标准:- 开头,所有 HotSpot 都支持。
  • 非标准:-X 开头,特定版本 HotSpot 支持。通过 java -X 查看。
  • 不稳定:-XX 开头,下个版本可能取消。通过 java -XX:+PrintFlagsFinal -version 查看。

常用GC参数组合(1.8版)

Linux 中没找到默认 GC 的查看方法,而 Windows 中会打印 UseParallelGC。
可通过-XX:+PrintCommandLineFlags -version(仅在 Windows 有打印) 或通过 GC 日志来分辨。

1.8.0_222 默认 PS + PO。

  • -XX:+UseSerialGC:Serial New(DefNew) + Serial Old

    适用于小型程序。

  • -XX:+UseParNewGC:ParNew + SerialOld

    此组合很少适用(某些版本已废弃)。

  • -XX:+UseConcMarkSweepGC: ParNew + CMS + SerialOld

  • -XX:+UseParallelGC:Parallel Scavenge + Parallel Old

    1.8 版本默认配置。

  • -XX:+UseParallelOldGC:Parallel Scavenge + Parallel Old

  • -XX:+UseG1GC:G1

JVM常用参数

  • -Xmn 年轻代大小。
  • -Xms 最小堆大小。
  • -Xmx 最大堆大小。
  • -Xss 栈空间大小。
  • -XX:MaxMetaspaceSize:方法区大小。
  • -XX:+PrintVMOptions 打印JVM启动参数。
  • -XX:+PrintFlagsFinal 打印JVM参数。
  • -XX:+PrintFlagsInitial 初始化默认参数。
  • -verbose:class 类加载详细过程。
  • -XX:-DisableExplicitGC 屏蔽 System.gc() 显式调用。
  • -XX:MaxTenuringThreshold 升代年龄,最大值15。
  • -XX:+HeapDumpOnOutOfMemoryError:OOM 时,自动 Memory Dump。
  • -XX:+UseTLAB 使用TLAB,默认打开,不建议调整。
  • -XX:+PrintTLAB 打印TLAB的使用情况,不建议调整。
  • -XX:TLABSize 设置TLAB大小,不建议调整。
  • -XX:PreBlockSpin 锁自旋次数,不建议调整。
  • -XX:CompileThreshold 热点代码检测参数,不建议调整。

GC日志参数

  • -Xloggc:/path/logs/gc.log 日志文件目录。
  • -XX:+PrintGC:打印GC信息。
  • -XX:+PrintGCDetails:打印详细GC信息。
  • -XX:+PrintGCCause:打印GC产生的原因。
  • -XX:+PrintHeapAtGC GC时打印堆栈情况。
  • -XX:+PrintGCTimeStamps:打印GC产生时详细系统时间。
  • -XX:+PrintGCDateStamps:打印GC产生的日期+时间。
  • -XX:+PrintGCApplicationConcurrentTime (低)打印应用程序时间。
  • -XX:+PrintGCApplicationStoppedTime (低)打印应用程序暂停时长。
  • -XX:+PrintReferenceGC (低)记录回收了多少种不同引用类型的引用。

Parallel常用参数

  • -XX:SurvivorRatio 幸存区比例。
  • -XX:PreTenureSizeThreshold 大对象体积。
  • -XX:MaxTenuringThreshold 生代年龄,最大15。
  • -XX:+ParallelGCThreads 并行收集器的线程数,同样适用于CMS,一般设为和CPU核数相同。
  • -XX:+UseAdaptiveSizePolicy 自动选择各区大小比例。

CMS常用参数

  • -XX:+UseConcMarkSweepGC 指定 GC 为 CMS。
  • -XX:ParallelCMSThreads CMS 线程数量。
  • -XX:CMSInitiatingOccupancyFraction 老年代使用多少比例后,启动CMS,默认 68%(近似值)。
  • -XX:+UseCMSCompactAtFullCollection 在 FGC 时进行压缩。解决
  • -XX:CMSFullGCsBeforeCompaction 多少次 FGC 后进行压缩。
  • -XX:+CMSClassUnloadingEnabled 回收永久代。
  • -XX:CMSInitiatingPermOccupancyFraction 达到多少比例时,进行 Perm 回收。
  • -XX:GCTimeRatio 设置 GC 时间占应用程序运行时间的百分比。
  • -XX:MaxGCPauseMillis 停顿时间,是一个建议值,GC 会尝试用各种手段达到这个时间,比如减小年轻代。

G1常用参数

  • -XX:+UseG1GC 指定 GC 为 G1。

  • -XX:MaxGCPauseMillis 建议值,G1会尝试调整 Young 区的块数来达到这个值。

  • -XX:GCPauseIntervalMillis GC的间隔时间。

  • -XX:+G1HeapRegionSize 分区大小,建议逐渐增大该值,1 2 4 8 16 32。
    随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长。ZGC做了改进(动态区块大小)。

  • -XX:G1NewSizePercent 新生代最小比例,默认为5%。

  • -XX:G1MaxNewSizePercent 新生代最大比例,默认为60%。

  • -XX:GCTimeRatio GC时间建议比例,G1会根据这个值调整堆空间。

  • -XX:ConcGCThreads 线程数量。

  • -XX:InitiatingHeapOccupancyPercent 启动G1的堆空间占用比例。

13、案例


  • 案例1:垂直电商,最高每日百万订单,处理订单系统需要什么样的服务器配置?
    这个问题比较业余,因为很多不同的服务器配置都能支撑。
    找到最巅峰的瞬间,例如某个小时内产生40万订单,做到能够支撑平均 100订单/秒即可。
    关于内存,可以计算一个订单产生需要多少内存。一般 512K 就能存储很多数据了。
    专业的问法:要求相应时间 100ms。
    压测!最简单的是加机器。
  • 案例2:12306遭遇春节大规模抢票应该如何支撑?
    12306应该是中国并发量最大的秒杀网站,号称并发量 100W 最高。
    CDN -> LVS -> NGINX -> 业务系统 -> 每台机器1W并发<font color="#F00">(单机10K问题)</font> 100台机器
    普通电商订单 -> 下单 -> 订单系统(IO)减库存 -> 等待用户付款
    12306的一种可能的模型:下单 -> 减库存和订单(redis kafka)同时异步进行 -> 等待付款
    减库存最后还会把压力压到一台服务器
    可以做分布式本地库存 + 单独服务器做库存均衡
  • 案例3:怎么得到一个事务会消耗多少内存?

    1. 弄台机器,看能承受多少TPS?是不是达到目标?扩容或调优,让它达到。
    2. 用压测来确定。
  • 案例4:硬件升级,系统反而卡顿。

    有一个50万PV的资料类网站(从磁盘提取文档到内存)原服务器32位,1.5G的堆,用户反馈网站比较缓慢,因此公司决定升级,新的服务器64位,16G的堆内存,结果用户反馈卡顿十分严重,反而比以前效率更低了,为什么?如何优化?

    原网站由于内存较低,很多数据 load 到内存,内存不足,会频繁GC,响应时间变慢。

    升级后,内存扩大了,但 GC 没有调整,GC 和 YGC 频率变低了,但 STW 时间更长了。

    可以更换 PS + PO 为 PN + CMS 或 G1。

  • 案例5:系统CPU经常100%,如何调优?

    CPU 100% 一定有线程在占用系统资源:

    1. top 命令找出哪个进程 CPU 高。
    2. top -Hp 命令找出该进程中哪个线程 CPU 高。
    3. jstack 命令导出该线程的堆栈。
    4. jstack 命令查找哪个方法(栈帧)消耗时间。
    5. 需要确定是工作线程,还是GC线程占比高。
  • 案例6:系统内存飙高,如何查找问题?

    内存飚高,一定是堆内存占用比较多:

    1. jmap 导出堆内存。
    2. jhat jvisualvm mat jprofiler 等工具分析。
  • 案例7:JIRA 问题 - 全球协同办公,多地在使用的线上系统,系统不停的 FGC,使用十分卡顿,但是能用,实在用不了的情况下重启。启动参数:

    _> /opt/atlassian/jira/jre/bin/java \
    _>     -Djava.util.logging.config.file=/opt/atlassian/jira/conf/logging.properties \
    _>     -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager \
    _>     -Xms1024m \
    _>     -Xmx9216m \
    _>     -Djava.awt.headless=true \
    _>     -Datlassian.standalone=JIRA \
    _>     -Dorg.apache.jasper.runtime.BodyContentImpl.LIMIT_BUFFER=true \
    _>     -Dmail.mime.decodeparameters=true \
    _>     -Dorg.dom4j.factory=com.atlassian.core.xml.InterningDocumentFactory \
    _>     -XX:-OmitStackTraceInFastThrow \
    _>     -Datlassian.plugins.startup.options= \
    _>     -Djdk.ephemeralDHKeySize=2048 \
    _>     -Djava.protocol.handler.pkgs=org.apache.catalina.webresources \
    _>     -Xloggc:/opt/atlassian/jira/logs/atlassian-jira-gc-%t.log \
    _>     -XX:+UseGCLogFileRotation \
    _>     -XX:NumberOfGCLogFiles=5 \
    _>     -XX:GCLogFileSize=20M \
    _>     -XX:+PrintGCDetails \
    _>     -XX:+PrintGCDateStamps \
    _>     -XX:+PrintGCTimeStamps \
    _>     -XX:+PrintGCCause \
    _>     -classpath /opt/atlassian/jira/bin/bootstrap.jar:/opt/atlassian/jira/bin/tomcat-juli.jar \
    _>     -Dcatalina.base=/opt/atlassian/jira \
    _>     -Dcatalina.home=/opt/atlassina/jira \
    _>     -Djava.io.tmpdir=/opt/atlassina/jira/temp \
    _>     org.apache.catalina.startup.Bootstrap start
    

    解决过程:

    1. 调整堆内存 -Xms9216M -Xmx9216M,阻止弹性扩容缩。
    2. 由于不能再生产使用 jmap, 增加 -XX:+HeapDumpOnOutOfMemoryError 参数,宕机时导出堆。
    3. 将 JVM 内存调整到64G,调整 GC 为 G1,之后运行一个月没有出现卡顿,运行正常。
    4. 直到最后问题解决,也没有找到原因。
  • 案例8:Lambda 表达式导致方法区溢出问题

    Lambda 表达式会对每一个对象实例产生内部类(新的 class),GC 回收不过来,最终抛出 java.lang.OutOfMemoryError: Compressed class space 异常。

    方法区的清理,每个GC不同,有些GC不会清理,有些GC会清,但条件很苛刻(不存在该 class 对象)。这件事情很少发生。

  • 案例9:重写 finalize 引发频繁GC

    小米云,HBase 同步系统,系统通过 nginx 访问超时报警,最后排查,C++ 程序员重写 finalize 引发频繁 GC 问题。

    为什么 C++ 程序员会重写 finalize?C++ 语言中,需要手动回收内存,new 调用构造函数开辟内存,delete 调用析构函数回收内存。

    由于重写了 finalize 函数,每次回收执行大量逻辑代码,耗时较长,导致 GC 回收不过来。

  • 案例10:Disruptor OOM 问题

    Disruptor 可以设置链的长度,如果过大,且对象很大,消费完不主动释放,会产生溢出。

  • 案例11:内存一直消耗不超过10%,FGC 总是频繁发生。

    手动调用了 System.gc()

    可以设置 JVM 参数 -XX:-DisableExplicitGC 屏蔽 GC 显式调用。

附录:常用命令


  • 查看非标参数:java -X

  • 查看不稳定参数:java -XX:+PrintFlagsFinal -version

  • 打印启动参数:-XX:+PrintCommandLineFlags -version Windows 包含 GC。

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

推荐阅读更多精彩内容