Java内存结构分析

一、Java内存结构划分

 Java虚拟机的运行时数据区域主要包括程序计数器、Java虚拟机栈、本地方法栈、堆、方法区。

(1)程序计数器(Program Counter Register)

它是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

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

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

(2)Java虚拟机栈(Java Virtual Machine Stacks)

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

局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象应用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是一个代表对象句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

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

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

一个线程中的方法调用链可能会很长,,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图:

(3)本地方法栈(Native Method Stack)

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

(4)堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,即所有的对象实例都在这里分配内存。

Java堆是垃圾收集器的主要区域,因此很多时候也被称作”GC堆“。从内存回收的角度来看,由于现在收集器基本都是采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

(5)方法区(Method Area)

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

相对而言,垃圾收集行为在这个区域是比较少出现的。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

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

常量池表(Constant Pool Table)

我们写的每一个Java类被编译后,就会形成一份class文件(每个class文件都有一个class常量池)。class文件除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant Pool Table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic Reference)。

  • 字面量,即通过字面我们就能知道其含义。包括:1、文本字符串;2、八种基本类型的值;3、被声明为final的常量等。
  • 符号引用包括:1、类和接口的全限定名;2、字段的名称和描述符;3、方法的名称和描述符

常量池表会在类加载之后存放到方法区的运行时常量池。

运行时常量池(Runtime Constant Pool)

是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放,由此可知,运行时常量池也是每一个类都有一个

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

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

class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询 字符串常量池,以保证 运行时常量池 引用的字符串与字符串常量池中所引用的是一致的。

字符串常量池(String Pool)

字符串常量池存的是引用值,而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。是在类加载完成后,经过验证、准备阶段之后,在堆中生成字符串的对象实例,然后将该字符串对象实例的引用值存放到String Pool中。

直接内存(Direct Memory)

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

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

符号引用和直接引用

  • 符号引用:包含三种:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
  • 直接引用:内存地址

二、Java GC回收机制

1、判断对象是否存活的方法

(1)引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的对象。

客观地说,引用计数法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是,至少主流的Java虚拟机里面没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

比如:对象objA和objB都有字段instance,赋值令objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用技术都不为0,于是引用计数法无法通知GC收集器回收它们。

(2)可达性分析(根搜索法)

在主流的商用程序语言(Java、C#等)的主流实现中,都是通过可达性分析(Reachability Analysisi)来判定对象是否存活的。这个算法的基本思路就是通过一系列称为”GC Roots“的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  • 方法区中静态属性引用的对象(一般指被static修饰的对象,加载类的时候就加载到内存中)
  • 方法区中常量引用的对象

上面的可达性分析算法中不可达的对象就一定会被回收掉么?

即使在可达性分析算法中不可达的对象,也并非是”非死不可“的,这时候它们暂时处于”缓刑“阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为”没有必要执行“。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的”执行“是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个操作内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只需要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除”即将回收“的集合;如果对象这个时候还没有逃脱,那基本上它就真的被回收了。

2、引用类型

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用

(1)强引用(StrongReference)

强引用就是指在程序代码中普遍存在的,类似 Object obj=new Object()   这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

(2)软引用(SoftReference)

软引用时用来描述一些还有用但是并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

(3)弱引用(WeakReference)

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能存活到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

public class WeakReference<T> extends Reference<T> {

    /**
     * Creates a new weak reference that refers to the given object.  The new
     * reference is not registered with any queue.
     *
     * @param referent object the new weak reference will refer to
     */
    public WeakReference(T referent) {
        super(referent);
    }

    /**
     * Creates a new weak reference that refers to the given object and is
     * registered with the given queue.
     *
     * @param referent object the new weak reference will refer to
     * @param q the queue with which the reference is to be registered,
     *          or {@code null} if registration is not required
     */
    public WeakReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }

}

可以看到弱引用WeakReference继承自Reference,有两个构造方法:

  • 只有一个参数的构造方法,是把传入的对象包装成弱引用;
  • 有两个参数的构造方法,除了把传入的对象包装成弱引用之外,还与传入的ReferenceQueue关联。(弱引用被注册到引用队列中)

