Java8 JVM运行时数据区概述 (极其详细长文)

运行时数据区概述

b36ed71d7ad95f2840950edb73b5464a.png

7f28cb5876b790d7503e6e72e27acc9c.png

程序计数器(Program Counter Register)

是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。

虚拟机栈 (Stack Area)

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

本地方法栈 (Native Method Area)

JVM 中的栈包括 Java 虚拟机栈和本地方法栈,两者的区别就是,Java 虚拟机栈为 JVM 执行 Java 方法服务,本地方法栈则为 JVM 使用到的
Native 方法服务。

堆 (Heap Area)

堆是Java虚拟机所管理的内存中最大的一块存储区域。堆内存被所有线程共享。主要存放使用new关键字创建的对象。所有对象实例以及数组都要在堆上分配。垃圾收集器就是根据GC算法,收集堆上对象所占用的内存空间。

Java堆分为年轻代(Young Generation)和老年代(Old
Generation);年轻代又分为伊甸园(Eden)和幸存区(Survivor区);幸存区又分为From Survivor空间和 To
Survivor空间。

方法区(Method Area)、 元空间区(MetaSpace)

方法区同 Java
堆一样是被所有线程共享的区间,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。更具体的说,静态变量+常量+类信息(版本、方法、字段等)+运行时常量池存在方法区中。常量池是方法区的一部分。

JDK 8 使用元空间 MetaSpace 代替方法区,元空间并不在JVM中,而是在本地内存中

在运行时数据区中包括那几个区域?

1、线程私有区域:1. 程序计数器 2. 虚拟机栈 3. 本地方方法栈

2、线程共享区域:4. 方法区(元空间) 5. 堆

JVM中的线程说明

线程是一个程序中的运行单元,JVM允许一个应用有多个线程并行的执行任务。

在Hotspot
JVM中,每个线程都与操作系统的本地线程之间映射,当一个Java线程准备好执行后,此时一个操作系统的本地线程也会同时创建,Java线程执行终止后,本地线程也会回收。

操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程的run()方法。

JVM线程的主要几类:

  • 虚拟机线程: 这种线程的操作是需要JVM到达安全点才会出现,这些操作必须在不同的线程中发生的原因是它们都要到达安全点,这样堆才不会发生变化。这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁的撤销。
  • 周期任务线程: 这种线程是时间周期事件的体现(比如中断),它们一般用于周期性操作的调度执行。
  • GC线程: 这种线程对在JVM中不同类的垃圾收集行为提供了支持。
  • 编译线程: 这种线程在运行时会将字节码编译成本地代码。
  • 信号调度线程: 这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。

PC寄存器(PC Register)

PC寄存器介绍

