深入JVM垃圾回收

JVM

一、术语说明

Collector

用于进行垃圾回收的线程

Mutators

应用程序的线程,可以修改 heap

MS

mark-sweep 算法的简写,标记-清除算法

MC

mark-compact 算法的简写,标记-压缩算法

RC

reference-counting 的简写,引用计数算法

liveness

一个对象的可到达性,引用关系图,由可到达对象引用形成的图结构

locality

现代CPU在访问内存时,有多级缓存。缓存以 cache line (一般64字节)为最小操作单位,所以当访问内存中连续的数据时会比较高校,这称为 locality

parallel

并行,指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

concurrent

并发,指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上

STW

Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。

 

二、JVM运行时内存

1、jdk1.6的运行时内存

https://img-blog.csdnimg.cn/20190117235959969.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MjcxMTMyNQ==,size_16,color_FFFFFF,t_70

2、jdk1.7的运行时内存

https://img-blog.csdnimg.cn/20190118000008920.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MjcxMTMyNQ==,size_16,color_FFFFFF,t_70

3、jdk1.8的运行时内存

https://img-blog.csdnimg.cn/20190118000024709.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MjcxMTMyNQ==,size_16,color_FFFFFF,t_70

二、垃圾收集算法

1、引用计数法(Reference Counting)

给对象添加一个引用计数器,每当一个地方引用它时,计数器加1,每当引用失效时,计数器减少1.当计数器的数值为0时,也就是对象无法被引用时,表明对象不可在使用,这种方法实现简单,效率较高,大部分情况下不失为一个有效的方法。但是主流的Java虚拟机如HotSpot并   没有选取引用计数法来回收内存,主要的原因难以解决对象之间的相互循环引用的问题。

 

2、标记-清除算法(Mark-Sweep

 

标记-清除算法(Mark-Sweep分两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

缺点:

1)效率问题,GC 时间与 heap 空间大小成正比,标记和清除两个过程的效率都不高。

2)进行 GC 期间,整个系统会被挂起,即STW(stop-the-world)。

3)空间问题,标记清除之后会heap产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一个的垃圾收集动作。

4)破坏引用本地性(由于对象不会被移动,存活的对象与空闲空间交错在一起)

3、复制算法(Copying )

Copying GC是Marvin L.Minsky在1963年研究出来的算法。就是指把某个空间里的活动对象复制到其它空间,把原空间里的所有对象都回收掉。在此,将复制活动对象的原空间称为From空间,将粘贴活动对象的新空间称为To空间。

缺点:一个是需要分配额外的空间来处理对象的回收。 另一个是复制的效率问题,想要降低 GC 频率就直接增大内存空间,这样程序就需要更多的时间来填满它。

 

 

Eden Space字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的survivor区域。
Survivor Space幸存者区,用于保存在eden space内存区域中经过垃圾回收后没有被回收的对象。Survivor有两个,分别为To Survivor、 From Survivor,这个两个区域的空间大小是一样的。执行垃圾回收的时候Eden区域不能被回收的对象被放入到空的survivor(也就是To Survivor,同时Eden区域的内存会在垃圾回收的过程中全部释放),另一个survivor(即From Survivor)里不能被回收的对象也会被放入这个survivor(即To Survivor),然后To Survivor 和 From Survivor的标记会互换,始终保证一个survivor是空的。
为啥需要两个survivor?因为需要一个完整的空间来复制过来。当满的时候晋升。每次都往标记为to的里面放,然后互换,这时from已经被清空,可以当作to了。

 

 

 

一个对象的这一辈子

我是一个普通的java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,不知道是谁引发了一场大屠杀,98%的兄弟都被kill了,我作为幸存者进入Survivor区的“From”区,自从去了Survivor区,我就开始漂了,为了躲避各种追杀,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所,就这样躲躲藏藏很多年。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。

4、标记-压缩算法

标记-整理(Mark-Compact)算法,标记过程仍然和标记-清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存。

 标记清除 

标记压缩     

5、分代收集算法

当前商业虚拟机的垃圾收集都采用”分代收集“(Generational Collection)算法,这种算法根据对象存活周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代,每次垃圾收集时都发现大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率较高,没有额外的空间对它进行分配担保,就必须使用”标记-清理“和”标记-整理“算法来进行回收。

三、垃圾收集器

 

1、Serial收集器

一个单线程的收集器,在进行垃圾收集时候,必须暂停其他所有的工作线程直到它收集结束。
特点:CPU利用率最高,停顿时间即用户等待时间比较长。
适用场景:小型应用
通过JVM参数-XX:+UseSerialGC可以使用串行垃圾回收器。