(4)虚引用(PhantomReference)

虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否存在该对象的虚引用,来了解这个对象是否将要被回收。可以用来作为GC回收Object的标志。

虚引用有一个很重要的用途就是用来做堆外内存的释放,DirectByteBuffer(直接内存)就是通过虚引用来实现堆外内存释放的。

3、回收方法区

很多人认为方法区(或HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的”性价比“一般比较低:在堆中,尤其在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量与回收Java堆中的对象非常类似。以常量池中字面量回收为例,例如一个字符串”abc“已经进入了常量池中,但是当前系统没有任何一个String对象是叫做”abc“的,换句话说,就是没有任何String对象引用常量池中的”abc“常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个”abc“常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否是”废弃常量“比较简单,而要判定一个类是否是”无用的类“的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是”无用的类“:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是”可以“,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

4、垃圾回收算法

(1)标记-清除法(Mark-Sweep)

 如同它的名字一样,算法分为”标记“、”清除“两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

它的不足有两个:

  • 效率问题,标记和清除两个过程的效率都不高
  • 空间问题,标记清除之后会产生大量的不连续的内存碎片(空间碎片太多可能导致以后在程序的运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次GC)

(2)复制算法(Copying)

 为了解决效率问题,一种称为”复制“的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

不足:

只能使用一半的内存空间,持续复制长期生存的对象则导致效率低下。

(3)标记-整理法(Mark-Compact)

 该算法标记过程虽然与”标记-清除“算法一样,但后续步骤不是直接对回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

(4)分代回收算法(Generational Collection)

当前商业虚拟机的垃圾收集都采用”分代收集“算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去(朝生夕死),那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用”标记-清理“或者”标记整理“算法来进行回收。

由上图可以看出,把堆区分外新生代和老年代。新生代又分为Eden区、s0(FromSurvivor )、s1(ToSurvivor ),默认比例为8:1。

一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次GC后,如果仍然存活将会被移动到Survivor区。对象在Survivor区中每熬过一次GC,年龄就会增加1岁,当它的年龄增加到一定程度,就会被移动到老年代中。因为年轻代中的对象基本上都是朝生夕死(80%以上),所以在年轻代的垃圾回收算法使用的复制算法,复制算法不会产生内存碎片。

具体步骤如下:

第一次GC:

在不断创建对象的过程中,当Eden区域被占满,此时开始做Minor GC

1)第一次GC时Survivors中S0区和S1区都为空,将其中一个作为To Survivous(用来存储Eden区域执行GC后不能被回收的对象)。比如:将S0作为To Survivous,则S1为From Survivors。

2)将Eden区域经过GC不能被回收的对象存储到To Survivors(S0)区域(此时Eden区域的内存会在垃圾回收的过程中全部释放),但如果 To Survivors(S0)被占满了,Eden中剩下不能被回收对象只能存放到Old区域。

3)将Eden区空间清空,此时From Survivors区域(S1)也是空的。

4)S0与S1互相切换标签,S0为From Survivor,S1为To Survivors

第二次GC:

 当第二次Eden区域被占满时,此时开始做GC

1)将Eden和From Survivors(S0)中经过GC未被回收的对象迁移到 To Survivous(S1),如果To Survivors(S1)区放不下,将剩下的不能回收的对象放入Old区域

2)将Eden区域空间和 From Survivors(S0)区域空间清空

3)S0与S1互相切换,S0为To Survivors,S1为From Survivors

第三次,第四次依次类推,始终保持S0和S1有一个是空的,用来存储临时对象,用于交换空间的目的。反复多次没有被淘汰的对象,将会被放入Old区域中,默认15次(由参数--XX:MaxTenuringThreshold=15 决定)。

5、GC的分类

如上图所示,GC分为:Minor GC、Yong GC、Major GC、Full GC

(1)Minor GC

Minor GC是发生在新生代中的垃圾收集动作,准确的说是Eden区的GC(不包括S0和S1),采用的是复制算法