JVM中的程序计数器(Program Counter
Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能运行。

这里,并非广义上所指的物理寄存器,或许将其翻译为PC寄存器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

0637652dd597c13e93b107c75189eae9.png

PC寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码,由执行引擎读取下一条指令。

  • 它是一块很小的内存空间,也是运行速度最快的存储区域。
  • 在JVM规范中,每个线程都有它自己的PC寄存器,是线程私有的,生命周期与线程的生命周期保持一致。
  • 任何时间一个线程都只有一个方法执行,也就是所谓的当前方法。PC寄存器会存储当前线程正在执行的Java方法的JVM指令地址,如果执行的native方法,则是undefined。
  • 它是程序控制流中的指示器、分支、循环、跳转、异常处理、线程恢复等基础功能都需要PC寄存器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 它是唯一一个在Java虚拟机规范中,没有规定任何OutOfMemoryError的区域。

使用举例

public class PCRegister {

    public static void main(String[] args) {
        int i = 20;
        int j = 30;
        int k = i + j;
        String str = "hello";
        System.out.println(str);
    }

}

我们使用jclasslib看一下编译后:

0369413f98485fb7b31c42e03358750f.png

左侧是数字其实就是偏移地址,PC寄存器就是存储的这个,而右侧就是指令。

前面操作比较简单,其实就是将常量值20压入栈然后存入索引1的位置,然后将常量值30压入栈然后存入索引2,然后取出1,2,相加之后存入索引3。

我们重点说一下后面的操作,偏移地址10的位置。

ldc:将int, float或String型常量值从常量池中推送至栈顶。
而后面的#2的位置从下图常量池中,我们可以看到对应的是String,它又关联了#27#27对应的UTF-8
字符串为:hello。存入索引4的位置。但是我们发现偏移地址从10跳到了12,就是因为我们在ldc中执行了两步操作。

getstatic:获取静态变量引用,并将其引用推到操作数栈中。
我们可以看到它对应的常量池#3对应的属性 #28.#29 两个,#28对应的是Class 找到#34
我们可以看到是java.lang.System,#29对应了#35#36,也就是out和printStream。

然后读取aload 4 也就是str的值进行输入,最后return结束。

4fa5afe703177407b2e09543e1f14e49.png

局部变量表,操作数栈都是由执行引擎来操作的,再翻译成机器指令来操作cpu。

问题:使用PC寄存器存储字节码指令地址有什么用?为什么使用PC寄存器存储?

因为CPU需要不停的切换各个线程,这个时候切换回来后,需要知道从哪里接着继续执行。

JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

问题:为什么是线程私有?

多线程在一个特定的时间段内指挥执行其中某一个线程的方法,CPU会不停地做任务切换,这必然会导致经常中断或者恢复。

简单来说就是方便各个线程之间可以独立计算,不会出现相互干扰的问题。

虚拟机栈

概述

每个线程都会有一个虚拟机栈,多线程就会有多个虚拟机栈。是线程私有,虚拟机栈里面是一个一个的栈帧(Stack
Frame),每一个栈帧都是在方法执行的同时创建的,描述的是Java方法执行的内存模型。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。栈是先进后出的。

作用是主管Java程序的运行,它保存方法的局部变量、部分结果、并参与方法的调用与返回。

在活动线程中,只有一个栈帧是处于活跃状态的,也就是说只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

优点:跨平台,指令集小,编译器容易实现。

缺点:性能下降,实现同样的功能需要更多的指令。

虚拟机栈可能抛出的异常

  • 若是固定大小的JAVA虚拟机栈,那每一个线程的JAVA虚拟机栈容量可以在线程创建的时候独立选定,如果线程请求分配的栈容量超过JAVA虚拟机栈允许的最大容量,JAVA虚拟机将会抛出一个StackOverflowError错误。
  • 若是JAVA虚拟机栈可以动态扩展,并且在尝试扩展时的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建相应的虚拟机栈,那JAVA虚拟机将会抛出一个OutofMemroyError错误。

解决方案:

使用参数 -Xss 选项来设置线程最大栈空间,栈的大小直接决定了函数调用的最大可达深度。

在启动参数加入 -Xss256k 或者随意大小。

7c9ee8396a9317407bcb2f97f236fb9f.png

栈的存储单位

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈运行原理

  • 虚拟机栈的操作只有两个就是压栈出栈,遵循后进先出原则。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(位于栈顶)是有效的,这个栈帧被称为当前栈帧(Current Frame),定义这个方法的类就是当前类(Current Class)
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  • 如果在该方法调用了其它方法,对应新的栈帧就会被创建出来,压栈后成为新的当前栈帧。
  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另一个线程的栈帧。
  • 如果当前方法调用了其它方法,方法返回之际,当前栈帧会传回方法的执行结果给前一个栈帧,然后JVM丢弃掉当前栈帧,之后前一个栈帧变为栈顶的栈帧。
  • Java有两种返回函数的方式,一种是正常函数返回,一种是抛出异常返回,不管哪一种都会导致栈帧弹出。

栈帧的内部结构

分为五大类:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)
  • 动态链接(Dynamic Linking)
  • 方法返回地址(Return Address)
  • 一些附加信息

