JVM:常用的垃圾收集器与调优技巧


前言

垃圾收集器(Garbage Collector, GC)是Java虚拟机(JVM)中的一个关键组件,它自动地管理和释放不再使用的对象所占用的内存空间。这样可以防止内存泄漏,并简化了开发者的编程任务。以下是几种常见的垃圾收集器:

在这里插入图片描述


一、新生代垃圾收集器

1. Serial

Serial 收集器(单线程、复制算法)是最基本、历史最悠久的收集器。这是一个单线程的收集器,只使用一个CPU、一条收集线程去完成垃圾收集工作,而且在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(STW)

在这里插入图片描述

  • 优点:简单高效。在 Client 模式下,Serial 收集器是很好的选择。
  • 开启串行垃圾回收:-XX:+UseSerialGC=Serial+SerialOld

2. ParNew

ParNew 收集器(多线程、复制算法)是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其他行为包括Serial收集器可用的所有控制参数也完全一样,甚至这两种收集器也共用了很多代码

在这里插入图片描述

  • 可以通过选项 -XX:+UseParNewGC 手动指定年轻代使用ParNew垃圾收集器
  • 使用 -XX:+UseConcMarkSweepGC 选项后,默认新生代收集器是ParNew。
  • 这里的多线程指的是垃圾收集时,多线程并行,并不是垃圾收集与程序运行并行
  • 收集垃圾时,也需要暂停其他所有工作线程,然后多线程收集垃圾
  • 单CPU环境下,因为线程切换,性能较差

单核环境中,使用ParNew的效果不如Serial好,甚至双核环境中ParNew也未必100%超越Serial。但是随着CPU数量的增加,它对GC时系统资源的利用会更优。

默认情况下,它开启的收集线程数与CPU数量相同,在CPU非常多的情况下,可以使用 -XX:ParallelGCThreads参数来限制垃圾收集的线程数。

3. Parallel Scavenge

吞吐量优先多线程、复制算法、高效)。主要适用于在后台运算而不需要太多交互的任务。 自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别

  • 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间) ;

    • 吞吐量优先,意味着在单位时间内,STW的时间最短;
    • 与之相对应的 低延迟 就是暂停时间优先,尽可能让单次STW时间最短;这两个无法同时实现。
  • 收集垃圾时,也需要暂停其他所有工作线程,然后多线程收集垃圾。

  • 参数配置:

    -XX:+UseParallelGC:手动指定年轻代使用Parallel并行收集器执行内存回收任务。 
    
    -XX:+UseParallelOldGC:手动指定老年代都是使用并行回收收集器。 
      分别适用于新生代和老年代。默认jdk8是开启的。
      上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
      
    -XX:ParallelGCThreads:设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。  
    
    精准控制吞吐量的参数:
    -XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间(即STW时间)。单位是毫秒。 
      为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
      对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合 Parallel 进行控制。(该参数使用需谨慎)
    -XX:GCTimeRatio:垃圾收集时间占总时间的比例 (=1/(N+1))。用于衡量吞吐量的大小。 
      取值范围(0, 100)。默认值99,也就是垃圾回收时间不超过1%。
      与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
      
    -XX:+UseAdaptivesizePolicy:设置Parallel Scavenge收集器具有自适应调节策略 
      在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。
      在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。
    

二、老年代垃圾收集器

1. Serial Old

Serial Old(单线程、标记整理算法)是 Serial 收集器的老年代版本,它是单线程收集器,使用标记-压缩算法
这个收集器的主要意义在于给Client模式下的虚拟机使用

在这里插入图片描述

Server模式下两大用途:

  • 在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

2. Parallel Old

Parallel Old(多线程、标记整理算法)是Parallel Scavenge收集器的老年代版本。JDK1.6开始提供

  • 吞吐量优先,意味着在单位时间内,STW的时间最短;与之相对应的 低延迟 就是暂停时间优先,尽可能让单次STW时间最短;这两个无法同时实现
  • 若相同对于吞吐量要求较高,可以Parallel Scavenge搭配Parallel Old使用

在这里插入图片描述

3. CMS

CMS(Concurrent Mark Sweep)收集器(多线程、标记清除算法、低延迟)是一种以获取最短回收停顿时间为目标的收集器。它是HotSpot虚拟机中第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,具有划时代意义。运作过程分为四步:

在这里插入图片描述

  • 初始标记(Initial Mark):只是标记一下 GC Roots 能直接关联的对象以及「年轻代」指向「老年代」的对象(通过卡表实现),速度很快,需要暂停所有的工作线程(STW)
  • 并发标记(Concurrent Mark):进行 GC Roots 跟踪的过程,和用户线程一起工作,标记出根对象的可达路径。不需要暂停工作线程
  • 重新标记(Remark):由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要暂停所有的工作线程(STW)
  • 并发清除(Concurrent Sweep):清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程 (会产生浮动垃圾,留到下次GC清除)

并发标记与并发清除最耗时,但是不用STW。所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS为什么采用标记清除算法而不是标记整理算法呢?
因为当并发清除的时候,用标记整理算法整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行的前提是它运行的资源不受影响。标记整理算法更适合“Stop The World” 这种场景下使用

参数配置

-XX:+UseConcMarkSweepGC:手动指定使用CMS收集器执行内存回收任务。
	开启该参数后会自动将-XX:+UseParNewGC打开。即:ParNew(Young区用)+ CMS(Old区用)+ Serial Old的组合。

-XX:CMSInitiatingOccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
	JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%
	如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。
	反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Ful1Gc的执行次数。

-XX:+UseCMSCompactAtFullCollection:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

-XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理。

-XX:ParallelcMSThreads:设置CMS的线程数量。
	CMS默认启动的线程数是(ParallelGCThreads+3)/ 4,ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

CMS是一款优秀的收集器,主要优点是并发收集、低停顿。但它也不完美,至少有3个明显的缺点:

  1. 对CPU资源非常敏感(吞吐量):在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。默认回收线程数是(CPU数量+3)/4,也就是4个以上CPU时,并发回收时垃圾收集线程不少于25%的CPU资源。当CPU不足4个时,CMS对用户程序的影响就可能很大。

  2. 浮动垃圾(Floating Garbage)问题:由于CMS并发清理阶段用户线程还在运行,伴随程序运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理。这一部分垃圾就称为“浮动垃圾”。
    由于垃圾收集阶段用户线程还在运行,那就需要预留足够的内存空间给用户线程使用,因此需要预留一部分空间提供并发收集时的程序运作使用。参数-XX:CMSInitiatingOccupancyFraction用于指定老年代空间使用多少比例后触发并发标记清除(CMS)垃圾回收器。

    JDK1.5,该参数值为68%
    JDK1.6,该参数值提高至92%
    提高触发百分比,可以降低内存回收次数从而获取更好的性能。但是,如果CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了,性能也会随之降低。

  3. 空间碎片问题:CMS是基于标记-清除算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多时,会给大对象分配带来大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不触发一次Full GC。

    • 参数-XX:UseCMSCompactAtFullCollection用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并压缩过程,默认开启。这样空间碎片问题没有了,但停顿时间也变长了。
    • 参数-XX:CMSFullGCsBeforeCompaction用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次Full GC都进行碎片压缩)。

    对于产生碎片空间的问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。设置参数 -XX:CMSFullGCsBeforeCompaction 告诉 CMS,经过了 N 次 Full GC 之后再进行一次内存整理。


三、G1垃圾收集器

G1:Garbage First

特点:

  1. 同时注重 吞吐量(Throughput)低延迟(Low latency),默认暂停目标是200ms
  2. 适用于超大堆内存(会将堆划分为多个大小相等的 Region)
  3. 整体上是 标记-整理 算法,两个 Region 间是 复制 算法

JVM参数:

  • 启用G1收集器: -XX:+UseG1GC
  • JVM堆的分区数量:-XX:G1HeapReginSize=size
  • 最大GC停顿时间:-XX:MaxGCPauseMillis=time
  • 老年代占用堆比例达到阈值,进行并发标记:-XX:InitiatingHeapOccupancyPercent=percent

1. 垃圾回收阶段

G1 垃圾回收分为3个阶段:年轻代回收、年轻代回收+并发标记、混合回收
在这里插入图片描述

阶段一:Young Collection

G1垃圾回收器将堆内存划分为多个大小相等的区Region,其中E为划分为伊甸园的区域Eden。
在这里插入图片描述

Eden区空间占满后,触发Young GC,使用 复制算法 将幸存对象复制到Survivor区
在这里插入图片描述

