【JVM】运行时数据区——自问自答

JVM工作逻辑架构图(图片来自尚硅谷)

运行时方法区基本结构:


Q:Java 运行时数据区结构,哪些数据线程独占,哪些是线程共享?每个区域会产生GC和异常吗?

运行时数据区结构:
1、PC寄存器
2、堆区
3、JVM栈
4、Native栈
5、方法区

其中:

       PC寄存器、Native栈、JVM栈是线程独占的。
      堆区、方法区是线程共享的。
      
      PC寄存器 没有垃圾回收,没用任何异常
      Native栈、JVM栈会抛出StackOverFlowError 和OutOfMemory:Stack 异常,没有GC
      
      堆区分为新生代和老年代,新生代中的Eden区满触发YGC/Minor GC ,老年代满触发Major GC。
      堆区有OutOfMemory :heap 异常
      
      方法区:存在GC (Full GC),在JDK7 以前 存在OutOfMemory:PremGen
      JDK8及其以后是 OutOfMemory:MetaSpace

【补充】 关于指令

零地址指令、基于栈的指令、基于寄存器的指令。

java字节码之所以能实现跨平台,主要是因为字节码可以被操作系统之上的JVM虚拟机识别,执行

普通机器指令是基于CPU寄存器的,由于CPU架构不同,导致各个指令集有差异,有一地址,二地址等,这个地址指的就是寄存器地址:

比如 用汇编助记符表示:

MOV 0x02  10 ;

//这里写的很不严谨,只是为了好理解,这个指令第一个Mov表示操作为将数10,移动地址为0x02的寄存器上。

而零地址,是用基于栈的指令来实现的,因为栈是虚拟的数据结构,并不是实际的硬件。并且,它不需要地址来标记操作数,因为可以通过入栈和出栈来完成移动的效果

基于栈的指令集就可以避免不同CPU架构造成的指令集不同引起的跨平台性。

但是基于栈的指令集也是有缺点的。

寄存器指令集:指令集大,但是完成一个操作所需要的指令少

栈指令:指令集小,完成一个操作所需要的指令很多,相对效率就低。

指令集大小怎么理解?

比如你学英语,掌握50个词,和掌握5万个词

前者就是指令集小,后者指令集大。

前者你描述一件事,可能要说很多句才能说清楚,后者你可以从容的选择最简化的方式,描述这个事。

Q:具体说一说PC寄存器

设计参考CPU的PC寄存器,目的是存储指令相关的现场信息。

java的PC寄存器 准确的说理解为 指令计数器 (也有人翻译成程序钩子)。

 看一下这里的起始PC,其实就是指令对应的编号(地址),也就是将要被放在PC上的数据

行号是对应着源代码中所在的行号

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

  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。

  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。(线程切换, PC记录当前线程的当前方法地址)

  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值 (undefned)

  • 它是程序控制流的指示器,分支、循环.跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令

  • 它是唯一一个在Java 虚拟机规范中没有规定任何outotMemoryError情况的区域

PC上,没用GC,也没有任何异常


Q:具体说一说虚拟机栈

一个线程有一个JVM栈,当这个线程中进行一次方法调用的时候,会形成一个栈帧。压入这个栈中。
当这个方法执行结束的时候,栈帧会被弹出JVM栈。随着线程的生命周期结束,它对应的JVM栈也会被销毁。

JVM Stack中的栈帧的结构:

  1. 局部变量表 Local Varible table
  2. 操作数栈  Operand Stack
  3. 方法返回地址  Return Address
  4. 动态链接 Dynamic Linking
  5. 一些附加信息。

JVMStack 不涉及垃圾回收。
它在以下情况会抛出异常:

1、当栈的大小是固定的,当前线程中一个方法中不断的去嵌套式的调用其他方法(比如递归),
从内存的角度来说,就是不断的有栈帧被压入当前线程的JVM栈中,如果最后一次栈帧所需要的空间不足时,会抛出 StackOverFlowError

2、当栈的大小是动态扩容的,当前栈进行扩容时,如果物理内存空间小于栈要扩充的空间,就会抛出OutofMemory :stack 


栈帧内的结构说明:
 

局部变量表:


定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量

局部变量表在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
局部变量表的容量大小是在编译期确定下来的(前段编译 javac 生成字节码时期),并保存在方法的Code属性的maximum local variables数据项中,
在方法运行期间是不会修改局部变量表的大小的。
在方法调用结束后,局部变量表会随着方法栈帧的弹出而销毁。

局部变量表的Slot,是其最基本的存储单元。
LV中存放着编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
LV中,32位内的类型只占用一个slot,64位的类型(long double)占两个slot
    byte short char 都会被转成int ,boolean也会转成int, 0表示false, 非0 表示true (字节码指令集里可以看到 bint,sint)
    
