浅谈JVM(面试常考题)

JVM简介

JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box。

JVM 和其他两个虚拟机的区别:

  1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
  2. JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。

JVM 是一台被定制过的现实当中不存在的计算机

JVM 执行流程

程序在执行之前先要把java代码转换成字节码,JVM 首先需要把字节码通过一定的方式类加载器把文件加载到内存中运行时数据区 ,而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用本地库接口(其他语言的接口)来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。

在这里插入图片描述

JVM运行时数据区(内存布局)

JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型完全不同,属于完全不同的两个概念,它由以下 5 大部分组成

在这里插入图片描述


程序计数器(线程私有)

程序计数器是JVM内存中最小的一块区域,它的主要作用就是保存了下一条指令所在的地址,这里的指令就相当于字节码

程序想要运行,JVM就得把字节码加载到内存中,程序就会一条一条把指令从内存中取出来,放在CPU上执行,因此也就需要随时记住,当前执行到哪一条指令

举个栗子:

我们平时看书时,每次不看了,都会在该页中放一个书签,下次再看这本书时,才能知道该从哪里看着走,这里的看书就相当于CPU在执行指令,而书签就相当于是程序计数器

CPU是并发式的执行程序的,CPU不只为一个进程提供服务,它要为成百上千的进程提供服务,并且线程一个进程中有许多线程,而操作系统是以线程为单位进行调度的,因此每个线程都得记录自己的执行位置。所以每个线程都有一个私有的程序计数器

什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为"线程私有"的内存

Java虚拟机栈(线程私有)

Java 虚拟机栈的作用:Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈

Java 虚拟机栈中包含了以下 4 部分:

在这里插入图片描述

  1. 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。
  2. 操作栈:每个方法会生成一个先进后出的操作栈。
  3. 动态链接:指向运行时常量池的方法引用。
  4. 方法返回地址:PC 寄存器的地址。

这里说的栈虽然指的是JVM内存中的一个部分,但是这里的工作过程是和数据结构中的栈是非常类似的

在JVM中可以配置栈空间的大小,但是一般也就是几M或者几十M。因此栈是很有可能会满了的

比如,在写递归代码时,如果没有把握好递归返回的条件,很可能会导致栈溢出,JVM就会抛出一个StackOverflowException

在这里插入图片描述

堆(线程共享)

堆是内存中空间最大的区域,一个进程只有一份,多个线程共用一个堆空间。程序中创建的所有对象都在保存在堆中

关于网上有这么一个说法:
内置类型的变量在栈上,引用类型的变量在堆上
这个说法是错误的,正确的说法应该是:
局部变量在栈上,成员变量和new的对象在堆上

在这里插入图片描述

总的来说,变量在栈上或是在堆上跟内置类型和引用类型没关系,真正有影响的是这个变量的型态,是以局部变量的型态出现还是以成员变量的型态出现

堆快一点还是栈快一点?

栈快一点。因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。而堆的操作底层是系统调用,容易造成大量的内存碎片,并且可能引发用户态和核心态的切换,效率较低。在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。

除上诉提到的知识外,堆中还会涉及一个特别重要的概念:垃圾回收。后续会讲到

方法区(线程共享)

方法区的作用:用来存储被虚拟机加载的类信息(.class)、常量、静态变量,即时编译器编译后的代码等数据的。

我们所写的java代码,会成为.class文件(二进制字节码),.class会被加载到内存中,也就被JVM构造成了类对象(这个加载的过程就称为"类加载")

类对象就描述了这个类是什么样的:
类的名字是啥,里面有哪些成员,有哪些方法,每个成员叫啥名字是啥类型,public/ private,每个方法叫啥名字,是啥类型(public/ private…),方法里面包含的指令…

类对象中还有一个很重要的东西,静态成员,static修饰的成员,称为了"类属性",而普通的成员,叫做"实例属性"

运行时常量池

运行时常量池是方法区的一部分,存放字面量与符号引用

字面量 : 字符串(JDK 8 移动到堆中) 、final常量、基本数据类型的值
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符

小结

上述学习的这个内存区域划分,不一定是符合实际情况的
JVM在实现的时候具体怎么划分这个区域不一定完全相同。不同厂商,不同版本的JVM实现上可能会存在一些差异

JVM类加载

类加载就是把.class文件加载到内存中,构建成类对象

对于一个类来说,它的生命周期是这样的:

在这里插入图片描述

其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来说总共分为以下几个步骤

1.Loading
2.Verification
3.Preparation
4.Resolution
5.Initialization

类加载的具体过程

Loading

Loading(加载) 阶段是整个Class Loading(类加载) 过程中的一个阶段,它和类加载 Class Loading 是不同的,一个是加载 Loading 另一个是类加载 Class Loading