Survivor区空间占满后,会将大龄的对象复制到Old区,年龄较小的仍留下来,年龄 +1
在这里插入图片描述


阶段二:Young Collection + CM

在Young GC时会进行GC Root的初始标记
老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),阈值由此JVM参数决定:-XX:InitiatingHeapOccupancyPercent=percent(默认45%)
在这里插入图片描述

阶段三:Mixed Collection 混合垃圾回收
会对E、S、O进行全面垃圾回收 -XX:MaxGCPauseMillis=ms

  • 最终标记(Remark)会STW,目的是回收在并发标记时漏掉的垃圾对象(在并发标记时其他用户线程工作产生的垃圾对象)
  • 拷贝存活(Evacuation)会STW

在这里插入图片描述

2. 对象跨代引用

新生代回收的跨代引用(老年代引用新生代)问题

在这里插入图片描述

  • 卡表与Remembered Set
  • 在引用变更时通过post-write barrier + dirty card queue
  • concurrent refinement threads 更新Remembered Set

在这里插入图片描述

3. 重标记

pre-write barrier + satb_mark_queue

并发标记刚结束时,如果一个被标记为垃圾的对象再次由于其他用户线程的工作成为了GC Root引用链中的一员,此时会触发写屏障,将该对象加入队列中并标记为幸存对象,在重标记(最终标记)时,STW后会重新检查队列中的对象是否为垃圾对象。

在这里插入图片描述

4. G1相关优化项

JDK.8u20 字符串去重

-XX:+UseStringDeduplication(默认启用)

String s1 = new String("hello"); // char[]{'h', 'e', 'l', 'l', 'o'}
String s2 = new String("hello"); // char[]{'h', 'e', 'l', 'l', 'o'}

将所有新分配的字符串放入一个队列中,当新生代发生垃圾回收时,G1并发检查是否存在字符串重复,使值重复的字符串引用同一个char[]

  • 优点:节省大量内存
  • 缺点:略微多占用了CPU时间,新生代回收时间略微增加

注意:其优化原理与String.inter()不同
String.intern()关注的是字符串对象不重复
此字符串去重关注的是char[]不重复
在JVM内部,它们使用了不同的字符串表

JDK 8u40 并发标记类卸载

-XX:+ClassUnloadingWithConcurrentMark(默认启用)

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它加载的所有类

JDK 8u60 回收巨型对象

  • 一个对象大于region的一半时,称之为巨型对象
  • G1不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1会跟踪老年代所有incoming引用,这样老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉。(比如下图中右上角的H,此时O区域中的卡表未再引用它,它会在新生代垃圾回收时被回收)
    在这里插入图片描述

JDK 9 并发标记起始时间的调整

  • 并发标记必须在堆空间占满前完成,否则退化为Full GC
  • JDK 9之前需要-XX:InitiatingHeapOccupancyPercent设置触发并发标记的初始值
  • JDK 9可以进行数据采样并动态调整该值,会添加一个安全的空档空间来容纳浮动垃圾,尽可能避免退化为Full GC

JDK 9 更高效的回收

  • 250+增强
  • 180+bug修复

目前垃圾收集器理论发展最前沿的成果,对比CMS收集器有两点改进:

  1. 采用标记整理算法,不产生内存碎片
  2. 可以精确控制停顿时间,在不牺牲吞吐量的前提下,减少垃圾回收停顿。

G1收集器将堆内存划分为大小固定的几个区域(局部压缩),以分区为单位进行回收,将存活对象复制到另一个空闲空间。并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间, 优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率

内存分区上不存在老年代和新生代的区别,只是在逻辑上有分代的概念,随着G1的运行,每个分区都可能在不同代之间切换。

G1的收集都是STW,属于混合收集的方式,每次收集时可能只收集年轻代的区域,也可能收集年轻代的同时,也包含部分老年代区域。
G1内存模型如下,对于老年代和新生代只有逻辑上的分代。heap被划分为一系列大小相等的“小堆区”,也称为region。

  • 将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
  • 每个Region都是通过指针碰撞来分配空间

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。

  • 设置H的原因:对于堆中的对象 (Eden放不下的大对象),默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。

四、内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象占用空间,如果成立,那么 Minor GC 可以确认是安全的。

如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC


五、调优技巧

使用命令查看当前JVM的GC相关参数配置:java -XX:+PrintFlagsFinal -version | grep GC

