Java虚拟机 jvm内存模型

目录

1 JVM内存模型

2 类加载器

2.1  类加载器子系统

2.2 执行步骤

2.2.1 加载

2.2.2 链接

2.2.3 初始化

3 JVM运行时数据区域

3.1 方法区 MethodArea

3.2 Java堆Heap

3.3 虚拟机栈

3.3.1 栈帧

3.3.2  局部变量表

3.3.3 操作数栈

3.3.4 动态链接

3.3.5 方法出口

3.4 程序计数器

3.5  本地方法栈

3.6  直接内存

4 执行引擎

5 本地方法

目录

1 JVM内存模型

2 类加载器

2.1  类加载器子系统

2.2 执行步骤

2.2.1 加载

2.2.2 链接

2.2.3 初始化

3 JVM运行时数据区域

3.1 方法区 MethodArea

3.2 Java堆Heap

3.3 虚拟机栈

3.3.1 栈帧

3.3.2  局部变量表

3.3.3 操作数栈

3.3.4 动态链接

3.3.5 方法出口

3.4 程序计数器

3.5  本地方法栈

3.6  直接内存

4 执行引擎

5 本地方法


1 JVM内存模型

jvm内存模型共分为4块:类加载器子系统、运行时数据区、本地方法接口、执行引擎

2 类加载器

2.1  类加载器子系统

类加载器子系统:装载class文件的内容到运行时数据区中的方法区。加载过程是:当一个classloader启动时,它会去主机硬盘上去装载.class文件到运行时数据区的方法区,方法区中的这个字节文件会被虚拟机拿来在堆内存生成了一个字节码的对象。加载过程默认采用双亲委派机制。双亲委派机制,即某个加载器在收到加载请求时,首先将加载任务委托给自己的父类加载器,依次递归,如果父类加载器成功完成加载任务,则加载成功,否则自己加载。

2.2 执行步骤

类加载器子系统共三个步骤:加载、连接和初始化。

2.2.1 加载

总的来说类的加载是JVM使用类加载器在特定的加载路径里寻找class文件,并将class文件中的二进制数据读入到内存中,其中class的数据结构被放置在运行时数据区的方法区类,并且在堆区里创建该类的Class对象,用来封装类的数据结构信息。其中类加载类的方式有:文件系统加载、网络加载、zip jar 归档文件加载、数据库中提取、动态编译的源文件加载。

类加载的最终产品是位于堆区中的Class对象,其封装了类在方法区内数据结构,并且向Java程序员提供了访问方法区内数据结构的接口,需要指出的是,类的加载并不都是主动使用时才加载,加载器可以实现为有预加载功能,如使用一定的算法预测类的使用。

在加载过程由引导类加载器(BootStrap)、扩展类加载器(Extension)、应用程序加载器(Application)以及用户自定义加载器帮助完成加载。

1)BootStrap 类加载器:负责加载虚拟机的核心类库,如java.lang.*等。引导类加载器从系统属性sun.boot.class.path所指定的目录中加载类库。引导类加载器的实现依赖于底层操作系统,属于jvm实现的一部分,使用c++语言编写。它并没有ClassLoader类,也没有父加载器。使用如stringObject. getClass(). getClassLoader()将返null

2)Extension类加载器(Ext:它的父加载器是引导加载器。它从java.ext.dirs系统属性所指定的目录中加载类库,或者从jdk的安装目录的jre\lib\ext子目录下加载类库,如果你把用户创建的jar放在这个目录下会被扩展类加载器加载。扩展类加载器使用纯java实现,继承了ClassLoader

3)Application类加载器:它的父加载器是Ext加载器。它从环境变量里(安装JDK时设立的classpath)或从系统属性java.class.path加载类,它是用户自定义的类加载器的默认父加载器,采用纯java实现,继承自ClassLoader

4)用户自定义加载器:系统类加载器的子类,必须要继承自ClassLoader类,并且重写findClass方法。

2.2.2 链接

连接就是将已经读入到内存的类的二进制数据合并到虚拟机的运行环境中去。

在连接过程需要经过验证、准备和解析三个阶段。

a 验证阶段

