自动内存管理机制——运行时数据区、对象创建、内存布局、访问、垃圾回收

深入理解Java虚拟机

走进Java

优点:结构严谨、面对对象;摆脱硬件束缚;内存安全管理;热点代码检测和运行时编译优化;有一套完整的应用程序接口,第三方类库
JDK(Java Development Kit):Java程序设计语言、Java虚拟机、Java API类库三部分
JRE(Java Runtime Environment):Java API类库中的Java SE API子集、Java虚拟机两部分

自动内存管理机制

运行时数据区域

  • 程序计数器Program Counter Register:
    可以看作是当前线程所执行的字节码的行号指示器,线程私有内存,如果线程正在执行一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果是Native方法,计数器为空。此内存区域是唯一一个Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
  • Java虚拟机栈Java Virtual Machine Stacks
    线程私有内存,生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时会创建一个栈帧(Stack Frame):存储局部变量表、操作数栈、动态链接、方法出口。每一个方法的调用直至执行完成就对应着一个栈帧在虚拟机栈入栈到出栈的过程。
    局部变量表存储了编译期可知的各种基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)。long、double类型的数据64位占用2个局部变量空间Slot,其余只占用一个。局部变量表所需内存在编译期间就分配完成,当进入一个方法时,这个方法需要在帧中分配多大的局部变量表空间是完全确定的。
    此区域有两种异常:StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度;发生情况之一:递归调用终止条件设置错误
    OutOfMemoryError异常:如果虚拟机栈可以动态扩展,扩展时无法申请到足够内存。
  • 本地方法栈Native Method Stack
    虚拟机栈为虚拟机执行Java方法(字节码)服务;而本地方法栈则为虚拟机使用到的Native方法服务。
  • Java堆Heap
    存放对象实例(栈上分配,标量替换除外),Java Heap是垃圾收集器管理的主要区域,因此也被称为GC堆(Garbage Collected Heap)。堆还可以细分为新生代、老年代;再细致一点,Eden空间、From Survivor空间、To Survivor空间等。堆中也可以分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer)TLAB。
    内存泄露:GC Roots引用链与对象依然相关联,导致垃圾收集器无法回收他们
    内存溢出:内存中的对象都必须存活着,调高物理内存,检查对象生命周期是否可以修改
    此区域异常:OutOfMemoryError:堆中没有内存完成实例分配
  • 方法区Method Area
    和堆一样,各个线程共享的内存区域,被用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。对该区域的内存回收主要是针对常量池的回收和类卸载
    异常:OutOfMemoryError(经常动态生成大量Class应用)
  • 运行时常量池Runtime Constant Pool
    是方法区的一部分。Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池Constant Pool Table,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类的加载后进入方法区的运行时常量池。对于常量池,格式有着严格的规定,但对于运行时常量池,没有这些要求。除了保存Class文件中描述的符合引用,还会将翻译出来的直接引用也存储在运行时常量池,同时运行期间也可以将新的常量放入池中,比如String类的intern()方法。
    异常:作为方法区的一部分OutOfMemoryError
  • 直接内存Direct Memory
    NIO,通过Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,避免了Java堆和native堆的数据复制,提高了性能(可能堆类的数据被GC掉,不适合异步的IO操作,所有NIO都会将堆内数据存到DirectBuffer再异步IO)。
    异常:不会受到Java堆大小的限制,但受本机总内存及寻址空间的限制,OutOfMemoryError

对象的创建(普通Java对象)

语言层面上一个new关键字,首先检查这个指令的参数是否能在常量池中定位到一个类的引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过了。如果没有,进行类加载。
1.在类加载检查通过后,接下来虚拟机将为新生对象分配内存。其对象所需内存在类加载完即可确定。
分配内存的方式有两种:

  • 指针碰撞,内存绝对规整,已分配的放一边,空闲的放一边,指针挪动对象大小就好
  • 空闲列表,内存不规整,虚拟机维护一个列表,记录可用的内存块

考虑到对象内存的划分不是线程安全的行为,可能正在给对象A分配内存,指针还未修改,另一个线程又分配B对象,使用了原来的指针,因此应该对其进行同步处理,采用CAS加失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分到不同空间,也就是本地线程分配缓存TLAB。

2.内存分配完成后,虚拟机要将分配到的内存空间都初始化为零值(不包括对象头);这一步操作的目的是为了保证对象的实例字段在Java代码中可以不被赋值而直接使用,访问到各个字段的数据类型所对应的0值。接下来是对对象头的设置,例如这个对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象GC的分代年龄等。根据虚拟机当前运行状态不同,如是否采用偏向锁等,对象头会有不同的设置。

