虚拟机字节码执行引擎

  通常一部赛车的引擎是赛车的心脏,决定着赛车的性能和稳定性,赛车的速度、操纵感这些直接与车手相关的指标都是建立在引擎的基础上的。同样的,JVM的执行引擎是JAVA虚拟机最核心的组成部分之一。那么什么是JVM的执行引擎?我们在学习计算机组成原理等课程的时候,知道物理机的执行引擎是直接建立在处理、硬件、指令集和操作系统层面上的。而相对于物理机,JAVA虚拟机同样具有代码执行的能力,虚拟机的执行引擎是由自己实现的,因此可以自行定制指令集和执行引擎的结构体系。

       在JAVA虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,尽管现在JVM的实现各不相同,有编译执行(如BEA JRockit)也有解释执行(如Sun Classic VM),但是从概念模型的角度来看,所有JAVA虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

 

一、运行时栈帧(Stack Frame)结构

       我们知道程序、指令在运行的时候少不了计算机存储、组织的方式,这种存储和组织方式我们也称为数据结构。栈帧就是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址以及一些额外的附加信息。

        一个线程中的方法调用链可能会很长,很多方法都处于执行状态。但是对于执行引擎来说,活动线程中只有栈顶的栈帧是有效的,称为当前栈帧,对应的方法为当前方法。执行引擎的所有字节码指令都是只针对当前栈帧进行操作的。

图 1. 栈帧的概念结构

  栈帧需要多大的局部变量表、多深的操作数,在代码编译的时候就已经完全确定下来了,写在了方法表的Code属性之中,例如:

1         public int add(int i,int j){
2         int result;
3         result = i + j;
4         return result;
5     }
java文件中的add方法

   将上术方法所在java文件编译之后生成的class文件,通过命令 javap -verbose反编译之后add方法所对应的字节码如下:

 1 public int add(int, int);
 2   Code:
 3    Stack=2, Locals=4, Args_size=3
 4    0:    iload_1
 5    1:    iload_2
 6    2:    iadd
 7    3:    istore_3
 8    4:    iload_3
 9    5:    ireturn
10   LineNumberTable: 
11    line 7: 0
12    line 8: 4
13 
14   LocalVariableTable: 
15    Start  Length  Slot  Name   Signature
16    0      6      0    this       Ljvm/executionengine/stackframe/StackFrame;
17    0      6      1    i       I
18    0      6      2    j       I
19    4      2      3    result       I
clsss文件add方法字节码

   在上面的字节码中可以看到,方法的Code属性中会有这三个值Stack=2, Locals=4, Args_size=3,其中Stack和Locals就是操作数栈的最大深度以及局部变量表的大小,最后一个参数的个数。细心的朋友会发现局部变量明明只有3个(i,j,result)占用3个Slot(后面会讲到Slot),参数个数明明也只有2个(i,j),为什么都会多一个呢?这里面我们不要忘记了非静态方法都会有个隐藏的变量(参数),那就是“this”。

(1) 局部变量表

      局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。

  局部变量表的容量以变量槽(Slot)为最小单位,JAVA虚拟机规范中并没有指明一个Slot应占用的内存空间大小,只是说明一个Slot都应该能存放一个32位以内的数据类型,有boolean、byte、char、short、int、float、reference(长度与实际使用的是32位还是64位虚拟机有关)和returnAddress类型的数据。 returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。long和double是java语言中明确规定的64位数据类型,虚拟机会为其分配两个连续的Slot空间。

  虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this访问。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕之后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

  下面通过一个简单的例子来说明上述的Slot的描述:

1         public int slot(int i,int j){
2         double d = 5.23;
3         int result;
4         result = i + j;
5         return result;
6     }
演示Slot的java代码方法slot方法
 1 public int slot(int, int);
 2   Code:
 3    Stack=2, Locals=6, Args_size=3
 4    0:    ldc2_w    #16; //double 5.23d
 5    3:    dstore_3
 6    4:    iload_1
 7    5:    iload_2
 8    6:    iadd
 9    7:    istore    5
