JVM常见面试题总结

文章目录

主要参考:JavaGuide小林coding二哥的Java进阶之路,同时加上网上搜索整理和个人理解总结

1 JVM 组成

1.1 JVM架构组成🔥

  • 类加载子系统:负责将字节码文件读取、解析并保存到内存中。其核心就是类加载器。
  • 运行时数据区:管理 JVM 使用到的内存。
  • 执行引擎:分为解释器:解释执行字节码指令;即时编译器:找出程序中频繁调用的热点方法,将字节码编译成本地代码,优化代码执行性能;垃圾回收器将不再使用的对象进行回收。
  • 本地接口,保存了本地已经编译好的方法,使用 C/C++语言实现。

1.2 JVM 内存结构/内存模型🔥

  • 堆:存放创建出来的对象和数组,是垃圾回收器管理的主要区域。

  • 方法区:用于存放每一个加载的类的元信息、运行时常量池。JDK 1.8 之前使用永久代实现,JDK 1.8 及以后方法区的实现变成了元空间。元空间与永久代最大的区别:元空间并不在虚拟机中,而是使用本地内存。

  • 虚拟机栈:用于存储栈帧,当方法被调用时会创建一个栈帧入栈。栈帧里面存的是局部变量表、操作数栈、方法出口等信息。

  • 本地方法栈:与虚拟机栈功能类似,区别是虚拟机栈执行 java 方法,本地方法栈执行本地方法。

  • 程序计数器:存放的是当前线程所执行的字节码的行数。JVM 工作时通过改变这个计数器的值来选取下一个需要执行的字节码指令。

  • 直接内存:主要是 NIO 使用,由操作系统直接管理,不属于 JVM 内存。

JDK1.7 之前,字符串常量池存放在永久代(方法区)。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。JDK1.8 方法区的实现变成了元空间,从运行时数据区移到了本地内存中。

类是模板,对象是实体。类的结构信息(包括实例变量的定义)存储在方法区,而实例变量的具体值存储在堆内存中。这种分离使得类的结构信息在程序运行时是共享的,而实例变量的具体值则是每个对象独有的。

1.3 堆和栈区别🔥

  • 用途:栈主要用于存储局部变量和方法调用。堆用于存储对象的实例(包括类的实例和数组)。使用 new 关键字创建一个对象时,对象的实例就会在堆上分配空间。
  • 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束,其对应的栈帧就会被销毁。堆中的对象生命周期不确定,对象在被垃圾回收后才销毁。
  • 存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
  • 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由 JVM 管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
  • 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
  • 异常:栈空间不足:java.lang.StackOverFlowError。堆空间不足:java.lang.OutOfMemoryError

1.4 详细的介绍 Java 堆🔥

Java 中的堆属于线程共享的区域,主要用来保存对象实例,数组

  • 新生代(Young Generation):新生代分为Eden 区和Survivor 区(8:1:1)。在 Survivor 区中,分为两个大小相等的区域,称为 S0 和 S1。大多数新创建的对象首先存放在 Eden 区。当 Eden 区满时,会触发一次 Minor GC(新生代垃圾回收)。在每次 Minor GC 后,存活下来的对象会被移动到其中一个 Survivor 区。
  • 老年代(Old Generation/Tenured Generation):多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大(1:2),以存储更多的长期存活对象。
  • 大对象区(Large Object Space / Humongous Objects):在某些 JVM 实现中(如 G1 垃圾收集器),为大对象分配了专门的区域,称为大对象区。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的晋升而导致的内存碎片化问题。

1.5 JVM 为什么使用元空间替换了永久代?🔥

  1. 避免内存溢出问题: 永久代的大小是有限的,在一些场景下容易发生内存溢出。元空间采用了基于本地内存的存储方式,可以动态地调整大小,避免了永久代固定大小的限制,有效地解决了永久代内存溢出的问题。

  2. 更好的内存管理: 元空间的内存空间由操作系统进行管理,不受虚拟机的内存区域限制,可以根据应用程序的需求动态分配和释放内存。

  3. 性能优化: 元空间的内存分配和回收采用了更加高效的算法和数据结构,与永久代相比,可以减少垃圾回收的频率和成本,提高了虚拟机的性能和响应速度。

  4. 与 Java 开发生态的整合: 随着 Java 的发展,一些新的技术和框架的出现对永久代提出了更高的要求,例如,动态生成类的使用越来越广泛,对永久代的内存管理提出了更高的挑战。元空间的出现可以更好地满足这些新技术和框架的需求,使得 Java 虚拟机能够更好地适应和整合 Java 开发生态的变化。

1.6 内存溢出与内存泄漏的区别🔥

(1)内存泄漏:程序在使用完内存后,未能及时释放已分配的内存空间,导致这部分内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终可能导致内存溢出。

例如:未关闭的文件或数据库连接、未释放的资源对象、长时间被引用的集合类

(2)内存溢出:当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。在 Java 中,这种情况会抛出 OutOfMemoryError。

例如:创建非常多对象,而且还一直在被使用。


1.7 OOM 发生区

  • 堆内存溢出:出现 OutOfMemoryError:Java heap space 异常时,就是堆内存溢出了。原因是代码中可能存在大对象分配,或者发生了内存泄露,导致在多次 GC 之后,还是无法找到一块足够大的内存容纳当前对象。
  • 栈溢出:如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError
  • 元空间溢出:元空间的溢出,系统会抛出 OutOfMemoryError: Metaspace。出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
  • 直接内存内存溢出:在使用ByteBuffer中的 allocateDirect() 的时候会出现,出现该问题时会抛出 OutOfMemoryError: Direct buffer memory 异常。

