知道垃圾回收?你不懂Java运行时内存区域划分,你配称自己熟悉JVM?

本文详细介绍了Java运行时内存的划分,包括程序计数器、虚拟机栈、本地方法栈、堆、方法区(JDK8后变为元空间)和直接内存。每个区域的作用、线程安全性以及可能导致的错误如StackOverflowError和OutOfMemoryError都进行了阐述。此外,还讨论了为何从永久代移除并引入元空间的原因,以及各个区域的生命周期和线程私有或共享的依据。
摘要由CSDN通过智能技术生成

概述

对 Java 来说其一大特色便是其方便的自动内存管理机制,而这一机制实现的基础依赖于两点:

  1. “垃圾回收算法”
  2. 内存区域划分

我们这里着重学习第二点 Java 内存区域的划分。我之前的博客有说垃圾回收算法的文章,有兴趣的朋友可以去我的主页看看!

1、基本介绍:

运行时数据区通常包括这5个部分:程序计数器(Program Counter Register)、Java栈(VM Stack)、本地方法栈(Native Method Stack)、方法区(Method Area)、堆(Heap)。

2、程序计数器:【PC寄存器,每个线程都有一个单独的PC寄存器】

  • 是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器来完成。
  • 程序计数器(Program Counter Register),也有称作为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址,一旦该指令被执行,pc寄存器会被更新至下条指令的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。
  • 虽然JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示 执行哪条指令的。
  • 由于在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的。
  • 在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。
  • 由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

3、java虚拟机栈:【每个线程都会有一个自己的Java栈,线程安全】

  • Java栈也称作虚拟机栈(Java Vitual Machine Stack),也就是我们常常所说的栈。
  • Java栈中存放的是一个个的栈帧(Stack Frame),每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息
  • 当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。
  • 讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。【递归在自己方法还没执行完的时候又再次去调用自身方法,再次创建了一个栈帧】
  • 由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java虚拟机栈,互不干扰。
  • 下图表示了一个Java栈的模型:

在这里插入图片描述

3.1、局部变量表(Local Variable Table):
  • 存储方法中的局部变量(包括在方法中声明的非静态变量)、函数形参。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用
  • 在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。
  • 变量槽(Variable Slot):
    • 局部变量表的容量以变量槽为最小单位,每个变量槽都可以存储32位长度的内存空间,例如boolean、byte、char、short、int、float、reference。
    • 对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写。
    • 对于引用对象实例来说,虚拟机都能从引用中直接或者间接的查找到对象的以下两点 :在Java堆中的数据存放的起始地址索引。所属数据类型在方法区中的存储的类型数据。
    • 在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用。(在方法中可以通过关键字this来访问到这个隐含的参数)。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot。
    • 变量槽Slot复用:
      • 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,也就是说当PC计数器的指令指已经超出了某个变量的作用域(执行完毕),那这个变量对应的Slot就可以交给其他变量使用。
      • 优点 : 节省栈帧空间。
      • 缺点 : 影响到系统的垃圾收集行为。(如大方法占用较多的Slot,执行完该方法的作用域后没有对Slot赋值或者清空设置null值,垃圾回收器便不能及时的回收该内存。)
3.2、操作数栈:
  • 如果需要执行任何中间操作,操作数栈作为运行时工作区去执行指令。
  • 操作数栈和局部变量表一样,在编译时期就已经确定了该方法所需要分配的操作数栈的最大容量。
  • 操作数栈的每一个元素可用是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型占用的栈容量为2。
  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。
  • 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其它方法的时候是通过操作数栈来进行参数传递的。
3.3、指向运行时常量池的引用:
  • 因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
3.4、方法返回地址:
  • 当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
  • 当一个方法开始执行后,只有2种方式可以退出这个方法 :
    • 异常退出:在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。
    • 方法返回指令:执行引擎遇到一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。
  • 无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。
  • 一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。
  • 而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

4、本地方法栈:【每个线程都会有一个自己的本地方法栈】

  • 本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。
  • 本地方法栈保存本地方法信息。对每一个线程,将创建一个单独的本地方法栈。

5、堆:【每个JVM同样只有一个堆区,线程共享,线程不安全,主内存】

  • 如果是成员变量,或者定义在方法外对象的引用,它们存储在堆中。
  • Java中的堆是用来存储对象本身,它们相应的成员变量,以及数组(当然,数组引用是存放在Java栈中的)。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的,在JVM中只有一个堆。
  • -Xms:初始堆大小即最小内存值
  • -Xmx:堆最大可用大小
  • -Xmn:新生代空间的大小
  • -XXSurvivorRatio:定义了新生代中Eden区域和Survivor区域(From幸存区或To幸存区)的比例,默认为8,也就是说Eden占新生代的8/10,From幸存区和To幸存区各占新生代的1/10。
