JVM

0.JVM

0.1什么是JVM?

Windows 系统上一个软件包安装包是 exe 后缀的,而这个软件包在苹果的 Mac OSX 系统上是无法安装的。类似地,Mac OSX 系统上软件安装包则是 dmg 后缀,同样无法在 Windows 系统上安装。

为什么不同系统上的软件无法安装,这是因为操作系统底层的实现是不一样的。对于 Windows 系统来说,exe 后缀的软件代码最终编译成 Windows 系统能识别的机器码。而 Mac OSX 系统来说,dmg 后缀的软件代码最终编译成 Mac OSX 系统能识别的代码。

在这里插入图片描述

​ Java经过一次编译就可以在Windows、Linux和Mac等系统上运行,但是我们并没有生成多份不同的代码。Java 是怎么做到的呢?

Java 语言并不直接将代码编译成与系统有关的机器码,而是编译成字节码文件。但是各个系统也不认识这个字节码文件,无法直接去运行它。

JVM就是一个软件。C语言编写的。可以安装到不同的操作系统上。

原理图:
在这里插入图片描述

简单地说,对于同样一份 Java 源码文件,我们编译成字节码之后,无论是 Linux 系统还是 Windows 系统都不认识。这时候 Java 虚拟机就是一个翻译官,在 Linux 系统上翻译成 Linux 机器码给 Linux 系统听,在 Windows 系统上翻译成 Windows 机器码给 Windows 系统听。这样一来,Java 就实现了「Write Once,Run Anywhere」的伟大愿景了。

0.2为什么学习JVM?

  • 面试

  • 学习 Java 虚拟机能深入地理解 Java 这门语言。

  • 可以清楚知道Java程序是如何执行的。

  • 可以明白为什么Java等高级语言具有可移植性强的特性。

1.JVM的位置

1.1 JVM、JRE和JDK的关系

  • JDK(Java Development Kit Java开发工具包)包含:包含JRE,以及增加编译器和调试器等用于程序开发的文件。

  • JRE(Java Runtime Environment Java运行时环境)包含:Java虚拟机,库函数,运行Java应用程序所必须的文件。

在这里插入图片描述
在这里插入图片描述

2.JVM的体系结构

2.1体系结构图

在这里插入图片描述

2.2程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定时刻,一个处理器(多核处理器是一个内核)都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序技术器,每条线程之间计数器互不影响,独立存储

像程序计数器这样的内存区域被称为“线程私有的内存”。

例子 假如现在有一本书,有好几个同学都想看,我们采取这样的策略让所有同学都能看到:每个人看一天,不管看没看完都要交给下一个人看,不断循环,直到所有人看完。每个同学都有一个小卡片记录自己看到了哪里,这样下次轮到自己看的时候就能快速的接着上次看到的地方继续看。

2.3Java虚拟栈

2.3.1 什么是栈?

栈是一块连续的内存的区域。

栈的一端是封死的(栈底),所以栈的插入(入栈)和删除(出栈)只能在另一端(栈顶)进行。

而且每次(出栈)的元素都是最后进栈的元素,故栈也被称为后进先出(LIFO ,Last In First Out的缩写)表。

2.3.2 JVM栈

JVM栈是线程私有的,他的生命周期与线程相同(随线程而生,随线程而灭)。每个线程都会分配一个栈的空间,即每个线程拥有独立的栈空间

Java虚拟机栈描述的是Java方法执行的内存模型

2.3.3栈帧

栈帧是栈的元素。每个方法在执行时都会创建一个栈帧。栈帧中存储了局部变量表、操作数栈、动态链接和方法出口等信息。每个方法从调用到运行结束的过程,就对应着一个栈帧在栈中压栈到出栈的过程。

对应关系:一个线程对应一个 JVM栈。JVM 栈中包含一组 栈帧。线程每调用一个方法就对应着 JVM栈 中 栈帧的入栈,方法执行完毕或者异常终止对应着出栈(销毁)。

2.3.4栈帧的结构

局部变量表(Local Variable Table)

  • 局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量

