JAVA虚拟机

2022-04-03  本文已影响0人  独自闯天涯的码农

一、Java内存区域和内存模型

1、JDK体系和Android体系

1. JDK体系
JDK体系

JDK:Java Development Kit(java开发工具包),包含JRE和开发工具包,例如javac、javah(生成实现本地方法所需的 C 头文件和源文件)。
JRE:Java Runtime Environment(java运行环境),包含JVM和类库。
JVM:Java Virtual Machine(Java虚拟机),负责执行符合规范的Class文件。


JDK跨平台特性
JDK跨平台特性

JDK跨平台特性是因为可以将class文件转换成不同系统的机器码执行。
Java包含两个编译器(编译器和即时编译器)和一个解释器;

2. Android体系
Android体系

Android虚拟机进化史:

3. JVM和DVM区别

1、解释

2、关系

3、基于的架构不一样

所以cpu直接访问自己上面的一块空间的数据的效率肯定要大于访问内存上面的数据。

4、执行的字节码文件不一样

.jar文件里面包含多个.class文件,每个.class文件里面包含了该类的头信息(如编译版本)、常量池、类信息、域、方法、属性等等,当JVM加载该.jar文件的时候,会加载里面的所有的.class文件,这样会很慢,而移动设备的内存本来就很小,不可能像JVM这样加载,所以它使用的不是.jar文件,而是.apk文件,该文件里面只包含了一个.dex文件,这个.dex文件里面将所有的.class里面所包含的信息全部整合在一起了,这样再加载就很快了。.class文件存在很多的冗余信息,dex工具会去除冗余信息,并把所有的.class文件整合到.dex文件中。减少了I/O操作。

4、执行的字节码文件不一样

2、Java内存区域

JVM运行时内存区域

用户存储已被虛拟机加载的类信息,常量,静态常量,即时编译器编译后的代码等数据。异常状态 OutOfMemoryError。
其中包含常量池:用户存放编译器生成的各种字面量和符号引用。

JM所管理的内存中最大的一块。唯一目的就是存放实例对象,几乎所有的对象实例都在这里分配。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。异常状态 OutOfMemoryError。

描述的是java方法执行的内存模型:每个方法在执行时都会创建一个栈帧,用户存储局部变量表,操作数栈,动态连接,方法出口等信息。一个方法从调用直至完成的过程,就对应着一个栈帧在虛拟机栈中入栈到出栈的过程。对这个区域定义了两种异常状态 OutOfMemoryError和StackOverflowError。

与虛拟机栈所发挥的作用相似。它们之间的区别不过是虛拟机栈为虛拟机执行java方法,而本地方法栈为虛拟机使用到的Native方法服务。

一块较小的内存,当前线程所执行的字节码的行号指示器。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

3、Java内存模型

Java内存模型
1. Java内存模型JMM(Java memory model)

定义:Java 内存模型中规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存(类比缓存理解),线程的工作内存中保存了该线程使用到主内存中的变量拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递(通信)均需要在主内存来完成,
作用:屏蔽掉各种硬件/操作系统的内存访问差异,以实现让java程序在各个平台下都能达到一致的内存访问效果。
主要目标:是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

2. 八种操作

关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节。java内存模型定义了8种操作来完成。这8种操作每一种都是原子操作。8种操作如下:

Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

参考: JVM的艺术—JAVA内存模型

二、Java类加载机制和类加载器

1、Java类加载机制

1. 定义

把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始
化,最终形成可以被虛拟机直接使用的Java类型。

在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策咯虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。

2. 类的生命周期
类的生命周期

