JVM学习-内存结构与垃圾回收

JVM内存结构

  1. 虚拟机栈:其中的数据为Stack Frame 栈帧,属于线程私有的内存空间。栈用于存放局部变量表、操作栈、动态链接和方法出口等信息。一个方法的执行过程就是对栈帧的入栈出栈过程。
  2. 程序计数器(Program Counter):很小的内存空间,用来取标识当前执行线程的执行字节码行号,属于线程私有的内存空间。
  3. 本地方法栈:主要用于处理本地方法。
  4. 堆(Heap):JVM管理的最大一块内存空间, 所有的线程共享,与堆相关的一个重要概念是垃圾收集器。现代几乎所有的垃圾收集器采用的都是分代收集算法,所有堆空间也基于这一点进行了相应的划分:新生代与老年代。Eden空间,From Survivor空间与To Survivor空间。
  5. 方法区(Method Area):存储元信息。存放了每个Class的结构信息,包括常量池、字段描述、方法描述。永久代(Permanment Generation),从JDK1.8开始,已经彻底废弃了永久代。使用元空间(meta space)。GC的非主要工作区域。
  6. 运行时常量池:方法区的一部分内容。
  7. 直接内存(Direct Memory):不由JVM直接管理,由操作系统来进行管理。与JAVA NIO密切相关。JVM通过堆上的DriectByteBuffer来操作直接内存。
image.png
public void method1() {
    Object obj = new Object();
}

-. 生成了2部分的内存区域。

  1. obj这个引用变量是方法内的变量,放到JVM Stack里面
  2. 真正的Object class的实例对象放到Heap里。
    -. 上述的new语句一共消耗了12个Byte, JVM规定引用占4个bytes(JVM Stack),空对象是8个byte(Heap)
    -. 方法结束后,对应Stack中的变量马上回收,但是Heap中的对象要等到GC来回收。

JAVA对象创建过程

new 关键字创建对象的3个步骤

  1. 在堆内存中创建出对象的实例。
  2. 为对象的实例成员变量赋初值。
  3. 将对象的引用返回。

指针碰撞(前提是堆中的空间通过一个指针进行分割,一侧是已经被占用的空间,另一侧是未被占用的空间)
空闲列表(前提是堆内存空间中已被使用和未被使用的空间是交织在一起的,这时,虚拟机就需要通过一个列表来记录哪些空间是可以使用的,哪些空间是已被使用的,接下来找出可以容纳新创建的对象的且未被使用的空间,在此空间存放该对象,同时还要修改列表上的记录)

对象在内存中的布局

  1. 对象头
  2. 实例数据(即我们在一个类中所声明的各项信息)
  3. 对齐填充(可选)

引用访问对象的方式

  1. 使用句柄的方式。
  2. 使用直接指针的方式。

方法区产生内存溢出错误

使用GCLIB可以动态生成类,类信息存放在metaspace。

JVM垃圾回收(GC)模型

-. 垃圾判断算法
-. GC算法
-. 垃圾回收器的实现和选择

垃圾判断的算法