d54f8c25a064b39ed640fe08c863ca50.png

局部变量表(Local Variables)

  1. 局部变量表也被称之为局部变量数组或本地变量表。
  2. 定义为一个数字数组,主要用于存储方法参数和定义在方体内的局部变量,这些数据包含基本数据类型,对象引用,以及returnAddress类型。
  3. 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据的安全问题
  4. 局部变量表所需的容量大小是在编译期间确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表大小的。
  5. 方法嵌套调用的次数由栈的大小来决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增加的需求。进而函数调用就会占用更多的栈空间,导致其嵌套的次数就会减少。
  6. 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  7. 局部变量表中最基本的存储单元是Slot(变量槽)

关于Slot的理解

  1. 在局部变量表中,32位以内的类型占一个Slot,64位的类型占用两个Slot。
  2. JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
  3. 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。
  4. 如果需要访问局部变量表中的一个64位的局部变量值时,只需要使用前一个索引即可。
  5. 如果当前帧是由构造方法或者实例方法创建的,那么该对象的引用this将会存放在index为0的Slot处,其余参数按照顺序排列。

c548413005e9016eae1d8056486a4e96.png

代码小例子:

public String test(Date dateP,String name2){
        dateP = null;
        name2 = "Jack";
        double weight = 1.1;
        char gender = '男';
        return dateP + name2;
}

我们使用jclasslib来看的话可以看到Index也就是Slot,我们发现3也就是double是占了两个Slot的。

c06d862499b5fc80818e1b8904b018a3.png

操作数栈

  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也称之为表达式栈
  • 操作数栈,在方法执行的过程中,根据字节码指令、往栈中写入或取出数据,即入栈/出栈
  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,进行操作之后再将结果压入栈。
  • 比如:复制、交换、求和等操作。

95edc5d2ef95e3b761bcd3acbf6f84ee.png

  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  • 操作数栈是JVM执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧就会随之创建出来,这个时候方法的操作数栈是空的。
  • 每一个操作数栈都会有一个明确的栈深度用于存储数值,其所需的最大深度在编译期间就已经定义好了,保存在方法的Code属性中,为max_stack的值。
  • 栈中任意一个元素都可以是任意的Java数据类型。
    • 32bit占用一个栈单位深度
    • 64bit占用二个栈单位深度

操作数栈的字节码指令分析

首先我们创建如下简单的代码:

public class OperandStackTest {

    public void testAddOperand(){
        byte i = 15;
        int j = 8;
        int k = i + j;

    }

}

使用jclasslib反编译以后我们看到如下指令:

Code:
    stack=2, locals=4, args_size=1
        0 bipush 15
        2 istore_1
        3 bipush 8
        5 istore_2
        6 iload_1
        7 iload_2
        8 iadd
        9 istore_3
        10 return

在标注灰色的地方,我们看一看到指令地址是0,所以右侧的PC寄存器就是0,bipush操作就是将常量值15存入我们的操作数栈的栈顶,现在局部变量表中还是初始化的状态。

8a818ebecd93b3aad0fe7cb0f3e96b99.png

当指令执行到了2的位置,PC寄存器里存放的就是2,执行的istore指令,将操作数栈中数据取出存入对应的局部变量表中。

fe7542c303341f81fab2b6ea088a9bfe.png

当指令执行到了3的位置,PC寄存器里存放的就是3,bipush操作就是将常量值8存入我们的操作数栈的栈顶,现在局部变量表中只有对应下标为i的值为15。

615720eb9682051a0d775fd13ddec9c7.png

当指令执行到了5的位置,PC寄存器里存放的就是5,执行的istore指令,将操作数栈中数据取出存入对应的局部变量表中。

0bd621c2b6e4c9a1b4f660510d52b9d8.png

当指令执行到了6的位置,PC寄存器里存放的就是6,执行的iload指令,将局部变量表中的数据取出存入操作数栈的栈顶。(指令地址7也同理)