1.8 程序计数器的作用,为什么私有

Java 程序是支持多线程一起运行的,多个线程一起运行的时候 cpu 会给它们分配时间片。比如说会给线程 1 分给一个时间片,在时间片内如果它的代码没有执行完,它就会先暂存线程 1,切换到线程 2,执行线程 2 的代码。等线程 2 的时间片也用完了,再切换回来,再继续执行线程 1 中剩余部分的代码。

如果在线程切换的过程中,会用程序计数器来记录指令执行到哪里了。每个线程都有自己的程序计数器,因为它们各自执行的代码的指令地址是不一样的。

1.9 什么是指针碰撞?

指针碰撞是一种内存分配和垃圾回收的技术,在内存管理中常见于使用标记-清除算法的垃圾收集器中。

指针碰撞的基本思想是将堆内存划分为两个连续的区域,一部分用于存放已分配的对象,另一部分用于空闲内存。当需要分配一个新的对象时,垃圾收集器会将分配指针设置为空闲内存区域的起始位置,然后将分配指针向前移动,直到找到足够大的空闲内存来分配对象。分配完成后,分配指针会继续向前移动,指向下一个空闲内存块的起始位置。

指针碰撞的优点包括简单高效,适用于内存连续、没有碎片的情况。但它也有一些局限性,例如需要堆内存是连续的,不能有碎片;在多线程环境下需要保证分配指针的线程安全;无法处理内存碎片化等问题。

需要注意的是,指针碰撞通常是针对堆内存的分配,而不是针对栈内存的分配。在栈内存的分配中,通常使用栈指针(Stack Pointer)来动态调整栈帧的位置,而不需要像指针碰撞那样进行手动移动分配指针。

1.10 什么是TLAB?

TLAB(Thread-Local Allocation Buffer)是Java虚拟机为每个线程分配的私有内存区域,用于加速对象的分配。TLAB的主要作用是减少多线程并发分配对象时的竞争,提高对象分配的效率。

在Java虚拟机中,对象的分配通常是在堆内存中进行的。为了减少多线程并发分配对象时的竞争,Java虚拟机采用了TLAB技术。具体而言,当一个线程需要分配对象时,虚拟机会为该线程分配一个私有的TLAB区域。该线程可以在自己的TLAB区域中进行对象的分配,而无需与其他线程进行竞争。只有当TLAB区域被填满时,才会触发对堆内存的申请和分配。

TLAB的优点包括:

  1. 减少竞争:多线程并发分配对象时,减少了对堆内存的竞争,提高了分配效率。
  2. 提高局部性:同一线程分配的对象通常具有相似的生命周期,存放在同一个TLAB区域中可以提高缓存的命中率,提高内存访问的局部性。
  3. 减少内存碎片:TLAB的内存分配是连续的,可以有效减少内存碎片,提高堆内存的利用率。

虽然TLAB可以提高对象分配的效率,但也存在一些局限性,例如需要占用额外的内存空间来存放TLAB区域,且需要额外的处理逻辑来管理TLAB区域,可能会增加虚拟机的开销。因此,TLAB的大小需要根据应用程序的实际情况进行调整。

1.11 对象的大小如何计算?

在Java中,对象的大小通常由以下几个因素决定:

  1. 对象头(Object Header):对象头是存储对象元数据的部分,包括对象的哈希码、锁状态、GC标记等信息。对象头的大小在不同的JVM实现中可能会有所不同,通常在32位JVM中为8字节,64位JVM中为12字节。

  2. 实例变量(Instance Variables):实例变量是对象中声明的所有非静态变量,其大小取决于变量的类型和数量。基本数据类型的大小在Java中是固定的,例如 int 类型占用4字节,double 类型占用8字节。引用类型的大小通常为4字节或8字节,取决于JVM的具体实现。

  3. 对齐填充(Padding):为了提高内存访问的效率,对象的大小通常会进行对齐填充。对齐填充会在对象的末尾添加一些额外的字节,使得对象的大小是某个特定大小的倍数(通常是8字节或者16字节)。对齐填充的大小取决于对象的实例变量和对象头的大小。

对象大小 = 对象头大小 + 实例变量大小 + 对齐填充大小

需要注意的是,对象的大小可能因为JVM的不同实现而有所差异,可以通过JVM提供的工具(如 jmapjcmdVisualVM 等)来查看对象的内存布局和大小信息。

1.12 对象一定分配在堆中吗

在 Java 中,大多数对象通常都是分配在堆内存中。堆内存是 Java 虚拟机管理的主要内存区域,用于存储对象的实例变量和引用。

然而,并非所有的对象都必须分配在堆中。在某些情况下,Java 虚拟机可以通过逃逸分析等技术来确定对象的生命周期,如果确定对象不会逃逸到方法外部或线程之外,就可以将对象分配在栈上或者进行标量替换。

  1. 栈上分配:一些简单的对象,尤其是在方法中创建的局部对象,可以被分配在栈上。栈上分配的对象生命周期与方法调用的生命周期相同,当方法执行完毕时,对象就会被销毁,无需进行垃圾回收。

  2. 标量替换:对象的实例变量也可以被分解成标量(Scalar),这些标量可以被分配在栈上或者寄存器中。通过标量替换技术,一些对象的实例变量可以不被分配在堆上,而是直接被分配在栈上或者寄存器中,以提高访问效率。