在加载 Loading 阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(.class文件)
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

Loading中有个关键环节,.class文件到底里面是什么样的?

在JVM官方文档中,有这样一张图,详细说明了.class文件的格式

在这里插入图片描述

观察这个格式,就可以看到,.class 文件就把java文件中的核心信息都表述进去了,只不过组织格式上发生了转变

实现编译器,就要按照这个格式来构造,实现JVM就要按照这个格式来加载

Linking

Linking连接一般就是建立好多个实体之间的联系

Linking中又分为了三步:Verification、Preparation和Resolution

Verification

Verification的主要作用是确保Class文件的字节流中包含的信息符合规定的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全

如果这些信息不符合规定,就会类加载失败,并且抛出异常

主要包括:文件格式验证、字节码验证和符号引用验证

Preparation

Preparation段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的为0阶段

Resolution

Resolution阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程

.class文件中,常量是集中放置的,每个常量有一 个编号。.class文件的结构体里初始情况下只是记录了编号。Resolution时就需要根据编号找到对应的内容,填充到类对象中

Initialization

Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。Initialization阶段就是执行类构造器方法的过程,尤其是针对静态成员。

类加载的执行顺序

先看以下代码的执行顺序:

class A{
    public A(){
        System.out.println("A 的构造方法");
    }

    {
        System.out.println("A 的构造代码块");
    }

    static {
        System.out.println("A 的静态代码块");
    }
}
class B extends A{
    public B(){
        System.out.println("B 的构造方法");
    }

    {
        System.out.println("B 的构造代码块");
    }

    static {
        System.out.println("B 的静态代码块");
    }
}

public class Test extends B{
    public static void main(String[] args) {
    	new Test();
    }
}

打印结果

在这里插入图片描述

  1. 类加载阶段会进行静态代码块的执行,想要创建实例,势必要进行类加载,类加载根据继承关系也是有加载顺序的
  2. 静态代码块只是在类加载阶段执行一次
  3. 构造方法和构造代码块,每次实例化都会执行,构造代码块在构造方法执行前面
  4. 在继承关系中,父类执行在前,子类执行在后
  5. 程序是从main开始执行的,而main是Test的方法,因此要执行main,就需要先加载Test

根据这五大原则,对上述代码进行分析

  1. 程序是从main方法开始执行,而main方法在类Test中,因此就得需要加载类Test,而Test是继承于类B,类B是继承于类A,因此会先加载类A,其次类B,最后类C,所以先会打印A的静态代码块,其次是B的静态代码块,最后,因为C没有静态代码块,所以不打印
  2. 在main方法中new了一个Test对象,Test是继承于B,因此需要先构造B,B又继承于A,因此需要先构造A,所以先回执行A都构造方法,再执行B的构造方法,最终才会执行Test的构造方法。当然,由于Test的构造方法什么也没有,因此没有打印对应的信息

何时进行类加载?

只要这个类被用到了,就要先加载这个类
实例化,调用方法,调用静态方法,被继承…都算被用到

双亲委派模型

提到类加载机制,不得不提的一个概念就是“双亲委派模型”
站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

双亲委派模型,描述的就是JVM中的类加载器如何根据类的全限定名( java.lang.String )找到.class文件的过程,它是类加载的一个环节,这个环节处于Loading阶段中比较靠前的部分

在java程序中,.class文件存放的位置有很多,有的放到JDK目录里,有的放到项目目录里,还有的放在特定位置中。因此JVM里面提供了多个类加载器,每个类加载器负责一块区域

默认的类加载器主要有3个:

1.BootStrapClassLoader负责加载标准库中的类(String,ArrayList,Random,Scanner… )
2.ExtensionClassLoader负责加载JDK扩展的类
3.ApplicationClassLoader负责加载当前项目目录中的类

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载

考虑加载java.lang.String

  1. 程序启动,先进入ApplicationClassLoader类载器
  2. ApplicationClassLoader就会检查下,它的父加载器是否已经加载过了,如果没有,就调用父加载器ExtensionClassLoader
  3. ExtensionClassLoader也会检查下,它的父加载器是否加载过了,如果没有,就调用父加载器BootStrapClassLoader
  4. BootStrapClassLoader也会检查下,它的父加载器是否加载过,自己没有父亲,于是自己扫描自己负责的目录
  5. java.lang.String这个类在标准库中能找到!! 直接由BootStrapClassLoader 负责后续的加载过程。查找环节就结束了

在这里插入图片描述