737c654ec1c0a2e0153e9228e02b2207.png

65bd6a216798f301602cf7441fc15671.png

当指令执行到了8的位置,PC寄存器里存放的就是8,执行的iadd指令,将栈顶的两个数据取出进行相加,将结果存入操作数栈栈顶。(相加操作由执行引擎将字节码指令来翻译成机器指令来操作cpu。)

d1cda4b86127d97af5cdd2d8986ba5d0.png

当指令执行到了9的位置,PC寄存器里存放的就是9,执行的istore指令,将栈顶的元素取出存入对应的局部变量表中。

8e5835569eb854c8649b98cd5ac56ea9.png

stack=2, locals=4, args_size=1

stack对应的就是我们的操作数栈的深度。

locals对应的就是我们的局部变量表的长度。

args_size对应的就是参数的长度,静态代码块为0。

动态链接(或指向运行时常量池的方法引用)

8b03fe9e3bcb3eae568574a6f042ac5b.png

  • 每一个栈帧内部都包含一个指向运行时常量池中栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如:invokedynamic指令

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

    public class DynamicLinkingTest {

    int num = 10;
    
    public void methodA() {
        System.out.println("methodA...");
    }
    
    public void method() {
        System.out.println("methodB...");
        methodA();
        num++;
    }
    

    }

使用jclasslib反编译以后我们看到如下指令:

Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokevirtual #2                  // Method a:()V
         4: return

invokevirtual 后面的#2符号引用对应的就是Constant
pool中的直接引用。#2对应了方法引用,#3#17……最终对应到方法A()

Constant pool:
   #1 = Methodref          #4.#16         // java/lang/Object."<init>":()V
   #2 = Methodref          #3.#17         // com/suanfa/jvm/OperandStackTest.a:()V
   #3 = Class              #18            // com/suanfa/jvm/OperandStackTest
   #4 = Class              #19            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lcom/suanfa/jvm/OperandStackTest;
  #12 = Utf8               a
  #13 = Utf8               b
  #14 = Utf8               SourceFile
  #15 = Utf8               OperandStackTest.java
  #16 = NameAndType        #5:#6          // "<init>":()V
  #17 = NameAndType        #12:#6         // a:()V
  #18 = Utf8               com/suanfa/jvm/OperandStackTest
  #19 = Utf8               java/lang/Object

方法调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关。

静态链接

当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期间保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

动态链接

如果被调用方法在编译期间无法被确定下来,只能在程序运行时将调用方法的符号引用转换为直接引用,由于这种引用转换的过程具备动态性,因此也就被称为动态链接。