计算公式: 
		Eden = (R*Y)/(R+1+1) 
		From = Y/(R+1+1) 
		To = Y/(R+1+1) 

		R:SurvivorRatio比例
		Y:新生代空间大小
复制代码

6、方法区:【每个JVM只有一个方法区,线程共享,线程不安全】

  • 方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了所有类级别数据(包括类的名称等)、静态变量、静态方法、常量、编译器编译后的代码、运行时常量池等
  • 在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将原本放在永久代的字符串常量池移至堆中。
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。

7、永久代:【PermGen】

  • 方法区和“PermGen space”的区别:前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。
  • 所以说永久代就是HotSpt虚拟机对于方法区的实现。
  • -XX:PermSize:永久代的初始大小。
  • -XX:MaxPermSize:永久代的最大空间。

8、元空间:【Metaspace】

  • JDK 1.8 中,已经没有方法区(永久代),而是将方法区直接放在一个与堆不相连的本地内存区域(Native Memory),这个区域被叫做元空间。
  • JDK 1.8 中,这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace) 。
  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
    • -XX:MetaspaceSize:初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
    • -XX:MaxMetaspaceSize:最大空间,默认是没有限制的。
    • -XX:MinMetaspaceFreeRatio:在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。
    • -XX:MaxMetaspaceFreeRatio:在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。

9、为啥移除永久代,引入元空间:

9.1、永久代缺点:
  • 字符串存在永久代中,现实使用中易出问题, 由于永久代内存经常不够用或发生内存泄露,爆出异常” java.lang.OutOfMemoryError: PermGen“
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
9.2、引入元空间好处:
  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。因此,我们就不会遇到永久代存在时的内存溢出错误,也不会出现泄漏的数据移到交换区这样的事情。最终用户可以为元空间设置一个可用空间最大值,如果不进行设置,JVM会自动根据类的元数据大小动态增加元空间的容量。

作者:longyuan5
链接:https://juejin.cn/post/6945370778657521701
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分成若干不同的数据区域,这些区域有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则依赖用户线程的启动和结束而建立和销毁。【领取资料】

具体哪些区域会一直存在?哪些区域会“昙花一现”呢?

我们首先看下 JDK 内存区域划分图(JDK1.6 及其之前):

JDK1.8 以后,内存布局发生了一些变化:

从内存布局图中不难看出,线程私有的区域主要有:

  • 虚拟机栈
  • 本地方法栈
  • 程序计数器
    所有线程共享的空间主要有:
  • 方法区
  • 直接内存(非运行时数据区的一部分)

简单来说,共享的区域会一直存在(区域会一直存在,但区域内的数据会有变化),伴随 Java 程序运行的整个生命周期,而线程私有的空间则会线程的创建而创建,随着线程的结束而消逝。【领取资料】

那某个区域是线程私有还是共有又是依据什么来划分的?

具体要回答好这个问题,我们必须要深入理解 Java 每个区域的功能以及其在程序运行过程中所扮演的角色。

程序计数器

首先,我们先了解一下 程序计数器 (Program Counter Register),如果之前有学过《计算机组成原理》那对这个 程序计数器 一定不会陌生,它永远指向下一条指令。在 Java 中程序计数器的含义与之类似但稍有不同,在 Java 虚拟中, 程序计数器 里边存放的也是下一条需要执行的 字节码指令 , 字节码解释器 在工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

但由于 Java 虚拟机的多线程技术是通过线程轮流切换、分配处理器的执行时间的方式来实现的,在 任何确定时刻 ,一个处理器(单核)只能执行一条线程中的指令,且在时间片结束之后当前线程必然要发生切换,因而 为了线程切换后能够恢复到正确的执行位置 ,每条线程都需要一个独立的 程序计数器 ,各个线程之间的计数器独立存储,互不干扰。

从上边的描述中,我们可以总结出程序计数器的两个功能:

  1. 字节码计数器通过改变程序计数器来实现程序的流程控制,比如顺序执行、选择、循环和异常处理等。
  2. 在多线程环境下,程序计数器用来记录当前线程的执行位置,以便于进程被切换回来时,可以知道程序的执行位置。

同时,该片内存区域有一个特点, 它是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域 。

这是为什么呢?

因为程序计数器本质上保存的就是下一条需要执行的字节码指令的 偏移地址 。当执行到下一条指令的时候,改变的只是程序计数器中的地址,并不会 申请新的空间 ,因而不会出现 OutOfMemoryError的问题。换句话说,由于虚拟机的地址长度是固定的,因而存储偏移地址的程序计数器的空间也是固定的,因而也就不会出现 OutOfMemoryError的问题。