确定服务的GC需求,是 “低延迟” 还是 “高吞吐”,进而选择合适的垃圾回收器

First Of All:最快的GC是不发生GC
查看Full GC前后内存占用,考虑以下几个问题:

  1. 数据是否太多?
    • resultSet = statement.executeQuery(“SELECT * FROM big_table”);
  2. 数据是否太臃肿?
    • 对象图,例:查询出来的对象图比实际需要用到的数据多得多
    • 对象大小,例:Integer 中对象头16字节,4字节int值,对齐后占用32字节,而int只需要4字节
  3. 是否存在内存泄漏?
    • 例:static Map map = new HashMap<>(),不断往其中放数据会触发OOM。可使用软引用、弱引用优化

1. 新生代调优

新生代特点

  • 所有new操作的内存分配都非常廉价,因为在对象创建时会有TLAB(thread-local allocation buffer)
  • 死亡对象的回收代价是零,因为垃圾回收采用复制算法时,会将Eden与SurvivorFrom中的存活对象复制到SurvivorTo中,剩下的空间都被清空
  • 大部分对象用过即死
  • Minor GC时间远远低于Full GC

新生代(-Xmn)越大越好吗?
-Xmn设置堆中年轻代的初始值和最大值,GC在年轻代区域发生的频率比其他区域高。如果年轻代太小会导致频繁Minor GC;太大会导致仅有Full GC而占用大量时间。建议年轻代占整个堆内存的25%~50%

幸存区调优

  • 幸存区达到能保留“当前活跃对象 + 需要晋升对象”
  • 晋升阈值配置得当,让长时间存活对象尽快晋升(如果晋升慢,From与To多次进行对象复制会消耗较多时间,Minor GC主要时间就在对象复制)

调整最大晋升阈值:-XX:MaxTenuringThredshold=${threshold}
幸存区对象年龄与占用监控:-XX:+PrintTenuringDistribution
在这里插入图片描述
左列为改年龄的对象总占用的内存,右列尾小于等于该年龄的对象总占用的内存

2. 老年代调优

以CMS为例:

  • CMS的老年代内存越大越好(防止CMS垃圾回收退化为Serial Old)
  • 先尝试不做调优,如果没有发生Full GC,那么说明老年代很充裕无需优化,即使发生了Full GC,也需要优先考虑调优新生代
  • 观察发生Full GC时老年代内存占用,将老年代内存预设调大1/4 ~ 1/3
    -XX:CMSInitiatingOccupancyFractioin=percent

3. Case讨论

1、Minor GC和Full GC频繁
分析:可能是因为新生代较小,导致Minor GC频繁,进一步导致进入老年代的对象变多,从而导致老年代也频繁发生Full GC。
解决:可以尝试调大新生代,降低Minor GC频率,并调高新生代对象晋升老年代的阈值,以减少老年代占用

2、请求高峰期发生Full GC,单次暂停时间特别长(CMS)
分析:查看GC日志,GC每个阶段所耗费的时间会显示在其中。CMS垃圾回收器的重新标记阶段比较耗时,判断日志是否符合该情况
解决:符合时可使用-XX:+CMSScavengeBeforeRemark参数,在重标记前先进行垃圾回收,以减少重标记所耗费时间

3、老年代充裕情况下,发生Full GC(CMS JDK1.7)
分析:元空间内存不足导致Full GC
解决:增大元空间内存占用


总结

JVM参数

GC日志相关

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=mem.dump
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=10M

Full GC 辨析

Serial GC与Parallel GC

  • 新生代内存不足发生的垃圾回收:Minor GC
  • 老年代内存不足发生的垃圾回收:Full GC

CMS与G1

  • 新生代内存不足发生的垃圾回收:Minor GC

  • 老年代内存不足,触发并发标记与混合垃圾回收时,如果垃圾回收速度高于垃圾产生速度,此时不是Full GC,还是并发垃圾回收;只有在垃圾回收速度低于垃圾产生速度时,并发垃圾回收会失败,退化为串行垃圾回收而发生Full GC,此时会进行更长时间的STW

  • 想要最小化使用内存和并行开销,选Serial GC;

  • 想要最大化应用程序的吞吐量,选Parallel GC;

  • 想要最小化GC的中断或停顿时间,选CMS GC。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

自传丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值