对应的绑定机制为:早期绑定(Early Binding)、晚期绑定(Late
Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用,这个过程仅发生一次。

早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法所属的类型进行绑定,这样一来,由于明确了被调用方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用替换为直接引用。

晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关方法这种绑定就叫做晚期绑定。

方法的调用:虚方法与非虚方法

如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称之为非虚方法。

静态变量、私有方法、final方法、实例构造器、父类方法都是非虚方法。

其它方法称之为虚方法、

普通调用指令:

  1. invokestatic : 静态方法,解析阶段确定唯一方法版本
  2. invokespecial : 调用<init>方法、私有方法以及父类方法,解析阶段确定唯一方法版本
  3. invokevirtual : 调用所有虚方法
  4. invokeinterface : 调用接口方法

动态调用指令:

  1. invokedynamic : 动态解析所需要调用的方法,然后执行

前四条指令固化在虚拟机的内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定版本。其中invokevirtual和invokestatic指令调用的方法称为非虚方法,其余的(final修饰除外)称为虚方法。

/**
 * 解析调用中非虚方法、虚方法的测试
 */
class Father {
    public Father(){
        System.out.println("Father默认构造器");
    }

    public static void showStatic(String s){
        System.out.println("Father show static"+s);
    }

    public final void showFinal(){
        System.out.println("Father show final");
    }

    public void showCommon(){
        System.out.println("Father show common");
    }

}

public class Son extends Father{
    public Son(){
        super();
    }

    public Son(int age){
        this();
    }

    public static void main(String[] args) {
        Son son = new Son();
        son.show();
    }

    //不是重写的父类方法,因为静态方法不能被重写
    public static void showStatic(String s){
        System.out.println("Son show static"+s);
    }

    private void showPrivate(String s){
        System.out.println("Son show private"+s);
    }

    public void show(){
        //invokestatic
        showStatic(" 大头儿子");
        //invokestatic
        super.showStatic(" 大头儿子");
        //invokespecial
        showPrivate(" hello!");
        //invokespecial
        super.showCommon();
        //invokevirtual 因为此方法声明有final 不能被子类重写,所以也认为该方法是非虚方法
        showFinal();
        //虚方法如下
        //invokevirtual
        showCommon();//没有显式加super,被认为是虚方法,因为子类可能重写showCommon
        info();

        MethodInterface in = null;
        //invokeinterface  不确定接口实现类是哪一个 需要重写
        in.methodA();

    }

    public void info(){

    }

}

interface MethodInterface {
    void methodA();
}

invokedynamic指令

  • invokedynamic指令是在JDK7中增加的,为了实现动态类型语言支持而做的一种改进。
  • 但是在JDK7中并没有提供直接生成的invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到JDK8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接生成的方式。

方法返回地址(Return Address)

  • 存放调用该方法的PC寄存器的值。
  • 一个方法的结束要么正常执行结束,要么出现未处理异常,非正常退出。
  • 无论哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC寄存器的值作为返回地址,即调用该方法的下一条指令地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中不会保存这部分信息。

区别在于,通过异常完成的出口不会给它上层调用者产生任何的返回值

栈的相关面试题

  • 举例栈溢出的情况?栈帧存放空间不足导致出现StackOverflowError异常。通过 -Xss设置栈的大小。
  • 调整栈的大小,就能保证不出现溢出吗?不能保证。
  • 分配的栈内存越大越好吗?不是。在同一台机器上,如果jvm设置的内存过大,就会导致其它程序所占用的内存小。比如elasticsearch、kafka,虽然它们都是基于jvm运行的程序(java和scala都是依赖于jvm),但是它们的数据不是放到jvm内存中,而是放到os cache中(操作系统管理的内存区域),避免了jvm垃圾回收的影响。
  • 垃圾回收是否会涉及到虚拟机栈?
    不会
运行时数据区ErrorGC
  • 方法中定义的局部变量是否线程安全?不一定,可能会发生方法逃逸。

    public StringBuilder escapeDemo1() {
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append(“a”);
    stringBuilder.append(“b”);
    return stringBuilder;
    }

方法逃逸:在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。

本地方法栈

什么是本地方法?

一个Native Method是这样的Java方法:该方法的实现由非Java语言实现,比如C。

本地方法的作用就是为了融合不同编程语言为Java所用。

使用native关键字修饰的方法就是本地方法。

19968dc6c34aae5eb47c6a5e4cd1cd57.png

本地方法栈简介

  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
  • 本地方法栈也是线程私有的。
  • 允许被实现成固定或者可动态扩展的内存大小。
  • 本地方法是用C语言实现的。
  • 具体做法就是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

98385e986cf19c5d65a5ac483990271f.png

  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。2. 它甚至可以直接使用本地处理器中的寄存器。3. 直接从本地内存的堆中分配任意数量的内存。

概述

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
  • 堆内存的大小是可以调节的。

《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区( ThreadLocal Allocation Buffer, TLAB) 。
  • 《Java虛拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area fromwhich memory for all class instances and arrays is allocated )
  • 我要说的是:“几乎”所有的对象实例都在这里分配内存。从实际.使用角度看的,数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
  • 堆是GC ( Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

堆的核心概述

91d45f315602d9cf5e5903e8604f2997.png

堆空间大小的设置

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项"-Xmx"和"-Xms"来进行设置。

  • "-Xms"用 于表示堆区的起始内存,等价于-XX: InitialHeapSize
  • “-Xmx” 则用于表示堆区的最大内存,等价于-XX :MaxHeapSize
  • 一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。
  • 通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
  • 默认情况下,初始内存大小:物理电脑内存大小 / 64,最大内存大小:物理电脑内存大小 / 4。

年轻代和老年代

存储在JVM中的Java对象可以分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
  • 另外一类对象的生命周期较长,在某些极端的情况下还能够与JVM的生命周期保持一致。

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)