10    9:    iload    5
11    11:    ireturn
12   LineNumberTable: 
13    line 6: 0
14    line 8: 4
15    line 9: 9
16 
17   LocalVariableTable: 
18    Start  Length  Slot  Name   Signature
19    0      12      0    this       Ljvm/executionengine/stackframe/StackFrame; //this占用slot0
20    0      12      1    i       I                                                    //int型 i  占用slot1
21    0      12      2    j       I                                                    //int型 j 占用slot2
22    4      8      3    d       D                                                   // double型 d 占用slot3  slot4
23    9      3      5    result       I                                              //int型 result 占用slot5
class文件slot方法字节码

   Slot是可以重用的,当Slot中的变量超出了作用域,那么下一次分配Slot的时候,将会覆盖原来的数据。Slot对对象的引用会影响GC(要是被引用,将不会被回收)。系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。为了说明Slot的可复用,下面举了一个在实际中基本不会出现的代码:

1     public void slotReuse(){
2         {
3             int m = 100;
4         }
5         int n = 200;
6     }
slot可重用的例子slotReuse方法
 1 public void slotReuse();
 2   Code:
 3    Stack=1, Locals=2, Args_size=1   //变量个数3个(this,m,n),但是局部变量表大小为2
 4    0:    bipush    100
 5    2:    istore_1                                   //把m的值100放入下标1的slot中
 6    3:    sipush    200
 7    6:    istore_1                                  //把n的值200放入下标1的slot中,超出了m作用域,复用了
 8    7:    return
 9   LineNumberTable: 
10    line 7: 0
11    line 9: 3
12    line 10: 7
13 
14   LocalVariableTable: 
15    Start  Length  Slot  Name   Signature
16    0      8      0    this       Ljvm/executionengine/stackframe/StackFrame;
17    7      1      1    n       I
class文件slotReuse方法字节码

   Slot的复用在某些情况下会直接影响到系统的垃圾回收行为,我们来通过下面的3块代码来说明这个问题:

 1 /**
 2  * 三段代码加上虚拟机的运行参数“-verbose:gc”来观察垃圾收集的过程
 3  */
 4 /*代码一:gc的时候,变量placeHolder还处于作用域,没有回收掉*/
 5     public static void main(String[] args) {
 6         byte[] placeHolder = new byte[64*1024*1024];
 7         System.gc();
 8     }
 9 /*gc过程:
10 [GC 66167K->65824K(120576K), 0.0012810 secs]
11 [Full GC 65824K->65690K(120576K), 0.0060200 secs]
12 */
13 
14 /*代码二:gc的时候,变量placeHolder还处于作用域之外了,但是还是没有回收掉*/
15     public static void main(String[] args) {
16         {
17             byte[] placeHolder = new byte[64*1024*1024];
18         }
19         System.gc();
20     }
21 /*gc过程:
22 [GC 66167K->65760K(120576K), 0.0013770 secs]
23 [Full GC 65760K->65690K(120576K), 0.0055290 secs]
24 */
25 
26 /*代码三:gc的时候,变量placeHolder还处于作用域之外了,通过一个新的变量i赋值,复用掉placeHolder原先的slot,垃圾回收了*/
27     public static void main(String[] args) {
28         {
29             byte[] placeHolder = new byte[64*1024*1024];
30         }
31         int i = 0;
32         System.gc();
33     }        
34 /*gc过程:
35 [GC 66167K->65760K(120576K), 0.0017850 secs]
36 [Full GC 65760K->154K(120576K), 0.0064340 secs]
37 */
slot复用影响到垃圾回收演示

  上述代码中, placeHolder能否被回收的根本原因就是:局部变量表中的Slot是否还存在关于placeHolder数组对象的引用。代码二中虽然已经离开了placeHolder的作用域,但是此后没有局部变量表的读写操作,placeHolder原本占用的Slot还没有被其他变量复用,所以在GC的时候仍然保留有对数组对象的引用。代码三中,认为的定义的一个局部变量i,并且赋值以达到复用刚才placeHolder的Slot,消除了对数组对象的引用,然后GC就可以回收掉了。