1) 类文件结构的检查,确保类的文件遵循java类文件的固定格式。

2) 语义检查:确保类本身符合java语言的语法规定,比如验证final类型没有子类,final方法没有被从写,private没有被重写。

3) 字节码验证:确定字节码流可以被java虚拟机安全的执行。

4) 二进制兼容验证:确保相互应用的类之间协调一致。

b、准备阶段

是为所有静态变量分配内存并分配默认值和设置为初始值;

c、解析阶段

把类中的二进制中的符号引用替换为直接引用

2.2.3 初始化

初始化的主要步骤

检查该类是否已经被加载和连接;如果该类有父类,且没有初始化,对父类进行加载 连接 初始化;假如类中存在初始化语句,依次执行初始化语句。而静态变量的声明以及静态代码块都被看作是类的初始化语句,java虚拟机会按照初始化语句在类文件中的顺序依次来执行他们。

当JVM初始化一个类时要求他的父类已经被初始化,这样的机制并不适用于接口,初始化一个类时,它实现的接口并不需要被初始化,初始化一个接口时,其父接口也不需要被初始化,只有当程序首次使用接口中的静态变量时,才会导致接口的初始化。

3 JVM运行时数据区域

3.1 方法区 MethodArea

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

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

在JDK8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(PermanentGeneration),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如BEAJRockit、IBMJ9等来说,是不存在永久代的概念的。在JDK6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(NativeMemory)来实现方法区的计划了[1],到了JDK7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替,把JDK7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

运行时常量池