考虑加载自己写的Test类

  1. 程序启动,先进入ApplicationClassLoader类加载器
  2. ApplicationClassLoader就会检查下,它的父加载器是否已经加载过了。如果没有,就调用父加载器ExtensionClassLoader
  3. ExtensionClassLoader也会检查下它的父加载器是否加载过了。如果没有,就调用父加载器BootStrapClassLoader
  4. BootStrapClassLoader也会检查下,它的父加载器是否加载过,发现自己没有父亲,于是自己扫描自负责的目录,没扫描到!!回到子加载器继续扫描
  5. ExtensionClassLoader也扫描自己负责的目录,也没扫描到,回到子加载器继续扫描
  6. ApplicationClassLoader也扫描自负责的目录,如果能找到Test类,于是进行后续加载,查找目录的环节结束。如果不能找到Test类,就会抛出异常

在这里插入图片描述

双亲委派模型的优点

  1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了
  2. 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了

如果是自定义类加载器,是否也要遵守双亲委派模型?

可以遵守,也可以不遵守,看需求
比如,像tomcat加载webapp中的类,就没有遵守(因为遵守了也没啥意义,webapp中的类都是程序员写好的,然后再部署的,如果自定义的类加载器都找不到,还能指望其他标准库的加载器能找到?毕竟自己写的类不会在标准库中出现)

JVM中的垃圾回收机制(GC)

对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。对于C/C++程序来说,在堆上申请的空间需要自己手动释放,否者会造成内存泄漏。而对于Java而言,在堆上申请的空间,并不需要程序员自己手动释放,而是在合适的时机,由JVM帮我们处理,至于什么时候叫做合适,这得看JVM中的垃圾回收机制了

在Java代码中,创建一个变量(申请一个内存),这个变量啥时候不再使用了?也不是那么容易就能确定的。
如果内存释放的时机有问题,就会使我们变得非常难受

吃麻辣烫:

冯同学今天心情很好,去食堂吃麻辣烫。结果吃到中途的时候,突然有一个电话打来了,由于食堂太吵了,冯同学就去食堂外面接电话,结果等冯同学通话完后,准备继续吃,发现麻辣烫被食堂阿姨端走了,本来心情挺好的,一下子就一落千丈——这就相当于内存释放时机太早了

图书馆占座:

冯同学今天心情又很好,打算去图书馆学习,比较寝室不是学习的地方。到图书馆了,发现所有桌子都被使用完了,要么有人正在使用,要么书在人不在。由于冯同学是一个素质好的大学生,想了一想,还是不去抢占这些位置了,可能这些人都有事,一会儿就回来。于是冯同学又回到了寝室,打算再隔一个小时再去,应该就会有位置了。一个小时之后,又去图书馆查看一下情况,发现还是老样子。冯同学又回寝室,冯同学又跑去图书馆查看情况…这一天都在干这个事,结果直到晚上图书馆闭馆,都始终有人占着座没来。——这就相当于内存释放时机太迟了

总的来说,内存释放早了也不行,晚了也不行,必须恰到好处。因此各个语言都有自己的方式

C语言:内存释放这个事俺管不了,你们程序猿自己看着办,反正出了问题又不扣我的钱。
因此,在C语言中就经常会遇到一个臭名昭著的问题,“内存泄露" (申请了之后,忘了释放了) 就会导致可用内存越来越少,最终无内存可用了

C++:不像C语言那样爱摆烂,还是想努力抢救一下的,提出了一个智能指针这样的机制通过智能指针,就能一定程度上的降低内存泄漏的可能性。智能指针依赖的是RAII机制,但还是存在泄漏的可能

Java,Go,Python,PHP…现在市面上的大部分主流编程语言,都采取了一个方案,就是垃圾回收机制!!
大概就是由运行时环境(JVM,Python解释器,Go运行时…)来通过更复杂的策略判定内存是否可以回收,并进行回收的动作

垃圾回收的劣势:
1.消耗额外的开销(消耗资源更多了)
2.可能会影响程序的流畅运行(垃圾回收经常会引入STW问题)

SWT:

STW 是 GC 中很重要的概念,全称 Stop the world,即程序全局暂停时间,GC 优化算法都是围绕减少 STW 的时间或频率

  1. 在STW 状态下,JAVA的所有线程都是停止执⾏的, GC线程除外(native代码可以执行)
  2. 一旦Stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务
  3. STW是不可避免的,垃圾回收算法执⾏一定会出现STW,我们要做的只是减少停顿的时间

垃圾判断算法

引用计数(Java并未采用)

给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。

在这里插入图片描述

什么时候,引用计数会为0

void func(){
	Test t = new Test();
	Test t2 = t;
}

