Java之JVM学习笔记

 

转自:

http://blog.csdn.net/yfqnihao/article/details/8289363

http://blog.csdn.net/zapldy/article/details/7401063

 

 

第一步:先定义一个类JVMTestForJava.java

package test;

 

publicclass JVMTestForJava {

    publicstaticvoid main(String[] args)throws InterruptedException {

        Thread.sleep(10000000);

    }

}

 

第二步:在命令行cmd窗口输入:java test.JVMTestForJava

第三步:打开任务管理器-进程

你会看到一个叫java.exe的进程,这个就是java虚拟机。

java xxx命令就是用来启动一个java虚拟机,main函数就是一个java应用的入口。

 

第四步:改用eclipse执行程序,打开你的ecplise,右键run application,再run application一次(执行两次程序)

第五步:打开任务管理器-进程

会发现多了两个叫javaw.exe,因为刚才运行了两次程序,所以会有两个,即一个javaapplication应用程序对应一个java.exe/javaw.exe(java.exejavaw.exe实际是差不多的,只不过java.exe是在命令行dos下运行的进程,有窗口界面的,而javaw.exe是没有dos窗口界面的)你运行几个application就有几个java.exe/javaw.exe。或者更加具体的说,你运行了几个main函数就启动了几个java应用,同时也启动了几个java的虚拟机。

 

总结:

什么是java虚拟机,什么是java的虚拟机实例?java的虚拟机相当于我们的一个java类,而java虚拟机实例,相当我们new一个java类,不过java虚拟机不是通过new这个关键字而是通过java.exe或者javaw.exe来启动一个虚拟机实例。

 

 

2jvm的生命周期

第一步:测试例子

package test; 

 