其中年轻代又划分为Eden空间、Survivor0和Survivor1空间(也叫做from区、to区)。

94011a3041ce8f3085c2d199017850db.png

在Hotspot中,Eden空间和Survivor0和Survivor1空间缺省比例是8:1:1。

也可以使用"-XX:SurvivorRatio" 调整这个空间比例。比如-XX:SruvivorRatio=8。

几乎所有的Java对象都在Eden区被new出来的。

6f6fe6999b7c7042c713f9f3631f97a1.png

对象的分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

1、new的对象先放在Eden区,此区域有大小限制。

2、当Eden的空间填满时,程序又需要创建新的对象,JVM的垃圾回收器将对Eden区不再被其它对象所引用的对象进行销毁。再加载新的对象放到Eden。

3、 然后将Eden区剩余的对象移动到Survivor0区。

56ef2f6291f790515c1686222e5e056c.png

4、如果再次触发垃圾回收,此时上次幸存下来的放在Survivor0区,如果没有回收,就会放到Survivor1区。

eec84534fd7644c440042f0a10fc6481.png

5、 如果再次经历垃圾回收,此时会重新放回Survivor0区,接着再去Survivor1区。

6、 当"年龄"到达15之后就会被放到old区。可以设置参数:-XX:MaxTenuringThreshold=<N>

d6982d8cd0e8e67284121cd7092db7e7.png

7、 当old区内存不足时,再次触发 GC:Major GC,进行old区内存清理。

8、 如果old区在进行了GC后依然无法进行对象的保存,就会产生OOM异常。

关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

分配内存的特殊情况

如果对象一开始就过大,如果Eden区放不下的话会直接放入old区。

如果old区也放不下,则会发生Full GC 。如果GC后还是放不下则会报错OOM。

f215b13b933e6bc3f354758fb40f2d7c.png

Minor GC、Major GC、 Full GC对比

JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代;方法区/元空间)区域一起回收的,大部分时候回收的手是指新生代。

针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大类,一种是部分收集(Partial GC),一种是完整收集(Full GC)。

  • 部分收集
    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集。
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
    • 混淆收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。

目前只有CMS GC会有单独收集老年代的行为。

注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。

目前只有G1 GC会有这种行为。

  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

年轻代GC(Minor GC)触发机制

当年轻代空间不足时候,就会触发Minor GC,这里的年轻代满指的是Eden区满,Survivor满不会触发GC。(每次Minor
GC就会清理Eden区内存)

因为Java对象大多都具备朝生夕灭的特性,所以 Minor GC非常频繁,一般回收速度也比较快。

Minor GC会引发STW,暂停其它用户线程,等垃圾回收结束,用户线程才会恢复运行。

老年代GC(Major GC/Full GC)触发机制

指发生在老年代的GC,对象从老年代消失时,我们说”Major GC“ 或者 ”Full GC”发生了。

出现了Major GC,经常会伴随至少一次Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major
GC的策略选择过程)。

也就是在老年代空间不足时,先尝试触发Minor GC,如果之后空间还是不足,则触发Major GC。

Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。

如果Major GC后内存还不足就会报OOM了。

Full GC触发机制

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行。
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区、Survivor0向Survivor1区复制时,对象大小大于Survivor1可用内存,则把对象转移到老年代,且老年代的可用内存小于该对象大小时。

