Java中对象何时需要回收?常见的 GC 回收算法有哪些?

一、JVM 体系结构概述

  • 1、类加载器 ClassLoader
  • 2、执行引擎 (Execution Engine)
  • 3、本地方法栈 (NativeMethodStack)、本地方法接口(NativeInterface)、本地方法库
  • 4、PC寄存器
  • 5、方法区 (Method Area)

 JVM(Java 虚拟机)主要包括三个内存空间。分别是:栈内存、堆内存、方法区内存。(堆内存和方法区内存各一个。一个线程一个栈内存)

1、什么是垃圾回收
垃圾回收(Garbage Collection),释放垃圾占用的空间,防止内存泄露,对内存中已经死亡的或者长时间没有使用的对象进行清除和回收。

为什么不回收栈?栈是什么?

栈也叫栈内存:主管Java程序的运行,是在线程创建时创建 (私有的),它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量 + 对象的引用变量 + 实例方法都是在函数的栈内存中分配。

栈存储什么?

栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集。

栈帧中主要保存3 类数据:

本地变量(Local Variables):输入参数和输出参数以及方法内的变量(局部变量)
栈操作(Operand Stack):记录出栈、入栈的操作。
栈帧数据(Frame Data):包括类文件、方法等等。
PC寄存器:作用在栈中,指向下一个方法的指针,决定方法执行顺序

  • PC寄存器

记录程序执行的顺序。每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记

栈溢出 (不是栈内存):StackOverflowError 通常出现在递归调用时。解决:递归调用要有出口

为什么不回收方法区?方法区是什么?

 方法区:此区属于共享区间,是各个线程共享的内存区域(就是我们常说的永久代(Permanent Generation)【Java7 及以前】,其生命周期长,垃圾回收少),静态变量+常量+类信息(构造方法/接口定义)+运行时常量池(用于存放编译期生成的各种字面量和符号引用)存在方法区中

 变量图解:

在这里插入图片描述

静态变量+实例变量具体代码了解:

public class MyStatic {
    //静态变量:被所有类实例对象所共享,在内存中只有一个副本,当且仅当在类初次加载时会被初始化
    private static int index = 0;
    //实例变量:实例对象所拥有的,在创建对象的时候被初始化,存在多个副本,各个实例对象拥有的副本互不影响
    private int count = 0;

    public void inc() {
        index++;
        count++;
    }

    public String get() {
        return "静态变量index = " + index + ",  实例变量count = " + count;
    }

    public void getCount() {
        int count = 2; //局部变量
        System.out.println("局部变量:a=" + count);
        System.out.println("实例变量:a=" + this.count);//局部变量的作用域内引用实例变量:this.变量名
    }

    public static void main(String[] args) {
        MyStatic myStatic1 = new MyStatic();
        MyStatic myStatic2 = new MyStatic();
        MyStatic myStatic3 = new MyStatic();
        myStatic1.inc();
        System.out.println("myStatic1的index、count增加1:");
        System.out.println("myStatic1的" + myStatic1.get());
        System.out.println("myStatic2的" + myStatic2.get());
        System.out.println("myStatic3的" + myStatic3.get() + "\n");
        myStatic2.inc();
        System.out.println("myStatic2的index、count增加1:");
        System.out.println("myStatic1的" + myStatic1.get());
        System.out.println("myStatic2的" + myStatic2.get());
        System.out.println("myStatic3的" + myStatic3.get() + "\n");
        myStatic3.inc();
        System.out.println("myStatic3的index、count增加1:");
        System.out.println("myStatic1的" + myStatic1.get());
        System.out.println("myStatic2的" + myStatic2.get());
        System.out.println("myStatic3的" + myStatic3.get());
    }
}

2、如何定义垃圾
既然要做垃圾回收,那么就得知道垃圾的定义是什么,哪些内存需要回收。

堆的了解:

堆 (Heap)

存储实例对象

Java虚拟机规范,一套规范,落地的产品:

三种JVM:•Sun公司的HotSpot  •BEA公司的JRockit  •IBM公司的J9 VM

(1) Java7 及以前堆的组成

Heap 堆:一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存逻辑上分为三部分:

Young Generation Space 新生区 Young/New
Tenure generation space 养老区 Old/Tenure
Permanent Space 永久区 Perm
也称为:新生代(年轻代)、老年代、永久代(持久代)。

70%、80会触发垃圾回收。老年区满后 内存溢出。永久代不属于堆内存的范畴,属于方法区

 (2) Java8 后堆逻辑上的变化