1、其中验证,淮备,解析3个部分统称为连接。
2、加载,验证,准备,初始化,卸载这5个阶段的顺序是确定的,而解析阶段则不一定:它在某些情況下可以在初始化完成后在开始,这是为了支持Java语言的运行时绑定。
3、其中加载,验证,准备,解析及初始化是属于类加载机制中的步骤。注意此处的加载不等同于类加载。

  1. 加载
    类加载过程的一个阶段,ClassLoader通过一个类的完全限定名查找此类字节码文件,并利用字节码文件转换为方法区内的运行时数据结构,在内存中创建一个class对象作为方法区这个类的数据访问入口。

  2. 验证
    目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身的安全,主要包括四种验证:文件格式的验证,元数据的验证,字节码验证,符号引用验证。

  3. 准备
    为类变量(static修饰的字段变量)分配内存并且设置该类变量的初始值,(如static int i = 5 这里只是将 i 赋值为0,在初始化的阶段再把 i 赋值为5),这里不包含final修饰的static ,因为final在编译的时候就已经分配了。这里不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中。

  4. 解析
    这里主要的任务是把常量池中的符号引用替换成直接引用。

  5. 初始化
    初始化就是对类变量进行赋值及执行静态代码块。
    这里是类加载的最后阶段,执行类构造器<clinit>()方法的过程。如果该类具有父类就对父类进行初始化,执行其静态初始化器(静态代码块)和静态初始化成员变量(前面已经对static 初始化了默认值,这里我们对它进行赋值)。成员变量也将被初始化。

方法的区别:
Class.forName()得到的class是已经初始化完成的。
Classloader.loaderClass得到的class是还没有链接(验证,准备,解析三个过程被称为链接)的。

注意执行顺序:
静态代码块>构造代码块>构造函数>普通代码块
如果有父类顺序:
父类静态代码块>子类静态代码块>父类构造代码块>父类构造方法>子类构造方法>子类构造代码块

public class CodeBlock {
    static{
        //静态代码块在类被加载的时候就运行了,而且只运行一次
        System.out.println("静态代码块");
    }
    {
        //构造代码块在创建对象时被调用,每次创建对象都会调用一次,但是优先于构造函数执行。
        System.out.println("构造代码块");
    }
    public CodeBlock(){
        //构造函数的功能主要用于在类的对象创建时定义初始化的状态。
        System.out.println("无参构造函数");
    }
     
    public void sayHello(){
        {
            //普通代码块的执行顺序和书写顺序一致
            System.out.println("普通代码块");
        }
    }
     
    public static void main(String[] args) {
        System.out.println("执行了main方法");
        new CodeBlock().sayHello();;
    }
}
3. 触发类加载的条件
  1. 遇到new、getstatic、pubstatic、invokestatic这四条字节码指令时。
    new :new关键字实例化对象;
    getstatic、pubstatic:读取或设置一个类的静态字段的时候;
    invokestatic:调用类的静态方法的时候;
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候;
  3. 初始化一个类发现父类没有没初始化,则先初始化父类;
  4. 虚拟机启动,先初始化用户指定的执行主类(即包含main方法)
  5. JDK1.7动态语言支持,一个java.lang.invoke.MethodHandler实例解析引用了静态类并且没有初始化,则先初始化这个类。

2、类加载器

作用:将类的Class文件读入内存,并为之创建一个java.lang.Class对象。

1. 类加载器分类
  1. 启动加载器(Bootstrap ClassLoader):
    由C++语言实现(针对HotSpot),负
    责将存放在Vib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中,即负责加载Java的核心类。
  2. 其他加载器:
    由Java语言实现,继承自抽象类ClassLoader。
2. 双亲委派模型
双亲委派模型的工作流程是:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范国中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。


双亲委派模型
优点:
  1. Java类随着它的类加载器一起具备一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类的时候,就没有必要子类加载器(ClassLoader)再加载一次。
  2. 考虑到安全因素比如所有Java对象的超级父类java.lang.Object,位于rt.jar,无论哪个类加载器加载该类,最终都是由启动类加载器进行加载,保证安全。即使用户自己编写一个java.lang.Object类并放入程序中,虽能正常编译,但不会被加载运行,保证不会出现混乱。

注意:
在JVM中标识两个Class对象,是否是同一个对象存在的两个必要条件:
1、类的完整类名必须一致,包括包名。
2、加载这个ClassLoader(指ClassLoader实例对象)必须相同。