变量槽(Variable Slot)

  • 局部变量表的容量以变量槽为最小单位,每个变量槽都可以存储32位长度的内存空间,例如boolean、byte、char、short、int、float、reference类型和returnAddress类型。

    • reference(对象实例的引用)一般来说,虚拟机都能从引用中直接或者间接的查找到对象的以下两点 :
      ①在Java堆中的数据存放的起始地址索引。
      ②所属数据类型在方法区中的存储的类型数据。
  • 对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写。

  • 面试题:基本数据和对象引用存储在栈中是否正确?。

    解析 这种说法虽然是正确的,但是很不严谨,只能说这种说法针对的是局部变量。   局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。   但是,如果是成员变量,或者定义在方法外对象的引用,它们存储在堆中。   因为在堆中,是线程共享数据的,并且栈帧里的命名就已经清楚的划分了界限 : 局部变量表!

操作数栈(Operand Stack)

  • 与局部变量表一样,均以字长为单位的数组。不过局部变量表用的是索引,操作数栈是弹栈/压栈来访问。操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区。
  • 存储的数据与局部变量表一致含int、long、float、double、reference、returnType,操作数栈中byte、short、char压栈前(bipush)会被转为int。
  • 数据运算的地方,大多数指令都在操作数栈弹栈运算,然后结果压栈。
  • java虚拟机栈是方法调用和执行的空间,每个方法会封装成一个栈帧压入占中。其中里面的操作数栈用于进行运算,当前线程只有当前执行的方法才会在操作数栈中调用指令(可见java虚拟机栈的指令主要取于操作数栈)。

动态链接(Dynamic Linking)

  • 动态链接主要就是指向运行时常量池的方法引用

  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference )保存在class文件的常量池里。比如,描述一个方法调用其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

    符号引用和直接引用 1.符号引用(Symbolic References):   符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如:org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。 2.直接引用: 直接引用是:直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针) 直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

返回地址(Return Address)

  • 存放调用该方法的PC寄存器的值。

  • 方法结束方式:

    1. 正常结束
    2. 出现未处理异常,非正常退出(通过异常完成出口退出的不会给他的上层调用者生产任何的返回值

两种异常

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutofMemoryError异常(OOM异常)。
例子 public class Demo3 { public static void main(String[] args) { aa(); } public static void aa(){ bb(); } public static void bb(){ aa(); } }

2.4 本地方法栈

2.4.1 native

native关键字说明其修饰的方法是一个原生态方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。

2.4.2本地方法栈

本地方法栈(Native Method Stacks)与 Java 虚拟机栈所发挥的作用是非常相似的(线程私有),其区别不过是JVM栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。

  • 本地方法栈也是一个后入先出(Last In First Out)栈。
  • 由于是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
  • 本地方法栈会抛出 StackOverflowErrorOutOfMemoryError 异常。
2.4.3 native方法

Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。

protected native Object clone() throws CloneNotSupportedException;

2.5堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例数组几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。

在这里插入图片描述

2.5.1年轻代

所有新生成的对象首先都是放在年轻代的(如果该对象占用内存非常大,则直接分配到老年代区)。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被移动到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被移动到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区移动过来的并且此时还存活的对象,将被移动“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden移动过来 对象,和从前一个Survivor移动过来的对象,而移动到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

2.5.2老年代

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

2.6方法区

方法区与Java堆一样是所有线程共享的内存区域,用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap非堆),目的是与Java堆区分开。

1、 类信息

  • 类的完整名称(比如,java.long.String)
  • 类的直接父类的完整名称
  • 类的直接实现接口的有序列表(因为一个类直接实现的接口可能不止一个,因此放到一个有序表中)
    类的修饰符

可以看做是,对一个类进行登记,这个类的名字叫啥,他爸比是谁、有没有实现接口, 权限是啥;

2、类的常量池 (即运行时常量池)

每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 ;

字面值:就是像string, 基本数据类型,以及它们的包装类的值,以及final修饰的变量,简单说就是在编译期间,就可以确定下来的值;

存在这里面的数据,类似于保存在数组中,外部根据索引来获得它们 ;

3、字段信息

  • 声明的顺序
  • 修饰符
  • 类型
  • 名字

