JVM(四):经典垃圾收集器

回顾

前面我们已经分析了HotSpot对于垃圾算法的实现了,但也仅仅还是处于理论的阶段,而垃圾收集器正是这些所有理论的实现和运用

垃圾收集器

HotSpot实现的垃圾收集器有7种,下面分别对其管理的区域进行分组

  • 新生代垃圾收集器
    • Serial
    • ParNew
    • Parallel Scavenge
  • 老年代收集器
    • CMS
    • Serial Old(MSC)
    • Paralled Old
  • 老年代与新生代都参与收集的收集器
    • GI

其中比较广泛使用也相对的复杂的收集器为GI与CMS

Serial收集器

Serial收集器是最基础、历史最悠久的收集器,是JDK1.3.1之前唯一的新生代收集器(对于HotSpot),当然对于老年代有对应的Serial Old收集器,是一套对应的

这个收集器是一个**"单线程"处理器**,但这里的单线程并不仅仅说这个处理器是单线程运行的,也就是不仅仅说它只会使用一个处理器(一个线程)或一条收集线程去完成收集工作,更重要是强调这个处理器运行时,会暂停其他所有的工作线程,也就是前面提到过的Stop The World

其运行的过程如下所示
在这里插入图片描述
对于新生代,Serial收集器采用标记-赋值的方法;对于老年代,Serial Old收集器采用标记-整理算法,并且会停止所有的工作线程,所以其实现基本上是按照之前基本的算法理论来做的

这里之所以要进行停止所有线程,就不再解释了,看前面的文章。。

虽然经过Java这么多年的升级,Serial收集器目前来说已经老而无用了,但它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,它有着优于其他收集器的地方,第一点是简单而高效(简单实现,这里的高效是针对于单线程情况来说);第二点是额外的内存消耗少,Serial收集器是所有的收集器上占用额外内存最少的;对于单核处理器或者处理器核心数较少的环境来说,单线程的Serial收集器没有切换多线程切换上下文的开销,可以专心做垃圾收集,获取最高的单线程收集效率

而且对于应用来说,一般分配给虚拟机管理的内存并不会很大,只要不是很频繁地发生GC,Serial处理器是完全可以应付过来的

ParNew收集器

ParNew收集器其实是Serial收集器的多线程并行版本(注意是并行),不过这里的多线程仅仅就是使用多条线程进行垃圾收集,其他的地方跟Serial收集器是完全一致的,比如下面的一些条件,但这里要注意,ParNew仅仅只会对新生代进行处理,也就是跟Serial一样,但没有匹配的老年代处理器,老年代依然使用的还是Serial Old,也就是单线程进行GC回收

  • 控制参数
  • 收集算法
  • Stop The World:中断所有的工作线程
  • 对象分配规则
  • 回收策略

其工作方式如下图所示
在这里插入图片描述
ParNew支持新生代的回收,与它配合使用的老年代收集器是CMS,并且在JDK5的时候CMS收集器仅仅只能与Serial与ParNew进行配合使用

可以说,CMS的出现巩固了ParNew的地位,但随着垃圾收集器的技术的不断改进,越来越多的收集器代替了ParNew与CMS的组合(具体是G1收集器),直接取消了ParNew与Serial Old、Serial与CMS的组合,也就是说,如果使用ParNew则一定要使用CMS

分析一下ParNew的性能

ParNew收集器在单核心处理器的环境中,性能一定不会比Serial好,因为ParNew是多线程的,对于单核处理器是无法完成并行的,只能是并发,还要额外的上下文切换的消耗;如果在超线程伪双核的处理器中也不一定会比Serial好(超线程伪双核是指一个CPU可以同时并行地去执行两个线程任务,依然是单核)

ParNew收集器开启的线程数默认与处理器的核心数数量相同(因为默认是相同的,所以用户线程不停止的话会与垃圾收集处理器存在并发关系),不过可以进行限制线程数,使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数

并发与并行

并发(Concurrent)是指:多个线程,但只有一个处理器,一个处理器去执行多个线程,中间会产生上下文切换,逻辑意义上的同时发生(能不能发生并发还要看机器的处理器)