3.上述操作完成后,从虚拟机的视角来看,一个新的对象已经产生,但从Java程序来看,才刚开始。执行init方法,由字节码是否跟随invokespecial指令决定。将对象按照程序员的意愿初始化后,对象才算完全产生出来。

创建对象的线程不安全(双重检查锁定)Java并发编程艺术:
在这里插入图片描述单线程下首先分配内存,而后可以直接设置instance指向刚分配的内存空间,随后在初始化,这并不影响程序的执行结果,因此是被允许进行指令重排序优化的
在这里插入图片描述而多线程下,这样的指令重排序可能造成线程B所得到的访问对象并非已完成初始化的对象,毕竟线程B仅进行了判空,而此时对象非空,造成了线程不安全问题,因此应该为对象加上关键词volitail。

对象的内存布局

对象的内存布局可以分为3块区域:对象头(header)、实例数据(instance Data)、对齐填充(Padding)

  • 对象头Header
    分为两部分信息,第一部分用于存储对象自身的运行时数据,HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。官方称为MarkWord,大小32或者64bit(未开启压缩指针)。
    对象头的另一部分信息为类型指针,即对象指向他类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。
    如果对象为一个数组,还需要对象头有一块用于记录数组长度的指针,因为虚拟机可以通过类型指针查到元数据信息来确定Java对象的大小以分配内存,而数组不可以通过元数据获得大小。
  • 实例数据Instance Data
    对象真正存储数据部分,程序中所定义的各种类型的字段内容。无论是父类继承还是子类定义而来。父类定义的变量会出现在子类之前。
  • 对齐填充Padding
    不是必然存在的,也没有特殊意义,起着占位符的作用。自动内存管理系统要求对象的起始地址必须是8字节的整数倍。对象头是8字节的整数倍,因此当对象实例数据部分没有对齐时,通过对齐填充来补全。

对象的访问定位

建立对象是为了使用对象,在局部变量表中reference数据操作堆上的具体数据:

  • 句柄访问:Java堆上划分出一块内存作为句柄池,reference对象存储的是对象的句柄地址,而句柄中包含了对象实例数据和对象类型数据的具体指针。
    优势:在对象被移动时,只需要改变句柄中的实例数据指针,而不需要改变reference本身。
  • 直接指针访问:reference中存放的直接就是对象地址,HotSpot使用的就是这种访问方式。
    优势:访问速度快,节省了一次指针定位时间。

垃圾收集器与内存分配策略

Garbage Collection1960年就有了,人们就考虑内存动态分配和垃圾收集。需要考虑三件事情:哪些内存需要回收?什么时候回收?如何回收?
程序计数器、虚拟机栈、本地方法栈随线程诞生消亡;栈中的栈帧随着方法的进入和退出而有条不紊的进行,每个栈帧分配多少内存也是类结构确定下来时就已知的(不考虑运行期的编译器优化),因此这几个区域的内存分配回收都具备确定性。随着方法或线程的结束,内存也就回收了。
堆和方法区则不一样,这部分内存的分配和回收都是动态的,因此垃圾收集器需要关注这一部分。

堆回收——对象死亡判断

  • 引用计数算法
    给对象添加一个引用计数器,当一个地方引用他,计数器值就加1;引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
    但主流虚拟机未采用,未被采用的原因是很难解决对象之间相互引用的问题:
    对象objA和objB都有字段instance,令objA.instance=objB; objB.instance=objA。除此之外,两对象再无引用,实际上对象不可能被访问,但是却因为相互引用着对方,导致引用计数器不为0。
  • 可达性分析算法
    基本思想:通过一系列GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链Reference Chain。当从GC Roots到此对象不可达时就判断为可回收对象。
    tracing GC的本质是通过找出所有活对象来把其余空间认定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间。
    GC Roots对象包括:
    虚拟机栈(栈中局部变量表)中引用的对象
    方法区中类静态属性引用的对象
    方法区中常量引用的对象
    本地方法栈中引用的对象
    (保证正在虚拟机栈中运行的还在用的对象(还没消失的局部变量表中的引用,毕竟出方法后你还想用会自己new,而静态属性引用的对象可不需要new)以及可以用的对象(静态属性引用的对象)都不会被回收)。全局性的引用与执行上下文中
    在分代收集算法中,不同代的GC Roots还不太一样。
    引用:
    JDK1.2之前,引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这个内存代表着一个引用。状态只有两种(引用,没引用)。后续对引用概念进行了扩充:
    强引用:只有存在,就不会被回收
    软引用:描述一些还有用但非必须,会被列入第二次GC对象,如果还没有足够的内存,报溢出
    弱引用:只能生存到下一次GC,不管内存是否足够都会被GC
    虚引用:无法通过虚引用获得实例,唯一目的是能在这个对象被回收时收到一个系统通知