普通对象方法和构造函数的LV中,index0的变量是 this,而静态方法没用。

slot是可以被复用的比如:

public class SlotTest{
    public void localVar(){
        int a =0;
        Sout(a);
        int b=0;
    
    }

    public void localVar(){
    {
        int a =0; //a作用域在代码块中,出离了代码块,a这个局部变量就失去作用域,从LV角度来看,a的Slot位就空了,而下面的局部变量b可以対之复用
        Sout(a);
    }    
        int b=0;
    }

}

需要注意的一点是,局部变量表不存在系统默认初始化的过程,(这一点区别于静态变量)

所以,使用局部变量时,务必手动初始化。

局部变量也是可达性分析的根节点,只要是被局部变量表中直接或间接引用的对象,都不会被回收。


操作数栈、

每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(LIFO)操作数栈,也可以称之为表达式栈(Expression stack)


操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈 (push)/出栈 (pop)。


某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。
比如:执行复制、交换、求和等操作

这就是上面所说的基于栈的指令集

执行引擎会把字节码指令翻译成CPU认识的机器指令,交给CPU计算,计算结果再由执行引擎放回操作数栈

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max stack的值。
  • 栈中的任何一个元素都是可以任意的Java数据类型。
    • > 32bit的类型占用一个栈单位深度
    • > 64bit的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问。
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
【补充:栈顶缓存】

将操作数栈顶元素 缓存在物理CPU寄存器中,因为虚拟机是基于栈结构的零地址指令,执行操作效率较低。

------------------------------------------------分割线--------------------------------------------------------

【补充:方法引用】

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

静态链接:

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

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

对应的方法的绑定机制为:早期绑定(Early Binding)晚期绑定(Late Binding)

绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

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


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

什么叫方法被确认下来?

准确来说,就是方法的地址有没有被确定,如果方法已经生成,它的地址就相当于这个方法的入口。

在C++中也存在早绑定和晚绑定,其含义如下:

早绑定,在编译阶段确定了函数的地址

晚绑定,允许阶段才确定函数地址

这个知识点是在讨论C++的静态多态和动态多态时阐述的。

静态多态:函数重载 和 运算符重载属于静态多态,复用函数名

动态多态:派生类和虚函数实现运行时多态

C++内存模型是怎样的?

C++程序在执行时,将内存大方向划分为4个区域

  • 代码区:存放函数体的二进制代码,由操作系统进行管理的

  • 全局区:存放全局变量和静态变量以及常量

  • 栈区:由编译器自动分配释放, 存放函数的参数值,局部变量等

  • 堆区:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收

关于Java的 方法存储、,C++的函数存储位置这些知识我会单独开文讲解:

链接: //TODO

类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。


Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于c++语言中的虚函数 (c++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法

1、关于C++的虚函数:

加上了Virtual 就和java的普通成员函数一样了,在编译期无法确定函数地址。

C++的普通函数、类内非virtual函数,都是静态绑定的,java如果想实现静态绑定,就需要用final修饰这个函数,防止它被子类重写。

class Animal
{
public:
	//Speak函数就是虚函数
	//函数前面加上virtual关键字,变成虚函数,那么编译器在编译的时候就不能确定函数调用了。
	virtual void speak()
	{
		cout << "动物在说话" << endl;
	}
};

class Cat :public Animal
{
public:
	void speak()
	{
		cout << "小猫在说话" << endl;
	}
};


class Dog :public Animal
{
public:

	void speak()
	{
		cout << "小狗在说话" << endl;
	}

};

//如果函数地址在编译阶段就能确定,那么静态联编
//如果函数地址在运行阶段才能确定,就是动态联编
void DoSpeak(Animal & animal)
{
	animal.speak();
}

2、C++的virtual关键字 

         1)虚继承

菱形继承会出现子类内存中重复出现父类属性,容易出现二义性。

通过虚继承,解决重复属性问题,让子类对象存入的不再是属性本身,而是变成了虚基类指针vbptr,通过vbptr指向虚基类表 vbtable,根据表内数值确定偏移量,解决子类对象菱形继承重复继承属性问题。

//
// Created by alex on 2023/9/25.
//
#include "iostream"
#include "string"

using namespace std;

//虚基类测试
class VBase {
public:
    int age;


};


class Son_A : virtual public VBase {
};

class Son_B : virtual public VBase {
};

class GrandSon : public Son_A, public Son_B {
    //菱形继承 虚继承
};

class Son_C : public VBase {
};

class Son_D : public VBase {
};

class GrandSon2 : public Son_C, public Son_D {
    //菱形继承
};