并行(Parallel)是指:多个线程,但有多个处理器,处理器去执行对应的线程,中间不会产生上下文切换,物理意义上的同时发生

在垃圾收集器的场景中

  • 并行:指多条垃圾收集器线程之间的关系,同一时间多条垃圾收集器线程在同时工作,但此时用户线程是停止的
  • 并发:指垃圾收集器线程与用户线程的关系,一般发生在用户线程没有及时到达安全点的时候,这个时候垃圾收集器线程与用户线程都在运行,会互相竞争CPU资源,影响了应用程序的处理吞吐量

Parallel Scavenge收集器

Parallel Scavenge是新生代收集器,同样也是基于标记-复制算法实现的收集器,也是能够并行处理的(前提也是多核或者超线程)

跟ParNew收集器不同,Parallel Scvengr收集器重点目标是达到一个可控制的吞吐量,而不是与CMS、ParNew等收集器去尽可能地缩短垃圾收集时用户线程的停顿时间;说白了就是一个想保证应用服务的吞吐量(使用处理器运行用户代码的时间与处理器总消耗时间作比值,而总消耗时间是运行用户代码的时间加上运行垃圾收集时间),另一个想尽量地去缩短用户线程停顿时间,从而尽快恢复服务

Parallel Scavenge收集器提供了两个参数来精确控制吞吐量

  • 控制最大垃圾收集停顿时间:-XX:MaxGCPauseMills,该参数设置的是一个大于0的毫秒数,收集器会尽力保证内存回收花费时间不超过这个预定值,不过也不能把这个值随便调小,因为底层是通过减少新生代空间来实现的,会导致GC回收更加频繁,会减少程序的吞吐量(因为GC回收次数频繁反而会导致总的运行垃圾收集时间更久)
  • 控制最大吞吐量大小:-XX:GCTimeRatio,该参数设置一个大于0或100的整数,代表运行用户代码时间与运行垃圾收集时间的比率,可以用来控制吞吐量,如果说我设置为19,那么就代表运行用户代码19S会运行垃圾收集时间1S,对应的垃圾收集时间占总时间的比率就是1/(1+19),对应的吞吐量就是19/(1+19),该参数默认值为99,即1%的垃圾收集时间

因为与吞吐量密切联系,所以该收集器也被称为吞吐量优先收集器

自适应调节策略

还有一个比较重要的参数**-XX:+UserAdaptiveSizePolicy**,这是一个开关参数,我们就不需要人工去指定新生代的大小、Eden与Survivor区的比例与晋升老年代对象大小,对应的就是不需要去指定**-Xmn、-XX:SurvivorRatio和-XX:PremenureSizeThreadShold参数**,当设置了这个参数之后,虚拟机会自动根据当前系统的运行情况去收集性能监控信息,动态跳帧这些参数以提供最合适的停顿时间或者最大的吞吐量,这就是Parallel Scavenge自带的自适应调节策略

有了自适应调节策略,我们只需要把基本的内存数据设置好,比如-Xmx设置最大堆,然后给HotSpot虚拟机定下最大垃圾收集停顿时间和最大吞吐量大小,开启了自适应策略之后,就可以让虚拟机自己调优了

自适应策略也是Parallel Scavenge收集器区别于其他收集器的一个重要特性

Serial Old收集器

前面提及了一下这个收集器,一般是跟Serial收集器配合使用的,在JDK5时也可以配合ParNew收集器一起使用,但后面就不允许了, 负责对老年代进行垃圾收集

Serial Old也是一个单线程收集器,使用标记整理算法

该收集器有两种作用

  • 与ParNew配合使用(JDK5支持,后面就不支持了)
  • 作为CMS收集器发生失败时的后备份预案,在并发收集发生Concurrent Mode Failure时使用

其工作流程跟Serial类似,也是会发生Stop The World

在这里插入图片描述

Parallel Old收集器

Parallel Old是对老年代进行回收的收集器,与Parallel Scavenge收集器配合使用的,支持多线程并行收集,基于标记-整理算法来实现的,而且其同样是优先注重程序的吞吐量,而不是优先去减少垃圾回收占用的时间