4、方法信息

  • 声明的顺序
  • 修饰符
  • 返回值类型
  • 名字
  • 参数列表(有序保存)
  • 异常表(方法抛出的异常)
  • 方法字节码(native、abstract方法除外,)
  • 操作数栈和局部变量表大小

5、类变量(即static变量)

非final类变量

在java虚拟机使用一个类之前,它必须在方法区中为每个非final类变量分配空间。非final类变量存储在定义它的类中;

final类变量(不存储在这里)

由于final的不可改变性,因此,final类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面 ;final类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;

6、对类加载器的引用

jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。

7、对Class类的引用

jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类数据联系起来, 因此,类数据里面保存了一个Class对象的引用;

3.类加载器

定义:当 Java 虚拟机将 Java 源码编译为字节码之后,虚拟机便可以将字节码读取进内存,从而进行解析、运行等整个过程,这个过程我们叫:Java 虚拟机的类加载机制。

作用:加载Class文件。

JVM 虚拟机执行 class 字节码的过程可以分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载。

3.1加载

加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口。

其实加载阶段用一句话来说就是:把代码数据加载到内存中。

3.2验证

当 JVM 加载完 Class 字节码文件并在方法区创建对应的 Class 对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行。这个校验过程大致可以分为下面几个类型:

  • **JVM规范校验。**JVM 会对字节流进行文件格式校验,判断其是否符合 JVM 规范,是否能被当前版本的虚拟机处理。
  • **代码逻辑校验。**JVM 会对代码组成的数据流和控制流进行校验,确保 JVM 运行该字节码文件后不会出现致命错误。例如一个方法要求传入 int 类型的参数,但是使用它的时候却传入了一个 String 类型的参数。一个方法要求返回 String 类型的结果,但是最后却没有返回结果。代码中引用了一个名为 User的类,但是你实际上却没有定义 User类。

当代码数据被加载到内存中后,虚拟机就会对代码数据进行校验,看看这份代码是不是真的按照JVM规范去写的。

3.3准备(重点)

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点,即内存分配的对象以及初始化的类型。

  • **内存分配的对象。**Java 中的变量有「类变量」和「类成员变量」两种类型,「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于「类成员变量」。在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始。

例如:下面的代码在准备阶段,只会为 age属性分配内存,而不会为 address 属性分配内存。

public static int age = 23;
public String address = "西安";
  • **初始化的类型。**在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。

例如:下面的代码在准备阶段之后,id的值将是 0,而不是 3。

public static int id = 3;

但如果一个变量是常量(被 static final 修饰)的话,那么在准备阶段,属性便会被赋予用户希望的值。例如下面的代码在准备阶段之后,number 的值将是 3,而不是 0。

public static final int number = 3;

之所以 static final 会直接被赋值,而 static 变量会被赋予零值。其实我们稍微思考一下就能想明白了。

两个语句的区别是一个有 final 关键字修饰,另外一个没有。而 final 关键字在 Java 中代表不可改变的意思,意思就是说 number 的值一旦赋值就不会在改变了。既然一旦赋值就不会再改变,那么就必须一开始就给其赋予用户想要的值,因此被 final 修饰的类变量在准备阶段就会被赋予想要的值。而没有被 final 修饰的类变量,其可能在初始化阶段或者运行阶段发生变化,所以就没有必要在准备阶段对它赋予用户想要的值。

3.4解析

当通过准备阶段之后,JVM 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类引用进行解析。这个阶段的主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用。

3.5初始化(重点)

到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化,一般来说当 JVM 遇到下面 5 种情况的时候会触发初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

3.6使用

当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。这个阶段也只是了解一下就可以。

3.7卸载

当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。这个阶段也只是了解一下就可以。

3.8面试

(1)

public class Book {
    public static void main(String[] args)
    {
        System.out.println("Hello JVM.");
    }