2、Parallel收集器

采用多线程来通过扫描并压缩堆
特点:停顿时间短,回收效率高,对吞吐量要求高。
适用场景:大型应用,科学计算,大规模数据采集等。
通过JVM参数 XX:+USeParNewGC 打开并发标记扫描垃圾回收器。

3、CMS收集器

采用“标记-清除”算法实现,使用多线程的算法去扫描堆,对发现未使用的对象进行回收。
(1)初始标记
(2)并发标记
(3)并发预处理
(4)重新标记
(5)并发清除
(6)并发重置
特点:响应时间优先,减少垃圾收集停顿时间
适应场景:服务器、电信领域等。
通过JVM参数 -XX:+UseConcMarkSweepGC设置

4、G1收集器

在G1中,堆被划分成 许多个连续的区域(region)。采用G1算法进行回收,吸收了CMS收集器特点。

特点:支持很大的堆,高吞吐量
  --支持多CPU和垃圾回收线程
  --在主线程暂停的情况下,使用并行收集
  --在主线程运行的情况下,使用并发收集
实时目标:可配置在N毫秒内最多只占用M毫秒的时间进行垃圾回收
通过JVM参数 –XX:+UseG1GC 使用G1垃圾回收器

 

5、对比图表

 

 

串行复制

Copy

Young

-XX:+UseSerialGC

the serial copy collector, uses one thread to copy surviving objects from Eden to Survivor spaces and between Survivor spaces until it decides they've been there long enough, at which point it copies them into the old generation.

串行复制收集器使用一个线程将幸存对象从Eden复制到Survivor空间,并在Survivor空间From和To之间复制,直到确定它们已经存在足够长的时间,然后将它们复制到老年代中。

并行复制

PS Scavenge

Young

-XX:+UseParallelGC

the parallel scavenge collector, like the Copy collector, but uses multiple threads in parallel and has some knowledge of how the old generation is collected (essentially written to work with the serial and PS old gen collectors).

并行复制收集器与串行复制收集器类似,使用多个线程,并对老年代收集器的收集方式有一定的准备(基本上是为与老年代的多线程标记压缩收集器一起使用)。

并行复制(新)

ParNew 

Young

-XX:+UseParNewGC

the parallel copy collector, like the Copy collector, but uses multiple threads in parallel and has an internal 'callback' that allows an old generation collector to operate on the objects it collects (really written to work with the concurrent collector).

并行复制(新版)收集器与串行复制收集器类似,使用多个线程,并具有内部“回调”,允许老年代收集器对其收集的对象进行操作(实际上是为使用并发收集器而编写的)。

G1 年轻代

G1 Young Generation

Young

-XX:+UseG1GC

the garbage first collector, uses the 'Garbage First' algorithm which splits up the heap into lots of smaller spaces, but these are still separated into Eden and Survivor spaces in the young generation for G1.

G1垃圾收集器使用“Garbage First”算法,该算法将堆分成许多较小的空间,但在G1的年轻一代中,这些空间仍然被分成Eden和Survivor空间。

串行标记压缩

MarkSweepCompact

Tenured

-XX:+UseSerialGC

the serial mark-sweep collector, the daddy of them all, uses a serial (one thread) full mark-sweep garbage collection algorithm, with optional compaction.

串行标记压缩收集器(是串行复制收集器的上一层,指年轻代)使用串行(单线程)标记压缩收集算法,并具有可选的压缩。

并行标记清除

PS MarkSweep

Tenured

-XX:+UseParallelOldGC

the parallel scavenge mark-sweep collector, parallelised version (i.e. uses multiple threads) of the MarkSweepCompact.

并行标记清除是就是使用多线程并行处理MarkSweepCompact。

并发标记清除

ConcurrentMarkSweep 

Tenured

-XX:+UseConcMarkSweepGC

the concurrent collector, a garbage collection algorithm that attempts to do most of the garbage collection work in the background without stopping application threads while it works (there are still phases where it has to stop application threads, but these phases are attempted to be kept to a minimum). Note if the concurrent collector fails to keep up with the garbage, it fails over to the serial MarkSweepCompact collector for (just) the next GC.

并发收集器(concurrent collector)是一种垃圾收集算法,它尝试在后台执行大部分垃圾收集工作,而不在工作时停止应用程序线程(仍有一些阶段必须停止应用程序线程,但这些阶段试图保持在最小值)。注意:如果并发收集器无法跟上垃圾产生速度,则它将停顿转移到下一个GC的串行MarkSweepCompact收集器。