publicclass JVMTestLife { 

    publicstaticvoid main(String[] args) { 

        new Thread(new Runnable() { 

            @Override 

            publicvoid run() { 

                for(int i=0;i<5;i++){ 

                    try

                        Thread.currentThread().sleep(i*10000)

                        System.out.println("睡了"+i*10+""); 

                    } catch (InterruptedException e) { 

                        System.out.println("干嘛吵醒我"); 

                   

               

           

        }).start();  

         

        for(int i=0;i<50;i++){ 

                System.out.print(i); 

       

   

 

第二步:ecpliserun application运行这个例子

第三步:打开任务管理器-进程,看到一个javaw.exe的虚拟机在跑

第四步:查看控制台输出,并观察任务管理器中的javaw.exe什么时候消失

输出结果:

012345678910111213141516睡了0

171819202122232425262728293031323334353637383940414243444546474849睡了10

睡了20

睡了30

睡了40

 

这是我ecplise里的输出结果,而如果你观察控制台和任务管理器的javaw.exe会发现,当main函数的for循环打印完的时候,程序还没有退出,而等到整个new Thread()里的匿名类的run方法执行结束后,javaw.exe才退出。我们知道在c++win32编程(CreatThread()),main函数执行完了,寄宿线程也跟着退出了,在c#中如果你用线程池(ThreadPool)的话,结论也是如此,线程都跟着宿主进程的结束而结束。但是在java中貌似和我们的认知有很大的出入,这是为什么呢?

 

这是由于java的虚拟机种有两种线程,一种叫守护线程,一种叫非守护线程main函数就是个非守护线程,虚拟机的gc就是一个守护线程。java的虚拟机中,只要有任何非守护线程还没有结束,java虚拟机的实例都不会退出,所以即使main函数这个非守护线程退出,但是由于在main函数中启动的匿名线程也是非守护线程,它还没有结束,所以jvm没办法退出。

 

总结:java虚拟机的生命周期,当一个java应用main函数启动时虚拟机也同时被启动,而只有当在虚拟机实例中的所有非守护进程都结束时,java虚拟机实例才结束生命。虚拟机的gc是一个守护线程。

 

 

 

3java虚拟机的体系结构

 

 

 

在了解jvm的结构之前,我们有必要先来了解一下操作系统的内存基本结构。

操作系统内存布局:

 

 

 

 

 

那么jvm在操作系统中如何表示的呢?

操作系统中的jvm

 

 

 

 

 

Jvm的内存是分布在操作系统的堆内存中的。

 

为什么jvm的内存是分布在操作系统的堆中呢??因为操作系统的栈是操作系统管理的,它随时会被回收,所以如果jvm放在栈中,那java的一个null对象就很难确定会被谁回收了,那gc的存在就一点意义都没有了,而要对栈做到自动释放也是jvm需要考虑的,所以放在堆中就最合适不过了。

 

操作系统+jvm的内存简单布局

 

 

 

从上图中,你有没有发现什么规律,jvm的内存结构居然和操作系统的结构惊人的一致,你能不能给他们对号入座?还不能,没关系,再来看一个图,我帮你对号入座。看我下面红色的标注

 

 

 

从这个图,你应该不难发现,原来jvm的设计的模型其实就是操作系统的模型基于操作系统的角度,jvm就是个该死的java.exe/javaw.exe,也就是一个应用,而基于class文件来说,jvm就是个操作系统,而jvm的方法区,也就相当于操作系统的硬盘区,所以你知道我为什么喜欢叫他permanent区吗,因为这个单词是永久的意思,也就是永久区,我们的磁盘就是不断电的永久区嘛,是一样的意思啊,多好对应啊。而java栈和操作系统栈是一致的,无论是生长方向还是管理的方式,至于嘛,虽然概念上一致目标也一致,分配内存的方式也一致 (new,或者malloc等等),但是由于他们的管理方式不同,jvm是gc回收,而操作系统是程序员手动释放,所以在算法上有很多的差异,gc的回收算法,估计是jvm里面的经典啊。

 

看下面的图。

 

 

 

将这个图和上面的图对比多了什么?没错,多了一个pc寄存器,我为什么要画出来,主要是要告诉你,所谓pc寄存器,无论是在虚拟机中还是在我们虚拟机所寄宿的操作系统中功能目的是一致的,计算机上的pc寄存器是计算机上的硬件,本来就是属于计算机,(这一点对于学过汇编的同学应该很容易理解,有很多的寄存器eaxesp之类的32位寄存器,jvm里的寄存器就相当于汇编里的esp寄存器),计算机用pc寄存器来存放伪指令或地址,而相对于虚拟机pc寄存器它表现为一块内存(一个字长,虚拟机要求字长最小为32)虚拟机的pc寄存器的功能也是存放伪指令,更确切的说存放的是将要执行指令的地址,它甚至可以是操作系统指令的本地地址,当虚拟机正在执行的方法是一个本地方法的时候,jvmpc寄存器存储的值是undefined,所以你现在应该很明确的知道,虚拟机的pc寄存器是用于存放下一条将要执行的指令的地址(字节码流)

 

再对上面的图扩展

 

 

 

 

多了什么?没错多了一个classLoader,其实这个图是要告诉你,当一个classLoder启动的时候,classLoader的生存地点在jvm中的堆,然后它会去主机硬盘上将A.class装载到jvm的方法区,方法区中的这个字节文件会被虚拟机拿来new A字节码(),然后在堆内存生成了一个A字节码的对象,然后A字节码这个内存文件有两个引用一个指向Aclass对象,一个指向加载自己的classLoader,如下图。

 

 

 

A字节码这个内存文件有两个引用一个指向Aclass对象,一个指向加载自己的classLoader

那么方法区中的字节码内存块,除了记录一个class自己的class对象引用和一个加载自己的ClassLoader引用之外,还记录了什么信息呢??我们还是看图,

 

 

 

 

 

 

你仔细将这个字节码和我们的类对应,是不是和一个基本的java类惊人的一致?下面你看我贴出的一个类的基本结构。

 

package test;

 

import java.io.Serializable;

 

publicfinalclassClassStructextends Objectimplements Serializable {// 1.类信息

    // 2.对象字段信息

    private Stringname;

    privateintid;

 

    // 4.常量池

    publicfinalintCONST_INT = 0;

    publicfinal StringCONST_STR ="CONST_STR";

 

    // 5.类变量区

    publicstatic Stringstatic_str ="static_str";

 

 

    // 3.方法信息

    publicstaticfinal String getStatic_str()throws Exception {

        return ClassStruct.static_str;

    }

}

 

你将上面的代码注解和上面的那个字节码码内存块按标号对应一下,有没有发现,其实内存的字节码块就是完整的把你整个类装到了内存而已。

所以各个信息段记录的信息可以从我们的类结构中得到

 

 1.类信息:修饰符(public final)

                        是类还是接口(class,interface)

                        类的全限定名(Test/ClassStruct.class)

                        直接父类的全限定名(java/lang/Object.class)

                        直接父接口的权限定名数组(java/io/Serializable)

     也就是 public final class ClassStruct extends Object implements Serializable这段描述的信息提取

      

2.字段信息:修饰符(pirvate)

                            字段类型(java/lang/String.class)

                            字段名(name)

        也就是类似private String name;这段描述信息的提取

       

3.方法信息:修饰符(public static final)

                          方法返回值(java/lang/String.class)

                          方法名(getStatic_str)

                          参数需要用到的局部变量的大小还有操作数栈大小(操作数栈我们后面会讲)

                          方法体的字节码(就是花括号里的内容)

                          异常表(throws Exception)

       也就是对方法public static final String getStatic_str ()throws Exception的字节码的提取
      

 4.常量池:

              4.1.直接常量:

                  1.1  CONSTANT_INGETER_INFO整型直接常量池public final int CONST_INT=0;

                  1.2  CONSTANT_String_info字符串直接常量池   public final String CONST_STR="CONST_STR";

                   1.3  CONSTANT_DOUBLE_INFO浮点型直接常量池

                 等等各种基本数据类型基础常量池(待会我们会反编译一个类,来查看它的常量池等。)

                    

4.2.方法名、方法描述符、类名、字段名,字段描述符的符号引用

            也就是所以编译器能够被确定,能够被快速查找的内容都存放在这里,它像数组一样通过索引访问,就是专门用来做查找的。

            编译时就能确定数值的常量类型都会复制它的所有常量到自己的常量池中,或者嵌入到它的字节码流中。作为常量池或者字节码流的一部分,编译时常量保存在方法区中,就和一般的类变量一样。但是当一般的类变量作为他们的类型的一部分数据而保存的时候,编译时常量作为使用它们的类型的一部分而保存

     

5.类变量:

                  就是静态字段( public static String static_str="static_str";)

                  虚拟机在使用某个类之前,必须在方法区为这些类变量分配空间。

     

 6.一个到classLoader的引用,通过this.getClass().getClassLoader()来取得为什么要先经过class呢?思考一下,然后看第七点的解释,再回来思考。(是因为看那个图,指向classLoader对象的是在方法区里的class字节码?)这个引用实际上是地址信息,不是引用对象。见4.5。引用对象是存放在栈中的

     

7.一个到class对象的引用,这个对象存储了所有这个字节码内存块的相关信息。所以你能够看到的区域,比如:类信息,你可以通过this.getClass().getName()取得

所有的方法信息,可以通过this.getClass().getDeclaredMethods(),字段信息可以通过this.getClass().getDeclaredFields(),等等,所以在字节码中你想得到的,调用的,通过class这个引用基本都能够帮你完成。因为他就是字节码在内存块在堆中的一个对象。这个引用实际上是地址信息,不是引用对象。见4.5。引用对象是存放在栈中的

 

8.方法表,如果学习c++的人应该都知道c++的对象内存模型有一个叫虚表的东西,java本来的名字就叫c++- -,它的方法表其实说白了就是c++的虚表,它的内容就是这个类的所有实例可能被调用的所有实例方法的直接引用。也是为了动态绑定的快速定位而做的一个类似缓存的查找表,它以数组的形式存在于内存中。不过这个表不是必须存在的,取决于虚拟机的设计者,以及运行虚拟机的机器是否有足够的内存

 

通篇总结:

       首先,当一个程序启动之前,它的class会被类装载器装入方法区(不好听,其实这个区我喜欢叫做Permanent)执行引擎读取方法区的字节码自适应解析,边解析就边运行(其中一种方式),然后pc寄存器指向了main函数所在位置,虚拟机开始为main函数在java中预留一个栈帧(每个方法都对应一个栈帧),然后开始跑main函数,main函数里的代码被执行引擎映射成本地操作系统里相应的实现,然后调用本地方法接口,本地方法运行的时候,操纵系统会为本地方法分配本地方法栈,用来储存一些临时变量,然后运行本地方法,调用操作系统API等等。

 

 

 

4Java虚拟机内存模型

 

 

 

4.1、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,又叫PC寄存器,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

         由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

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

对于natvie关键字以及方法,请参考

http://www.blogjava.net/shiliqiang/articles/287920.html

实际就是java调用本地其他语言的库.

 

 

4.2Java虚拟机栈

        与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame①)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

        经常有人把Java 内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的“堆”在后面会专门讲述,而所指的就是现在讲的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

        局部变量表存放了编译期可知的各种基本数据类型booleanbytecharshortintfloatlongdouble)、对象引用reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

        其中64 位长度的long和double 类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

        在Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java 虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

 

4.3、本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

 

4.4Java

        对于大多数应用来说,Java 堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配①,但是随着JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换②优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

        Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden 空间、From Survivor空间、To Survivor 空间等。如果从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域的作用进行讨论,Java堆中的上述各个区域的分配和回收等细节将会是下一章的主题。

         根据Java 虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

 

4.5、方法区

       方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。方法区就是非堆。这就是为什么tomcat加载项目时项目太大又没有调非堆内存大小的话,会报java.lang.OutOfMemoryError: PermGen space溢出,是因为你load了太多class文件进非堆,导致非堆溢出。java.lang.OutOfMemory错误的堆溢出

        对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为永久代Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockitIBM J9等)来说是不存在永久代的概念的。即使是HotSpot虚拟机本身,根据官方发布的路线图信息,现在也有放弃永久代并搬家Native Memory来实现方法区的规划了。

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

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

4.6运行时常量池

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

         Java 虚拟机对Class文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

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

 

4.7直接内存

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

         在JDK 1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native 堆中来回复制数据。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM 及SWAP 区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常

 

5、具体实例

逻辑内存模型我们已经看到了,那当我们建立一个对象的时候是怎么进行访问的呢?

        在Java 语言中,对象访问是如何进行的?对象访问在Java语言中无处不在,是最普通的程序行为,但即使是最简单的访问,也会却涉及Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系,如下面的这句代码:

 

Object obj = new Object();

 

假设这句代码出现在方法体中,“Object obj”这部分的语义将会反映到Java栈的本地变量表中,作为一个reference类型数据出现“new Object()”这部分的语义将会反映到Java堆中,形成一块存储了Object类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在Java堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

 

综上所述,Object obj这个引用对象(reference类型数据)是存放在Java中的,是存放在Java栈中的本地变量表中,new Object()新建的对象是存放在java堆中的,此外,方法区还存放了查找到这个对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息。

所以,引用是存放在栈中的,对象是存放在堆中的,地址信息是存放在方法区中的。

 

于reference类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。如果使用句柄访问方式,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如下图所示:

 

 

 如果使用直接指针访问方式,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址,如下图所示

 

 

 

 

 

 

 

 

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值