    Book()
    {
        System.out.println("书的构造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    {
        System.out.println("书的普通代码块");
    }

    int price = 110;

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

    static int amount = 112;
}

解析
书的静态代码块
Hello JVM.

在我们代码中,我们只知道有一个构造方法,但实际上Java代码编译成字节码之后,是没有构造方法的概念的,只有类初始化方法 和 对象初始化方法 。
那么这两个方法是怎么来的呢?
类初始化方法。编译器会按照其出现顺序,收集类变量的赋值语句、静态代码块,最终组成类初始化方法。类初始化方法一般在类初始化的时候执行。
上面的这个例子,其类初始化方法就是下面这段代码了:
static
{
System.out.println(“书的静态代码块”);
}
static int amount = 112;
对象初始化方法。编译器会按照其出现顺序,收集成员变量的赋值语句、普通代码块,最后收集构造函数的代码,最终组成对象初始化方法。对象初始化方法一般在实例化类对象的时候执行。
上面这个例子,其对象初始化方法就是下面这段代码了:
{
System.out.println(“书的普通代码块”);
}
int price = 110;
System.out.println(“书的构造方法”);
System.out.println(“price=” + price +",amount=" + amount);
类初始化方法 和 对象初始化方法 之后,我们再来看这个例子,我们就不难得出上面的答案了。
但上面的这个例子其实没有执行对象初始化方法。
因为我们确实没有进行 Book 类对象的实例化。如果你在 main 方法中增加 new Book() 语句,你会发现对象的初始化方法执行了!

(2)

class Grandpa
{
    static
    {
        System.out.println("爷爷在静态代码块");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在静态代码块");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("儿子在静态代码块");
    }

    public Son()
    {
        System.out.println("我是儿子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的岁数:" + Son.factor);	//入口
    }
}

解析
爷爷在静态代码块
爸爸在静态代码块
爸爸的岁数:25

这是因为对于静态字段,只有直接定义这个字段的类才会被初始化(执行静态代码块)。因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
对面上面的这个例子,我们可以从入口开始分析一路分析下去:
首先程序到 main 方法这里,使用标准化输出 Son 类中的 factor 类成员变量,但是 Son 类中并没有定义这个类成员变量。于是往父类去找,我们在 Father 类中找到了对应的类成员变量,于是触发了 Father 的初始化。
但根据我们上面说到的初始化的 5 种情况中的第 3 种(当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化)。我们需要先初始化 Father 类的父类,也就是先初始化 Grandpa 类再初始化 Father 类。于是我们先初始化 Grandpa 类输出:「爷爷在静态代码块」,再初始化 Father 类输出:「爸爸在静态代码块」。
最后,所有父类都初始化完成之后,Son 类才能调用父类的静态变量,从而输出:「爸爸的岁数:25」。

(3)

class Grandpa
{
    static
    {
        System.out.println("爷爷在静态代码块");
    }

    public Grandpa() {
        System.out.println("我是爷爷~");
    }
}
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在静态代码块");
    }

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("儿子在静态代码块");
    }

    public Son()
    {
        System.out.println("我是儿子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        new Son(); 	//入口
    }
}
解析 首先在入口这里我们实例化一个 Son 对象,因此会触发 Son 类的初始化,而 Son 类的初始化又会带动 Father 、Grandpa 类的初始化,从而执行对应类中的静态代码块。因此会输出:「爷爷在静态代码块」、「爸爸在静态代码块」、「儿子在静态代码块」。 当 Son 类完成初始化之后,便会调用 Son 类的构造方法,而 Son 类构造方法的调用同样会带动 Father、Grandpa 类构造方法的调用,最后会输出:「我是爷爷」、「我是爸爸」、「我是儿子~」。

4.双亲委派机制

4.1类加载器的类别

BootstrapClassLoader(启动类加载器)

c++编写,加载java核心库 java.*,构造ExtClassLoader和AppClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

ExtClassLoader (标准扩展类加载器)

java编写,加载扩展库,如classpath中的jre ,javax.*或者java.ext.dir 指定位置中的类,开发者可以直接使用标准扩展类加载器。

AppClassLoader(系统类加载器)

java编写,加载程序所在的目录,如user.dir所在的位置的class

CustomClassLoader(用户自定义类加载器)

java编写,用户自定义的类加载器,可加载指定路径的class文件

4.2什么是双亲委派机制