注意,栈上分配和标量替换等优化技术通常只适用于某些特定的场景和情况,而且取决于具体的 JVM 实现和运行时条件。在大多数情况下,对象仍然会被分配在堆内存中。

1.13 栈中存的到底是指针还是对象?

在JVM内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组。

栈中存储的不是对象,而是对象的引用。在方法中声明一个对象,比如MyObject obj = new MyObject();,这里的obj实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在64位系统上是8字节),它指向堆中分配给对象的内存区域。

1.14 方法区中的方法的执行过程?

当程序中通过对象或类直接调用某个方法时,主要包括以下几个步骤:

  • 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
  • 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
  • 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。

2 类初始化和类加载

2.1 对象创建流程🔥

助记:查分零头 init

  • 类加载检:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  • 配内存:虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。对象所需的内存大小在类加载完成后便可确定。
  • 初始化值:虚拟机将分配到的内存空间都初始化为零值(不包括对象头),保证了对象的字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  • 设置对象虚拟机设置必要的对象头信息,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。
  • 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

2.2 什么是类加载器,有哪些🔥

JVM 只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到 JVM 中,从而让 Java 程序能够启动起来。

  • 启动类加载器(Bootstrap Class Loader):这是最顶层的类加载器,负责加载Java的核心库(位于/jre/lib中的类),它是用C++编写的,是JVM的一部分。
  • 扩展类加载器(Extension Class Loader):它是Java语言实现的,负责加载Java扩展目录(jre/lib/ext)下的类。
  • 应用程序类加载器(Application Class Loader):这也是Java语言实现的,负责加载用户类路径(ClassPath)上的类,是我们平时编写Java程序时默认使用的类加载器。
  • 自定义类加载器(Custom Class Loader):开发者可以根据需求定制类的加载方式,比如从网络、数据库中加载类。自定义类加载器时,需要继承ClassLoader 类,并至少重写其中的findClass方法,若想打破双亲委托机制,需要重写loadClass方法

扩展:如何判断JVM中类和其他类是不是同一个类?

取决于类加载器:每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否“相等”,只有在同一个类加载器下才有比较意义。即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

2.3 什么是双亲委派模型?🔥

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
  • 如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类),每一个层次的类加载器都是如此,因此所有的加载请求最终都应该委托到顶层的启动类加载器中。
  • 只有当父类加载器反馈自己无法完成这个加载请求(它的搜索返回中没有找到所需的类)时,子类加载器才会尝试自己去加载。
  • 如果子类加载器也无法加载这个类,那么会抛出一个 ClassNotFoundException 异常。

2.4 双亲委派机制作用🔥

  • 避免类的重复加载:通过委托机制,确保所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况。
  • 保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,防止了用户自定义类覆盖核心类库的可能。
  • 支持层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,启动类加载器加载核心库。这种层次化的划分保证了各个层级类加载器的职责清晰,也便于维护和扩展。
  • 简化了加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理的类的数量,简化了类的加载过程,提高了加载效率。

2.5 打破双亲委派🔥

  • 自定义加载器,继承 ClassLoader 类,重写 loadClass() 方法
  • 利用线程上下文类加载器,原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。

例如:Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。

2.6 说一下类加载的过程?🔥

类从加载到虚拟机中,直到卸载为止,整个生命周期分为 7 个阶段。

助记:家宴准接厨师谢

  • 载:通过类的全限定名(包名 + 类名),查找和导入 class 文件

  • 证:确保 class 文件中包含的信息,符合虚拟机的要求,保证加载类的准确性

  • 备:为类变量分配内存并设置类变量初始值,比如 int 类型初始值是 0。

  • 析:把类中的符号引用转换为直接引用

  • 始化:执行初始化方法 <clinit> ()<clinit> () 方法是编译之后自动生成的,是类加载的最后一步

  • 使用:使用类或者创建对象

  • 载:卸载类也就是该类的 Class 对象被 GC,需要满足

    1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象
    2. 该类没有在其他任何地方被引用
    3. 该类的类加载器的实例已被 GC

其中,验证、准备和解析这三个部分统称为连接


2.7 打破双亲委派后可以替换 java. 包的类吗

Java 通过双亲委派模型保证了 java 核心包中的类不会被破坏,但破坏双亲委派能够脱离加载范围的限制,增强第三方组件的能力,那么我们可以替换 java. 包的类吗?

无法替换 java. 包的类,即使打破双亲委派,依然需要调用父类中的 defineClass()方法来把字节流转换为一个 JVM 识别的 Class 对象,而 defineClass()方法通过 preDefineClass()方法限制类全限定名不能以 java. 开头。

2.8 ClassLoader 类中的方法

  • loadClass():默认的双亲委派机制在此方法中实现
  • findClass():根据名称或位置加载 .class 字节码,用于重写类加载逻辑
  • definclass():把 .class 字节码转化为 Class 对象

类加载过程是线程安全的,在 loadClass 方法中,是被 synchronized 加了锁的

3 垃圾回收基础

3.1 简述 Java 垃圾回收机制?(GC 是什么?)🔥

JVM 有垃圾回收机制的原因是为了解决内存管理的问题。在传统的编程语言中,开发人员需要手动分配和释放内存,这可能导致内存泄漏、内存溢出等问题。