说明:在开发中尽量避免 Full GC,这样STW时间会更短

TLAB

为什么要有TLAB(Thread Local Allocation Buffer)

堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。

由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是不安全的。

为避免多个线程操作统一地址,需要使用加锁等机制,进而影响分配速度。

a48fc4135c99107b9477e9a878b15cfd.png

什么是TLAB

从内存模型而不是垃圾收集的角度,对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden区内。

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

TLAB说明

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM的确是将TLAB作为内存分配的首选

在程序中,开发人员可以通过选项"-XX:UseTLAB"设置是否开启TLAB空间。

默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,可以通过"-XX:TLABWasteTargetPercent"设置TLAB空间所占用Eden空间的百分比大小。

一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

2cfa8f6dba830e2547d4bf9e756835df.png

堆是分配对象存储的唯一选择吗?

  • 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析( Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
  • 此外,基于 OpenJDK深度定制的 TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低Gc的回收频率和提升GC的回收效率的目的。

逃逸分析手段

  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段
  • 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
  • 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其它地方中。

14045ca929c9f62de4b7b534c2ff088e.png

结论:开发中能用局部变量的,就不要使用在方法外定义。

方法区

运行时数据区图解

b6aef2f790377172ae7a3aac0fbf1464.png

栈、堆、方法区、的交互关系

4e53d07ce4332dd2c3aac3c8041bba7a.png

方法区基本理解

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”
但对于HotspotJVM而言,方法区还有一个别名叫做Non-Heap,目的就是要和堆分开。

所以方法区看作是一块独立于Java堆的内存空间。

fca4ebf7c625e001e58ad22d0a6d246b.png

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,和堆空间一样,可以选择固定大小和可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机就会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang.OutOfMemoryError: Metaspace。
  • 关闭JVM就会释放这个区域的内存。

设置方法区内存大小

JDK7及以前(永久代):

  • 通过"-XX:PermSize"设置永久代初始分配空间,默认值20.75M。
  • "-XX:MaxPermSize"来设置永久代最大可分配空间。32位机器默认是64M,64位机器默认是82M。
  • 当JVM加载的类信息容量超过了这个值,则会报出OutOfMemoryError:Permgen Space。

JDK8(元空间):

  • 元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,替代上述原有的两个参数。

  • 默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1, 即没有限制。

  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。 如果元数据区发生溢出,虚拟机一样会拋出异常OutOfMemoryError: Metaspace。

  • -XX:MetaspaceSize: 设置初始的元空间大小。对于一个64位的服务器端JVM来说, 其默认的-XX:MetaspaceSize值为21MB.这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

    jdk7及以前:
    查询 jps -> jinfo -flag PermSize [进程id]

    -XX:PermSize=100m -XX:MaxPermSize=100m

    jdk8及以后:

    查询 jps -> jinfo -flag MetaspaceSize [进程id]

    -XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m

解决报错OOM:(内存泄漏、内存溢出)

  1. 要解决00M异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory 0verflow)。
  2. 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链(堆当中的闲置对象由于引用链的引用关系无法被回收,虽然它已经属于闲置的资源)。于是就能找到泄漏对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  3. 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(一Xmx与一Xms) ,与机器物理内存对比看是否还可以调大,从代码_上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

代码案例:

/**
 * jdk6/7中:
 * -XX:PermSize=10m -XX:MaxPermSize=10m
 *
 * jdk8中:
 * -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
 *
 */
public class OOMTest extends ClassLoader {

    public static void main(String[] args) {
        int j = 0;
        try {
            OOMTest test = new OOMTest();
            for (int i = 0; i < 10000; i++) {
                //创建ClassWriter对象,用于生成类的二进制字节码
                ClassWriter classWriter = new ClassWriter(0);
                //指明版本号,修饰符,类名,包名,父类,接口
                classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                //返回byte[]
                byte[] code = classWriter.toByteArray();
                //类的加载
                test.defineClass("Class" + i, code, 0, code.length);//Class对象
                j++;
            }
        } finally {
            System.out.println(j);
        }
    }
}