void test_Gs1() {
    GrandSon gs;

    gs.Son_A::age = 100;
    gs.Son_B::age = 300;
    gs.age = 400;
    gs.Son_A::VBase::age = 10086;

    //内存中只有一份,结果一致,都是10086
    cout << "gs.Son_A::age = " << gs.Son_A::age << endl;
    cout << "gs.Son_B::age = " << gs.Son_B::age << endl;
    cout << "gs.age = " << gs.age << endl;
}


void test_Gs2() {
    GrandSon2 gs;


    gs.Son_C::age = 100;
    gs.Son_D::age = 300;
    // gs.age =10086;

    //各改各自的
    cout << "gs.Son_C::age = " << gs.Son_C::age << endl;
    cout << "gs.Son_D::age = " << gs.Son_D::age << endl;
    // cout << "gs.age = " << gs.age << endl;
}

int main() {

    /**
gs.Son_A::age = 10086
gs.Son_B::age = 10086
gs.age = 10086
--------------
gs.Son_C::age = 100
gs.Son_D::age = 300
     */

    test_Gs1();
    cout << "--------------" << endl;
    test_Gs2();

}

2) 纯虚函数

不再举例 

virtual  返回类型 func() =0; 作用和java的抽象函数类似。

纯虚函数:
    //类中只要有一个纯虚函数就称为抽象类
    //抽象类无法实例化对象
    //子类必须重写父类中的纯虚函数,否则也属于抽象类

上面关于纯虚函数和抽象类的描述 是不是和java神似?

我们再回到java

java的非虚方法:
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的这样的方法称为非虚方法。
静态方法、私有方法、fina1方法、实例构造器、父类方法都是非虚方法
其他方法称为虚方法

构造方法也是不可以被重写的(super和this区分的很明确)

父类方法被调用的时候,也是明明白白的,super.method() 指明了具体的方法。

** 准确的说是 子类调用父类方法。

继承、方法的重写,其实是一种动态绑定(虚方法)的体现,这是动态多态最明显的特征,java的非虚函数都不是动态多态的表现,它们都不可以被重写,换句话说,在编译期,他们就确定了方法的地址

关于invokeDynamic:

JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现[动态类型语言] 支持而做的种改进。

Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。

动态类型语言和静态类型语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
说的再直白一点就是,静态类型语言是判断变量自身的类型信息:动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息这是动态语言的一个重要特征。

动态类型语言最典型的就是:

js:

var  name="Alex";

var age =99;

Java/C/C++都是强类型语言

int  maxValue=65535;

【补充:方法的重写】

在面向对象的编程中I 会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
那么虚方法表什么时候被创建?虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

先借图意思一下,后续补充实例

方法返回地址、

存放调用该方法的pc寄存器的值。
当一个方法的结束,有两种方式:
1、正常执行完成
2、出现未处理的异常,非正常退出


无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息 。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。


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

当一个方法开始执行后,只有两种方式可以退出这个方法:

1、执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char.short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。

2、在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。


动态链接 

也称为指向运行时常量池的方法引用


Q:具体说一说Java的堆


Q:具体说一说Java的方法区

Q:类加载系统

        参考此文【JVM】类加载子系统——自问自答

   

Q:执行引擎的结构和工作

        //单独开文


Q:String的不可变性分析

首先说明String的几个问题:

String 是被final修饰的,表示它不可再被继承

jdk8及其以前 String 内部是 final  char[] value,Jdk9 之后改成了 byte[]

真正想解释这个所谓的不可变性,还是得从JVM内存结构来分析。

不可变性的表现: 对字符串重新赋值时,需要重新制定内存区域赋值,不能使用原来的value进行赋值。

当对现有字符串进行链接操作时,也需要重新指定内存区域赋值,不能使用原来的value进行赋值。

调用String的replace() 修改指定字符或字符串时,同上

1、字面量创建一个字符串的过程:

此时字符串的值声明在字符串常量池中。

pc =0 ldc #2是什么?:

 

 从运行时常量池中取一个元素进操作数栈。

紧接着:astore_1  表示 将操作数栈的引用型变量 保存入局部变量表index1的位置

如果运行时常量池中的条目是字符串常量,即对string类实例的引用,则将对该实例的引用value压入操作数栈。

注意点:

字符串常量池不会存储相同的字符串。

String的 String pool 是一个固定大小的Hashtable,默认长度是 1009,若放入的String 过多,会导致hash冲突严重,从而导致链表很长,链表最大的特点就是插入删除简单,但是遍历时间复杂度高,这样就导致调用String.intern性能大幅下降(intern是会先查重,再插入的,有个遍历过程)

可以使用-XX:StringTableSize设置StringTable的长度

jdk6 StringTableSize 默认1009,大小可以随便修改

jdk7 StringTableSize 默认是60013 大小可以随便设置

jdk8开始,设置StringTableSize长度,不可低于1009