3. 双亲委派代码实现
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized(this.getClassLoadingLock(name)) {
            //检查该类是否已被加载过了
            Class<?> c = this.findLoadedClass(name);
            if (c == null) {
                //该类没有被加载过进入
                long t0 = System.nanoTime();
                try {
                    if (this.parent != null) {
                        //父类加载器不为空则父类加载
                        c = this.parent.loadClass(name, false);
                    } else {
                        //父类加载器为空则调用启动加载器加载
                        c = this.findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException var10) {
                      //非空父类加载器无法找到相应的类抛出异常
                }

                if (c == null) {
                    //父类加载器无法加载则调用findClass加载;自定义加载器也是覆写这个方法
                    long t1 = System.nanoTime();
                    c = this.findClass(name);
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }

            if (resolve) {
                 //对类进行link操作(即包括校验,准备,解析)
                this.resolveClass(c);
            }

            return c;
        }
    }
4. Android的ClassLoader

PathClassLoader 和 DexClassLoader类

//类路径:/libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
    super(dexPath, null, librarySearchPath, parent);
}

//类路径:/libcore/dalvik/src/main/java/dalvik/system/DexClassLoader.java
 public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
    super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}

//类路径: /libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
    super(parent);
    this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}

可以看到,两者都是继承自BaseDexClassLoader ,构造方法的具体逻辑在父类中实现,唯一不同的是一个参数:optimizedDirectory;
最终在Native 层的源码追踪中DexFile_openDexFileNative

static jint DexFile_openDexFileNative(JNIEnv* env, jclass, jstring javaSourceName, jstring javaOutputName, jint) {    
    //...略
    const DexFile* dex_file;    
    if (outputName.c_str() == NULL) {// 如果outputName为空,则dex_file由sourceName确定
        dex_file = linker->FindDexFileInOatFileFromDexLocation(dex_location, dex_location_checksum); 
    } else {// 如果outputName不为空,则在outputName目录中去寻找dex_file
        std::string oat_location(outputName.c_str());    
        dex_file = linker->FindOrCreateOatFileForDexLocation(dex_location, dex_location_checksum, oat_location);  
    }    
    //...略
    return static_cast<jint>(reinterpret_cast<uintptr_t>(dex_file));    
}

PathClassLoader 和 DexClassLoader分别调用不同的方法;
DexClassLoader通过 outputName拿到oat_location地址,加载对应地址 oat_location的dex文件(即SDK存储位置文件);
PathClassLoader从 dex_location 中拿到 dex 文件;

总结
1、Android 中,apk 安装时,系统会使用 PathClassLoader 来加载apk文件中的dex,PathClassLoader的构造方法中,调用父类的构造方法,实例化出一个 DexPathList ,DexPathList 通过 makePathElements 在所有传入的dexPath 路径中,找到DexFile,存入 Element 数组,在应用启动后,所有的类都在 Element 数组中寻找,不会再次加载。
2、在热更新时,实现 DexClassLoader 子类,传入要更新的dex/apk/jar补丁文件路径(如sd卡路径中存放的patch.jar),通过反射拿到 DexPathList,得到补丁 Element 数组,再从Apk原本安装时使用的 PathClassLoader 中拿到旧版本的 Element 数组,合并新旧数组,将补丁放在数组最前面,这样一个类一旦在补丁 Element 中找到,就不会再次加载,这样就能替换旧 Element 中的旧类,实现热更新。

三、对象的创建、内存布局、访问定位

1、对象的创建过程

  1. 虛拟机遇到一个new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用;
  2. 检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有,那必须先执行响应的类加载过程;
  3. 在类加载检查通过后,为新生对象分配内存。对象所需的内存大小在类加载完成后便可完全确定;
  4. 内存分配完成以后,虚拟机需要将分配的内存空间都初始化为零值,保证了对象的实例字段在Java代码中可以不赋予初值能直接使用,程序能访问到这些字段的数据类型对应的零值。

2、内存布局

对象的内存布局一般分为三个部分:对象头,实例数据,对齐填充

1. 对象头中包括两部分:

第一部分:存放着对象自身的运行时数据,如哈希码,GC分带年龄,锁状态标志,偏向线程ID,线程持有的锁。
第二部分:类型指针,即对象指向它的类元数据的指针。若是数组还有记录数组长度的数据。因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小。

2. 实例数据:

对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

3. 对齐填充:

对齐填充不是必然存在的。HotSpot VV的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对其补充来补全了。

3、访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。
目前主流的方式有两种:句柄访问和直接指针

1. 句柄访问:

Java堆中会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对实例数据与类型数据各自具体的地址信息,