Jdk1.6及之前: 有永久代,常量池1.6在方法区

Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池1.7在堆

Jdk1.8及之后: 无永久代,常量池1.8在堆中。元空间(对方法区的实现)

永久代与元空间的最大区别之处:

永久代使用的是jvm的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存。因此,默认情况下,元空间的大小仅受本地内存限制。

 (3) 物理层面,堆内存划分

默认在新生代抗过15次gc,升级到养老区。From与To区会进行交换,随后清空Eden、To

新生区、养老区、永久代(元空间)

新生区 (新生代)

新new出的对象,会存入伊甸区,80%空间被占用后,产生轻gc:伊甸园区中销毁没有被其他对象引用的对象。将伊甸园中的剩余对象移动到幸存 From区。

GC垃圾回收过程:复制 -> 清空 -> 互换

1、幸存者从 eden、From 复制到 To区,年龄+1

2、清空 eden、From 区 (垃圾回收)

3、To 和 From 互换  (To区永远是空的,From区存储对象)

大对象特殊情况:幸存区存不下,直接进入养老区

养老区 (老年代)

经历多次GC仍然存在的对象(默认是15次),老年代的对象比较稳定,不会频繁的GC

若养老区也满了,那么这个时候将产生重GC(FullGC),进行养老区的内存清理。若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

如果出现 java.lang.OutOfMemoryError: Java heap space 异常,说明Java虚拟机的堆内存不够(堆空间出现的内存溢出)。

原因有二:

(1) Java虚拟机的堆内存设置不够,通过参数-Xms(初始)、-Xmx(最大) 来调整。

(2) 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集 (存在被引用)。

什么对象一定进入养老区:池对象 (连接池、线程池)

永久区 (永久代) (非堆)

永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class、Interface 的元数据,运行时所需要的环境。关闭 JVM 才会释放此区域所占用的内存。

实际而言,方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等,虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开(物理上)。

Jdk1.6及之前: 有永久代,常量池1.6在方法区

Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池1.7在堆

Jdk1.8及之后: 无永久代,常量池1.8在堆中。元空间(对方法区的实现)

永久代与元空间的最大区别之处:

永久代使用的是jvm的堆内存,但是java8以后的元空间是使用本机物理内存,并不在虚拟机中。因此,默认情况下,元空间的大小仅受本地内存限制。
 

堆参数调优

1、1.7、1.8参数调优:-Xms(初始堆内存大小) -Xmx(最大堆内存大小)

为什么效率会变高:内存变大,重GC次数变少

常用JVM参数

怎么对jvm进行调优?通过参数配置 

参数备注
-Xms初始堆大小。只要启动,就占用的堆大小,默认是内存的1/64
-Xmx最大堆大小。默认是内存的1/4
-Xmn新生区堆大小
-XX:+PrintGCDetails输出详细的GC处理日志
    public static void main(String[] args) {
        long maxMemory = Runtime.getRuntime().maxMemory(); // 堆的最大值,默认是内存的1/4
        long totalMemory = Runtime.getRuntime().totalMemory(); // 堆的当前总大小,默认是内存的1/64
        System.out.println("maxMemory = " + maxMemory);
        System.out.println("totalMemory = " + totalMemory);
    }

idea运行时设置方式如下:

执行前配置参数:-Xmx50m -Xms30m -XX:+PrintGCDetails

 立竿见影

OOM演示:

System.gx(); 是否能执行垃圾回收?

程序结束时,内存快满时,才会触发GC

final  finally finalize区别?

final 修饰的对象(类 方法 变量),不可变

try/catch/finally  无论是否异常都会执行

finalize  垃圾回收释放内存时候调用的方法

二、GC 垃圾回收

面试题:

- JVM内存模型以及分区,需要详细到每个区放什么
- 堆里面的分区:Eden,survival from to,老年代,各自的特点。
- Minor GC与Full GC分别在什么时候发生
- GC的三种收集方法:标记清除、标记整理、复制算法的原理与特点,分别用在什么地方?
JVM垃圾判定算法:(对象已死?)

- 引用计数法(Reference-Counting)
- 可达性分析算法(根搜索算法)
GC垃圾回收主要有四大算法:(怎么找到已死对象并清除?)

- 复制算法(Copying)
- 标记清除(Mark-Sweep)
- 标记压缩(Mark-Compact),又称标记整理
- 分代收集算法(Generational-Collection)
 

1、垃圾判定