垃圾回收机制的主要目标是自动检测和回收不再使用的对象,从而释放它们所占用的内存空间,避免内存泄漏和内存溢出。

通过垃圾回收机制,JVM 可以在程序运行时自动识别和清理不再使用的对象,使得开发人员无需手动管理内存。提高开发效率、减少错误,并且使程序更加可靠和稳定。

3.2 如何判断对象可以被回收🔥

  1. 引用计数法:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加 1;当引用失效时,计数器减 1。当计数器为 0 时,表示对象不再被任何变量引用,可以被回收。
    1. 缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为 0,导致对象无法被回收。
  2. 可达性分析算法:通过一系列的称为 “GC Roots” 的节点作为起点,从这些节点开始向下搜索,若从 GC Roots 到这个对象不可达,说明此对象需要被回收。
    1. GC Roots 对象:虚拟机栈中引用的对象、本地方法栈中引用的对象等等

3.3 强引用、软引用、弱引用、虚引用的区别?🔥

  • 强引用:如 A a = new A() 这种方式,强引用是平常使用最多的引用,即使在内存不足的时候也不会被回收。
  • 软引用:非必须存活的对象,当内存不足时,就会将软引用中的数据进行回收。
    • 高速缓存就用到了软引用,内存够用时就保留,不够时就回收
  • 弱引用:非必须存活的对象,在垃圾回收时,不管内存够不够都会直接被回收
    • ThreadLocal 中的 key 就用到了弱引用
  • 虚引用:等同于没有引用,虚引用唯一的作用就是配合引用队列来监控引用的对象是否被加入到引用队列中,也就是让我们知道对象什么时候被回收。
    • 直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。

3.4 6.1.1 JVM 垃圾回收算法有哪些?🔥

  • 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。
    • 缺点:标记和清除过程的效率都不高;会导致空间不连续,产生比较多的内存碎片,可能导致后续没有连续空间,需要进行一次 GC
  • 复制算法:将内存分成两块,每次都只使用其中的一块,当内存不够时,将这一块内存中所有存活的对象复制到另一块上。然后再把原来的那块内存整个清理掉。
    • 解决了空间碎片的问题。但只能使用一半的内存空间,内存利用率严重不足。
    • 在存活对象比较多时,会执行较多的复制操作,效率就会下降。
  • 标记-整理算法:标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象移动到内存一端,移动结束后直接清理掉剩余部分。
    • 缺点:对象需要移动,性能较低
  • 分代回收算法:分代回收算法是将内存划分成了新生代和老年代。分配的依据是对象经历过的 GC 次数。对象创建时,一般在新生代申请内存,经历一次 GC 之后,对象的年龄 +1。当年龄超过一定值(默认是 15)后,如果对象还存活,那么该对象会进入老年代。

3.5 详细说一下分代回收🔥

堆被分为了两份:新生代和老年代,它们默认空间占用比例是 1:2。对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区默认空间占用比例是 8:1:1。

1)当创建一个对象的时候,那么这个对象会被分配在新生代的 Eden 区。当 Eden 区要满了时候,触发 YoungGC。
2)进行 YoungGC 后,此时在 Eden 区存活的对象被移动到 S0 区,并且当前对象的年龄会加 1,清空 Eden 区。
3)当再一次触发 YoungGC 的时候,会把 Eden 区中存活下来的对象和 S0 中的对象,移动到 S1 区中,这些对象的年龄会加 1,清空 Eden 区和 S0 区。
4)当再一次触发 YoungGC 的时候,会把 Eden 区中存活下来的对象和 S1 中的对象,移动到 S0 区中,这些对象的年龄会加 1,清空 Eden 区和 S1 区。
5)对象的年龄达到了某一个限定的值(默认 15 岁 ),那么这个对象就会进入到老年代中。

当然也有特殊情况,如果进入 Eden 区的是一个大对象,在触发 YoungGC 的时候,会直接存放到老年代

当老年代满了之后,触发 FullGCFullGC 同时回收新生代和老年代。只会存在一个 FullGC 的线程在执行,其他的线程全部会被挂起。我们在程序中要尽量避免 FullGC 的出现。

3.6 minorGC、majorGC、fullGC 区别🔥

Minor GC (Young GC)

  • 作用范围:只针对年轻代进行回收,包括 Eden 区和两个 Survivor 区(S0 和 S1)。
  • 触发条件:当 Eden 区空间不足时,JVM 会触发一次 Minor GC,将 Eden 区和一个 Survivor 区中的存活对象移动到另一个 Survivor 区或老年代(Old Generation)。
  • 特点:通常发生得非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。

Major GC

  • 作用范围:主要针对老年代进行回收,但不一定只回收老年代。
  • 触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发 Major GC。
  • 特点:相比 Minor GC,Major GC 发生的频率较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。

Full GC

  • 作用范围:对整个堆内存(包括年轻代、老年代以及永久代/元空间)进行回收。

  • 触发条件

    • 直接调用 System.gc()Runtime.getRuntime().gc() 方法时,虽然不能保证立即执行,但 JVM 会尝试执行 Full GC。
    • Minor GC(新生代垃圾回收)时,老年代空间不足以容纳存活的对象,会触发 Full GC。
    • 当永久代(Java 8 之前的版本)或元空间(Java 8 及以后的版本)空间不足时,会触发 Full GC。
  • 特点:Full GC 是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少 Full GC 的触发。