双亲委派机制:是指当一个类加载器收到一个类加载请求时,该类加载器首先会把请求委派给父类加载器。每个类加载器都是如此,只有在父类加载器在自己的搜索范围内找不到指定类时,子类加载器才会尝试自己去加载。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1BY0zNQ2-1609081666095)(JVM.assets/20200403152412969.png)]

4.3委派机制的流程图和源码

protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查这个class是否已经加载过了
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // c==null表示没有加载,如果有父类的加载器则让父类加载器加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        //如果父类的加载器为空 则说明递归到bootStrapClassloader了
                        //bootStrapClassloader比较特殊无法通过get获取
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {}
                if (c == null) {
                    //如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
                    long t1 = System.nanoTime();
                    c = findClass(name);
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

在这里插入图片描述

那如果有一个Hello.class文件是如何被加载到JVM中的呢?

从上图中我们就更容易理解了,当一个Hello.class这样的文件要被加载时。不考虑我们自定义类加载器,首先会在AppClassLoader中检查是否加载过,如果有那就无需再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类中同理会先检查自己是否已经加载过,如果没有再往上。注意这个过程,知道到达Bootstrap classLoader之前,都是没有哪个加载器自己选择加载的。如果父加载器无法加载,会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException

面试题:Java在new一个对象时虚拟机做了什么?

解析 Java虚拟机遇到字节码new指令时 ①先看看这条指令对应的参数能否在常量池中定位到类的符号引用 ②若没有,则执行类加载的过程(加载,验证,解析,初始化) ②若有,则为对象分配内存(防止并发情况下线程不安全的问题,采用CAS分配和TLAB方法) ③接下来设置对象头(此对象时哪个类的实例,对象的哈希码,GC分代年龄等) ④调用构造函数,初始化对象

在这里插入图片描述

4.4双亲委派机制的作用

案例:

自定义:java.lang.String

public class String {
    static {
        System.out.println("我是自定义在java.lang包下的String类");
    }

   /* public static void main(String[] args) {
        System.out.println("aaaaaaaa");
    }*/
}

测试类test.StringTest

public class StringTest {
    public static void main(String[] args) {
        String str = new String();
        System.out.println("开始测试。。。。。。。");
    }
}

自定义类

package java.lang;

public class aaa {
    public static void main(String[] args) {
        System.out.println("hello!");
    }
}

通过上面的例子,我们可以知道,双亲机制可以

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改
    • 自定义类:java.lang.String (没用)
    • 自定义类:java.lang.aaa(报错:阻止创建 java.lang开头的类)

5.沙箱安全机制

5.1什么是沙箱?

Java安全模型的核心就是Java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。

所有的Java程序运行都可以指定沙箱,可以定制安全策略。

5.2 java中的安全模型:

在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱 (Sandbox) 机制。如下图所示 JDK1.0安全模型

但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的 Java1.1 版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。如下图所示 JDK1.1安全模型

在 Java1.2 版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示 JDK1.2安全模型

当前最新的安全机制实现,则引入了域 (Domain) 的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域 (Protected Domain),对应不一样的权限 (Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示 最新的安全模型(jdk 1.6)

6.GC

6.1概述

垃圾回收 Garbage Collection 通常被称为“GC”,它诞生于1960年 MIT 的 Lisp 语言。 jvm 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存垃圾回收主要集中于 java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的.

6.2为什么需要GC

如果不进行垃圾回收,内存迟早都会被消耗空,因为我们在不断的分配内存空间而不进行回收。除非内存无限大,我们可以任性的分配而不回收,但是事实并非如此。所以,垃圾回收是必须的。

6.3到底谁是垃圾?

联想其日常生活中,如果一个东西经常没被使用,那么这个对象可以说就是垃圾。在 Java 中也是如此,如果一个对象不可能再被引用,那么这个对象就是垃圾,应该被回收。

6.4JVM如何判定一个对象是否应该被回收?

判断一个对象是否应该被回收,主要是看其是否还有引用。判断对象是否存在引用关系的方法包括引用计数法以及可达性分析

6.4.1引用计数法

在一个对象被引用时加一,被去除引用时减一,这样我们就可以通过判断引用计数是否为零来判断一个对象是否为垃圾。

存在的问题:无法解决对象相互循环引用的问题。

举例 A 引用了 B,B 引用了 C,C 引用了 A,它们各自的引用计数都为 1。但是它们三个对象却从未被其他对象引用,只有它们自身互相引用。从垃圾的判断思想来看,它们三个确实是不被其他对象引用的,但是此时它们的引用计数却不为零。这就是引用计数法存在的循环引用问题。
6.4.2可达性分析法

这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

那么问题又来了,如何选取GCRoots对象呢?在Java语言中,可以作为GCRoots的对象包括下面几种:

  1. 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

  2. 方法区中的类静态属性引用的对象。

  3. 方法区中常量引用的对象。

  4. 本地方法栈中JNI(Native方法)引用的对象。

四种引用:

在JDK1.2之前,Java中引用的定义很传统:如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过于狭隘,一个对象只有被引用或者没被引用两种状态。我们希望描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。

1、强引用:只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

2、软引用:描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。

3、弱引用:描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

4、虚引用:这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。

6.5垃圾回收算法

6.5.1标记-清除(Mark-Sweep)算法

这是最基础的算法,标记-清除算法就如同它的名字样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。

缺点:

  1. 产生大量不连续的内存碎片 ,内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

  2. 标记和清除效率都不高

标记-清除算法执行过程如图:

6.5.2复制(Copying)算法

复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉

优点:

  1. 不会出现内存碎片。
  2. 只需移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

缺点:

  1. 将内存缩小为原来的一半。
  2. 在对象存活率较高时会进行较多复制操作,效率较低。
6.5.3标记-整理(Mark-Compact)算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

6.5.4分代收集算法

现在虚拟机基本都采用分代收集算法来进行垃圾回收。

根据对象的存活周期不同将内存划分为新生代和老年代,存活周期短的为新生代,存活周期长的为老年代。这样就可以根据每块内存的特点采用最适当的收集算法。
新生代的中每次垃圾收集中会发现有大批对象死区,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代中因为对象的存活率高,没有额外的控件对它进行分配担保,就必须使用“标记-清扫”或者“标记-整理”算法来进行回收。

新生代中的复制算法

根据复制算法把新生代堆分为两份,一份为使用区,一份为存活区。
使用区:存活区=8:2

每次新的对象都是在使用区创建,对使用区进行垃圾回收之后如果存活,就放入存活区。
新生代使用区和存活区的比例是8:2,所以很有可能新生代执行了复制回收算法之后,存活区的内存不够。这个时候,存活区无法容纳的对象就会直接进入老年代。

新生代中有三个区域:eden,from和to。三者内存大小比例为8:1:1。
假设此时数据存储在eden和from中,垃圾回收之后还留存的数据存储在to中。那么下一次就是回收eden和to中的内存,留下的数据存储在from中。
比例为什么是8:1:1?
根据 IBM 公司对对象存活时间的统计,他们发现 80% 的对象存活时间都很短。于是他们将 Eden 区设置为年轻代的 80%,这样可以减少内存空间的浪费,提高内存空间利用率。

对象如何进入老年代

1、大对象直接进入老年代
因为新生代是使用的复制算法,所以要尽量减少复制的内存,所以对象内存到一定的值后就会直接进入老年代。

2、新生代对象年龄到一定程度后进入老年代
每个对象会有一个Age的计数器,初始值为0,每经过一次GC并且存活,这个对象的Age就会加1,如果增加到一定程度(默认为15)。那么就会进入老年代中。

3、动态对象年龄判定
如果在新生代存活区中相同年龄所有对象大小的总和大于存活区的一半,年龄大于或等于该年龄的对象就会直接进入老年代。
比如现在存活区有三个对象,Age分别为2、2、3。那么Age为3的这个对象就会进入老年代。

空间分配担保

在新生代回收之前,虚拟机首先会检查老年代的最大可用连续空间是否大于新生代对象的总空间,如果是的,那么就可以保证这次内存回收是安全的。(就是假设新生代的所有对象都会进入老年代)
如果上述不成立呢,就会查看是否允许担保失败。

  • 如果允许失败,那么就不管够不够,还是启动新生代的垃圾回收,当回收失败时,就启动老年代的垃圾回收,然后再重新执行新生代的垃圾回收。
  • 如果不允许失败,那么就会先启动老年代的垃圾回收,然后再启动新生代的垃圾回收。

7垃圾收集器

在GC机制中,如果说垃圾回收算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。

几个相关概念:

并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。

吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%。

Minor GC:从年轻代空间回收内存被称为 Minor GC,有时候也称之为 Young GC。当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。

Major GC:从老年代空间回收内存被称为 Major GC,有时候也称之为 Old GC。

分配对象内存时发现内存不够,触发 Minor GC。Minor GC 会将对象移到老年代中,如果此时老年代空间不够,那么触发 Major GC。因此许多 Major GC 是由 Minor GC 引起的。

Full GC:Full GC 是清理整个堆空间 —— 包括年轻代、老年代和永久代(如果有的话)。因此 Full GC 可以说是 Minor GC 和 Major GC 的结合。

GC触发条件:

  • System.gc()方法的调用。
  • 老年代空间不足。
  • Permanet Generation空间满了。
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
  • 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

Stop-The-World:中文一般翻译为全世界暂停,是指在进行垃圾回收时因为标记或清理的需要,必须让所有执行任务的线程停止执行任务,从而让垃圾回收线程回收垃圾的时间间隔。

7.1新生代垃圾收集器

7.1.1Serial收集器

概述:Serial是一类用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条单线程执行垃圾收集工作,它还在收集的同时,所用的用户必须暂停。其执行过程如下图所示

从上图可知当应用程序进行到一个安全的节点的时候,所有的线程全都暂停,等到GC完成后,应用程序线程继续执行。这就像是你一边扫地,旁边要是有人一边嗑瓜子,那你这要一直扫下去的节奏,只能先让他别吃了,然后你才能干活。

  • 优势:简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。

  • 缺点:会在用户不知道的情况下停止所有工作线程,用户体验感极差,令人难以接受。

  • 适用场景:Client 模式(桌面应用);单核服务器。

  • 参数: 可以使用命令如下开启Serial作为新生代收集器

    -XX:+UserSerialGC #选择Serial作为新生代垃圾收集器
    
7.1.2ParNew收集器

parNew收集器其实就是Serial的一个多线程版本,其在单核cpu上的表现并不会比Serail收集器更好,在多核机器上,其默认开启的收集线程数与cpu数量相等。可以通过如下命令进行修改

-XX:ParallelGCThreads #设置JVM垃圾收集的线程数  

如下是ParNew收集器和Serial Old 收集器结合进行垃圾收集的示意图.

当用户线程都执行到安全点时,所有线程暂停执行,采用复制算法进行垃圾收集工作,完成之后,用户线程继续开始执行。

  • 优点:随着cpu的有效利用,对于GC时系统资源的有效利用有好处。
  • 缺点:和Serial是一样的。
-XX:UseParNewGC #新生代采用ParNew收集器  
7.1.3Parallel Scavenge收集器

概述:Parallel Scavenge也是一款用于新生代的多线程收集器,也是采用复制算法。与ParNew的不同之处在于 Parallel Scavenge收集器的目的是达到一个可控制的吞吐量,而ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间。

其与Parallel Old收集器运行示意图如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SeO4v4aQ-1609081666109)(JVM.assets/16289066-de620659288f79f7.jpg)]

  • 优点: 追求高吞吐量,高效利用CPU,是吞吐量优先,且能进行精确控制。
  • 适用场景:注重吞吐量高效利用CPU,需要高效运算,且不需要太多交互。
  • 参数:
    • -XX:MaxGCPauseMilis。 控制最大垃圾收集停顿时间,参数值是一个大于0的毫秒数,收集器尽可能保证回收花费时间不超过设定值。但将这个值调小,并不一定会使系统垃圾回收速度更快,GC停顿时间是以牺牲吞吐量和新生代空间换来的。
    • -XX:GCTimeRadio。设置吞吐量大小,参数值是一个(0,100)两侧均为开区间的整数。也是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。若把参数设置为19,则允许的最大GC时间就占总时间的5%(1/(1+19))。默认值是99,即允许最大1%的垃圾收集时间。
    • -XX:+UserAdaptiveSizePolicy。这是一个开关函数,当打开这个函数,就不需要手动指定新生代的大小,Eden与Survivor区的比例(-XX:SurvivorRatio,默认是8:1:1),晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等参数。JVM会动态调整这些参数,以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略.