句柄访问
2. 直接指针:

reference中存储的直接就是对象地址。


直接指针
3. 两种方式比较:

使用句柄的好处就是引用中存储的是稳定的句柄地址,当被移动时只会修改句柄中的实例数据指针,而引用地址不会被改变。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次访问指针定位的时间开销,引用直接指向存放实例数据的堆内存,在该内存中存放着指向方法区的类型数据地址。

4、Java对象的生命周期

java对象的生命周期可以分为7个阶段:创建阶段、使用阶段、不可视阶段、不可达阶段、可收集阶段、终结阶段、释放阶段。

  1. 创建阶段
    java创建一个对象的方式:
  1. 使用阶段
    四类对象引用:强引用、软引用、弱引用、虚引用。
  1. 不可视阶段
    对象使用已经结束,并且在其可视区域不再使用。此时应主动将对象置为null,有助于JVM及时发现该垃圾对象。

  2. 不可达阶段
    JVM通过可达性分析,从root集合中找不到该对象直接或间接的强引用。此时该对象被标注为GC回收的预备对象,但没有被直接回收。
    在可达性分析时可作为root的对象:

  1. 可收集阶段
    GC已经发现该对象不可达。

  2. 终结阶段
    finalize方法已经被执行

  3. 释放阶段
    对象空间已经被重用

四、JVM垃圾回收机制

1、垃圾收集算法

1. 标记-清除法(Mark-Sweep)

分标记清楚两部分:首先标记需要被清除数据;标记完成后统一回收。
缺点:

2. 复制算法

解决标记-清除法效率问题;将内存按容量大小划分为大小相等的两块;每次只使用一块,当一块内存使用完了,将存活对象复制到另一块。
缺点:

HotSpot虚拟机将内存分为Eden和两块Survivor,比例为8:1:1;

3. 标记-整理法

复制算法对象存活率高,多次复制,效率变低;根据老年代特点,首先标记需要被清理的数据,然后让存活的对象都向一端移动,清理掉边界外的内存;

4. 分代收集算法

把JAVA堆分为新生代和老年代,根据各个年代特点选用适当算法:
新生代:存活率低,选用复制算法;
老年代:存活率高,选用标记整理法;

2、垃圾回收机制知识

架构图
1. 垃圾回收

收集并删除未引用的对象。可以通过调用System.gc()来触发[垃圾回收],但并不保证会确实进行垃圾回收。JVM的垃圾回收只收集哪些由new关键字创建的对象。所以,如果不是用new创建的对象,你可以使用finalize函数来执行清理。

2. JVM中的年代

把对象根据存活概率进行分类,采用分代回收机制,从而减少扫描垃圾时间及GC频率。

JVM内存划分为堆内存和非堆内存:

2、老年代(Old Generation)
如果OldGeneration满了就会产生FullGC,老年代满的原因总结:
(1)from survive中对象的生命周期到一定阈值;
(2)分配的对象直接是大对象;
(3)由于To 空间不够,进行GC直接把对象拷贝到老年代(老年代GC时候采用不同的算法)

新生代GC过程:
虚拟机在进行MinorGC(新生代的GC)的时候,会判断要进入OldGeneration区域对象的大小,是否大于Old Generation剩余空间大小,如果大于就会发生Full GC。

刚分配对象在Eden中,如果空间不足尝试进行GC,回收空间,如果进行了MinorGC空间依旧不够就放入Old Generation,如果OldGeneration空间还不够就OOM了。
比较大的对象,数组等,大于某值(可配置)就直接分配到老年代,(避免频繁内存拷贝)

3. Minor GC和Full GC区别
4. 空间分配担保

在发生Minor GC前:老年代最大可用连续空间是否大于新生代所有对象总空间?大于则Minor GC安全:小于则虚拟机查看HandlePromotionFailure设置允许担保失败?不允许则Full GC:允许则检查老年代最大可用连续空间是否大于历次晋升到老年代对象平均大小?大于则尝试进行一次Minor GC:小于则Full GC;

3、垃圾收集器

垃圾收集器

JAVA中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。