调用func()方法过程中,创建了对象(分配了内存),在执行过程中,引用计数是2
当func()方法执行结束,由于 t 和 t2 都是局部变量,跟着栈帧一起释放了,这一释放就导致引用计数为0(没有引用指向这个对象,也就没有代码能够访问到这个对象了),此时就默认这个对象就是个垃圾

引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采用引用计数法进行内存管理,C++中智能指针share_ptr底层就是用的引用计数

但是它有两个致命的缺陷:

  1. 空间利用率比较低!
    每个new的对象都得搭配个计数器(计数器假设4个字节),如果对象本身很大(几百个字节),多出来4个字节,就不算啥。如果本身对象很小(自己才4个字节)多出4个字节,相当于空间被浪费了一倍
  2. 会有循环引用的问题
class Test {
	Testt = nul;
}

Test t1 = new Test();
Test t2 = new Test(); .
t1.t = t2;
t2.t = t1

在这里插入图片描述

t1 = null; t2 = null;

在这里插入图片描述
此时此刻,两个对象的引用计数,不为0,所以无法释放。但是由于引用存在于彼此的对象里面,外界的代码由无法访问到这两个对象
此时此刻,这俩对象就被孤立了,既不能使用,又不能释放这就出现了"内存泄露"的问题

C++中的share_ptr也会有循环引用的问题,因此采用了weak_ptr去解决

可达性分析(Java采用)

Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活

可达性分析是通过额外的线程,定期的针对整个内存空间的对象进行扫描
从有一些起始位置(称为GCRoots)触发,会类似于深度优先遍历一样,把可以访问到的对象都标记一遍(带有标记的对象就是可达的对象),没被标记的对象,就是不可达,也就是垃圾

在这里插入图片描述
对象Object1-Object3到GC Roots是可达的,因此它们不会被判断为可回收对象。对象Object4-Object6之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象

以下变量或者对象都可被作为GC Roots

1.栈上的局部变量
2.常量池中的引用指向的对象
3.方法区中的静态成员指向的对象

可达性分析的优点就是克服了引用计数的两个缺点:空间利用率低和循环引用

可达性分析的缺点就是系统开销大,遍历一次可能比较慢

找垃圾,核心就是确认这个对象未来是否还会使用
什么算不使用了?
没有引用指向,就不使用了

垃圾回收算法

对于垃圾回收(释放内存), 也有三种基本策略

标记-清除算法

"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先经过可达性分析,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象

在这里插入图片描述

此时如果直接释放,虽然内存是还给系统了,但是被释放的内存是离散的(不是连续的)分散开,带来的问题就是"内存碎片"

内存碎片的危害:

空闲的内存,有很多,假设一共是1G
如果要申请500M内存,也是可能申请失败的
(因为要申请的500M是连续内存)每次申请,都是申请的连续的内存空间
而这里的1G可能是多个碎片加在一起,才是1G

这个问题其实非常影响程序的执行

复制算法

为了解决内存碎片,引入的复制算法。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图 :

在这里插入图片描述

虽然复制算法解决了内存碎片问题,但是又引入了新的问题:

1.内存空间利用率低
2.如果要保留的对象多,要释放的对象少,此时复制开销就很大

标记-整理算法

标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。这个过程就类似于顺序表删除中间元素,存在一个搬运过程

在这里插入图片描述

这个方案空间利用率是提高了,但是仍然没有解决复制/搬运元素开销大的问题

分代算法

上述三种方案,虽然都能解决问题,但是都有缺陷。实际JVM中的实现,会把多种方案结合起来使用

当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法将其复制到老年代;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。

在这里插入图片描述

  1. 刚创建出来的对象,就放在伊甸区

  2. 如果伊甸区的对象熬过一轮GC扫描,就会被拷贝到幸存区(复制算法)。根据实际经验,大部分的对象都是"朝生夕死",真正能熬过一轮GC的对象并不多

  3. 在后续的几轮GC中,幸存区的对象就在两个幸存区之间来回拷贝(复制算法),每一轮都会淘汰掉一波幸存者

  4. 在持续若干轮之后,对象终于"多年媳妇熬成婆",进入老年代
    老年代有个特点:里面的对象都是比较老的(年龄大的)。基本的经验规律:一个对象越老,继续存活的可能性就越大。因此老年代的GC扫描频率大大低于新生代,并且老年代中使用标记整理的方式进行回收

分代回收中,还有一个特殊情况,有一类对象可以直接进老年代,它就大对象,大对象占用内存多,拷贝开销比较大,不适合使用复制算法

在这里插入图片描述

这里有个坑!!
网上有种说法:98%的新对象是熬不过一轮GC,2%的新对象会进入幸存区
这些数字是如何靠证出来的,我也不知道,面试时,最好不要说这种数字

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值