转载

面试常问点:深入剖析 JVM 的那些事

文章较长,主要讲解了JVM的整个流程,其次介绍Dalvik与JVM的区别及ART

Class文件结构 -> JVM内存模型 -> 类加载器 -> 类加载过程 -> 类的引用方式 -> 内存分配策略 -> GC -> 对象的引用类型 -> 类卸载

先前知识

众所周知java是一种跨平台的语言,但实际上跨平台的并不是java而是JVM。

JVM(Java Virtual Machine)是一种虚拟机,用来将由java文件编译成的class字节码文件再编译成机器语言,供机器识别。有了JVM中间人的存在就不需要直接与操作系统打交道,且不同的操作系统有不同的JVM,于是就屏蔽了操作系统间的差异,从而使java成为跨平台语言。

DVM又是什么?

Dalvik Virtual Machine简称DVM也是一种虚拟机,是专门为Android平台开发的,它与JVM是有差别的。

Dalvik基于寄存器,而JVM 基于栈。性能有很大的提升。基于寄存器的虚拟机对于更大的程序来说,在它们编译的时候,花费的时间更短。

寄存器的概念

寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC),在中央处理器的算术及逻辑部件中,包含的寄存器有累加器(ACC)

栈的概念

栈是线程独有的,保存其运行状态和局部自动变量的(所以多线程中局部变量都是相互独立的,不同于类变量)。栈在线程开始的时候初始化(线程的Start方法,初始化分配栈),每个线程的栈互相独立。每个函数都有自己的栈,栈被用来在函数之间传递参数。操作系统在切换线程的时候会自动的切换栈,就是切换SS/ESP寄存器。栈空间不需要在高级语言里面显式的分配和释放。

JVM

java的使用流程:

  • 1、编写.java文件

  • 2、编译成.class

    • a、打包成.jar(Java Archive)  .war(Web Archive)使用

    • b、命令行则直接使用.class

其实.jar和.war是.class文件的压缩包,其中还包含了不同的配置文件,使用时通过类加载器取其内部的.class字节码文件加载到JVM。

Class文件结构

JVM接收的最初数据是class字节码文件,由.java文件编译产生。并不是只有java语言可以编译成class文件,其他语言也是可以(Scala、Groovy)。

面试常问点:深入剖析 JVM 的那些事
class文件结构

class文件是由8位为一组的字节为基本单位,构成的二进制文件,为什么是二进制文件呢?

  • 1、机器语言为二进制,所以使用便捷;

  • 2、占用空间小,3.1415927用文本文件存储需要将各个位转成ASCII码再存储需占用9字节,二进制文件存储只需四字节;

  • 3、存储数据精确不会丢失。

结构如上图,最上方为起始位,内容包含了.java文件的信息。

类型

class文件中只有两种类型:无符号数和表

无符号数为基本类型,有:u1、u2、u4、u8,数字代表字节数。无符号数可以代表数字、索引引用、数量值,或者按照UTF-8编码构成字符串值

表则是由基本类型和表构成的类型,属于组合类型

magic

文件最初的4个字节,称为魔数,是计算机识别文件类型的依据,不同于感官,.word .png .avi这种通过扩展名识别文件类型的方式,计算机识别多数文件是通过文件头部的魔数。

这种做法的优点在于安全性,文件的扩展名可以人为随意的修改,也许并不会造成文件的不可用(早年间的“图种”一词不知多少人有经历),也可能造成文件不可用,但文件的类型在文件创建之初就被赋予魔数的话,就可以大限度的保证文件的安全性。

version

表示此.class的版本信息,有minor_version和major_version两种类型共占了4字节。

不同版本Java有不同的特性,产生的class结构也会不同,JVM通过识别版本从而确定是否可识别此文件。JVM是向下兼容的,如果.class版本过高则不能运行(Unsupported major.minor version **)。

constant

constant_pool为常量池,用来存放常量,constant_pool_count为池中的计数。

constant_pool的索引从1开始,当指针不想引用此constant_pool时则将指针指向0,此操作简单(赋值永远比删除简单)。

常量池中有两大类常量类型:字面量和符号引用

  • 1、字面量为java中的基本数据类型

  • 2、符号引用:以一组符号来描述所引用的目标,引用的目标并不一定已经加载到内存中,在类加载过程的解析阶段JVM将常量池内的符号引用替换为直接引用。类型有:

    1、CONSTANT_Class_info 类和接口的全限定名(该名称在所有类型中唯一标识该类型)

    2、CONSTANT_Fieldref_info 字段的名称和描述符

    3、CONSTANT_Methodref_info 方法的名称和描述符