引用计数算法(Reference-Counting) (不再使用)
通过在对象头部分配一个空间,来保存该对象被引用的次数。对象被其他对象引用,则它的计数加1,删除该对象的引用,计数减1,当该对象计数为0时,会被回收。

String m = new String("方糖算法");

创建一个字符串m,这时候"方糖算法"字符串被m引用了,"方糖算法"字符串计数加1。

引用计算算法

此时将m设置为null,则"方糖算法"的引用次数就变为0,意味着要被回收了。

m = null;

引用计数算法

引用计数算法将垃圾回收分摊到整个程序运行中,而不是在垃圾收集时,不属于严格意义上的"Stop-The-World"的垃圾收集机制。

JVM放弃了引用计数算法,这是为什么?我们看下面的例子。

public class ReferenceCountingGC {

    public Object instance;

    public ReferenceCountingGC(String name){}
}

public static void testGC(){

    ReferenceCountingGC a = new ReferenceCountingGC("objA");
    ReferenceCountingGC b = new ReferenceCountingGC("objB");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
}
  1. 定义2个对象a,b
  2. 相互引用
  3. 声明引用置空

引用计数缺点

从图中,ab置空后,这两个对象已经不能被访问了,但是他们相互引用对方,导致他们两个的计数永远不为0,永远不会被回收。

*优点:简单、高效

*缺点:1. 引用和去引用伴随着加减算法,影响性能,2. 很难处理循环引用,相互引用的两个对象则无法释放

2.2 可达性分析算法

通过引用链(GC Root)作为起点,向下搜索,搜索过的路径被称为(Reference Chain)。当一个对象不能被引用链搜索到,说明该对象不可用,被回收。

可达性算法

通过可达性算法,可以解决引用计数算法无法解决的循环依赖问题,只要不能被GC Root搜索到,就会被回收。

真正标记以为对象为可回收状态至少要标记两次。

第一次标记:不在 GC Roots 链中,标记为可回收对象。

第二次标记:判断当前对象是否实现了finalize() 方法,如果没有实现则直接判定这个对象可以回收,如果实现了就会先放入一个队列中。并由虚拟机建立一个低优先级的程序去执行它,随后就会进行第二次小规模标记,在这次被标记的对象就会真正被回收了!
 

四种引用 (了解)

平时只会用到强引用和软引用。

强引用:如果一个对象具有强引用,那么垃圾回收器绝不会回收它。就算在内存空间不足的情况下,Java虚拟机宁可抛出OutOfMemoryError错误,使程序异常终止,也不会通过回收具有强引用的对象来解决内存不足的问题

软引用:在内存空间足够的情况下,如果一个对象只具有软引用,那么垃圾回收器就不会回收它,但是如果内存空间不足,垃圾回收器就会回收这个对象(回收发生在OutOfMemoryError错误之前)。只要垃圾回收器没有回收它,这个对象就能被程序使用

软引用可以用来实现内存敏感的高速缓存

弱引用:对象只能生存到下一次垃圾收集之前。无论当前内存是否紧缺,GC都会回收被弱引用关联的对象。不过,由于GC是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象

虚引用:无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。虚引用等同于没有引用,在任何时候都可能被GC回收

 还是上面的实体类MyStatic:

 //强引用:表示一个对象处在【有用,必须】的状态
  MyStatic myStatic = new MyStatic();
 //软引用:表示一个对象处在【有用,但非必须】
 SoftReference softReference = new SoftReference(myStatic);
 //弱引用:表示一个对象处在【可能有用,但非必须】
 WeakReference weakReference = new WeakReference(myStatic);
 //虚引用:表示一个对象处在【无用】
 ReferenceQueue referenceQueue = new ReferenceQueue();
 PhantomReference phantomReference = new PhantomReference(myStatic, referenceQueue);

那么哪些属于GC Root?往下看鸭!

2.3 Java内存区域

在Java中,GC Root 对象包括四种:

  • 虚拟机栈中的引用对象
  • 方法区静态属性引用的对象
  • 方法区常量引用的对象
  • 本地方法栈JNI引用的对象

 内存区域

虚拟机栈中的引用对象
此时的 s,即为GC Root。 当s置空时,localParameter对象也断掉与GC Root 的引用链,将被回收。

public class StackLocalParameter {
    public StackLocalParameter(String name){}
}

public static void testGC(){
    StackLocalParameter s = new StackLocalParameter("localParameter");
    s = null;
}

 方法区静态属性引用的对象
s 为 GC Root,s 置空后,s 指向的 properties对象被回收。
m 为类静态属性,也属于GC Root,parameter对象依旧与 GC Root 连接着,所以不会被回收。