不过在没有Parallel Old之前,Parallel Scavenge都是跟本身架构中的PS MarkSweep收集器来进行老年代收集,不过这个PS MarkSweep收集器的实现与Serial Old几乎是一样的,所以可以理解成Parallel Scavenge是跟Serial Old一起配合的(其他性能更好的老年代收集器,比如CMS是无法与Parallel Scanvege进行配合的)

在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合

Parallel Old的工作流程跟Parallel Scavenge类似

在这里插入图片描述

CMS收集器

CMS是Concurrent Mark Sweep的简写,该收集器与前面提到的Parallel Scavenge不同,CMS收集器会优先注重减少垃圾回收的停顿时间,并且该收集器是一种以获取最短回收停顿时间为目标的收集器

CMS收集器是对老年代进行垃圾回收的,其采用标记清除算法,与前面学到的老年代处理器都不太相同(采用的是标记整理),而且其目标为优先注重减少垃圾回收的停顿时间,所以可以知道其也会发生Stop The World,它的运作过程相对于前面几种垃圾处理器会比较复杂一些,总共有以4个步骤

  • 初始标记:需要Stop The World
  • 并发标记:不需要Stop The World
  • 重新标记:需要Stop The World
  • 并发清除:不需要Stop The World

下面就来看看这四个过程干了什么

初始化标记

这里初始化标记只是对GC Roots直接关联到的对象进行标记,在这个过程中是会发生Stop The World,但持续的时间并不长,因为仅仅只是针对直接关联到的对象进行标记

并发标记

并发标记就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程不需要Stop The World,其耗时较长,但不需要去停顿用户线程,用户线程与垃圾收集处理器是一起并发运行的,并且采用增量标记的方法

重新标记

重新标记就是修正并发标记时,因为用户线程持续工作,导致标记产生变动得到那一部分的标记记录,并且并发标记的过程中这里采用的是增量标记的方法,在这个阶段也是会发生Stop The World的,否则又会停留到并发标记的过程,在这个阶段停留的时间远比并发标记的短,但是会比初始化标记的长

并发清除

并发清除其实就是清理掉标记阶段判断的已经死亡的对象,但此时并不会发生Stop The World,这是因为采用的是标记清除的方法,不需要去移动对象,既没有改变对象的地址,所以这个阶段是允许与用户线程同时并发的

但假如在并发清除这个阶段遇上了对象图发生改变呢?CMS是考虑到这个情况的

在垃圾收集阶段时,用户线程还会继续运行,CMS会预留足够的内存空间(称为free List)给用户线程使用,此时新晋升的对象会保存在这里,进行垃圾清理时也会检查这块内存空间,避免了新晋的新生代被回收掉

在整个过程中,耗时最长的两个阶段是并发标记和并发清除,但这两个阶段都是允许垃圾清除线程与用户线程一起并发工作的,所以总体上来看,可以看成是没有发生Stop The World

CMS收集器的工作流程如下所示
在这里插入图片描述
可以看到,每一个过程都要进入到SafePoint才会开始