对象在Eden区和From区出生后,在经过一次Minor GC,如果对象还存活,并且能够被To区所容纳,那么在使用复制算法时这些存活的对象就会被复制到To区域,然后清理掉Eden区和From区,并将这些对象的年龄设置为1,以后对象在Survivor区每熬过一次Minor GC,就将对象的年龄+1,当对象的年龄达到某个值时(默认是15岁,可以通过参数 --XX:MaxTenuringThreshold设置),这些对象就会成为老年代。

(2)Yong GC

Yong GC也是发生在新生代中的GC,包括Eden区、S0和S1。

(3)Major GC/Old GC

Major GC 是发生在老年代的GC,当老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC。

CMS 收集器中,当老年代满时会触发Major GC。

目前,只有CMS收集器会有单独收集老年代的行为。其他收集器均无此行为。

(4)Full GC

Full GC是对整堆(新生代、老年代)和方法区的GC。包含(Eden、S0、S1、Old)。

  • 当老年代空间不足时会触发Full GC
  • 调用System.gc时,系统建议执行Full GC,但是不一定会执行
  • 通过Minor GC后进入老年代的对象大小 大于 老年代的剩余空间大小(空间分配担保失败)
  • 方法区空间不足

6、什么情况下对象会从新生代进入老年代

(1)一定次数的Minor GC 后

常规对象被创建之后时存储在新生代的Eden区,每一个对象都有年龄,在Minor GC后,survivor1区还存活的对象的年龄全部+1,当对象年龄达到15时,会被移交到老年代,15是系统默认的,我们可以通过JVM参数-XX:MaxTenuringThreshold来设置

(2)Minor GC后Survivor放不下

在Minor GC之后存活的对象超过了Survivor区的大小,会将这些对象直接转移到老年代。

(3)动态对象年龄判断

如果在Survivor区,有某一年龄的对象的总大小超过了Survivor区大小的50%,年龄大于或等于该年龄的对象就可以直接进入老年代

(4)大对象直接进入老年代

所谓大对象就是指需要大量的连续内存空间的Java对象,比如很长的字符串或数组。

7、空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代中最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于或者HandlePromotionFailure设置不允许冒险,那么这时也要改为进行一次Full GC。

8、垃圾收集器

(1)Serial

从名字可以看出,这是一个串行收集器

Serial收集器是Java虚拟机中最基本、历史最悠久的收集器。在JDK1.3之前是Java虚拟机新生代收集器的唯一选择。Serial收集器并不是只能使用一个CPU进行收集,而是当JVM需要进行垃圾回收的时候,需暂停所有的用户线程,直到回收结束。

使用算法:复制算法

(2)SerialOld

SerialOld是Serial收集器的老年代收集器版本,它同样是一个单线程收集器,这个收集器目前主要用于Client模式下使用。

如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

使用算法:标记-整理法

(3)ParallelOld(JDK8默认)

ParallelOld是并行收集器,和SerialOld一样,ParallelOld是一个老年代收集器。这个收集器是在JDK1.6之后才开始提供的。

(4)CMS

CMS(Concurrent Low Pause Collector)是一个老年代收集器,它是对于相应时间的重要性需求大于吞吐量要求的收集器,对于要求服务器响应速度高的情况下,使用CMS非常适合。

CMS的一大特点,就是用两次短暂的暂停来代替串行或并行标记整理算法时候的长暂停。

使用算法:标记-清理法

(5)G1(GarbageFirst)

这是一个新的垃圾回收器,既可以回收新生代也可以回收老年代。通过重新划分内存区域,整合优化CMS,同时注重吞吐量和响应时间。

三、其他

1、GC为什么会导致应用程序卡顿?

GC线程导致工作线程停止,进而引发用户端出现卡顿。导致工作线程停止的机制叫做STW(Stop the world)。GC线程开启的同时暂停一切用户线程,造成卡顿。

2、GC线程是否可以与工作线程并行?

不可以,如果GC线程与工作线程并行,会引发两个问题:

(1)回收不彻底。GC开始时,不是垃圾(有被引用),而GC结束时又变为垃圾(删除引用)

(2)发生空指针。GC开始时,是垃圾(没有被引用),当结束时又被使用(建立引用)。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值