public class MethodAreaStaicProperties {
    public static MethodAreaStaicProperties m;
    public MethodAreaStaicProperties(String name){}
}

public static void testGC(){
    MethodAreaStaicProperties s = new MethodAreaStaicProperties("properties");
    s.m = new MethodAreaStaicProperties("parameter");
    s = null;
}

方法区常量引用的对象
m 为常量引用,是GC Root,s 置空后,final对象也会被回收。

public class MethodAreaStaicProperties {
    public static final MethodAreaStaicProperties m = MethodAreaStaicProperties("final");
    public MethodAreaStaicProperties(String name){}
}

public static void testGC(){
    MethodAreaStaicProperties s = new MethodAreaStaicProperties("staticProperties");
    s = null;
}

本地方法栈中引用的对象

任何 Native 接口都会使用某种本地方法栈,实现的本地方法是使用 C 连接模型的话,那么它的本地方法栈就是 C 栈。
当线程调用 Java 方法时,虚拟机会创建一个新的栈帧并压入 Java 栈,然而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,虚拟机只是简单地动态连接并直接调用指定的本地方法。

3、怎么回收垃圾

在确定哪些垃圾可以回收后,我们来讨论一下如何高效的回收垃圾呢?

Java虚拟机没有规定实现垃圾收集器,所以各个厂商的虚拟机可以采用不同的方法实现垃圾收集器。

3.1 标记清除算法

标记清除

标记清除算法(Mark - Sweep),标记出需要回收的对象,使用的标记算法均为可达性分析算法。分为 2 部分,①先把内存中可回收的标记出来 ②再把这些清理掉 ,清理完的区域变成未使用,等待下次使用。

但是存在一个很大的问题,那就是内存碎片。

假设图中 中等方块是 2M,小的是 1M,大的是 4M。等回收完,内存就会被切成很多段。而开辟内存需要的是连续区域,需要一个 2M 的内存,用 2个 1M 是没法用的。这样就导致,其实内存还挺多,但是分散了无法使用。

优点:节省内存

缺点:效率问题 (扫描两次),空间问题 (产生内存碎片)

使用地点:养老区 FullGC

3.2 复制算法

复制算法

复制算法(Copying),是在MS算法上演变而来,解决了内存碎片问题。它将内存按容量平分成两块,每次使用其中的一块。当一块用完了,将其存活的对象复制到另一块上,再把这一块内存清理掉。保证了内存连续可用,不会产生内存碎片,逻辑清晰,运行高效。

但是明显暴露了一个问题,合着我 300 平的别墅,只能当 150 平的小三房来使?代价太高了

优点:实现简单、效率高,不产生内存碎片

缺点:浪费一半内存空间,如果对象存活率高,复制这一工作浪费时间

使用地点新生代 Minor GC,这种GC算法采用的是复制算法(Copying)。 (年轻代内存空间小,存活率低)。复制必交换,谁空谁是TO

3.3 标记整理算法 

标记整理

标记整理算法(Mark - Compact)标记过程与MS算法一样,但后续不是直接回收对象,而是让存活的对象向一端移动,再清理端边界以外的内存。

不仅解决了内存碎片,也规避了只能使用一半的内存。但是问题又来了,它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比MS算法差很多。

优点:没有内存碎片,节省内存

缺点:效率低

使用地点:养老区 FullGC

老年代一般是由标记清除或者是标记清除与标记整理的混合实现

3.4 分代收集算法

分代收集算法(Generational Collection),融合上面 3 种思想。 根据对象存活周期的不同划分为几块,一般分为新生代和老年代,根据年代的特点采用适当的收集算法。

新生代:每次回收发现有大量对象死去,少量存活,则使用复制算法,付出少量存活对象复制的成本完成回收。
老年代:存活率高,没有额外空间分配,则使用MS,MC算法来回收。

问题又来了,内存区域被分为哪几块,每一块又用什么算法合适?