-. 引用计数算法(Reference Counting)
给对象添加与一个引用计数器,当有一个地方引用它,计数器加1,当引用失效,计数器减1,任何时刻计数器为0的对象就是不再被引用的对象。
引用计数算法无法解决对象循环引用的问题。
-. 根搜索算法(Root Tracing)
在实际的生产语言中(Java、C#等),都是使用跟搜索算法判断对象是否存活。
算法基本思路就是通过一系列的称为“GC Roots”的点作为起始进行向下搜索,当一个对象到GC Roots没有任何引用链(Reference Chain)相连,则证明此对象是不可用的。
在Java语言中,GC Roots包括:

  1. 在JVM栈(帧中的本地变量)中的引用
  2. 方法区中的静态引用
  3. JNI(Native方法)中的引用

方法区垃圾回收

-. 主要回收两部分内容:废弃常量与无用类
-. 类回收需要满足如下3个条件

  1. 该类的所有实例都已经被GC,也就是JVM中不存在该Class的任何实例
  2. 加载该类的ClassLoader已经被GC
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,例如不能在任何地方通过反射访问该类的方法。
    -. 在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都

JVM常见GC算法

-. 标记-清除算法(Mark-Sweep)
算法分为标记清除两个阶段。首先标记出所有需要回收的对象,然后回收所有需要回收的对象
缺点:

  1. 效率问题,标记和清理两个过程效率都不高,需要扫描所有对象,堆越大,GC越慢。
  2. 空间问题,标记清理之后会产生大量不连续的内存碎片,空间碎片太多可能会导致后续使用中无法找到足够的连续内存而提前触发另一次垃圾搜集动作。GC次数越多,碎片越严重。
    -. 标记-整理算法(Mark-Compact)
    标记过程仍然一样,后续步骤不是进行直接清理,而是令所有存活的对象一端移动,然后直接清理掉这端边界以外的内存。
    总结:
  3. 没有碎片产生
  4. 耗费更多的时间进行Compact
    -. 复制算法(Copying)
    将可用的内存划分为两块,每次只使用其中的一块,当半区内存用完了,仅将还存活的对象复制到另外一块上边,然后就把原来整块内存空间一次性清理掉。
    这样使得每次内存回收都是对整个半区的回收,内存分配时也就不用考虑内存碎片等复杂情况,主要移动堆顶指针,按顺序分配内存就可以了,实现简单,运行高效。只是这种算法是将内存缩小为原来的一半,代价高昂。
    现在的商业虚拟机中都是用了这一种收集算法来回收新生代。
    将内存分为一块较大的eden空间和2块较少的survivor空间,每次使用eden和其中一块survivor,当回收时将eden和survivor还存活的对象一次性拷贝到另外一块survivor空间上,然后清理掉eden和用过survivor。
    Oracle Hotspot虚拟机默认eden和survivor的大小比例是8:1,也就是每次只有10%的内存是浪费的。
    如果不向浪费50%的空间,就需要额外的空间进行分配担保用于应付半区内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
    复制搜集算法在对象存活率高的时候,效率有所下降,
    总结:
  5. 只需要扫描存活的对象,效率更高
  6. 不会产生碎片
  7. 需要浪费额外的空间作为复制区。
  8. 复制算法非常适合生命周期比较短的对象,因为每次GC总能回收大部分的对象,复制的开销比较小。
  9. 根据IBM的研究,98%的Java对象只会存活1个GC周期,对这些对象很适合用复制算法。而且不用1:1的划分工作区和复制区的空间。
    -. 分代算法(Generational)
    当前商业虚拟机的垃圾手机都是采用分代收集算法,根据对象不用的存活周期将内存划分为几块。
    一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特性采用最适当的收集算法,例如新生代每次GC都有大批对象死去,只有少量存活,那就选用复制算法只需要付出少量存活对象的复制成本就可以完成收集。
  10. 年轻代(Young Generation)
    -. 新生成的对象都放在新生代。年轻代用复制算法进行GC。
    -. 年轻代分三个取。一个Eden区,两个Survivor区。对象在Eden区中生成。当Eden区满时,还存活的对象被复制到一个Surivor区,当这个Survivor区满时,此区的存活对象被复制到另外一个Survivor区,当第二Survivor区也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制到老年代。2个Survivor是完成对称的,轮流替换。
    -. Eden和两个Survivor的缺省比例是8:1:1,也就是10%的空间会被浪费。
  11. 老年代(Old Generation)
    -. 存放了经过一次或者多次GC还存活的对象
    -. 一般采用Mark-Sweep或者Mark-Compact算法进行GC
    -. 有多种垃圾收集器可以选择。每种垃圾收集器可以看作一个GC算法的具体实现。

JVM垃圾回收(GC)模型

-. 垃圾判断算法
-. GC算法
-. 垃圾回收器的实现和选择

垃圾判断的算法

-. 引用计数算法(Reference Counting)
给对象添加与一个引用计数器,当有一个地方引用它,计数器加1,当引用失效,计数器减1,任何时刻计数器为0的对象就是不再被引用的对象。
引用计数算法无法解决对象循环引用的问题。
-. 根搜索算法(Root Tracing)
在实际的生产语言中(Java、C#等),都是使用跟搜索算法判断对象是否存活。
算法基本思路就是通过一系列的称为“GC Roots”的点作为起始进行向下搜索,当一个对象到GC Roots没有任何引用链(Reference Chain)相连,则证明此对象是不可用的。
在Java语言中,GC Roots包括:

  1. 在JVM栈(帧中的本地变量)中的引用
  2. 方法区中的静态引用
  3. JNI(Native方法)中的引用

方法区垃圾回收

-. 主要回收两部分内容:废弃常量与无用类
-. 类回收需要满足如下3个条件

  1. 该类的所有实例都已经被GC,也就是JVM中不存在该Class的任何实例
  2. 加载该类的ClassLoader已经被GC
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,例如不能在任何地方通过反射访问该类的方法。
    -. 在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都

JVM常见GC算法

-. 标记-清除算法(Mark-Sweep)
算法分为标记清除两个阶段。首先标记出所有需要回收的对象,然后回收所有需要回收的对象
缺点:

  1. 效率问题,标记和清理两个过程效率都不高,需要扫描所有对象,堆越大,GC越慢。
  2. 空间问题,标记清理之后会产生大量不连续的内存碎片,空间碎片太多可能会导致后续使用中无法找到足够的连续内存而提前触发另一次垃圾搜集动作。GC次数越多,碎片越严重。
    -. 标记-整理算法(Mark-Compact)
    标记过程仍然一样,后续步骤不是进行直接清理,而是令所有存活的对象一端移动,然后直接清理掉这端边界以外的内存。
    总结:
  3. 没有碎片产生
  4. 耗费更多的时间进行Compact
    -. 复制算法(Copying)
    将可用的内存划分为两块,每次只使用其中的一块,当半区内存用完了,仅将还存活的对象复制到另外一块上边,然后就把原来整块内存空间一次性清理掉。
    这样使得每次内存回收都是对整个半区的回收,内存分配时也就不用考虑内存碎片等复杂情况,主要移动堆顶指针,按顺序分配内存就可以了,实现简单,运行高效。只是这种算法是将内存缩小为原来的一半,代价高昂。
    现在的商业虚拟机中都是用了这一种收集算法来回收新生代。
    将内存分为一块较大的eden空间和2块较少的survivor空间,每次使用eden和其中一块survivor,当回收时将eden和survivor还存活的对象一次性拷贝到另外一块survivor空间上,然后清理掉eden和用过survivor。
    Oracle Hotspot虚拟机默认eden和survivor的大小比例是8:1,也就是每次只有10%的内存是浪费的。
    如果不向浪费50%的空间,就需要额外的空间进行分配担保用于应付半区内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
    复制搜集算法在对象存活率高的时候,效率有所下降,
    总结:
  5. 只需要扫描存活的对象,效率更高
  6. 不会产生碎片
  7. 需要浪费额外的空间作为复制区。
  8. 复制算法非常适合生命周期比较短的对象,因为每次GC总能回收大部分的对象,复制的开销比较小。
  9. 根据IBM的研究,98%的Java对象只会存活1个GC周期,对这些对象很适合用复制算法。而且不用1:1的划分工作区和复制区的空间。
    -. 分代算法(Generational)
    当前商业虚拟机的垃圾手机都是采用分代收集算法,根据对象不用的存活周期将内存划分为几块。
    一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特性采用最适当的收集算法,例如新生代每次GC都有大批对象死去,只有少量存活,那就选用复制算法只需要付出少量存活对象的复制成本就可以完成收集。
  10. 年轻代(Young Generation)
    -. 新生成的对象都放在新生代。年轻代用复制算法进行GC。
    -. 年轻代分三个取。一个Eden区,两个Survivor区。对象在Eden区中生成。当Eden区满时,还存活的对象被复制到一个Surivor区,当这个Survivor区满时,此区的存活对象被复制到另外一个Survivor区,当第二Survivor区也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制到老年代。2个Survivor是完成对称的,轮流替换。
    -. Eden和两个Survivor的缺省比例是8:1:1,也就是10%的空间会被浪费。
  11. 老年代(Old Generation)
    -. 存放了经过一次或者多次GC还存活的对象
    -. 一般采用Mark-Sweep或者Mark-Compact算法进行GC
    -. 有多种垃圾收集器可以选择。每种垃圾收集器可以看作一个GC算法的具体实现。

内存回收

GC要做的是将那些dead的对象所占用的内存回收掉
-. Hotspot认为没哟引用的对象是dead的。
-. Hotspot将引用分为四种:Strong、Soft、Weak、Phantom。Strong即默认通过Object o = new Object()这种方式赋值引用的。Soft、Weak、Phantom这三种则都是继承Reference。
在Full GC时会对Reference类型的引用进行特殊处理。
-. Soft: 内存不够时一定会被GC、长期不用也会被GC
-. Weak:一定会被GC,当被mark为dead会在ReferenceQueue中通知。
-. Phantom:本来就没引用,当从jvm heap中释放时会通知。

GC的时机

在分代模型的基础上,GC从时机上分为两种:Scavenge GC 和 Full GC
-. Scavenge GC(Minor GC)
触发时机:Eden空间满了
理论上Eden区大多数对象会在Scavenge GC回收,复制算法的执行效率会很高,Scavenge GC时间比较短。
-. Full GC
对整个JVM进行整理,包括Yound、Old和Perm
主要触发时机:

  1. Old满了
  2. Perm满了
  3. system.gc()
    效率很低尽量减少Full GC

垃圾回收器

-. 分代模型:GC的宏观愿景
-. 垃圾回收器:GC的具体实现
-. Hotspot JVM提供多种垃圾回收器,我们需要根据具体应用的需要采用不同的回收器
-. 没有万能的垃圾回收器,每种垃圾回收器都有自己的适用场景

垃圾收集器的并行和并发

-. 并行(Parallel):指多个收集器的线程同时工作,但是用户线程处于等待状态。
-. 并发(Concurrent):指收集器在工作的同时,可以允许用户线程工作。并发不代表解决了GC停顿的问题,在关键的步骤还是要停顿。比如在收集器标记垃圾的时候。但在清除垃圾的时候,用户线程可以和GC线程并发执行。

Java内存泄露的经典原因

-. 对象定义在错误的范围(Wrong Scope)
如果Foo实例对象的生命较长,会导致临时性内存泄露。(这里的names变量其实只有临时作用)

class Foo {
    private String[] names;
    public void doIt(int length) {
        if(names == null || name.length < length) {
            populate(names);
            print(names);
        }
    }
}

JVM喜欢生命周期短的对象,这样做已经足够高效。

class Foo {
    public void doIt(int length) {
        String[] names = new String[length];
        populate(names);
        print(names);
    }
}

-. 异常(Exception)处理不当

Connection conn = DriverManager.getConnection(url, name, password);
try {
    String sql = "do a query sql";
    PreparedSatement stmt = conn.prepareStatument(sql);
    ResultSet rs = stmt.executeQuery();
    while(rs.next()) {
        doSomeStuff();
    }
    rs.close();
    conn.close();
} catch (Exception e) {
    //如果doSomeStuff() 抛出异常
    //rs.close和conn.close 不会被调用
    //会导致内存泄露和db连接泄露
} finally { 
    //一定要关闭
}

-. 集合数据管理不当
当使用Array-based的数据结构(ArrayList, HashMap等)时,尽量减少resize
比如new ArrayList,尽量估算size,在创建的时候把size确定,较少resize可以避免没有必要的array copying, gc等问题。
如果一个List只需要顺序访问,不需要随机访问(Random Access), 用LinkedList代替ArrayList。LinkList本质是链表,不需要resize,但只适用于顺序访问。

GC过程理解

参数:
-verbose:gc 显示详细的GC过程
-Xms20M 堆容量初始大小
-Xmx20M 堆容量最大大小
-Xmn10M 新生代大小10M
-XX:+PrintGCDetails 打印GC详细信息
-XX:SurvivorRatio=8 eden : from survivor :to survivor = 8:1:1
-XX:MaxTenuringThreshold=5 从新生代晋升到老年代的阈值最大值,该参数的默认值为15,CMS中默认值为6,G1中默认为15(在JVM中,该数值是由4个bit来表示的)
经历了多次GC后,存活的对象在From Survivor与To Survivor之间来回存放,而这里面的一个前提则是这两个空间有足够的大小来存放这些数据,在GC算法中,会计算每个对象的年龄的大小,如果达到某个年龄后发现总大小已经大于Survivor空间的50%,那么这时候就需要调整阈值,不能再继续等到默认的15次GC后才完成晋升,应为这样会导致Survivor空间不足,所以需要调整Survivor空间不足,所以需要调整阈值,让这些存活对象对象完成晋升。

public Class MyTest1 {
     public static void main(String[] args) {
        int size = 1024 * 1024;
        byte[] myAlloc1 = new byte[2 * size];
        byte[] myAlloc2 = new byte[2 * size];
        byte[] myAlloc3 = new byte[3 * size];
        byte[] myAlloc4 = new byte[3 * size];
        //无法在新生代完成分配, 直接在老年代完成分配
        System.out.println("hello world");
    }
}

安全点

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那么将会需要大量的额外空间,这样GC的空间成本将会变得更高。
实际上HotSpot并没有为每条指令都生成OopMap,而只是在“特定的位置”记录了这些信息,这些位置被成为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停。
Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负载。所以,安全点的选定基本上是以“是否具有让长时间执行的特征”为标准进行选定的,因为这个原因而过长时间运行, ”长时间执行“的最明显特征就是指令序列服用,例如方法调用、循环跳转、异常跳转等,所有具有这些功能的指令才会产生Safepoint。
对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都跑到最近的安全点上再停顿下来:抢占式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。
-. 抢占式中断:它不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。
-. 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动取轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
现在几乎没有虚拟机采用抢占式中断来暂停线程从而响应GC事件。

安全区域

在使用Safepoint似乎已经完成完美地解决了如何进入GC的问题,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但如果程序在”不执行“的时候呢。所谓程序不执行就是没有分配CPU时间,典型的例子就是处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,JVM也明显不太可能等待线程重新分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决了。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开Safe Region的信号为止。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么Minor GC可以确保是安全的。当大量对象在Minor GC后仍然存活,就需要老年代进行空间分配担保,把Survivor无法容纳的对象直接进入老年代。如果老年代判断到剩余空间不足(根据以往每一次回收晋升到老年代对象容量的平均值作为经验值),则进行一次Full GC。

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

推荐阅读更多精彩内容