要真正宣布一个对象死亡,至少两次标记:第一次发现不可达,进行标记并进行一次筛选,筛选的条件是该对象是否有必要执行finalize()方法,当对象没有finalize()方法或者已经执行过一次,就可以视为不需要执行。只能通过finalize方法自救一次。

回收方法区

方法区主要回收两部分内容:废弃常量和无用的类。

  • 废弃常量:
    以常量池中“abc”为例,此时系统没有任何一个String对象是叫做“abc”的,没有任何对象引用了“abc”这个常量,因此可以将其清理出常量池。
  • 无用的类:
    需要满足三个条件才可以被回收:1、该类所有实例都被回收 2、加载该类的ClassLoader被回收 3、该类对应的class对象没有任何地方被引用,无法通过反射访问该类的方法

垃圾收集算法

  • 标记——清除算法 Mark-Sweep
    分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
    不足:效率问题,标记和清除效率都不高;空间问题,会产生大量不连续的内存碎片,可能导致后续需要分配大对象时,无法找到足够的连续内存而不得不提前触发一次GC

  • 复制算法 Copying
    将内存按照容量大小分为大小相等的两块,每次使用其中一块。当这一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已经使用过的内存空间清理一遍。内存分配不用考虑碎片情况,只有移动指针,按顺序分配内存即可。
    不足:内存缩小为原来的一半了
    并不需要按照1:1分配,98%对象都是朝生息死。将内存分配为Eden空间(8成)、两块较小的Survivor空间。也就新生代每次可用内存为90%,只有10%被浪费掉了。如果回收后多于10%内存的对象存活,也就Survivor空间不够用的时候,使用老年代内存。

  • 标记——整理算法 Mark-Compact
    复制算法在对象存活率较高时,需要进行较多复制操作,效率很低,此外如果不想浪费50%空间,就需要额外的空间进行内存担保(老年代)。所以老年队一般不能直接采用这种算法,毕竟他存活对象多、没有其他内存担保。
    让存活的对象都向一端移动,然后直接清理掉端边界以外的内存

  • 分代收集算法 Generational Collection
    根据对象存活周期不同将内存划分为几块。把Java堆分为新生代和老年代,根据年代的特点采用不同的收集算法。
    新生代:每次垃圾收集都要大量对象死亡,只有少量存活,采用复制算法。
    老年代:对象存活率高,没有额外空间对他分配进行担保,使用标记—清理或者标记—整理算法进行回收。
    young GC比full GC的GC roots还要更大一些,包括新生代中满足GC Root定义的对象、卡表中老年代引用的新生代对象
    具体到分两代的分代式GC来说,如果第0代叫做young gen,第1代叫做old gen,那么如果有minor GC / young GC只收集young gen里的垃圾,则young gen属于“收集部分”,而old gen属于“非收集部分”,那么从old gen指向young gen的引用就必须作为minor GC / young GC的GC roots的一部分。试想如果根据GC Roots追到老年代数据区,你不停手继续往下追,追完发现没有新生代的数据,岂不是白追了。可是又不能不追,那会导致错误回收新生代。所有在回收新生代的时候,对老年代的看法,不是他是不是属于full GC 对应的GC Roots,而是他是否有引用新生代对象。
    在young GC中不收集old gen,所以old gen里的对象此时全部都会被认为是活的,那么从这些对象出发指向young gen的引用就必须被认为是活的。因而把这些引用看作root set的一部份。但是怎么知道哪些老年代区域的对象指向新生代对象呢?
    那就需要卡表card table。
    卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