常量池中每一项常量都是一个表

面试常问点:深入剖析 JVM 的那些事
常量类型

讲解:

class A{
    int i=9;
    int b=new B();
}

class B{
}

编译时会产生A.class和B.class,此时A.class有两个常量9和B。JVM加载A.class时,将由于常量9属于字面量即基本数据类型,直接放入常量池。

到常量B时,由于常量B不属于字面量即基本数据类型,所以此时产生一个符号引用来代表常量B。

等到A.class加载到了解析阶段,需要将符号引用改为直接引用,但找不到符号引用B的直接引用,

在使用阶段,由于A对象主动引用了B类,所以JVM通过类加载器开始加载B.class(同样的加载步骤),并创建了B对象,并将符号引用B改为B对象的直接引用。

access

access_flags即java中的类修饰符

面试常问点:深入剖析 JVM 的那些事

类的身份信息

一个类要有类名,关系要有extends和implement。java中类是单继承,所以除Object外所有类都有一个父类,而接口则可以有多实现。

this_class是这个类的全限定名

super_class是这个类父类的全限定名

interfaces是这个类实现接口的集合

interfaces_count是这个类实现接口的数量

fields

fields_count表示fields表中的数量

fields是表结构用来存放字段,字段即为类中声明的变量。字段包括了类级变量或实例级变量,static修饰符为判断依据。

public static final transient String str = "Hello World";

一个字段包含的信息有:

  • 1、作用域(public、private、protected修饰符)

  • 2、类级变量还是实例级变量(static修饰符)

  • 3、可变性(final)

  • 4、并发可见性(volatile修饰符,是否强制从主内存读写)

  • 5、可否序列化(transient修饰符)

  • 6、字段数据类型(基本类型、对象、数组)

  • 7、字段名称(str)

一个字段有多种修饰符,每种修饰符只有两种状态:有、没有,所以采用标志位来表示最为合理。字段的其他信息,叫什么名字、被定义为什么数据类型,这些都是无法固定的,所以引用常量池中的常量来描述。

面试常问点:深入剖析 JVM 的那些事
字段结构

access_flags:修饰符

面试常问点:深入剖析 JVM 的那些事
修饰符标志位

name_index:简单名称

指变量名,存放在常量池。例如字段str的简单名称“str”。

descriptor_index:描述符

描述字段的类型

面试常问点:深入剖析 JVM 的那些事
类型列表

例子:

java.lang.String[][]  ——  [[Ljava/lang/String

int[]  ——  [I

String s  ——  Ljava/lang/String

attributes:属性集合,以用于描述某些场景专有的信息。

上面的类型只定义了变量信息,那变量的初始赋值操作呢?

赋值操作是将常量赋值给变量,常量有字面量和符号引用,字面量会在常量池中,符号引用依据情况会在解析或使用阶段改为直接引用。

字段赋值的时机:

a:对于非静态的field字段的赋值将会出现在实例构造方法

()中

b:对于静态的field字段,有两个选择:

1、在类构造方法

()中进行;

2、使用ConstantValue属性进行赋值

编译器对于静态field字段的初始化赋值策略:

如果final和static同时修饰一个字段,并且这个字段是基本类型或者String类型的,

那么编译器在编译这个字段的时候,会在对应的field_info结构体中增加一个ConstantValue类型的结构体,在赋值的时候使用这个ConstantValue进行赋值;

如果该field字段并没有被final修饰,或者不是基本类型或者String类型,那么将在类构造方法中赋值。

对于全局变量的值是被编译在构造器中赋值的

https://www.cnblogs.com/straybirds/p/8331687.html

methods

methods_count表示methods表中的数量

methods是表结构用来存放方法,表结构和字段的表结构一致

面试常问点:深入剖析 JVM 的那些事

由于部分关键字相对于变量和方法是有区别的

面试常问点:深入剖析 JVM 的那些事

例子:

int indexOf(char[] source,int sourceOffset,int sourceCount,char[] targetOffset,int targetCount,int fromIndex)  ——  ([CII[CII)I

方法内部的代码存到什么地方了?

attributes:属性表

在字段表和方法表中都有属性表

面试常问点:深入剖析 JVM 的那些事
属性表结构

属性表所能识别的属性有

面试常问点:深入剖析 JVM 的那些事

方法中到具体代码就存放在方法表中属性表的Code属性中

面试常问点:深入剖析 JVM 的那些事

参考 https://blog.csdn.net/sinat_37138973/article/details/54378263

在了解了字节码文件的结构后,JVM要想使用此文件,首先要将其加载到内存,那内存结构是怎样的呢?

JVM内存模型

面试常问点:深入剖析 JVM 的那些事
图来自https://www.cnblogs.com/xing901022/p/7725961.html
  • 1、PC

    与CPU中的PC不同,CPU中的PC是记录即将执行的下条指令的地址,而JVM中记录的是正在执行的虚拟机字节码指令的地址,且执行native方法时PC为空

  • 2、虚拟机栈

    每个方法被执行的时候都会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

    如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;如果虚拟机栈可以动态扩展当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。

  • 3、本地方法栈

    为虚拟机使用到的Native方法服务

    本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。

  • 4、方法区

    用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。

  • 5、堆

    存放对象实例,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

参考 https://www.cnblogs.com/dingyingsi/p/3760447.html

在知道了class字节码文件结构和JVM内存模型后,需要一个过程将字节码文件加载到内存。

类加载器

负责将字节码文件载入到内存,

BootstrapClassLoader – JRE/lib/rt.jar

ExtensionClassLoader – JRE/lib/ext或者java.ext.dirs指向的目录

ApplicationClassLoader – CLASSPATH环境变量, 由-classpath或-cp选项定义,或者是JAR中的Manifest的classpath属性定义

从上至下依次为父子关系,并不是继承关系。

机制

  • 1、委托机制

    当加载B.class时,请求首先发到ApplicationClassLoader,ApplicationClassLoader看都不看就交给父亲ExtensionClassLoader,ExtensionClassLoader也是看都不看就交给父亲BootstrapClassLoader,BootstrapClassLoader为始祖了,于是在自己的管辖区内查找看有没有B.class,有就加载没有就告诉儿子ExtensionClassLoader,你自己处理,ExtensionClassLoader收到父亲的信息后在自己的管辖区内查找B.class,有就加载没有就告诉儿子ApplicationClassLoader,你自己处理,ApplicationClassLoader收到父亲的信息后在自己的管辖区内查找B.class,有就加载没有就ClassNotFoundException。

  • 2、可见性机制

    子类加载器可以看到父类加载器加载的类

  • 3、单一性机制

    因委托机制的关系,一个类(唯一的全限定名)只能被一个类加载器加载一次

加载方式

  • 1、显式加载

    通过class.forname()等方法,显式加载需要的类

  • 2、隐式加载

    程序在运行过程中当碰到通过new等方式生成对象时,隐式调用类装载器加载对应的类到jvm中

自定义类加载器

以上三种类加载器,在某些场景下就不适用。由于以上三种类加载器都是加载指定位置的class,当加载异地加密的class时就无法使用,此时需要自定义类加载器,加载指定位置的class到内存并在执行解密后使用。

有了class字节码文件结构、JVM内存模型和类加载器这三个部分的初识,接着就是三个独立部分合作的场景:类加载过程

类加载过程

类加载器将字节码文件载入JVM内存的过程

面试常问点:深入剖析 JVM 的那些事
  • 1、加载

    主要是获取定义此类的二进制字节流,并将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,最后在Java堆中生成一个代表这个类的java.lang.Class对象作为方法区这些数据的访问入口。

    类加载器参与的阶段。

  • 2、验证

    确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。主要验证过程包括:文件格式验证(魔数、版本号),元数据验证(类关系),字节码验证(数据流、控制流)以及符号引用验证(引用可达)。

  • 3、准备

    正式为类变量(static变量)分配内存并设置类变量初始值的阶段。关于准备阶段为类变量设置零值的唯一例外就是当这个类变量同时也被final修饰,那么在编译时,就会直接为这个常量赋上目标值。

  • 4、解析

    解析时虚拟机将常量池中的符号引用替换为直接引用。

  • 5、初始化

    初始化阶段是执行类构造器 ()方法的过程。类构造器

    ()方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生。

    当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    虚拟机会保证一个类的 ()方法在多线程环境中被正确加锁和同步。

参考 https://www.cnblogs.com/dooor/p/5289994.html

一个项目、jar包、war包中有数百成千上万的字节码文件,一个字节码文件只能被加载一次(类加载器的委托机制),JVM是一次性加载全部文件的吗?肯定不是,具体的实现由不同的JVM自由发挥,但对于初始化阶段JVM有明确要求(被引用),自然初始化之前的阶段也必须完成。

类的引用方式

主动引用

1、当使用new关键字实例化对象时,当读取或者设置一个类的静态字段(被final修饰的除外)时,以及当调用一个类的静态方法时(比如构造方法就是静态方法),如果类未初始化,则需先初始化。

2、通过反射机制对类进行调用时,如果类未初始化,则需先初始化。

3、当初始化一个类时,如果其父类未初始化,先初始化父类。

4、用户指定的执行主类(含main方法的那个类)在虚拟机启动时会先被初始化。

被动引用

除了上面这4种方式,所有引用类的方式都不会触发初始化,称为被动引用。如:

通过子类引用父类的静态字段,不会导致子类初始化;

通过数组定义来引用类,不会触发此类的初始化;

引用类的静态常量不会触发定义常量的类的初始化,因为常量在编译阶段已经被放到常量池中了。

参考 https://blog.csdn.net/zcxwww/article/details/51330327

初始化的对象会被放在什么地方呢?

内存分配策略

两个存储位置:本地线程缓存TLAB和堆

新对象产生时首先检查本地线程是否开启了缓存,是则存储在TLAB,否则去堆中寻找位置。

堆又分了:Eden、两个Survivor、Tenured共4个区,Eden与Survivor大小比是8:1,Eden和Survivor称为新生代,Tenured称为老年代(JDK8已经没有持久代了)

当新对象产生时,存放在Eden,当Eden放不下时触发Minor GC,将Eden中存活的对象复制到一Survivor中。继续存放对象到Eden,当Eden放不下时触发Minor GC,将Eden和非空闲Survivor中存活的对象复制到空闲Survivor中,往复操作。每经过一次Minor GC,对象的年龄加1,当对象年龄达到阀值(默认15)进入Tenured。如果在Minor GC期间发现存活对象无法放入空闲的Survivor区,则会通过空间分配担保机制使对象提前进入Tenured。如果在Survivor空间中的相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于和等于该年的对象就可以直接进入老年代,无需等到指定的阀值。

空间分配担保机制:

在执行Minor GC前, VM会首先检查Tenured是否有足够的空间存放新生代尚存活对象,由于新生代使用复制收集算法,为了提升内存利用率,只使用了其中一个Survivor作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代,但前提是老年代需要有足够的空间容纳这些存活对象。但存活对象的大小在实际完成GC前是无法明确知道的,因此Minor GC前,VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小,如果条件成立, 则进行Minor GC,否则进行Full GC(让老年代腾出更多空间)。然而取历次晋升的对象的平均大小也是有一定风险的,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然可能导致担保失败(Handle Promotion Failure,老年代也无法存放这些对象了),此时就只好在失败后重新发起一次Full GC(让老年代腾出更多空间)。

分代的唯一理由就是优化GC性能,让GC在固定区域工作。

GC

  • 1、Minor GC

    在年轻代(Eden和Survivor)中执行的GC

  • 2、Major GC

    在老年代(Tenured)中执行的GC

  • 3、Full GC

    清理整个堆空间包括年轻代和老年代

垃圾回收最重要的一点是如何判断对象为垃圾?

可达性分析算法

面试常问点:深入剖析 JVM 的那些事

通过一系列称为GC Roots的对象作为起点,然后向下搜索,搜索所走过的路径称为引用链/Reference Chain,当一个对象到GC Roots没有任何引用链相连时,即该对象不可达,也就说明此对象是不可用的,如图:Object5、6、7虽然互有关联,但它们到GC Roots是不可达的,因此也会被判定为可回收的对象。

回收时的回收算法:

分代

根据对象存活周期的不同将内存划分为几块,如JVM中的新生代、老年代,这样就可以根据各年代特点分别采用最适当的GC算法:

在新生代:每次垃圾收集都能发现大批对象已死,只有少量存活。因此选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

在老年代:因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记—清理”或“标记—整理”算法来进行回收,不必进行内存复制,且直接腾出空闲内存。

新生代-复制算法

面试常问点:深入剖析 JVM 的那些事

该算法的核心是将可用内存按容量划分为大小相等的两块,每次只用其中一块,当这一块的内存用完,就将还存活的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。

这使得每次只对其中一块内存进行回收,分配也就不用考虑内存碎片等复杂情况,实现简单且运行高效。

但由于新生代中的98%的对象都是生存周期极短的,因此并不需完全按照1:1的比例划分新生代空间,所以新生代划分为一块较大的Eden区和两块较小的Survivor区。

老年代-标记清除算法

该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象。

面试常问点:深入剖析 JVM 的那些事
面试常问点:深入剖析 JVM 的那些事

该算法会有以下两个问题:

1、效率问题:标记和清除过程的效率都不高;

2、空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集。

老年代-标记整理算法

标记清除算法会产生内存碎片问题,而复制算法需要有额外的内存担保空间,于是针对老年代的特点,又有了标记整理算法。标记整理算法的标记过程与标记清除算法相同,但后续步骤不再对可回收对象直接清理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。

面试常问点:深入剖析 JVM 的那些事

分区

将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。这样做的好处是可以控制一次回收多少个小区间。

在相同条件下,堆空间越大,一次GC耗时就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次GC所产生的停顿。

参考 http://www.importnew.com/23035.html

对象逃逸(点到为止)

本该销毁的对象,逃到了它处。

public class A {
    public static Object obj;
    public void globalVariableEscape() {  // 给全局变量赋值,发生逃逸
        obj = new Object();//new的对象本该在栈帧出栈时销毁,但被外部static引用导致进入方法区常量池
    }
    public Object methodEscape() {  // 方法返回值,发生逃逸
        return new Object();//new的对象本该在栈帧出栈时销毁,但被外部方法或线程引用,导致对象只能在外部方法栈帧出栈或线程销毁时被清理
    }
    public void instanceEscape() {  // 实例引用发生逃逸
        b = new B(this); //(示意而已,并不准确)新建B对象时引用了A对象,除B使用外,其余无引用A,此时本可以回收A,但B却引用导致无法回收。循环引用就是A在引用B,导致互相引用都不能被回收。
    }
}

public class B {
    public static Object obj;
    public void instance(A a) {  // 引用传入的实例
        obj = a;
    }
}

对象的引用类型(强软弱虚)

JVM中真正将一个对象判死刑至少需要经历两次标记过程:

第一个过程,可达性分析算法

第二个过程,判断这个对象是否需要执行finalize()方法。

第一次GC时,对象在经历了可达性分析算法被标记后,若对象重写了finalize()方法且没被执行过则会被放入F-Queue队列中,否则回收。

第二次GC时,JVM会有一个优先级比较低的线程去执行队列中对象的finalize()方法,执行只是触发finalize()方法并不会确保方法一定完成,防止死循环或异常等情况导致对象不可被回收,这时第二次标记完成对象被回收。

只有当对象存在引用链连接GC Roots时才确保不会被回收,即对象为强引用。那么有些对象,我们希望在内存充足的情况下不要回收,在内存不足的时候再将其回收掉。如果只有强引用,那这个对象永远都不会被回收。于是有了软引用、弱引用、虚引用的概念。

  • 1、强引用

    即使OOM也不会被回收

  • 2、软引用

    内存不足时才会被回收

  • 3、弱引用

    只要GC就会被回收

  • 4、虚引用

    唯一的作用就是监听被回收

参考 https://blog.csdn.net/huachao1001/article/details/51547290

类卸载

卸载需要满足三个条件:

1、该类所有的实例已经被回收

2、加载该类的ClassLoder已经被回收

3、该类对应的java.lang.Class对象没有被引用

JVM自带的根类加载器、扩展类加载器和系统类加载器,JVM本身会始终引用这些类加载器,因此条件2不会形成。

而这些类加载器则会始终引用它们所加载的类对象,因此条件3也不会形成。

唯一会被卸载的类只有自定义的类加载器加载的类。

Dalvik(DVM)

为什么大篇幅讲JVM,因为Dalvik虚拟机是Google按照JVM虚拟机规范定制的虚拟机,所应用的多是处理能力、内存、和存储等处理能力受限的设备,更符合移动设备的环境要求。

架构

JVM基于栈架构,Dalvik基于寄存器架构,因此读写速度较快

空间

可执行程序的字节码不同

JVM:java -> class -> jar

DVM:java -> class -> dex -> apk

面试常问点:深入剖析 JVM 的那些事

jar由多个class构成,而dex是由多个class合并构成,消除了数据冗余节省了空间,但合并后方法数变多,产生了方法数受限(65535)的问题。

沙盒

Dalvik虚拟机允许在内存中创建多个实例,以隔离不同的应用程序。这样,当一个应用程序在自己的进程中崩溃后,不会影响其它进程的运行。

ART

在Dalvik下,应用每次运行都需要通过即时编译器(JIT)将字节码转换为机器码,即每次都要编译加运行,所以启动时间长。

ART则在应用安装时就预编译字节码到机器语言,所以安装过程较Dalvik时间长存储空间占用更大,但应用每次运行时不需要编译减少了CPU的负担,启动时间短。

原创作者:s1991721

原文链接:https://www.jianshu.com/u/0790f0629fc6

面试常问点:深入剖析 JVM 的那些事

原文  https://mp.weixin.qq.com/s?__biz=MzI2OTQxMTM4OQ==&mid=2247486633&idx=1&sn=07336f531f3977522c457f237a08fe49&chksm=eae1f9fbdd9670ed3f87a1769f4461ca3959c6dc00e35b42a87de6cfa5392d4c2761687354d6
正文到此结束
Loading...