CMS收集器尽量地减少了用户线程的停顿,被称为并发低停顿收集器,但并不没有达到完美的程度,还存在缺点

  1. CMS收集器对于处理器资源十分敏感,因为其允许垃圾清除线程与用户线程并发执行,所以这两个线程是会发生处理器资源竞争的,降低了程序的吞吐量,CMS默认启动的回收线程数是处理器核心数量的四分之一(向上取整)
    1. 首先我们要知道,处理器的数量一般来说都是2的幂次方
    2. 当CMS收集器大于4的时候,比如8和16,那么垃圾清除线程占用处理器资源最高不会超过哦25%,这是可以接受的
    3. 但当CMS收集器小于4的时候,比如1和2,那么垃圾清除线程占用处理器资源那就很高了,接近100%和50%,这是不可以接受的,本来应用程序就需要占绝大部分的CPU,垃圾清除线程竟然直接差不多全部抢来用了,那么对于应用程序的执行速度会大幅降低的,跟发生Stop The World有什么区别呢
    4. 为了解决对于处理器核心数量并不多出现的问题,虚拟机提供了一种称为“增量式并发收集器的CMS收集器”,这种收集器可以让在并发标记、并发清理的时候让收集器线程、用户线程进行交替运行,尽量减少垃圾收集线程的独占资源的时间,这样做的后果是,会导致垃圾收集过程边长,但减少了对用户程序的影响,但这种模式效果很一般,所以在JDK7已经被声明为过时了,并且JDK9之后就废弃了
  2. CMS收集器无法处理浮动垃圾,甚至有可能出现Concurrent Mode Failure失败进而发导致另一次完全的Stop The World的Full GC的产生,这是因为在并发标记和并发清除过程中,是允许用户线程并发运行的,会继续产生垃圾,继续产生的这部分垃圾已经不能再进行标记了,所以只好等待下一次垃圾收集时再清理掉,这部分的垃圾就称为浮动垃圾
  3. 同样也是因为并发清除的过程中允许用户线程并发运行,那么就不能像其他收集器那样等待到老年代几乎被填满时再进行回收,必须要预留足够的空间给用户线程使用
    1. 在JDK5的默认设置下,CMS收集器当老年代使用了68%空间的时候就会被激活,这是比较保守的,JDK提供了-XX:CMSInitiatingOccu-pancyFraction参数来设置阈值
    2. 在JDK6的默认设置下,CMS收集器会当老年代使用了92%空间的时候才会被激活
      1. 但阈值设置成这么高又会导致一个问题
      2. 要是CMS运行期间,预留的老年代内存不够用户线程使用,即用户线程不够内存去分配新对象了,那么就会出现并发失败
      3. 出现了并发失败,JVM就会采用后备预案(Full GC),冻结用户线程的执行(STW),并且临时启用Serial Old收集器来重新进行老年代的垃圾回收,这样就会导致停顿的时间更加长了,所以对于参数的设置一定要权衡场景,设置低会太过保守,GC次数频繁;设置太高容易出现并发失败,采用后备预案会消耗更多的时间
  4. 还有一个缺点就是,CMS是基于标记清除算法实现的,所以会产生内存碎片问题,当内存碎片过多时,会影响大对象的分配内存,导致的结果就是,明明老年代还有足够空间,却无法存放大对象,导致CMS收集器会发生Full GC,但FullGC了之后可能还没有效果,所以,CMS收集器提供了一个开关参数(-XX:+UseCMS-CompactAtFullCollection),用于CMS收集器不得不进行FullGC时开启内存碎片的合并整理过程,由于需要移动,所以此时是不允许出现并发的,也就导致停顿的时间更长了,CMS对此也是有一点优化的,就是减少发生FullGC时的内存合并操作,CMS收集器提供了一个控制参数(-XX:CMSFullGCsBefore-Compaction),该控制参数可以要求CMS收集器在执行若干次不整理空间的FullGC之后,下一次进入FullGC前会先进行碎片整理(该参数默认值为0,表示每次进入FullGC时都会进行碎片整理)

Garbage First收集器

Garbage First收集器又简称为G1收集器,是一个里程碑的结果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,而且我们可以看到这个G1收集器对于新生代和老年代都是可以进行回收的,所以它也被称为全能的垃圾收集器

JDK9之后,G1就替代了Parallel Scavenge+Parallel Old组合,成为了服务端模式下的默认垃圾收集器,CMS也被划成不推荐使用的收集器了

G1作为CMS收集器的替代者和继承人,也有着CMS的尽可能减少服务停顿时间的特性,除此之外,G1还是一个停顿时间模型的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,说白了就是想要尽可能维持垃圾收集过程消耗的时间,不让其变动

那么要怎样实现这个目标了,首先我们得要转换思想,首先,要控制整体的垃圾收集时间,那么就要对新生代和老年代都要进行考虑了,所以不能再将老年代和新生代都划分给不同的收集器来进行清除,就是抛弃了组合这个概念,G1收集器就是跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集进行回收,而且组成会收集的标准不再是分代,而是哪块内存中存放的垃圾数量最多,回收利益最大,G1的这种模式称为Mixed GC模式