1、JVM中新生代垃圾收集器:Serial收集器、ParNew收集器、Parallel收集器解析
新生代收集器 线程 算法 优点 缺点
Parallel Scavenge 多线程(并行) 复制算法 吞吐量优先适用在后台运行不需要太多交互的任务有GC自适应的调节策略开关 无法与CMS收集器配合使用
ParNew 多线程(并行) 复制算法 响应优先、适用在多CPU环境Server模式、一般采用ParNew和CMS组合、多CPU和多Core的环境中高效 Stop The World
Serial收集器 单线程(串行) 复制算法 响应优先、适用在单CPU环境Client模式下的默认的新生代收集器、无线程交互的开销,简单而高效(与其他收集器的单线程相比) Stop The World
老年代收集器 线程 算法 优点 缺点
Serial Old收集器 单线程(串行) “标记-整理”(Mark-Compact)算法 响应优先、单CPU环境下Client模式,CMS的后备预案。无线程交互的开销,简单而高效(与其他收集器的单线程相比) Stop The World
Parallel Old收集器 多线程(并行) 标记-整理 响应优先、吞吐量优先、适用在后台运行不需要太多交互的任务、有GC自适应的调节策略开关
CMS收集器 多线程(并发) 标记-清除 响应优先、集中在互联网站或B/S系统服务、端上的应用。并发收集、低停顿。 1、对CPU资源非常敏感:收集会占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低;2、无法处理浮动垃圾;3、清理阶段新垃圾只能下次回收;4、标记-清除算法导致的空间碎片
新/老年代收集器 线程 算法 优点
G1 多线程(并发) 标记-整理+复制 1、面向服务端应用的垃圾收集器;2、分代回收;3、可预测的停顿 这是G1相对CMS的一大优势;4、内存布局变化:将整个Java堆划分为多个大小相等的独立区域(Region);5、避免全堆扫描

垃圾回收分成四个阶段:
1、CMS-initial-mark初始标记阶段会stop the world,短暂的暂停程序根据跟对象标记的对象所连接的对象是否可达来标记出哪些可到达
2、CMS-concurrent-mark并发标记,根据上一次标记的结果确定哪些不可到达,线程并发或交替之行,基本不会出现程序暂停。
3、CMS-remark再次标记,会出现程序暂停,所有内存那一时刻静止,确保被全部标记,有可能第二阶段之前有可能被标记为垃圾的对象有可能被引用,在此标记确认。
4、CMS-concurrent-sweep并发清理垃圾,把标记的垃圾清理掉了,没有压缩,有可能产生内存碎片,不连续的内存块,这时候就不能更好的使用内存,可以通过一个参数配置,根据内存的情况执行压缩。

参考:JVM架构和GC垃圾回收机制(JVM面试不用愁)

五、JVM怎么判断对象已死

1、引用计数法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能被再使用的。主流的JVM里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象间的互循环引用的问题。

2、可达性分析法

通过一些列的称为“GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(就是从GC Roots 到这个对象是不可达》,则证明此对象是不可用的。所以它们会被判定为可回收对象。
在Java语言中,可以作为GC Roots 的对象包括下面几种:

总结就是:方法运行时,方法中引用的对象;类的静态变量引用的对象;类中常量引用的对象;Native方法中引用的对象。

可达性分析法中,一个对象真正宣布死亡经历两个过程:
1、对象可达性分析法后发现无GC Roots相连接引用链;则标记并第一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize(方法已经被虛拟机调用过,虚拟机将这两种情況都视为“没有必要执行”
2、如果这个对象被判定为有必要执行finalize方法,那么这个对象将会放置在一个叫做F-Queue队列之中,并在稍后由一个由虛拟机自动建立的、低优先级的
Finalizer线程去执行它。finalize()方法是对象逃脱死亡命运的最后一次机会,稍候GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己只要重新气引用链上的任何一个对象建立关联即可,譬如把自己this关键字赋值给某个类变量或者对象的成员变量,那在第二次标记时它将会被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

3、对象存活与引用的关系

Java对引用的概念进行了扩充,将引用分为强引用 (Strong Reference)、软引用(Soft Reference)、弱引用 ( Weak Reference)、虚引用(Phantom Reference)四种,这四种引用强度依次逐渐減弱。

上一篇 下一篇

猜你喜欢

热点阅读