2、new String("xxx")的过程

   public void test2(){
        String ok666 = new String("OK666");

    }

整个过程是 创建对象,从常量池里找到字符串实体对象的引用,通过构造函数给新建对象赋值。

所以:一次 new String(“XX”) 其实底层有两个对象

1、new 关键字在堆空间创建的对象

2、字符串常量池中的对象,字节码指令为 ldc

3、字符串连接符 + 

1、常量与常量的拼接结果在常量池,原理是编译期的优化

2、常量池中不会存在相同内容的常量

3、只要其中一个是变量,结果就存在堆中。变量拼接的底层原理是Stringbuilder

4、如果拼接的结果调用了intern(),则主动将常量池中还没有的字符串对象放入常量池中,并返回此对象的地址。

常量拼接

 @Test
    public void test3(){
        String s1 ="a"+"b"+"c";  //常量拼接
        String s2 ="abc";

        System.out.println(s1 == s2); //true
    }

 带变量的拼接

 @Test
    public void test3(){
        String s1 ="a"+"b"+"c";  //常量拼接
        String s2 ="abc";

        String s3 =s2+"test";

        System.out.println(s1 == s2); //true
    }
字节码执行流程如下:

含有变量的字符串拼接操作,用的是Stringbuilder 对象,append()操作。 最后 toString()返回,存入局部变量表

问题:在new String("a")+new String("b")中,一共创建了几个对象?

5个:

new StringBuilder()

new String("a")

常量池里的a

常量池里的b

new String("b")

new String("ab")

特别强调!!

Stringbuilder的toString()被调用,并不会在常量池中生成 "ab"实例

这里有个细节:

通过查看字StringBuilder的toString()节码, 我们发现他和普通的new String()并不一样。它没用ldc指令。

这个是StringBuilder的 toString(),它的内部看上去好像就是new String(),但是看看他的字节码

它并不是普通的new String("xx")构造法,会用ldc 从常量池加载一个字面量对象的引用变量入栈,直接进行 invokespecial <java/lang/String.<init>>操作。因此常量池在字节码编译期并没有创建一个字面常量对象 "ab"。

可以对比参考 2、new String("xxx")的过程

String intern()的使用

jdk6 中将这个字符串对象尝试放入常量池

 + 如果池中有,并不会放入。返回已有的池中对象的地址。

+ 如果没有,会把对象复制一份,放入池中,并返回池中对象的地址

jdk7 起 中将这个字符串对象尝试放入常量池

 +  如果池中有,并不会放入。返回已有的池中对象的地址。

+   如果没有,则会把对象的引用地址复制一份,放入池中,并返回池中的引用地址。

之所以jdk7有这个改变,我分析原因如下:

jdk6(发布时间 2006年12月) Stringtable 和静态变量还是在堆外的,严格的放在方法区中(永久代)

 jdk7(发布时间 2011年7月) 中    Oracle收购JRocket虚拟机(2008年)之后,重新定义了关于JVM的一些落地实现方案,

它把Stringtable 和静态变量移到了堆内,虽然这个时候方法区还是用的JVM内存,落地实现还叫永久代。

由于字符串常量已经放在了堆中,为了节省堆内存空间,就没用必要再在堆内常量池中再创建一个副本对象了,直接通过指针引用就可以了。

Q:i++和++i的底层理解

**首先,我是真的想问候一下出这种面试题的人,你们的目的何在?你认为一个普通程序员会去关注底层字节码吗?刷存在感呢?让程序员死记硬背这种问题有意思吗,活该你们一辈子当资本鹰犬,当奴才。

   public void test1(){
        int i =10;
        i++;
        System.out.println(i);
    }

上述方法:局部变量表最大slot数是2 index0 =this,index1 =i;

操作数栈的最大深度 2

i++ 具体执行过程如图示

 

 此时,index1位置的变量 i 变成 11

再看看++i

会发现执行结果是一致的:

 

这里总结一句:

自加操作在底层字节码表示的方式是一致的,但是要注意,自加指令是 inc index   by 1

这个操作是在局部变量表中完成的,并没有移入移出栈。这个原则会导致下面现象发生:

  public void test3(){
        int i =10;
        i=i++;
        System.out.println(i);
    }

输出结果是10,这里有个关键问题就是,自加操作,对变量i本身再次赋值:

赋值操作实际上是将 操作数栈中最新的变量,去覆盖对应局部变量表中旧的变量。

pc =3 iload_1 指把LV中 index1 的变量i 取到操作数栈中,

pc=4 iinc 1 by 1 ,LV中 index1 进行自加操作

pc=7 将操作数栈的内容写回 LV index=1的位置。

所以,此时出现取出i=10, i在局部变量表中自加(LV index1 i=11),写回i=10,重新覆盖了 局部变量表中的 i.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值