JVM

1、JVM

1.1 基本概念

JVM是可运行Java代码的假象计算机,包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收、堆和一个存储方法域。JVM是运行在操作系统之上的它与硬件没有直接的交互。
image.png

1.2 原理

image.png

运行期环境代表着Java平台,开发人员编写Java代码(.java文件),然后将之编译成字节码(.class文件),再然后字节码被装入内存,一旦字节码进入虚拟机,他就会被解释执行或者是被即时代码发生器有选择的转换成机器码执行。
JVM在它的生命周期中的任务就是运行Java程序,因此当Java程序启动的时候就产生JVM的一个实例,当程序运行结束的时候该实例也就消失了。JVM是程序与底层操作系统和硬件无关的关键。

1.3 结构

image.png
  • Class Loader类加载器
    负责加载.class文件,class文件在文件开头有特定的文件标识,并且ClassLoader负责class文件的加载等,至于它是否可以运行则由Execution Engine(执行引擎)决定。作用有定位和导入二进制class文件;验证导入类的正确性;为类分配初始化内存;帮助解析符号引用。
  • Native Interface本地接口
    本地接口的作用是融合不同的编程语言为Java所用。
  • Execution Engine执行引擎
    执行包在装载类的方法中的指令,也就是方法。
  • Runtime data area运行数据区
    虚拟机内存或者Jvm内存在整个计算机内存中开辟一块内存存储Jvm需要用到的对象变量等,运行区又分很多小区,分别为:方法区,虚拟机栈,本地方法栈,堆,程序计数器。

2、JVM数据运行区详解

image.png

2.1 PC Register程序计数器