3.7 垃圾回收器有哪些🔥

  • 串行垃圾收集器:使用单线程进行垃圾回收。Serial GC(新生代-复制算法)、Serial Old GC(老年代-标记整理)。

  • 并行垃圾收集器:采用多线程进行垃圾回收,支持用户线程与垃圾线程一起执行。ParNew GC(新生代-复制算法/JDK8 默认)、Parallel Old GC(老年代-标记整理)。

  • CMS(并发)垃圾收集器:并发标记清除,作用在老年代。具有高并发、低停顿的特点,追求最短的GC回收停顿时间。

  • G1 垃圾收集器:基于标记-整理算法实现,也就是说不会产生内存碎片。G1 收集器不再采用传统的新生代和老年代物理隔离的布局方式,仅在逻辑上划分新生代和老年代。G1收集器不同于之前收集器重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),之前的收集器回收的范围仅限于新生代或老年代,是JDK9 之后默认的垃圾回收器


3.8 为什么新生代被分为3份

新生代基于复制算法,gc后需要对存活的对象进行移动,提高空间利用率

复制算法要求是必须有一块区域是空着的,而如果使用标记-清除算法或者标记-整理算法的话,就会存在碎片和效率等问题

如果有3个区域: Eden区 和某块 Survivor区 会被使用的,另一块 Survivor区 是空着的,内存使用率90%

如果有2个区域:从 Eden区 复制到 Survivor区 后,再次分配新对象分配到 Survivor区,然后Survivor区 满了,把对象复制到 Eden区…,两个区都会负责新对象的分配,内存需要足够大1:1 ,内存使用率50%

3.9 空间分配担保原则

(1)问题

如果Survivor区域的空间不够,就要分配给老年代。但是,老年代也是可能空间不足的。

所以,在这个过程中就需要做一次空间分配担保(CMS),来保证对象能够进入老年代

(2)空间分配担保机制

  1. 在进行Minor GC之前,JVM首先会检查【老年代最大连续空闲空间】是否大于【当前新生代所有对象占用的总空间】

  2. 如果大于,那么说明此次的Minor GC是安全的,可以放心的进行Minor GC

  3. 如果小于,则JVM会去查看HandlePromotionFailure参数的值是否为true(表示是否允许担保失败,1.7就不在支持了,直接到滴6步)

  4. 如果不允许担保失败,则此时就会进行一次Full GC 以腾出老年代更多的空间

  5. 如果允许担保失败,则此时JVM会去检查【老年代最大连续空闲空间】是否大于【历次晋升到老年代的对象的平均大小】

  6. 如果小于,则JVM此时会进行一次Full GC,以便于腾出更多的老年代空间

  7. 如果大于,则JVM会冒险进行一次Minor GC

1、存活对象<survivor,存活对象进入survivor区中

如果 Full GC后,老年代还是没有足够的空间,此时就会发生OOM内存溢出了

2、存活对象>survivor,存活对象<老年代可用空间,直接进入老年代

如果 Full GC后,老年代还是没有足够的空间,此时就会发生OOM内存溢出了

3、存活对象>survivor,存活对象>老年代可用空间,就触发了 Full GC

如果 Full GC后,老年代还是没有足够的空间,此时就会发生OOM内存溢出了

3.10 JVM 内存为什么要分新生代,老年代?

Java堆分为老年代和新生代主要是为了优化垃圾回收。

新生代中的对象生命周期较短,大部分对象在短时间内就会变为垃圾,因此新生代采用复制算法进行垃圾回收,效率较高。

老年代中的对象生命周期较长,采用标记-清除或者标记-压缩等算法进行垃圾回收更为合适

划分新生代、老年代和元空间的主要目的是:

  • 优化垃圾回收效率:通过针对不同生命周期的对象采用不同的垃圾回收策略,提高垃圾回收的效率。

  • 减少垃圾回收停顿时间:通过将新生代和老年代分离,可以更精细地控制垃圾回收的时机和范围,减少垃圾回收造成的应用程序停顿时间。

  • 提高内存使用效率:通过动态管理元空间的内存,避免了永久代容易出现的内存溢出问题,提高了内存的使用效率和稳定性。

3.11 GC 只会对堆进行 GC 吗?

JVM 的垃圾回收器不仅仅会对堆进行垃圾回收,它还会对方法区进行垃圾回收。

  1. 堆(Heap): 堆是用于存储对象实例的内存区域。大部分的垃圾回收工作都发生在堆上,因为大多数对象都会被分配在堆上,而垃圾回收的重点通常也是回收堆中不再被引用的对象,以释放内存空间。
  2. 方法区(Method Area): 方法区是用于存储类信息、常量、静态变量等数据的区域。虽然方法区中的垃圾回收与堆有所不同,但是同样存在对不再需要的常量、无用的类信息等进行清理的过程。

3.12 什么是安全点?

安全点(Safepoint)是 Java 虚拟机(JVM)中的一种特殊状态,用于在程序执行时进行线程安全操作和垃圾收集。在安全点处,所有的线程都会被停顿,以便进行特定的操作,例如执行垃圾收集、线程栈的扫描、锁的撤销等。在安全点处,Java 虚拟机保证了线程的安全性,可以进行需要全局一致性的操作。