(2) 操作数栈

  Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。操作数栈也常被称为操作栈,就是一个栈结构(后入先出)。同局部变量表一样,操作数的最大深度在编译的时候就确定了,写在了方法的Code属性的max_stacks数据项中。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的执行过程中,会有各种字节码指令向操作数栈中写入和提取数据(入栈、出栈操作)。

  举个例子:整数加法的字节码指令iadd在运行之前要求操作数栈中最接近栈顶的两个元素已经存有两个int型数值,当指定iadd时候,会将这两个int值出栈相加,然后将结果入栈。

 1     /*java文件中add方法*/
 2     public int add(int i,int j) {
 3         return i+j;
 4     }
 5 
 6     /*class文件反编译之后的add方法字节码*/
 7 public int add(int, int);
 8   Code:
 9    Stack=2, Locals=3, Args_size=3
10    0:    iload_1                                       //将slot1的数值入栈  变量i的值
11    1:    iload_2                                       //将slot2的数值入栈  变量j的值
12    2:    iadd                                             //将栈中的两个值相加,并入栈
13    3:    ireturn
14   LineNumberTable: 
15    line 17: 0
16 
17   LocalVariableTable: 
18    Start  Length  Slot  Name   Signature
19    0      4      0    this       Ljvm/learn/workspace/SlotGC;
20    0      4      1    i       I
21    0      4      2    j       I
操作数栈add方法演示

   另外,在虚拟机概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的。但是大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在方法调用的时候可以共用一部分数据,减少了额外的参数复制传递的开销。

(3) 动态连接

  每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候化为直接引用,这话总转化叫做静态解析,还有一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

(4) 方法返回地址

  当一个方法被执行后,有两种方式退出这个方法:1.执行引擎遇到任意方法返回的字节码指令,这时可能会返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令决定。这种退出方式为正常完成出口。2.遇到异常并且没有在方法体内得到处理,无论是Java虚拟机内部产生异常还是使用athrow字节码指令产生异常,只要在本方法的异常表中没有搜到匹配的异常处理器,就会导致方法退出,这种退出方式是不会给它的上层调用者产生任何返回值的。这种退出方式为异常完成出口。

  一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表盒操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

(5) 附加信息

  虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如调试相关信息。

 

二、方法调用

  方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件中都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。这个特点给Java带来了更强大的动态扩展能力,但是也带来了复杂,需要在类加载甚至运行期间才能确定目标方法的直接引用。

  在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,不会延迟到运行期去完成,这种方法的调用称为解析(Resolution)。解析能成立的前提是:方法在程序真正运行之前就有可确定调用版本,并且在运行期间不可变。分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派,因此分派有四情况:静态单分派、静态多分派、动态单分派、动态多分派。

(1) 解析

  Java虚拟机里面有四条主要的方法调用字节码指令:

     1.invokestatic:调用静态方法

            2.invokespecial:调用实例构造器<init>方法、私有方法和父类方法

     3.invokevirtual:调用所有虚方法

     4.invokeinterface:弟阿勇接口方法,会在运行时再确定一个实现此接口的对象

  此外,JSR-292中引入了第5条新的字节码指令invokedynamic,在这里不做讨论。

  只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法。此外,final方法也是可以在解析阶段确定唯调用版本。

 1 /**
 2  * 方法静态解析演示
 3  */
 4 //Hello.java
 5 public class Hello {
 6     public void sayHello(){
 7         System.out.println("hello jvm");
 8     }
 9 }
10 //StackFrame.java
11 public class StackFrame {
12     
13     public static void staticInvoke(){
14         System.out.println();
15     }
16     public void showInvoke(){
17         staticInvoke();
18         Hello hello = new Hello();
19         hello.sayHello();
20     }
21 }
22 
23 //class文件反编译后showInvoke方法的字节码
24 public void showInvoke();
25   Code:
26    Stack=2, Locals=2, Args_size=1
27    0:    invokestatic    #27; //Method staticInvoke:()V
28    3:    new    #29; //class jvm/executionengine/stackframe/Hello
29    6:    dup
30    7:    invokespecial    #31; //Method jvm/executionengine/stackframe/Hello."<init>":()V
31    10:    astore_1
32    11:    aload_1
33    12:    invokevirtual    #32; //Method jvm/executionengine/stackframe/Hello.sayHello:()V
34    15:    return
35   LineNumberTable: 
36    line 15: 0
37    line 16: 3
38    line 17: 11
39    line 18: 15
40 
41   LocalVariableTable: 
42    Start  Length  Slot  Name   Signature
43    0      16      0    this       Ljvm/executionengine/stackframe/StackFrame;
44    11      5      1    hello       Ljvm/executionengine/stackframe/Hello;
方法静态解析演示

 (2) 分派