每个线程都有一个程序计数器,就是一个指针,指向方法区中的方法字节码(下一个要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.2 Native Method Stack本地方法栈

本地方法栈则为Native方法服务, 如果一个VM实现使用C-linkage模型来支持Native调用, 那么该栈将会是一个C栈,但HotSpot VM直接就把本地方法栈和虚拟机栈合二为一。

2.3 VM Stack虚拟机栈

是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

image.png

2.4 Heap堆 运行时数据区

是被线程共享的一块内存区域,创建的对象和数组都保存在Java堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代VM采用分代收集算法, 因此Java堆从GC的角度还可以细分为: 新生代(Eden区、From Survivor区和To Survivor区)和老年代。

image.png

2.5 方法区/永久代

即我们常说的永久代(Permanent Generation), 用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java虚拟机对Class文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

3、Heap堆

image.png

3.1 新生代

是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代又分为 Eden区、ServivorFrom、ServivorTo三个区。
Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。

3.1.1 MinorGC的过程(复制->清空->互换)采用复制算法

当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。

3.2 老年代

主要存放应用程序中生命周期长的内存对象
老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。
MajorGC采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

3.3 永久存储区

指内存的永久保存区域,主要存放Class和Meta(元数据)的信息,Class在被加载的时候被放入永久区域,它和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。
在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。

4、 GC常用算法

判断对象是否存活一般有两种方式:

  • 引用计数算法:此对象有一个引用则+1,删除一个引用则-1,只用收集计数为0的对象。缺点是a.无法处理循环引用的问题。B.引用计数的方法需要编译器的配合,编译器需要为此对象生成额外的代码。
  • 根搜索算法:建立若干中根对象,当任何一个根对象到某一个对象均不可达时则认为这个对象是可以被回收的。根对象一般为虚拟机栈中的引用的对象,方法区中的类静态属性引用的对象,方法区中的常量引用的对象,本地方法栈中的引用对象。
    在根搜索算法的基础上,现代虚拟机的实现当中垃圾搜集算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。
  • 标记-清除算法:当堆中的有效内存空间被耗尽的时候,就会停止整个程序,然后进行两项工作,第一项是标记,第二项是清除。标记的过程就是遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。缺点是效率低(递归与全堆对象遍历),清理出来的空间不连续,在进行GC的时候要停止应用程序。
  • 复制算法:将内存分为两个区间,在任意时间点所有动态分配的对象都只能分配在其中一个区间。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程,将活动区间的存活对象全部复制到空闲空间,且严格按照内存地址一次排列,与此同时,GC线程将更新存货对象的内存引用地址指向新的内存地址。此时空闲空间与活动区间交换。缺点是浪费了一半内存,如果对象的存活率很高,将很浪费。
  • 标记-整理算法:当堆中的有效内存空间被耗尽的时候,就会停止整个程序,然后进行两项工作,第一项是标记,第二项是整理。标记的过程就是遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。整理的过程就是移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。
  • 分代搜集算法:新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

5、GC垃圾收集器
image.png

image.png
  • Serial收集器(串行)
    是一个单线程的收集器,他在工作时,必须暂停其他所有的工作线程(Stop-The-World)

  • ParNew收集器(并行)
    是Serial收集器的多线程版本。

  • Parallel Scavenge收集器(并行)
    是一个新生代收集器,他也是使用复制算法。他的目标规则是达到一个可控制的吞吐量。尽可能缩短垃圾收集时用户线程的停顿时间
    吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那么吞吐量就是99%。

  • Serial Old收集器
    Serial Old是Serial收集器的老年代版本,他同样是一个单线程收集器,使用标记-整理算法。

    image.png

  • Parallel Old收集器
    Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。

  • CMS垃圾收集器(优点: 并发收集、低停顿 缺点: 产生大量空间碎片、并发阶段会降低吞吐量)的四个步骤:
    a.初始标记(需要暂停)仅仅标记GCroot能直接关联的对象
    b.并发标记(不需要暂停)GC Roots Tracing的过程
    c.重新标记(需要暂停)修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
    d.并发清除(不需要暂停)

  • G1垃圾收集器:
    特点是空间整合,采用标记-整理算法不会产生空间碎片;可预测停顿。
    将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
    a. 初始标记:标记出 GC Roots 直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行。
    b. 并发标记:从 GC Root 开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。
    c. 最终标记:修正在并发标记阶段引用户程序执行而产生变动的标记记录。
    d. 筛选回收:筛选回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是 Garbage First 的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。

jdk8环境下,默认使用 Parallel Scavenge(新生代)+ Serial Old(老年代)

6、JVM配置

堆设置

  • -Xms:初始堆大小
  • -Xmx:最大堆大小
  • -XX:NewSize=n:设置年轻代大小
  • -XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
  • -XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
  • -XX:MaxPermSize=n:设置持久代大小
  • -Xss:设置每个线程的栈大小
    收集器设置
  • -XX:+UseSerialGC:设置串行收集器
  • -XX:+UseParallelGC:设置并行收集器
  • -XX:+UseParalledlOldGC:设置并行年老代收集器
  • -XX:+UseConcMarkSweepGC:设置并发收集器

7、Minor GC ,Full GC 触发条件

Minor GC触发条件:
当Eden区满时,触发Minor GC。
Full GC触发条件:

  • 调用System.gc时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

8、Java四种引用类型

基本类型包括:byte,short,int,long,char,float,double,Boolean,returnAddress
引用类型包括:类类型,接口类型和数组。

  • 强引用
    把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java内存泄漏的主要原因之一。
  • 软引用
    软引用需要用SoftReference类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
  • 弱引用
    弱引用需要用WeakReference类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否足够,总会回收该对象占用的内存。
  • 虚引用
    虚引用需要PhantomReference类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。

9、堆内存溢出

  • java.lang.OutOfMemoryError:Java heap space:Java堆内存不够,一个原因是真不够,一个原因是程序中有死循环。
    image.png
  • java.lang.OutOfMemoryError:GC overhead limit exceeded:当GC为释放很小空间占用大量时间时抛出;一般是因为堆太小,没有足够的内存。
    image.png
  • java.lang.OutOfMemoryError:PermGen:space:这种是P区内存不够,可通过调整JVM的配置
    image.png
  • java.lang.StackOverflowError:线程栈的溢出,要么是方法调用层次过多,要么是线程栈太小。通过-Xss参数增加线程栈的大小。比如说太深的递归。递归函数会不断的占用栈空间。
    image.png

10、JVM的调优

  • 将新对象预留在年轻代
  • 让大对象进入年老代
  • 设置对象进入年老代的年龄
  • 尝试使用大的内存分页
  • 增大吞吐量提升系统性能
  • 年老代年轻代大小划分
  • 内存泄漏
  • 垃圾回收算法设置合理

11、Java对象的大小

基本数据的类型的大小是固定的,非基本类型的Java对象,其大小就值得商榷。
在Java中,一个空Object对象的大小是8byte,这个大小只是保存堆中一个没有任何属性的对象的大小。
看下面语句:

Object ob = new Object();

它所占的空间为:4byte+8byte。4byte是上面部分所说的Java栈中保存引用的所需要的空间。而那8byte则是Java堆中对象的信息。因为所有的Java非基本类型的对象都需要默认继承Object对象,因此不论什么样的Java对象,其大小都必须是大于8byte。

Class NewObject {
    int count;
    boolean flag;
    Object ob;
}

其大小为:空对象大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小(4byte)=17byte。
但是因为Java在对对象内存分配时都是以8的整数倍来分,因此大于17byte的最接近8的整数倍的是24,因此此对象的大小为24byte。

12、Java异常

image.png
  • Throwable:
    Throwable是Java语言中所有错误或异常的超类。
    Throwable包含两个子类:Error和Exception。它们通常用于指示发生了异常情况。
    Throwable包含了其线程创建时线程执行堆栈的快照,它提供了printStackTrace()等接口用于获取堆栈跟踪数据等信息。
  • Exception:(被检查的异常)
    Exception及其子类是Throwable的一种形式,它指出了合理的应用程序想要捕获的条件。Java编译器会检查它,此类异常要么通过throws进行声明抛出,要么通过try-catch进行捕获处理,否则不能通过编译。
  • RuntimeException:(运行时异常)
    那些可能在Java虚拟机正常运行期间抛出的异常的超类。如果代码会发生RuntimeException异常,则需要通过修改代码进行避免。例如除数为零。Java编译器不会检查它,也就是出现这类异常时,倘若没有通过throws声明抛出它也没有try-catch捕获它还是会编译通过。虽然java编译器不会检查运行时异常但是我们也可以通过throws进行声明抛出。
  • Error
    用于指示合理的程序不应该视图捕获的严重问题。编译器不会对错误进行检查。程序本身无法修复这些错误。

throw和throws的区别:

  • throws用在函数上,后面跟的是异常类,可以跟多个;而throw用在函数内,后面跟的是异常对象。
  • throws用来声明异常,让调用者只知道该功能可能出现的问题,可以给出预先的处理方式;throw抛出具体的问题对象,执行到throw,功能就已经结束了,跳转到调用者,并将具体的问题对象抛给调用者。也就是说throw语句独立存在时,下面不要定义其他语句,因为执行不到。
  • throws表示出现异常的一种可能性,并不一定会发生这些异常;throw则是抛出了异常,执行throw则一定抛出了某种异常对象。
  • 两者都是消极处理异常的方式,只是抛出或者可能抛出异常,但是不会由函数去处理异常,真正的处理异常由函数的上层调用处理。

13、逃逸分析

逃逸是指在某个方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被GC回收,由于其被其它变量引用。正常的方法调用中,方法体中创建的对象将在执行完毕之后,将回收其中创建的对象;故由于无法回收,即成为逃逸。

JIT会采用逃逸分析来减少内存堆分配压力,这是一种有效减少java程序中同步负载和内存分配压力的跨函数全局数据流分析算法,通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
使用逃逸分析编译器可以对代码做如下优化:

  • 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  • 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会被逃逸,对象可能是栈分配的候选,而不是堆分配。
  • 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分可以不存储在内存,而是存储在CPU寄存器中。

随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。

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

推荐阅读更多精彩内容

  • JVM架构 当一个程序启动之前,它的class会被类装载器装入方法区(Permanent区),执行引擎读取方法区的...
    cocohaifang阅读 1,667评论 0 7
  • 内存溢出和内存泄漏的区别 内存溢出:out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,...
    Aimerwhy阅读 743评论 0 1
  • Java和C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进来,墙里面的人想出来。 对象...
    胡二囧阅读 1,089评论 0 4
  • Java 虚拟机有自己完善的硬件架构, 如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM 屏蔽了与具体操作系...
    尹小凯阅读 1,691评论 0 10
  • 介绍JVM中7个区域,然后把每个区域可能造成内存的溢出的情况说明 程序计数器:看做当前线程所执行的字节码行号指示器...
    jemmm阅读 2,229评论 0 9