安全点的作用主要包括:

  1. 垃圾收集:在安全点处进行垃圾收集,可以确保所有的线程都处于停顿状态,避免了在并发垃圾收集过程中的一致性问题。

  2. 线程栈的扫描:在安全点处,可以扫描线程栈上的对象引用,以进行垃圾回收和对象生命周期的跟踪。

  3. 锁的撤销:在安全点处,可以对因为线程持有锁而等待的其他线程进行撤销操作,以减少死锁的发生。

在 Java 虚拟机中,安全点通常由以下几种情况触发:

  • 方法调用:在方法调用时进行安全点检查,确保所有的线程都处于安全点状态。

  • 循环跳转:在循环跳转时进行安全点检查,确保所有的线程都处于安全点状态。

  • 异常抛出:在异常抛出时进行安全点检查,确保所有的线程都处于安全点状态。

  • 代码缓存失效:在代码缓存失效时进行安全点检查,确保所有的线程都处于安全点状态。

总的来说,安全点是 Java 虚拟机中的一种重要机制,用于确保线程的安全性和全局一致性。通过在安全点处进行停顿,Java 虚拟机可以执行一些需要全局一致性的操作,例如垃圾收集和锁的撤销,以确保程序的正确性和稳定性。

4 CMS与G1

4.1 CMS 并发标记清除

以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

  1. 初始标记:暂停所有的其他线程,并记录下直接与 GC root 相连的对象,速度很快

  2. 并发标记:继续向下标识所有关联的对象,直到达到尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有STW。

  3. 重新标记:第 2 步并发标记并没有阻塞其它工作线程,其它线程在标记过程中,很有可能会产生新的垃圾。这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。

  4. 并发清理:开启用户线程,同时 GC 线程开始对未标记的区域做清扫,没有STW。

4.2 G1 Garbage First

G1 收集器不采用传统的新生代和老年代物理隔离的布局方式,仅在逻辑上划分新生代和老年代。

G1收集器通过跟踪Region中的垃圾堆积情况,每次根据设置的垃圾回收时间,回收优先级最高的区域(为什么是 Garbage-First 的原因 ),避免整个新生代或整个老年代的垃圾回收,使得stop the world的时间更短、更可控,同时在有限的时间内可以获得最高的回收效率。

通过区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。

  1. 初始标记(会STW):标记 GC Roots 根及其根下一级,需要停顿线程,但耗时很短

  2. 并发标记:标记 GC Roots 引用链,耗时较长,但可与用户程序并发执行

  3. 最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象

  4. 筛选回收(会STW):对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。

初始标记因为只标记 GC Roots 相连的对象,耗时较短。最终标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。复制阶段要处理所有存活的对象,耗时会较长,是主要的瓶颈。

4.3 垃圾回收器CMS 与 G1 区别🔥

区别一:范围不一样:

  • CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用
  • G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用

区别二:STW的时间:

  • CMS收集器是以最小的停顿时间为目标的收集器,无法预测停顿时间。
  • G1收集器可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)

区别三: 垃圾碎片

  • CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
  • G1收集器使用的是“标记-整理”算法,进行了空间整合,没有内存空间碎片。

区别四: 垃圾回收的过程(主要是第四阶段)

  • CMS:初始标记,并发标记,重新标记,并发清理
  • G1: 初始标记,并发标记,最终标记,筛选回收

区别五: CMS 会产生浮动垃圾

  • CMS产生浮动垃圾过多时会退化为serial old,效率低,因为在第四阶段,CMS清除垃圾时是并发清除的,这个时候,垃圾回收线程和用户线程同时工作会产生浮动垃圾,也就意味着CMS垃圾回收器必须预留一部分内存空间用于存放浮动垃圾
  • 而G1没有浮动垃圾,G1的筛选回收是多个垃圾回收线程并行gc的,没有浮动垃圾的回收,在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收。如果在清理过程中预留给用户线程的内存不足就会出现‘Concurrent Mode Failure’,一旦出现此错误时便会切换到SerialOld收集方式。

4.4 什么情况下使用CMS,什么情况使用G1?🔥

CMS适用场景:

  • 低延迟需求:适用于对停顿时间要求敏感的应用程序。
  • 老生代收集:主要针对老年代的垃圾回收。

G1适用场景:

  • 大堆内存:适用于需要管理大内存堆的场景,能够有效处理数GB以上的堆内存。
  • 对内存碎片敏感:G1通过标记整理来避免内存碎片,降低了碎片化对性能的影响。
  • 比较平衡的性能:G1在提供较低停顿时间的同时,也保持了相对较高的吞吐量。

4.5 G1回收器的特色是什么?🔥

G1 的特点:

  • G1最大的特点是引入分区的思路,弱化了分代的概念。
  • 合理利用垃圾收集各个周期的资源,解决了其他收集器、甚至 CMS 的众多缺陷

G1 相比较 CMS 的改进:

  • 空间碎片: G1 基于标记-整理算法, 不会产生空间碎片,在分配大对象时,不会因无法得到连续的空间,而提前触发一次 FULL GC 。
  • 停顿时间可控: G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象。
  • 并行与并发:G1 能更充分的利用 CPU 多核环境下的硬件优势,来缩短 stop the world 的停顿时间。

5 JVM 实践

5.1 常用的JVM启动参数有哪些🔥

根据jvm参数开头可以区分参数类型,共三类:“-”、“-X”、“-XX”