7.2老年代垃圾收集器

7.2.1Serial Old 收集器

概念:Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。下图是Serial收集器与Serial Old收集器的运行示意图。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9BDkPTeq-1609081666110)(JVM.assets/16289066-d9eada37830dbbc9.jpg)]

7.2.2Parallel Old收集器

概念:Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,可以充分利用多核CPU的计算能力。下图是两种收集器合作的运行示意图

  • 适用场景:注重吞吐量与CPU资源敏感的场合,与Parallel Scavenge 收集器搭配使用,jdk7和jdk8默认使用该收集器作为老年代收集器。
7.2.3CMS收集器

概念:CMS收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现的,是一种老年代收集器,通常与ParNew一起使用。运作过程分为四个步骤

CMS的垃圾收集过程分为4步:

  • 初始标记:需要“Stop the World”,初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快。

  • 并发标记:是主要标记过程,这个标记过程是和用户线程并发执行的。

  • 重新标记:需要“Stop the World”,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(停顿时间比初始标记长,但比并发标记短得多)。

  • 并发清除:和用户线程并发执行的,基于标记结果来清理对象。

  • 优点:并发收集,低停顿

  • 对CPU资源非常敏感,因为并发标记和并发清理阶段和用户线程一起运行,当CPU数变小时,性能容易出现问题。

  • 收集过程中会产生浮动垃圾,所以不可以在老年代内存不够用了才进行垃圾回收,必须提前进行垃圾收集。通过参数**-XX:CMSInitiatingOccupancyFraction的值来控制内存使用百分比。如果该值设置的太高,那么在CMS运行期间预留的内存可能无法满足程序所需,会出现Concurrent Mode Failure失败,之后会临时使用Serial Old收集器做为老年代收集器**,会产生更长时间的停顿。

    • 浮动垃圾:由于在应用运行的同时进行垃圾回收,所以有些垃圾可能在垃圾回收进行完成时产生,这样就造成了**“Floating Garbage”**,这些垃圾需要在下次垃圾回收周期时才能回收掉。所以,并发收集器一般需要20%的预留空间用于这些浮动垃圾。
  • 因为CMS采用的是标记清除算法,因此垃圾回收后会产生空间碎片。通过参数可以进行优化。