Mixed GC模式

G1开创的Mixed GC模式实现的关键在于开创了基于Region的堆内存布局,虽然这个设计也是根据分代理论来做的,但也存在着明显的区别

G1不再坚持固定大小以及固定数量的分代区域划分(之前是按Eden、Survivor,新生代占多少,老年代占多少进行划分的),而是把连续的Java堆划分为多个大小相等的区域,称为Region,每个Region都可以根据需要去扮演新生代的Eden空间、Survivor空间或者老年代空间,收集器能够对扮演不同角色的Region采用不同的策略去处理,这样就可以更加统一,更加抽象,无论是新创建的对象、还是存活了一段还是、还是老年代的对象都能够获取很好地收集效果

Region中还有一类特殊的Humongous区域,称为Humongous Region(Region和Humongous区域是不同的),是专门用来存储大对象的,此时G1收集器只要认为大小超过了一个Region容量的一半的对象判定为大对象,这些大对象就会存放在Humongous区域中,并且如果对于超大对象,就是超过了几个Region容量的,G1收集器会使用N个连续的Humongous Region去进行储存,同时Region的区域是可以通过-XX:G1HeapRegionSize设定的,取值范围为1MB~32MB,为2的N次幂,并且对于大对象,G1收集器一般都会视为是老年代的一部分来看待的

虽然G1还在保留着新生代和老年代的概念,但新生代和老年代不再是固定的区域了,而是统一以Region或者Humongous来解释,都是一系列区域的动态集合,并且还是相同规格的,也正因为每次进行回收的内存空间都是Region空间的整数倍,G1收集器才可以有计划地避免在整个Java堆中进行全区域的垃圾收集,同时也可以让G1收集器去跟踪各个Region里面的垃圾堆积的价值(回收获得的空间大小以及回收所需时间来决定),可以自身维护一个优先级列表,那么就可以优先去处理这部分的Region,达到一定时间内最大化垃圾收集收益,也是为什么称为Garbage First收集器

这种使用Region来规整地划分空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率,停顿的时间可以通过参数-XX:MaxGCPauseMillis来指定,并且默认值是200毫秒

那么对于Region,它解决了哪些问题、整体过程又是如何的呢?

跨引用问题如何解决

Java堆分成了多个独立的Region之后,是如何解决跨引用问题的?

之前解决跨引用的问题都是使用记忆集,具体来说是卡表来做的,因为不再明确地划分新生代和老年代,所以对于每个Region都要维护自己的记忆集,记忆集里面存放的是别的Region指向自己Region的指针,同时还要记录这些指针所在的卡页,不然是无法通过指针找到引用的Region的,而且单纯使用Card_table去存储已经不合适了,因为整个老年代已经不是固定的,单纯使用数组是无法满足需求i的(因为数组长度有限,对于不固定的老年代分布是很难做的),所以G1收集器使用了空间换时间的策略,提高了记忆集的结构复杂度

Region的记忆集本质上是一种哈希表(之前提到的记忆集的卡表只是一个数组,而G1在数组之上实现了哈希表),对于哈希表存储的节点,Key是Region的原始地址,而Value是一个集合,里面存储的集合则是卡表的索引,也就是记忆集的索引

整体的结构如下所示
在这里插入图片描述
那么为什么要这样存储了

我们重新回到刚开始认识的卡表

卡表是一个数组,并且索引代表的是卡页地址,索引对应的值代表这个卡页是否存在引用,而在这里,Region使用HashMap来实现卡表,这是因为老年代的区域不再是连续的,而是离散的Region组成,所以要使用HashMap,key就是引用Region的地址,而value则代表Region中的哪些卡页引用了它,所以,这里理解的关键是,一个Region不仅代表了这个对象,其实代表的是一块老年代区域,这块老年代区域也有自己的卡页划分

可以看到,这种双向的卡表结构设计比原来的卡表复杂多了,维护起来也很麻烦,并且分代数量明显要多得多(更多的老年代区域和新生代区域),但同时它的粒度小很多,提高了吞吐量