标准参数(-):所有的JVM实现都必须实现这些参数的功能,而且向后兼容;

  • -verbose:class:输出jvm载入类的相关信息,当jvm报告说找不到类或者类冲突时可进行诊断
  • -verbose:gc:输出每次GC的相关情况

非标准参数(-X):默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容;

  • -xms 堆的初始化大小 默认 电脑内存/64 Memory Start-up
  • -xmx 设置堆的最大内存 默认 电脑内存/4 Memory Maxumaximum
  • -xss 设置线程栈的最大内存空间 Stack size

非稳定参数(-XX):此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用;

  • -XX:+PrintGCDetails 打印GC详情
  • -XX:+HeapDumpOnOutOfMemoryError 当JVM发生OOM时,自动生成dump文件
  • -XX:HeapDumpPath=“保存堆内存快照的路径”
  • -XX:MaxDirectMemorySize 设置直接内存容量:默认堆的最大值一样
  • -XX:MaxTenuringThreshold 配置多少次进老年代
  • -XX:PretenureSizeThreshold 大对象阈值
  • -XX:+UseG1GC:设置垃圾回收收集器为 G1

5.2 平时调试 JVM 都用了哪些工具呢?🔥

  • jps 输出 JVM 中运行的进程状态信息
  • jstack 查看 java 进程内线程的堆栈信息。
  • jmap 用于生成堆内存快照
  • jstat 虚拟机运行时信息查看

还有一些可视化工具,像 jconsole 和 VisualVM 等

5.3 有做过 JVM 调优吗?

JVM 调优是一个复杂的过程,主要包括对堆内存、垃圾收集器、JVM 参数等进行调整和优化。

二哥的 Java 进阶之路:JVM 调优

①、JVM 的堆内存主要用于存储对象实例,如果堆内存设置过小,可能会导致频繁的垃圾回收。

②、在项目运行期间,我会使用 VisualVM 定期观察和分析 GC 日志,如果发现频繁的 Full GC,就需要特别关注老年代的使用情况。

接着,通过分析 Heap dump 寻找内存泄漏的源头,看看是否有未关闭的资源,长生命周期的大对象等。

之后,就要进行代码优化了,比如说减少大对象的创建、优化数据结构的使用方式、减少不必要的对象持有等。

5.4 CPU百分百问题如何排查🔥

三分恶面渣逆袭:CPU飙高

首先,使用 top 命令查看 CPU 占用情况,默认就是按 CPU 占用排行,找到占用 CPU 较高的进程 ID。

接着,使用 jstack 命令查看对应进程的线程堆栈信息。-l 会打印与锁相关的附加信息,> 是一个重定向操作符,会将输出的信息存到指定文件 thread-dump.txt

jstack -l <pid> > thread-dump.txt 

然后再使用 top -H -p 命令查看进程中线程的占用情况,找到占用 CPU 较高的线程 ID。-H 选项表示将显示每个进程的线程信息。-p <pid> 选项用于指定显示某个进程的信息

top -H -p <pid>

注意,top 命令显示的线程 ID 是十进制的,而 jstack 输出的是十六进制的,所以需要将线程 ID 转换为十六进制。

printf "%x\n" PID

然后,在 jstack 的输出的文件中搜索这个十六进制的线程 ID,找到对应的堆栈信息。在服务器上可以使用 vim 搜索指定字符串,按下冒号键,在命令行模式下输入 / 加上要搜索的字符串,回车后输入 n 是移到下一个匹配的字符串位置,输入 N 是移动到上一个匹配的字符串位置。

"Thread-5" #21 prio=5 os_prio=0 tid=0x00007f812c018800 nid=0x1a85 runnable [0x00007f811c000000]
   java.lang.Thread.State: RUNNABLE
    at com.example.MyClass.myMethod(MyClass.java:123)
    at ...

最后,根据堆栈信息定位到具体的业务方法,对可能引起 CPU 使用率过高的代码进行审查和分析。查看代码中是否存在大量的循环、IO 操作、数据库查询等耗时操作,尝试优化或者异步处理这些操作。

5.5 内存飙高问题怎么排查?

内存飚高一般是因为创建了大量的 Java 对象所导致的,如果持续飙高则说明垃圾回收跟不上对象创建的速度,或者内存泄漏导致对象无法回收。

排查的方法主要分为以下几步:

第一,先观察垃圾回收的情况,可以通过 jstat -gc PID 1000 查看 GC 次数和时间。

或者 jmap -histo PID | head -20 查看堆内存占用空间最大的前 20 个对象类型。

第二步,通过 jmap 命令 dump 出堆内存信息。

二哥的 Java 进阶之路:dump

第三步,使用可视化工具分析 dump 文件,比如说 VisualVM,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。

二哥的 Java 进阶之路:分析

5.6 如何处理内存泄漏问题?

推荐阅读:一次内存溢出的排查优化实战

严重的内存泄漏往往伴随频繁的 Full GC,所以排查内存泄漏问题时,需要从 Full GC 入手。主要有以下操作步骤:

第一步,使用 jps 查看运行的 Java 进程 ID

第二步,使用top -p [pid] 查看进程使用 CPU 和内存占用情况

第三步,使用 top -Hp [pid] 查看进程下的所有线程占用 CPU 和内存情况

第四步,将线程 ID 转换为 16 进制:printf "%x\n" [pid],输出的值就是线程栈信息中的 nid

例如:printf "%x\n" 29471,输出 731f