G1 混合代

G1 Mixed Generation

Tenured

-XX:+UseG1GC

the garbage first collector, uses the 'Garbage First' algorithm which splits up the heap into lots of smaller spaces.

G1垃圾收集器使用“Garbage First”算法,将堆分成许多较小的空间。可以不去考虑Eden\From\To等配比和使用问题,有G1垃圾收集器自动处理。

 

四、CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类尤其重视服务的响应速度,希望系统停顿时间最短。CMS收集器就非常符合这类应用的需求。

CMS基于 标记-清除算法实现。整个过程分为4个步骤:

  1. 初始标记(CMS initial mark) -stop the world
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark) -stop the world
  4. 并发清除(CMS concurrent sweep)

初始标记,重新标记这两个步骤仍然需要Stop The World, 初始标记仅仅标记以下GC Roots能直接关联的对象,速度很快。

并发标记就是进行GC Roots Tracing的过程;

而重新标记阶段则是为了修正并发标记期间因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段停顿比初始标记稍微长,但远比并发标记的时间短。

整个过程耗时最长的并发标记和并发清除过程,收集器都可以与用户线程一起工作。总体上来说,CMS收集器的内存回收过程与用户线程一起并发执行的。

CMS特点:并发收集,低停顿。

缺点

1.CMS收集器对CPU资源非常敏感。默认启动的回收线程数是(CPU+3)/4. 当CPU 4个以上时,并发回收垃圾收集线程不少于25%的CPU资源。

2.CMS收集器无法处理浮动垃圾(Floating Garbage), 可能出现”Concurrent Mode Failure“失败而导致另一次Full GC的产生。由于CMS并发清理时,用户线程还在运行,伴随产生新垃圾,而这一部分出现在标记之后,只能下次GC时再清理。这一部分垃圾就称为”浮动垃圾“。

由于CMS运行时还需要给用户空间继续运行,则不能等老年代几乎被填满再进行收集,需要预留一部分空间提供并发收集时,用户程序运行。JDK1.6中,CMS启动阈值为92%. 若预留内存不够用户使用,则出现一次Concurent Mode Failure失败。这时虚拟机启动后备预案,临时启用Serial Old收集老年代,这样停顿时间很长。

3.CMS基于”标记-清除“算法实现的,则会产生大量空间碎片,空间碎片过多时,没有连续空间分配给大对象,不得不提前触发一次FUll GC。当然可以开启-XX:+UseCMSCompactAtFullCollection(默认开),在CMS顶不住要FullGC时开启内存碎片合并整理过程。内存整理过程是无法并发的,空间碎片问题没了,但停顿时间变长。

 

 

面试题:CMS一共会有几次STW

首先,回答两次,初始标记和重新标记需要。

然后,CMS并发的代价是预留空间给用户,预留不足的时候触发FUllGC,这时Serail Old会STW.

然后,CMS是标记-清除算法,导致空间碎片,则没有连续空间分配大对象时,FUllGC, 而FUllGC会开始碎片整理, STW.

即2次或多次。

除直接调用System.gc外,触发Full GC执行的情况有如下四种。

五、CMS什么时候FUll GC

1、 旧生代空间不足

旧生代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误:
java.lang.OutOfMemoryError: Java heap space
为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

2、Permanet Generation空间满

PermanetGeneration中存放的为一些class的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:
java.lang.OutOfMemoryError: PermGen space
为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。

3、CMS GC时出现promotion failed和concurrent mode failure

对于采用CMS进行旧生代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。
promotionfailed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。
应对措施为:增大survivorspace、旧生代空间或调低触发并发GC的比率,但在JDK 5.0+、6.0+的版本中有可能会由于JDK的bug29导致CMS在remark完毕后很久才触发sweeping动作。对于这种状况,可通过设置-XX:CMSMaxAbortablePrecleanTime=5(单位为ms)来避免。

4、统计得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间

这是一个较为复杂的触发情况,Hotspot为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行Minor GC时,做了一个判断,如果之前统计所得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发Full GC。
例如程序第一次触发MinorGC后,有6MB的对象晋升到旧生代,那么当下一次Minor GC发生时,首先检查旧生代的剩余空间是否大于6MB,如果小于6MB,则执行Full GC。
当新生代采用PSGC时,方式稍有不同,PS GC是在Minor GC后也会检查,例如上面的例子中第一次Minor GC后,PS GC会检查此时旧生代的剩余空间是否大于6MB,如小于,则触发对旧生代的回收。
除了以上4种状况外,对于使用RMI来进行RPC或管理的Sun JDK应用而言,默认情况下会一小时执行一次Full GC。可通过在启动时通过- java-Dsun.rmi.dgc.client.gcInterval=3600000来设置Full GC执行的间隔时间或通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc。

 