如何保证用户线程与垃圾清除线程不干扰运行

在这里,G1收集器采用的是原始快照的算法来实现的

此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,所以在并发的过程中,程序只要继续运行就会去创建新的对象,并且此时垃圾清除还在继续进行,容易发生并发问题,所以,G1为每一个Region设计了两个名为TAMS(Top At Mark Start)的指针,会把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,也就是假如你对这个Region重新引用了,那么这个Region就会将新对象的地址保存在两个TAMS指针中,当G1收集器会默认这个地址以上的对象是被隐式标记过的,则默认是存活的,不会纳入回收范围

当然这个与CMS一样,如果内存回收的速度赶不上内存分配的速度,同样也会产生FullGC,导致长时间的STW发生

如何建立可靠的停顿预测模型

上面提到过,可以通过参数-XX:MaxGCPauseMillis来指定停顿时间,但这个只是一个期望值,即并不一定完全准确的

G1收集器的停顿预测模型是以衰减均值为基础来实现的

首先,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量(引用发生了变化就会产生脏卡)等各个可测量的步骤消耗的时间,并且分析得出平均值、标准偏差、置信度等统计信息,从而得出每个Region进行回收得到的收益,然后通过这些信息去预测由哪些Region组成的回收集才可以在不超过期望停顿时间的约束下获得最高的利益

G1收集器的运行流程

G1收集器的运行过程大致可以分为4个步骤

  • 初始标记:仅仅只是去标记GC Roots可以直接关联到的对象,并且修改Region里面的TAMS指针的值,让下一阶段的并发运行时,可以正确地在可用的Region中分配新对象,这个阶段需要STW,不过耗时很短,所以可以理解成G1收集器在这里没有额外的停顿
  • 并发标记:这个阶段就是去标记从GC ROOTS直接关联到的对象到堆中所有对象的关系图,找出要回收的对象,并且这个阶段不会发生STW,而且这里使用的是SATD(原始快照,Snapshot At Beginning),那么当扫描完对象图之后,还要重新处理SATD记录下的在并发时有引用变动的对象,重新标记这些对象
  • 最终标记:对用户线程做一个短暂的STW,用于处理并发阶段结束后遗留下来的最后那少量的SATD的记录
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户给定的期望值来指定回收计划,这一步可以自由地选择任意多个Region构成回收集,决定了之后,把决定回收的一部分的Region中仍然存活的对象复制到空的Region中(Region里面不一定只要一个对象,可以有多个对象),再清理掉整个需要回收的Region的全部空间,因为涉及到存活对象的移动,所以筛选回收整个过程是需要STW的,所以再最后的回收过程不需要考虑并发的问题

从整个过程可以看到,G1收集器除了并发标记之外,其他情况都是会发生停顿的,这也可以看出,G1收集器不是纯粹地追求的不是减少停顿时间,而是在期望时间内完成垃圾收集,实现了在延迟可控的情况下,获得了尽可能高的吞吐量

关于采用的收集算法

把决定回收的一部分的Region中仍然存活的对象复制到新的Region中,然后清掉整个需要回收的Region的全部空间,这种回收算法看起来像是标记复制,因为是涉及到两个Region之中,也就是两个内存块之间;但其实整体上是标记整理算法,因为这里没有固定哪两个Region内存块,更像是将存活的对象存放去连续的空内存中去

但无论如何,这两种算法都不会产生内存碎块的问题,清楚完了之后都可以提供连续的内存,有利于大对象的内存分配

不过G1依然存在着其缺点,相比于CMS,抛弃了传统的分代概念,但却使用了更复杂的卡表数据结构,产生了更多额外的内存占用,因为每个Region都有自己的卡表了,而CMS新生代里面只有一个卡表

还有执行负载的问题,CMS与G1都要使用写屏障来维护卡表,由于G1的卡表比较复杂,所以维护卡表的执行负载也就比较大,而且,G1不单单需要使用写后屏障来维护卡表,还要使用写前屏障去追踪SATB算法的集合存放的指针变化情况,也要占用资源

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值