HotSpot算法实现

  • 枚举根节点
    GC Roots由全局性的引用与执行上下文中,如果逐个检查必然消耗很多时间,此外分析工作必须在一个确保一致性的快照中进行,不可以出现分析过程中对象引用关系还在不断变化的情况。GC过程中所有Java执行线程需要停顿(Stop the World)。
    主流虚拟机采用准确说GC,准确式内存管理:知道内存中某个位置数据是什么类型的(例子:知道是地址还是数值),因此虚拟机是有办法直接得知到哪些对方存放着对象引用。
    使用一组OopMap的数据结构,在类加载完成后,Hotspot就把对象内什么偏移量是什么类型的数据结构计算出来;在JIT编译中,也会在特定位置记录下栈和寄存器中哪些位置是引用。这样GC扫描时就可以直接得到这些信息。
  • 安全点
    在OopMap帮助下可以快速完成根节点的枚举,如果为每一条指令都生成对应的OopMap,那么需要大量的额外空间,GC成本很高。
    实际上只是在特定的位置记录这些信息,这些位置称为安全点SafePoint。安全点选定基本上是以程序是否具有让程序长时间执行的特征为标准进行选定的。正常程序不太会因为指令流太长而长时间运行,因为每条指令执行时间非常短暂。因此长时间执行的明显特征是指令复用(方法调用、循环跳转、异常跳转等)。
    此外如何在GC发生时让所有线程都跑到最近的安全点再停顿下来。
    抢先式中断:Preemptive Suspension:GC发生时,首先让所有线程中断,如果发现有线程中断地方不在安全点,就恢复线程,让他跑到安全点上 。几乎没有虚拟机这么做。
    主动式中断:Voluntary Suspension:当GC需要中断线程时,仅仅设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志为真时,就自己中断挂起。发生轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
  • 安全区域
    SafePoint保证了程序执行时,在不太长的时间内就可以遇到可进入GC的SafePoint。但是程序不执行时,没有分配CPU时间(Sleep、Blocked),这是线程无法响应JVM的中断请求,运行到安全点中断挂起。需要使用安全区域Safe Region。
    安全区域指在一段代码执行中,引用关系不会发生改变。在这个区域任意地方发生GC都是安全的,可以将其看作是扩展的安全点。
    在JVM发起GC时候,不用管标识自己为Safe Region状态的线程了。在此线程要离开Safe Region时,检查系统是否已经完成根节点的枚举(或者整个GC过程),如果完成了,那线程继续执行,否则等到可以离开Safe Region的信号为止。

垃圾收集器