运行时常量池(RuntimeConstantPool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(ConstantPoolTable),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中[1]。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

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

3.2 Java堆Heap

对于Java应用程序来说,Java堆(JavaHeap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“FromSurvivor空间”“ToSurvivor空间”等名词,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(ThreadLocalAllocationBuffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

3.3 虚拟机栈

  • 每个线程运行所需要的内存空间,成为虚拟机栈;
  • 线程私有,生命周期和线程一致;
  • 每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存;
  • 每个线程只能有一个活动栈帧,对应当前执行的方法。

描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。

OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

3.3.1 栈帧

栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。

栈帧又是存储在栈中(包括Java虚拟机栈和本地方法栈),它随着方法调用而创建,随着方法结束而销毁,其实也就是一个方法执行的过程也对应着栈帧的入栈和出栈的过程。无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中,每一个栈帧都有自己的本地变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用。

本地变量表和操作数栈的容量在编译期确定,并通过相关方法的code属性保存及提供给栈帧使用。因此,栈帧数据结构的大小仅仅取决于Java虚拟机的实现。实现者可以在调用方法的时候给它们分配内存。

在某条线程执行的过程中的某个时间点,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧称为当前栈帧,这个栈帧对应的方法称为当前方法,定义这个方法的类称作当前类。对局部变量表和操作数栈的各种操作,通常都是值对当前栈帧的局部变量表和操作数栈所进行的操作。

如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。调用新方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法给前一个栈帧,然后虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

需要特别注意的是,栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一个线程的栈帧。

3.3.2  局部变量表

每个栈帧内部都包含一组称为局部变量表的变量列表。栈帧中局部变量表的长度由编译器决定,并却存储于类或接口的二进制表示之中,即通过方法的code属性保存及提供给栈帧使用。

局部变量表是存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址),其中boolean、byte、char、short、int、float、reference、returnAddress用一个变量表示,long、double和用两个局部变量表示。

局部变量使用索引来进行定位访问。首个局部变量的索引值为0。局部变量的索引值是个整数,它大于等于0,且小于局部变量表的长度。

Java虚拟机使用局部变量表来完成方法调用时的参数传递。当调用类方法时,它的参数将会依次传递到局部变量表中从0开始的连续位置上。当调用实例方法时,第0个局部变量一定用来存储该实例方法所在对象的引用(即Java语言中的this关键字)。后续其他参数将会传递至局部变量表中从1开始的连续位置上。

3.3.3 操作数栈

每个栈帧内部都包含一个称为操作数栈的后进后出栈。栈帧中操作数栈的最大深度由编译期决定,同时被与帧关联的方法的代码提供。

栈帧刚创建的时候操作数栈是空的。Java虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据以及把操作结果重新入栈。在调用方法时,操作数栈也用来准备调用方法的参数以及接收方法返回结果。

例如iadd字节码指令的作用是将两个int类型的数值相加,它要求在执行之前操作数栈的栈顶已经存在两个由前面的其他指令所放入的int类型数值。在执行iadd指令时,两个int类型数值出栈,相加求和之后求和结果重新入栈。

操作数栈的每个位置上可以保存一个Java虚拟机中定义的数据类型的值,包括long和double类型。

必须以适合操作数堆栈类型的方式操作操作操作数堆栈中的值,给操作数栈中push两个int值并随后将其视为long或push两个浮点值并随后使用iadd指令添加它们是不允许的。

少量Java虚拟机指令(dup指令(§dup)和swap(§swap))作为原始值操作运行时数据区域,而不考虑它们的特定类型;这些指令的定义方式不能用于修改或分解单个值。这些操作数堆栈操作的限制是通过class文件验证强制执行的。

在任意时刻,操作数栈都会有一个确定的栈深度,一个long或者double类型的数据会占用两个单位的栈深度,其他数据类型则会占用一个单位的栈深度。

3.3.4 动态链接

每一个栈帧都包含一个指向当前方法(currentmethod)所在类型的运行时常量池的引用,以支持方法代码的动态链接(DynamicLinking)。

在class文件里面,一个方法若要调用其他方法,或者访问成员变量,则需要通过符号引用(symbolic reference)来表示,动态链接就是将符号引用所表示的方法,转换成方法的直接引用。

这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(将变量的访问转化为访问这些变量的存储结构所在的运行时内存位置),这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存人口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

JVM的动态链接还支持运行期转化为直接引用。也可以叫做Late Binding,晚期绑定。

由于对其他类中的方法和变量进行了晚期绑定,所以即便哪些类发生变化,也不影响调用其他方法。

3.3.5 方法出口

当一个方法开始执行后,只有两种方式可以退出这个方法:方法调用正常完成,方法调用异常完成。

方法调用正常完成(NormalMethodInvocationCompletion):执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为方法调用正常完成。

Java虚拟机根据不同数据类型有不同的底层return指令。当被调用方法执行某条return指令时,会选择相应的return指令来让值返回。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压人调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

方法调用异常完成(AbruptMethodInvocationCompletion):在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为方法调用异常完成。一个方法使用方法调用异常完成的方式退出,是不会给它的上层调用者产生任何返回值的。

总的来说,方法的退出过程实际上就是一个很简单的出栈过程,这一过程一般会执行三步操作:

  • 恢复上层方法的局部变量表和操作数栈;
  • 将返回值压入调用方法的操作数栈中;
  • 调整PC计数器指向后一条指令。

3.4 程序计数器

程序计数器(ProgramCounterRegister)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。更确切的说,一个线程的执行,是通过字节码解释器改变当前线程的计数器的值,来获取下一条需要执行的字节码指令,从而确保线程的正确执行。

保存当前执行指令的地址,一旦程序执行,程序计数器将更新到下一条指令。

为了确保线程切换后(上下文切换)能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的计数器互不影响,独立存储。也就是说程序计数器是线程私有的内存。

如果线程执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native方法,计数器值为Undefined。此内存区域是唯一一个在《Java虚拟机规范》 中没有规定任何OutOfMemoryError情况的区域。

3.5  本地方法栈

本地方法栈(NativeMethodStacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规

定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

3.6  直接内存

直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

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

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

4 执行引擎

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟机,譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器。

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(UncommonTrap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(ClientCompiler)和“服务端编译器”(ServerCompiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK10时才出现的、长期目标是代替C2的Graal编译器。

5 本地方法

简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern"C"告知C++编译器去调用一个C的函数。

本地方法的原因

  • 有些层次的任务用java实现起来不容易
  • 要追求对程序的效率;
  • 与java环境外交互
  • java需要与一些底层系统如操作系统或某些硬件交换信息时的情况,它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。
  • 与操作系统交互

JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。它需要依赖于一些底层(underneath在下面的)系统的支持,这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值