方法区的内部结构

77da1c560e4221feea4e5bf5bd917c72.png

《深入理解Java虚拟机》书中对方法区存储内容描述如下:它用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

819129678fbb1a37470babdb80b65498.png

类型信息

对每个加载的类型( 类class、接口interface、枚举enum、注解annotation),JVM必 .须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)。
  2. 这个类型直接父类的完整有效名(对于interface或是java. lang.Object,都没有父类)。
  3. 这个类型的修饰符(public, abstract, final的某个子集)。
  4. 这个类型直接接口的一个有序列表。

域(Field)信息

  1. JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  2. 域的相关信息包括:域名称、 域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)。

方法信息(method)

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  1. 方法名称。
  2. 方法的返回类型(或void)。
  3. 方法参数的数量和类型(按顺序)。
  4. 方法的修饰符(public, private, protected, static, final, synchronized, native , abstract的一个子集)。
  5. 方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native 方法除外)。
  6. 异常表( abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。

non-final的类变量(非声明为final的static静态变量)

  1. 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
  2. 类变量被类的所有实例所共享,即使没有类实例你也可以访问它。

全局常量(static final)

被声明为final的类变量的处理方法则不同,每个全局常量在编译的
时候就被分配了。

public static int count = 1;
public static final int number = 2;

反编译后就可以看到如下代码:

public static int count;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC

  public static final int number;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 2

文件中常量池的理解

7003fe38c77c7efceccad792597a2f55.png

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Poo1
Table),包括各种字面量和对类型域和方法的符号引用。

一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java
中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池;而这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。

小结:字节码当中的常量池结构(constant pool),可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息。

4fa5afe703177407b2e09543e1f14e49.png

运行时常量池

  • 运行时常量池( Runtime Constant Pool)是方法区的一部分。
  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。

运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。

  • 运行时常量池类似于传统编程语言中的符号表(symbol table) ,但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

方法区的演进细节

首先明确:只有HotSpot才有永久代。 BEA JRockit、IBM
J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虛拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

Hotspot中 方法区的变化:

  • jdk1.6及之前:有永久代(permanent generation) ,静态变量存放在 永久代上。
  • jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中。
  • jdk1.8及之后: 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍留在堆空间。

a2e90bbf21c437a46c0d3de514d75c87.png

41624a67d2d3c6b528b6758711cbacb6.png

d806314e2ff3fa1548ae02891990d1ae.png

永久代为什么要被元空间替换

  • 随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类.的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace )。
  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因有:

  1. 为永久代设置空间大小是很难确定的。 在某些场景下,如果动态加载类过多,容易产生Perm区(永久代)的O0M。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。 “Exception in thread’ dubbo client x.x connector’java.lang.OutOfMemoryError:PermGenspace” 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
  2. 对永久代进行调优是很困难的。

StringTable为什么要调整

  • jdk7中将StringTable放到了堆空间中,正确。
  • 因为永久代的回收频率很低,在Full GC的时候才会触发。而Full GC是老年代的空间不足、永久代不足时才会触发。这就导致了StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存.

方法区的垃圾回收

有些人认为方法区(如Hotspot,虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java
虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如
JDK11 时期的ZGC 收集器就不支持类卸载)。

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug
列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 Hotspot 虚拟机对此区域未完全回收而导致内存泄漏。

方法区的垃圾收集主要回收两部分内容:常量池中废奔的常量不再使用的类型。

常量池中废奔的常量

  • 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。 字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念。

常量池中包括下面三类常量:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符
  • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。

常量池中不再使用的类型

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虛拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了一Xnoclassgc
参数进行控制,还可以使用一verbose:class以及一XX: +TraceClass一Loading、一XX:+TraceClassUnLoading查
看类加载和卸载信息。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

运行时数据区总结

ec20f45229ea2a91259b13e9991c1c35.png

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值