Parallel并行:用户线程处于等待状态,多条垃圾收集线程并行工作
Concurrent并发:用户线程和垃圾收集线程同时执行(但不一定是并行的,可能是交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

  • Serial收集器
    单线程收集器,暂停其他所有工作线程,使用单线程完成垃圾收集工作。
    新生代采用复制算法;老年代采用标记—整理算法
    用于client模式下的虚拟机,分配给虚拟机的内存不会很大,停顿时间可以控制在一百多毫秒内。
  • ParNew收集器
    是Serial收集器的多线程版本,可以和CMS(Concurrent Mark Sweep老年代收集器)搭配使用
    参数 -XX:ParallelGCThreads 控制线程数
    新生代采用复制算法;老年代采用标记—整理算法
  • Parallel Scavenge收集器
    新生代收集器,也是复制算法收集器,也是多线程并行的收集器,看起来和ParNew都一样,有什么特别之处呢?
    目标是为了达到可控制的吞吐量(Throughput:CPU用于用户代码的时间与CPU总消耗时间的比值),也就是虚拟机总共运行100min,垃圾收集1分钟,那么吞吐量99%。停顿时间越短越适合与用户交互的程序。
    参数:-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间 GC停顿时间的缩减是以牺牲吞吐量和新生代空间换取的:系统会将新生代调小,导致收集更加频繁,停顿时间在下降,但是吞吐量也在下降
    -XX:GCTimeRatio直接设置吞吐量大小:默认值是99,也就是1%,1/(1+99)
  • Serial Old收集器
    Serial收集器的老年代版本,采用标记整理算法
  • Parallel Old收集器
    Parallel Scavenge收集器老年代版本。在其出现之前,由于Parallel Scavenge收集器不能和CMS配合使用,只能和Serial Old配合使用,甚至还不如ParNew + CMS这个组合。
    在其出现后,在注意吞吐量和CPU资源敏感的场合,可以考虑Parallel Scavenge + Parallel Old
  • CMS收集器
    https://www.bilibili.com/read/cv7696168/
    以最短回收停顿时间为目标的收集器。尤其重视服务响应速度,希望系统停顿时间最短,给用户带来良好体验,CMS收集器非常符合此类应用的需求。
    运作过程分为4个步骤:
    1初始标记 CMS initial mark
    2并发标记 CMS concurrent mark
    3重新标记 CMS remark
    4并发清除 CMS concurrent sweep
    初始标记、重新标记这两个步骤仍需stop the world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。并发标记与用户线程并发执行,进行GC Roots Tracing过程;重新标记阶地stop the word,修正并发标记期间因为用户程序继续运作而导致并发标记产生变动的那一部分对象的标记记录,这一阶段停顿时间稍长于初始标记,但远比并发标记时间短。
    由于整个过程中耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户线程一起工作,因此总体上可以看作是并发执行的。
    缺点:
    1、对CPU资源非常敏感,在并发阶段,虽然不会导致用户线程停顿,但是由于占用CPU资源,导致应用程序缓慢,总吞吐量降低。
    2、CMS收集器无法处理浮动垃圾Floating Garbage,因为并行清理阶段,用户程序也会运行,尝试新的垃圾,在标记阶段之后,因此CMS无法收集处理,称为浮动垃圾。
    此外可能由于预留给并发阶段用户线程的内存不足,触发Full GC,不能像其他GC一样,填满再收集,需要预留一部分内存提供给并发收集时的程序使用。
    3、CMS基于标记—清除算法实现,收集结束会有大量内存碎片,可能因为老年代无法找到足够大的连续空间来分配当前对象而提前触发Full GC。为了解决此问题,提供了开关参数用于FullGC时开启内存合并整理的过程。
  • G1收集器
    https://www.zhihu.com/question/62277180/answer/196715976
    在G1收集器之前,其他收集器都是整个新生代、老年代的收集。而G1将整个堆分为多个大小相等的独立区域Region,得益于此,建立了可预测的停顿时间模型,追踪每个Region里面的垃圾堆积价值,优先回收价值最大的Region。
    但是如图分代收集,需要cardtable一样,以Region为单位收集垃圾,其他单位有此Region对象的引用该如何处理,使用Remembered Set来避免全堆扫描。G1每个Region都有对应的Remembered Set,在虚拟机发现对Reference类型数据进行写操作时,会产生一个Write Barrier中断,检查Reference引用的对象是否是不同Region的(分代例子中检查老年代的对象是否引用了新生代的对象,写入卡表,加入GC Roots),如果是,将其写入被引用对象所属Region的Remembered Set之中,内存回收时,将Remembered Set加入到GC根节点枚举范围。

内存分配与回收策略

  1. 对象优先在Eden分配
    大多数情况下,分配在Eden,当Eden没有足够空间分配,触发一次Minor GC(新生代GC)
    实验案例:-Xms20M -Xmx20M -Xmn10M 堆大小20M,不可扩展,新生代10M;
    -XX:SurvivorRatio=8 Eden区和Survivor区空间比例8:1
    -XX:+PrintGCDetails
    分配三个2M对象,一个4M对象:前三个放入Eden区,当第四个4M对象放入时,发现Eden空间只有2M,触发Minor GC,发现survivor区间只有1M大小,因此通过分配担保机制提前转移到老年代。其后,4M对象成功分配到Eden区。执行完GC日志显示,Eden区4M,占用50%;老年代占用6M,60%。
  2. 大对象直接进入老年代
    需要大量连续内存空间的对象,最典型的是很长的字符串或者数组,经常出现大对象会导致内存中还有不少空间时提前触发GC以获取足够的连续空间,虚拟机提供一个-XX:PretenureSizeThreshold参数,大于这个设置值的对象直接进入老年代分配。同时也避免了Eden区和Survivor区之间发生大量的内存复制。
    此参数只对Serial和ParNew两款收集器有效。
  3. 长期存活对象将进入老年代
    既然采用了分代收集算法,那么内存回收时必须能够识别哪些对象应该放在新生代还是放在老年代。为了做到这点,虚拟机给每个对象定义了年龄计数器,经过一次Minor GC,年龄加1,到一定程度(默认15)晋升到老年代。也避免了对象的来回复制。
  4. 动态对象年龄判定
    并不是要等到年龄达到MaxTenuringThreshod才能晋升老年代,如果Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于的对象直接进入老年代。
  5. 空间分配担保
    由于新生代采用复制算法,但是为了内存利用率,只留下了一个Survivor空间作为轮换备份,照理说安全的应该是一半对一半,如果Minor GC后存活对象过多,Survivor空间不够放,就需要老年代作为担保内存。
    因此在Minor GC前,虚拟机会检查一下担保人,也就是老年代最大可用连续空间是否大于新生代所有对象总空间,如果成立,那么Minor GC是安全的。虚拟机会查看HandlerPromotionfailure设置值是否允许担保失败,如果运行,会检查可用空间是否大于历次晋升的平均大小,如果大于,将尝试一次Minor GC,失败Full GC;如果小于,直接Full GC。

调优篇待续

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值