1.静态分派

 1 public class StaticDispatch {
 2     public class Human{
 3         
 4     }
 5     public class Man extends Human{
 6         
 7     }
 8     public class Women extends Human{
 9         
10     }
11     public void sayHello(Human human){
12         System.out.println("hello,human");
13     }
14     public void sayHello(Man man){
15         System.out.println("hello,man");
16     }
17     public void sayHello(Women women){
18         System.out.println("hello,women");
19     }
20     public static void main(String[] args) {
21         //man的静态类型是Human,实际类型是Man
22         Human man = new StaticDispatch().new Man(); 
23         //man的静态类型是Human,实际类型是Women
24         Human women = new StaticDispatch().new Women();
25         StaticDispatch sd = new StaticDispatch();
26         
27         sd.sayHello(man);        //输出:hello,human
28         sd.sayHello(women);      //输出:hello,human
29         sd.sayHello((Man)man);   //输出:hello,man
30         sd.sayHello((Women)women); //输出:hello,women
31     }
方法静态分派演示

  所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派最典型的应用就是方法重载。静态分派放生在编译阶段,因此确定静态分派的动作实际上不是有虚拟机来执行的。编译器能确定出方法的重载版本,并且这种重载版本不是唯一的,往往只能确定一个“更加合适”的版本。

 

 1 public class Overload {
 2     
 3     public static void sayHello(int i){
 4         System.out.println("hello int");
 5     }
 6     public static void sayHello(long i){
 7         System.out.println("hello long");
 8     }
 9     public static void main(String[] args) {
10         //发生了一次自动转换,自动转int(相比于long更合适)
11         sayHello('a');  //输出:hello int
12     }
13 }
重载方法匹配优先级

 2.动态分派

 1 public class DynamicDispatch {
 2 
 3     static abstract class Human{
 4         protected abstract void sayHello();
 5     }
 6     static class Man extends Human{
 7         protected void sayHello(){
 8             System.out.println("hello,man");
 9         }
10     }
11     static class Women extends Human{
12         protected void sayHello(){
13             System.out.println("hello,women");
14         }
15     }
16     public static void main(String[] args) {
17         Human man = new Man();
18         Human women = new Women();
19         man.sayHello();  //输出:hello,man
20         women.sayHello();//输出:hello,women
21     }
22 
23 }
方法动态分派展示

   显然也不是静态类型决定的,从上面代码可以看到,静态类型代码也都是Human,但是最后执行的结果却是不同的。导致的原因就是这个两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派呢?我们使用javap来查看一下字节码。

 1 public static void main(java.lang.String[]);
 2   Code:
 3    Stack=2, Locals=3, Args_size=1
 4    0:    new    #16; //class jvm/executionengine/stackframe/DynamicDispatch$Man
 5    3:    dup
 6    4:    invokespecial    #18; //Method jvm/executionengine/stackframe/DynamicDispatch$Man."<init>":()V
 7    7:    astore_1
 8    8:    new    #19; //class jvm/executionengine/stackframe/DynamicDispatch$Women
 9    11:    dup
10    12:    invokespecial    #21; //Method jvm/executionengine/stackframe/DynamicDispatch$Women."<init>":()V
11    15:    astore_2
12    16:    aload_1
13    17:    invokevirtual    #22; //Method jvm/executionengine/stackframe/DynamicDispatch$Human.sayHello:()V
14    20:    aload_2
15    21:    invokevirtual    #22; //Method jvm/executionengine/stackframe/DynamicDispatch$Human.sayHello:()V
16    24:    return
17   LineNumberTable: 
18    line 19: 0
19    line 20: 8
20    line 21: 16
21    line 22: 20
22    line 23: 24
23 
24   LocalVariableTable: 
25    Start  Length  Slot  Name   Signature
26    0      25      0    args       [Ljava/lang/String;
27    8      17      1    man       Ljvm/executionengine/stackframe/DynamicDispatch$Human;
28    16      9      2    women       Ljvm/executionengine/stackframe/DynamicDispatch$Human;
方法动态分派字节码

   字节码中0-15的字节码都是new了两个实例,并将实例放入第一个和第二个Slot中,接下来16-17是将Slot1中值压入栈(man实例的引用),并且调用方法,20-21是将Slot2中值压入栈(women实例的引用),并且调用方法。单从17和21两行来看,调用方法符号引用一模一样,但是这两条指令最终执行的目标方法确实不同。原因就跟invokevirtual指令的多态查找过程有关了,invokevirtual指令的运行时解析过程大致分为以下步骤:

  1) 找到操作数栈顶的第一个元素所指对象的实际类型,记作C。

  2) 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,通过就直接引用,结束;不通过就返回java.lang.IllegalAccessError异常。

  3) 否则,按照继承关系从下往上对C的各个父类重复第2步的搜索和校验。

  4) 如果始终没找到合适的方法,则抛java.lang.AbstractMethodError异常。

  从上面invokevirtual指令的运行时解析过程不难看出,代码中man和woman会找到实际类型中的方法调用。这个过程反映了java语言中方法重写的本质。