内存效率:复制算法>标记清除算法>标记整理算法 (此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

内存整齐度:复制算法=标记整理算法>标记清除算法。

内存利用率:标记整理算法=标记清除算法>复制算法。

年轻代(Young Gen):特点是区域相对老年代较小,对像存活率低,使用复制算法

老年代(Tenure Gen):区域较大,对像存活率高。使用标记清除、标记压缩

 3.5垃圾收集器 (了解)

 

回收器分类算法作用区域是否多线程类型备注
Serial复制算法新生代单线程串行简单高效,服务暂停,淘汰
ParNew复制算法新生代多线程并行唯一和CMS搭配的新生代回收器
Parallel Scavenge复制算法新生代多线程并行更关注吞吐量
Serial Old标记-整理老年代单线程串行搭配所有young gc使用
Parallel Old标记-整理老年代多线程并行搭配Prallel Scavenge
CMS标记-清除老年代多线程并发追求最短的暂停时间

1. Serial/Serial Old 串行收集器

串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代复制算法、老年代标记-压缩。垃圾收集的过程中会服务暂停(Stop The World) 。

2. ParNew 收集器

ParNew收集器收集器其实就是Serial收集器的多线程版本(借助于硬件发展)。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩。

3. Parallel / Parallel Old 收集器

Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。 可以通过参数来打开自适应调节策略。新生代复制算法、老年代标记-压缩。

4. CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于"标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些。

优点: 并发收集、低停顿

缺点: 产生大量空间碎片、并发阶段会降低吞吐量

5. G1收集器 (最新的)

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。

并行与并发、分代收集、空间整合、可预测的停顿

4、内存模型与回收策略

内存模型

Java 堆(Heap)是 JVM 管理的内存最大的一块,堆又是垃圾收集器管理的主要区域,我们来分析一下堆的结构。

堆主要分为 2 个区域,年轻代和老年代。年轻代分为 Eden 和 Survivor,其中 Survivor 又分为 From 和 To ,老年代分为 Old 。

 Eden
研究表名,98%对象是朝生夕死,所以大部分情况,对象会在新生代的 Eden 区分配,当 Eden 内存不足时,虚拟机发起一次 Minor GC, Minor GC 比 Major GC 更频繁,回收更快。

通过 Minor GC 后,Eden会被清空,绝大部分对象被回收,而那些存活对象会进入 Survivor 的 From 区(From 区不够,则进入 Old 区)。

Survivor
Survivor 相当于 Eden 区和 Old 区的一个缓冲区。通过 Minor GC 后,会将 Eden 区和 From 区的存活对象放到 To 区(To 区不够,则进入 Old 区)。

为啥需要缓冲区?

不就是新生代到老年代吗,直接 Eden 到 Old 不就好了?非要这么复杂。
如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到 Old 区,老年代很快被填满。而且很多对象虽然一次 Minor GC 后没有死,可能一会后就死了,直接把它送入老年代,明显不合适。

总结:Survivor 存在的意义就是减少被送到老年代的对象,减少 Major GC 的发送。Survivor 的预筛保证,只有经历16次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

为啥需要两个缓冲区?
两个 Survivor 最大的好处是解决内存碎片化。

如果 Survivor 有1个区域,Minor GC 执行后,Eden 区被清除,存活对象放入 Survivor 区,而之前 Survivor 区的对象可能也有一部分要清除。此时只能使用MS算法,那就会产生内存碎片,尤其是在新生代这种经常死亡的区域,产生严重碎片化。

如果 Survivor 有2个区域,每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责切换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。(有点复制算法的感觉)

这种机制最大的好处就是,永远有一个 Survivor 区是空的,另一个 区是无碎片的。那为啥不分更多的 Survivor 区呢?分的越多,每个区就越小,两块是经过权衡的最佳方案。

 Old
老年代占据着 2/3 的堆内存,只有 Major GC 才会清理,每次 GC 都会触发 “Stop-The-World”。内存越大 STW时间越长,所以内存不是越大越好。老年代对象存活时间较长,采用MC算法

除了上面说的,无法安置的对象会直接送入老年代,以下情况也可以。

大对象
大对象是需要大量连续空间的对象,不管是不是"朝生夕死",都会直接进入老年代。避免在 Eden 和 Survivor 中来回复制。
长期存活对象
虚拟机给每个对象定义了对象年龄计数器。对象在 Survivor 区每经历一次 Minor GC ,年龄加1,当年龄为15岁,直接进入老年代。这里的15可以设置。
动态对象年龄
虚拟机不关注年龄必须到15岁才可以进入老年代,如果Survivor 区相同年龄的所有对象大小超过 Survivor 空间一半,则年龄大于相同年龄的对象就进入老年代,无需成年。
 

转载:

1:Day125.JVM:栈、堆、GC 垃圾回收机制_焰火青年·的博客-CSDN博客

2:『Java面经』Java中对象何时需要回收?常见的 GC 回收算法有哪些?_一枚方糖的博客-CSDN博客_对象什么时候被gc回收

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值