Java 虚拟机栈

大部分小伙伴在刚开始接触 JVM 内存划分时,总会听到有人说:“Java 内存笼统分为堆内存和栈内存”,虽然实际上 Java 内存的实际划分远比这复杂,但也在一定程度上说明了 堆 和 栈 这两部分中两部分空间的重要性。

其中 堆 后续会专门进行讲述(具体可参考"堆" ),而 栈 通常指的就是 Java虚拟机栈 ,或者说更多情况下指的是虚拟机栈中 局部变量表 部分(实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)。

说了这么多,那这个 Java虚拟机栈 到底是干嘛用的呢?

简单来说, Java虚拟机栈 描述的是 Java 方法执行的 线程内存模型 :每个方法被执行时,每次方法调用的数据都是通过栈传递的。 虚拟机栈 可用类比数据结构中栈, 虚拟机栈 中保存的主要内容是栈帧,每一次 函数调用 都会有一个对应的栈帧被压入 虚拟机栈 ,每一个函数调用结束后,都会有一个栈帧被弹出。方法的具体执行过程可参考另一篇文章"方法在 JVM 虚拟机中的执行过程"。

此时,可能有伙伴会问了为什么 Java 虚拟机栈也是线程私有的呢?

我们考虑这样一种场景:在多线程场景下,线程 A 在执行方法 a() ,线程 B 执行方法 b() ,由于 方法a() 执行时间较长,在执行一半的情况下发生了线程切换,因而方法 a() 此时还在虚拟机栈底,方法 b() 开始执行,此时会造成一个问题, a() 方法中的数据此处对线程 B 来说是可见的,甚至是可被篡改的。

因而为了保证各个线程自己私有数据(如私有变量)的数据安全,虚 拟机栈必然也是线程私有的 。

Java 虚拟机栈会出现两种错误: StackOverFlowErrorOutOfMemoryError

  • StackOverFlowError : 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError : Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError 异常异常。【领取资料】

本地方法栈

本地方法栈与 Java 虚拟机所发挥的作用非常相似,不同点在于虚拟机栈为虚拟机执行 Java 方法提供服务,而本地方法栈则为虚拟机所使用到的 本地方法 (native)提供服务。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

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

为什么说是 几乎 呢?

这是因为随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在 栈上分配内存 。

Java 堆是垃圾回收器管理的主要区域因而又被称之为 GC堆 。从垃圾回收的角度来看,堆内存被分成三部分: 新生代 、 老年代 、 永久代 。其中新生代详细划分又可以分成 Eden区 、 From区 和 To区 ,具体可参考"Appel 式回收"。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为下面三部分:

  • 新生代内存
  • 老年代内存
  • 永久代内存

在 JDK8之后,方法区被彻底移除了,取而代之的是元空间,元空间使用的是直接内存。

堆空间在使用时情况如下:

大部分情况下对象会直接在 Eden 区分配。
在经过 一次新生代垃圾回收 之后,如果如果对象还存活会进入 s0 或者 s1 (其实就是 From 区),并且对象的年龄还会加 1
当年龄增加到到一定程度之后,就会被晋升到老年代中。对象晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 来设置。
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发 java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)

方法区

方法区和 Java 堆一样是各个线程共享的内存区域,它用于存储被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存 等数据。虽然说《Java 虚拟机规范》把方法区描述为 堆的一个逻辑部分 ,但实际上它实际上还有一个别名叫做非堆,目的主要是和 Java 堆区分开来。

方法区有时也被称之为 永久代 ,但两者实际上还是有差别的:

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说, 永久代是 HotSpot 的概念 ,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

前边我们也说了 JDK8 之后已经没有永久代这一概念了,取而代之的是元空间, 为什么要这样做呢?

元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西 , 合并之后就没有必要额外的设置这么一个永久代的地方了。
运行时常量池
运行时常量池 是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有 常量池表(用于存放编译期生成的各种字面量和符号引用) ,这部分内容在类加载之后存放到方法区的运行时常量池中。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

在具体应用方法 JDK1.4 新加入了 NIO 类,引入了一种基于通道与缓冲区的 I/O 方法,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为 避免了在 Java 堆和 Native 堆之间来回复制数据 。

总结

本文主要讲了 Java 运行时内存的区域划分。按照线程私有还是共有分成两类,其中私有部分包含了程序计数器、Java 虚拟机栈、本地方法栈这三部分区域,其他区域为线程共有,并且每个区域的具体作用,文中也有阐述。

最后,感谢大家的观看,谢谢大家的支持,能三连关注收藏就最好啦,祝各位面试必过,升职加薪,早日升职加薪!

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

牛战士从不脱下面具

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值