六、G1垃圾回收

1、垃圾回收历史

1)第一代:Serial时代,串行

-XX:+UseSerialGC

2)第二代:Parallel时代,并行

-XX:+UseParallelGC -XX:+UseParallelOldGC

3)第三代:Concurrent时代,并发

-XX:+UseParNewGC -XX:+UseConcMarkSweepGC

4)第四代:G1时代,混合

-XX:+UseG1GC

 

2、G1诞生

G1的第一篇paper(附录1)发表于2004年,在2012年才在jdk1.7u4中可用。oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。

 

1)G1的设计原则就是简单可行的性能调优

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

其中-XX:+UseG1GC为开启G1垃圾收集器,-Xmx32g 设计堆内存的最大内存为32G,-XX:MaxGCPauseMillis=200设置GC的最大暂停时间为200ms。如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

 

2)G1将新生代,老年代的物理空间划分取消了

3、G1对象分配策略

对象的分配策略。它分为3个阶段:

1.TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
2.Eden区中分配
3.Humongous区分配

TLAB为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。在Eden空间中,每一个线程都有一个固定的分区用于分配对象,即一个TLAB。分配对象时,线程之间不再需要进行任何的同步。

对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。

最后,G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的。

4、G1 yangGC

Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

Young GC 阶段:

阶段1:根扫描

静态和本地对象被扫描

阶段2:更新RS

处理dirty card队列更新RS

阶段3:处理RS

检测从年轻代指向年老代的对象

阶段4:对象拷贝

拷贝存活的对象到survivor/old区域

阶段5:处理引用队列

软引用,弱引用,虚引用处理

5、G1 Mix GC

Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

它的GC步骤分2步:

1.全局并发标记(global concurrent marking)
2.拷贝存活对象(evacuation)

在进行Mix GC之前,会先进行global concurrent marking(全局并发标记)。

global concurrent marking的执行过程分为五个步骤:

阶段1:初始标记(initial mark,STW)

在此阶段,G1 GC 对根进行标记。该阶段与常规的 (STW) 年轻代垃圾回收密切相关。

阶段2:根区域扫描(root region scan)

G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。

阶段3:并发标记(Concurrent Marking)

G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断

阶段4:最终标记(Remark,STW)

该阶段是 STW 回收,帮助完成标记周期。G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。

阶段5:清除垃圾(Cleanup,STW)

在这个最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

 

6、使用G1垃圾收回算法对JVM调优

 

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

 

-Xmx32g
最大堆内存空间
-XX:MaxGCPauseMillis
我们需要在吞吐量跟MaxGCPauseMillis之间做一个平衡。如果MaxGCPauseMillis设置的过小,那么GC就会频繁,吞吐量就会下降。如果MaxGCPauseMillis设置的过大,应用程序暂停时间就会变长。G1的默认暂停时间是200毫秒,我们可以从这里入手,调整合适的时间。

 

其他参数,非必要场景建议不要调整

-XX:G1HeapRegionSize=n
设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。


-XX:ParallelGCThreads=n
设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。

如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。


-XX:ConcGCThreads=n
设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。


-XX:InitiatingHeapOccupancyPercent=45
设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

 

避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。

 

7、G1触发Full GC

在某些情况下,G1触发了Full GC,这时G1会退化使用Serial收集器来完成垃圾的清理工作,它仅仅使用单线程来完成GC工作,GC暂停时间将达到秒级别的。整个应用处于假死状态,不能处理任何请求,我们的程序当然不希望看到这些。那么发生Full GC的情况有哪些呢?

 

1)并发模式失败
G1启动标记周期,但在Mix GC之前,老年代就被填满,这时候G1会放弃标记周期。这种情形下,需要增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads等)。

 

2)晋升失败或者疏散失败
G1在进行GC的时候没有足够的内存供存活对象或晋升对象使用,由此触发了Full GC。可以在日志中看到(to-space exhausted)或者(to-space overflow)。解决这种问题的方式是:

a. 增加 -XX:G1ReservePercent 选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。

b. 通过减少-XX:InitiatingHeapOccupancyPercent 提前启动标记周期。

c. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。

 

3)巨型对象分配失败
当巨型对象找不到合适的空间进行分配时,就会启动Full GC,来释放空间。这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize,使巨型对象不再是巨型对象。

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值