第五步,抓取线程栈:jstack 29452 > 29452.txt,可以多抓几次做个对比。

在线程栈信息中找到对应线程号的 16 进制值,如下是 731f 线程的信息。线程栈分析可使用 VisualVM 插件 TDA

"Service Thread" #7 daemon prio=9 os_prio=0 tid=0x00007fbe2c164000 nid=0x731f runnable [0x0000000000000000]
  java.lang.Thread.State: RUNNABLE

第六步,使用jstat -gcutil [pid] 5000 10 每隔 5 秒输出 GC 信息,输出 10 次,查看 YGCFull GC 次数。

通常会出现 YGC 不增加或增加缓慢,而 Full GC 增加很快。

或使用 jstat -gccause [pid] 5000 输出 GC 摘要信息。

或使用 jmap -heap [pid] 查看堆的摘要信息,关注老年代内存使用是否达到阀值,若达到阀值就会执行 Full GC。

如果发现 Full GC 次数太多,就很大概率存在内存泄漏了

第八步,使用 jmap -histo:live [pid] 输出每个类的对象数量,内存大小(字节单位)及全限定类名。

第九步,生成 dump 文件,借助工具分析哪个对象非常多,基本就能定位到问题根源了。

使用 jmap 生成 dump 文件:

# jmap -dump:live,format=b,file=29471.dump 29471
Dumping heap to /root/dump ...
Heap dump file created

第十步,dump 文件分析

可以使用 jhat 命令分析:jhat -port 8000 29471.dump,浏览器访问 jhat 服务,端口是 8000。

也可以使用图形化工具分析,如 JDK 自带的 visualvm,从菜单 > 文件 > 装入 dump 文件。

或使用第三方式具分析的,如 JProfilerGCViewer 工具。

注意:如果 dump 文件较大的话,分析会占比较大的内存。

在 dump 文析结果中查找存在大量的对象,再查对其的引用。基本上就可以定位到代码层的逻辑了。

5.7 如何处理内存溢出问题?🔥

OOM,也就是内存溢出,Out of Memory,是指当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。

当发生 OOM 时,可以导出堆转储(Heap Dump)文件进行分析。如果 JVM 还在运行,可以使用 jmap 命令手动生成 Heap Dump 文件:

jmap -dump:format=b,file=heap.hprof <pid>

生成 Heap Dump 文件后,可以使用 VisualVM 等工具进行分析,查看内存中的对象占用情况,找到内存溢出的原因。

如果生产环境的内存还有很多空余,可以适当增大堆内存大小,设置 -Xmx 参数。

或者检查代码中是否存在内存泄漏,如未关闭的资源、长生命周期的对象等。

之后,我会在本地进行压力测试,模拟高负载情况下的内存表现,确保修改有效,且没有引入新的问题。

5.8 如何优化 JVM 频繁 minor GC🔥

  • 如果新生代太小,容易导致频繁的 Minor GC,可以适当增大新生代的大小,通常占堆内存的比例为1/3到1/4之间。
  • 对象频繁创建和销毁会导致 Eden 区迅速填满,进而触发 Minor GC。可以缓存常用对象和避免使用不必要的临时变量来避免创建过多短命对象。
  • 根据应用程序的特点选择合适的垃圾回收器
  • 通过监控 GC 日志来分析垃圾回收的频率、时间开销等情况,从而做出针对性的优化。

5.9 频繁 Full GC 怎么办?

Full GC 是指对整个堆内存(包括新生代和老年代)进行垃圾回收操作。Full GC 频繁会导致应用程序的暂停时间增加,从而影响性能。

常见的原因有:

  • 大对象(如大数组、大集合)直接分配到老年代,导致老年代空间快速被占用。
  • 程序中存在内存泄漏,导致老年代的内存不断增加,无法被回收。比如 IO 资源未关闭。
  • 一些长生命周期的对象进入到了老年代,导致老年代空间不足。
  • 不合理的 GC 参数配置也导致 GC 频率过高。比如说新生代的空间设置过小。
5.9.1 该怎么排查 Full GC 频繁问题?

大厂一般都会有专门的性能监控系统,可以通过监控系统查看 GC 的频率和堆内存的使用情况。

否则可以使用 JDK 的一些自带工具,包括 jmap、jstat 等。

# 查看堆内存各区域的使用率以及GC情况
jstat -gcutil -h20 pid 1000
# 查看堆内存中的存活对象,并按空间排序
jmap -histo pid | head -n20
# dump堆内存文件
jmap -dump:format=b,file=heap pid

或者使用一些可视化的工具,比如 VisualVM、JConsole 等。

5.9.2 如何解决 Full GC 频繁问题?

假如是因为大对象直接分配到老年代导致的 Full GC 频繁,可以通过 -XX:PretenureSizeThreshold 参数设置大对象直接进入老年代的阈值。

或者能不能将大对象拆分成小对象,减少大对象的创建。比如说分页。

假如是因为内存泄漏导致的 Full GC 频繁,可以通过分析堆内存 dump 文件找到内存泄漏的对象,再找到内存泄漏的代码位置。

假如是因为长生命周期的对象进入到了老年代,要及时释放资源,比如说 ThreadLocal、数据库连接、IO 资源等。

假如是因为 GC 参数配置不合理导致的 Full GC 频繁,可以通过调整 GC 参数来优化 GC 行为。或者直接更换更适合的 GC 收集器,如 G1、ZGC 等。

  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值