-XX:UserCMSCompactAtFullCollection #开启碎片整理(默认是开的)
-XX:CMSFullGCsBeforeCompaction #执行多少次不压缩的Full GC之后,跟着来一次压缩的Full GC

适用场景:重视服务器响应速度,要求系统停顿时间最短。可以使用参数-XX:+UserConMarkSweepGC来选择CMS作为老年代回收器。

7.3新生代和老年代垃圾收集器

7.3.1G1收集器

概念: G1收集器是一款面向服务端应用的垃圾收集器,目前是JDK9的默认垃圾收集器。

G1收集器将这个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但两者之间不是物理隔离的。他们都是一部分Region的集合。下图是Java堆的划分示意图。

过程:

  • 初始标记。标记出GC Roots直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行。
  • 并发标记。从 GC Root 开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。
  • 最终标记。修正在并发标记阶段引用户程序执行而产生变动的标记记录。
  • 筛选回收。选回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是 Garbage First ,第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。

特点:

  • 并行与并发。G1能充分利用多CPU,多核环境下的硬件优势。
  • 分代收集。能够采用不同的方式去处理新创建的对象和已经存活了一段时间的对象,不需要与其他收集器进行合作。
  • 空间整合。G1从整体上来看基于“标记-整理”算法实现的收集器,从局部上看是基于复制算法实现的,因此G1运行期间不会产生空间碎片。
  • 可预测的停顿。G1能建立可预测的时间停顿模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

适用场景:要求尽可能可控 GC 停顿时间;内存占用较大的应用。可以用 -XX:+UseG1GC 使用 G1 收集器,jdk9 默认使用 G1 收集器

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值