3.单分派和多分派

  方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

 1 /**
 2  * 单分派、多分派
 3  */
 4 public class Dispatch {
 5     static class QQ{};
 6     
 7     static class _360{};
 8     
 9     public static class Father{
10         public void choice(QQ arg) {
11             System.out.println("father choose QQ");
12         }
13         public void choice(_360 arg) {
14             System.out.println("father choose 360");
15         } 
16     }
17     public static class Son extends Father{
18         public void choice(QQ arg) {
19             System.out.println("son choose QQ");
20         }
21         public void choice(_360 arg) {
22             System.out.println("son choose 360");
23         } 
24     }
25     
26     public static void main(String[] args) {
27         Father father = new Father();
28         Father son = new Son();
29         //动态类型Father  静态类型_360   根据方法接收者:Father 和 方法参数:_360 确定一个目标方法
30         father.choice(new _360());      //输出:father choose 360
31         动态类型Son  静态类型QQ       根据方法接收者:Son 和 方法参数:QQ 确定一个目标方法
32         son.choice(new QQ());           //输出:son choose QQ
33     }
34 }
单分派、多分派

 

4.虚拟机动态分派的实现

  由于动态分派非常繁琐以及虚拟机实际实现中基于性能考虑,通常都会对动态分派的实现做优化。最通常的优化方法就是在类的方法区中建一个虚方法表(Virtual Method Talbe,vtable),于此对应,invokeinterface执行时也用到接口方法表(Interface Method Table,itable)。

图2 方法表结构

   虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口地址与父类相同方法的地址入口地址一致,都指向父类的实现入口。如果子类重写了这个方法,那么子类虚方法表中地址将会被替换成指向子类实现版本的入口地址。上图中Son重写了来自Father的全部方法,因此Son方法表中这些方法的实际入口地址都指向了Son类型数据的方法。Son和Father都没有重写Object中的方法,所以方法表中的实际入口地址都指向了Object数据类型。

  为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

  方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

 三、基于栈的字节码解释执行引擎

  Java编译器输出的指令流,基本上是一种基于栈的指令集架构,它们依赖操作数栈进行工作。下面结合一个小例子看看虚拟机实际上是如何执行的。 

 1     //java文件中的一个计算方法
 2                public int showExample(){
 3         int a = 10;
 4         int b = 20;
 5         int c = 30;
 6         return a*(b+c);
 7     }
 8 
 9 
10 //class文件中 showExample方法的字节码
11 public int showExample();
12   Code:
13    Stack=3, Locals=4, Args_size=1  
14    0:    bipush    10
15    2:    istore_1
16    3:    bipush    20
17    5:    istore_2
18    6:    bipush    30
19    8:    istore_3
20    9:    iload_1
21    10:    iload_2
22    11:    iload_3
23    12:    iadd
24    13:    imul
25    14:    ireturn
26   LineNumberTable: 
27    line 5: 0
28    line 6: 3
29    line 7: 6
30    line 8: 9
31 
32   LocalVariableTable: 
33    Start  Length  Slot  Name   Signature
34    0      15      0    this       Ljvm/executionengine/stackframe/example;
35    3      12      1    a       I
36    6      9      2    b       I
37    9      6      3    c       I
基于栈的解释器执行过程演示

转载于:https://www.cnblogs.com/MRJoe/p/3192214.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值