JVM之垃圾回收

2 篇文章 0 订阅

说起垃圾回收,我们都知道,传统的C/C++等编程语言,需要程序员负责回收已经分配出去的内存。显示进行垃圾回收是一件令人头疼的事情,因为程序员并不总是知道内存应如何时进行释放。如果一些分配出去的内存不能及时的回收就会引起系统运行速度下降,甚至导致程序瘫痪,这种现象称为内存泄露

一、内存泄漏

1、概念

Java中的内存泄露,广义并通俗的说,就是:不再会被使用的对象的内存不能被回收,就是内存泄露。

通俗地说,就是程序员可能创建了一个对象,以后一直不再使用这个对象,这个对象却一直被引用,即这个对象无用但是却无法被垃圾回收器回收的,这就是java中可能出现内存泄露的情况

要了解Java中的内存泄露,首先就得知道Java中的内存是如何管理的。在Java程序中,我们通常使用new为对象分配内存,而这些内存空间都在堆(Heap)上。

Java内存模型这一块的知识,请详见我的另一篇博客:JVM内存模型

我们知道,对象都是有生命周期的,有的长,有的短,如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。我们举一个简单的例子:

public class Simple {
 
    Object object;
 
    public void method1(){
        object = new Object();
    //...其他代码
    }
}

这里的object实例,其实我们期望它只作用于method1()方法中,且其他地方不会再用到它,但是,当method1()方法执行完成后,object对象所分配的内存不会马上被认为是可以被释放的对象,只有在Simple类创建的对象被释放后才会被释放,严格的说,这就是一种内存泄露。解决方法就是将object作为method1()方法中的局部变量。当然,如果一定要这么写,可以改为这样:

public class Simple {
 
    Object object;
 
    public void method1(){
        object = new Object();
        //...其他代码
        object = null;
    }
}

这样,之前“new Object()”分配的内存,就可以被GC回收。

到这里,Java的内存泄露应该都比较清楚了,那么我们最常见的内存泄露都有哪些情况呢?

2、内存泄露情况

1、静态集合类

如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。
简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。
例:

Static Vector v = new Vector(10); 
for (int i = 1; i<100; i++) 
{ 
	Object o = new Object(); 
	v.add(o); 
	o = null; 
}

2、各种连接,如数据库连接、网络连接和IO连接等:
在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、Statement或ResultSet不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

3、变量不合理的作用域:
一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生。

public class UsingRandom {
		private String msg;
		public void receiveMsg(){
			readFromNet();// 从网络中接受数据保存到msg中
			saveDB();// 把msg保存到数据库中
	}
}

如上面这个伪代码,通过readFromNet方法把接受的消息保存在变量msg中,然后调用saveDB方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。

实际上这个msg变量可以放在receiveMsg方法内部,当方法使用完,那么msg的生命周期也就结束,此时就可以回收了。还有一种方法,在使用完msg后,把msg设置为null,这样垃圾回收器也会回收msg的内存空间。

4、内部类持有外部类:
如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。

5、改变哈希值
当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。

6、栈中的内存泄漏:
如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些队象,他们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽,需要引用置空。

7.缓存泄漏:
内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘,对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值。

8.监听器和回调:
内存泄漏还有一个常见来源是监听器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。需要确保回调立即被当作垃圾回收的最佳方法是只保存他的若引用,例如将他们保存成为WeakHashMap中的键。

9、单例造成的内存泄漏:
由于单例的静态特性使得其生命周期和应用的生命周期一样长,如果一个对象已经不再需要使用了,而单例对象还持有该对象的引用,就会使得该对象不能被正常回收,从而导致了内存泄漏。

那么,为了避免内存泄漏,我们除了在程序设计上注意之外,还需要别的工具协助我们,那就是垃圾回收器!

二、垃圾回收

1、Java中的引用和回收方式

1.1、强引用-FinalReference

强引用是我们用的最多的一种引用,他的生命周期非常长,就算程序内存不足(OOM)的时候也不会回收。
其使用方式为:

String str = new String("Hello,world!");

他的可用场景最为常见,一般就是在创建对象的时候用,地球人都知道,不再多说。

1.2、软引用-SoftReferencr

软引用的生命周期不是很长,它在程序内存不足的时候会被回收。
其使用方式为:

SoftReference<String> rstr= new SoftReference<String>(new String("Hello,world!"));

注意:rstr这个引用也是强引用,它是指向SoftReference这个对象的,
这里的软引用指的是指向new String(“Hello,world”)的引用,也就是SoftReference类中T

他的使用场景为: 创建缓存的时候,创建的对象放进缓存中,当内存不足时,JVM就会回收早先创建的对象。

1.3、弱引用-WeakReference

弱引用的声明周期一般很短,只要JVM发现了它,就会将它回收。
其使用方式为:

WeakReference<String> wstr = new WeakReference<String>("Hello,world!");

可用场景为:一旦我不需要某个引用,也希望处理它的时候,我们希望JVM会自动帮我处理它,这样我就不需要做其它操作,就可以用弱引用。

1.4、虚引用-PhantomReference

虚引用的回收机制跟弱引用差不多,但是它和其他引用的区别是:虚引用被回收之前,会被放入ReferenceQueue中;但其它引用是被JVM回收后才被传入ReferenceQueue中的。而且,虚引用创建的时候,必须带有ReferenceQueue。
使用方式为:

PhantomReference<String> pstr= new PhantomReference<String>(new String("Hello,world"), new ReferenceQueue<>());

可用场景:
虚引用一般用于对象销毁前的一些操作,比如说资源释放等等。和Object.finalize()有差不多的功能,但是Object.finalize()虽然也可以做这类动作,但是这个方式即不安全又低效,不如虚引用。

Java相比于C++一大特点便是其特有的自动垃圾回收机制,有了它,我们就可以不用再关系内存分配的问题了,也一般不需要担心出现内存泄漏。大家也可以愉快的聚焦于业务发展。那么Java的GC回收到底是怎么实现的呢,本文就来简单的说一下。
    
其实,Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:
(1)发现无用信息对象;
(2)回收被无用对象占用的内存空间,使该空间可被程序再次使用

那么怎么确定无用对象,接下来我们一一阐述。

2、如何确定无用对象

既然垃圾回收算法就是为了回收垃圾,那么垃圾收集器如何确定某个对象是“垃圾”?通过什么方法判断一个对象可以被回收呢?主要有以下几种方法。

2.1、引用计数法

众所周知,在java中是通过引用来和对象进行关联的,也就是说如果要操作对象必须通过引用来进行。

那么很显然一个最简单的办法就是通过引用计数来判断一个对象是否可以被回收,这种方法的具体措施就是给对象加一个引用计数器,被引用一次就加一,引用失败就减一,任何计数器为零的对象都可以被回收。

也就是说,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式成为引用计数法

这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,也就是说,如果一个对象陷入循环被反复引用,虽然它已经没用了,但是也会被无限计数,这种方法就失去了他的作用,因此在Java中并没有采用这种方式(Python采用的是引用计数法)。

为了解决这个问题,在Java中采取了另一种方法

2.2、可达性分析法

该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的。也就是说,他会将GC Roots对象作为节点开始向下搜索引用的对象,找到标记的就叫做非垃圾对象,未标记的就叫做不可达对象

GC Roots这个根节点一般选取线程栈的本地变量,静态变量,本地方法栈的变量等。

不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。

一般这些对象就可以被选作为GCRoot:

1、虚拟机栈中引用的对象

2、方法区类静态属性引用的对象

3、方法区常量池引用的对象

4、本地方法栈JNI引用的对象

讲了Java中怎么标记垃圾,那么接下来我们看看,怎么回收这些垃圾。

3、垃圾回收算法

由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。

现在常用的垃圾回收算法有Mark-Sweep(标记-清除)算法、Copying(复制)算法、Mark-Compact(标记-整理)算法(压缩法)以及最最常用的Generational Collection(分代收集)GC算法

3.1、Mark-Sweep(标记-清除)

这是最基础的垃圾回收算法,它最容易实现,思想也最简单的回收算法。

标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。

但是这种算法有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

3.2、Copying(复制)

为了解决Mark-Sweep算法容易产生内存碎片的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价。我们光想想它避免内存碎片的方法就能知道,它会出现什么问题。

因为能够使用的内存缩减到原来的一半。很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

3.3、Mark-Compact(标记-整理)

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。

该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,这次我们不在给对象划分区域,而是每次回收的时候,统一将存活对象都向一端移动,然后清理掉端边界以外的内存,这样每次回收存活对象都聚集在一起,也不会产生内存碎片,也避免了因为划分区域而产生的效率低下问题。

3.4、Generational Collection(分代收集)算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。

既然它将内存划分了区域,我们就先看一下JVM中的内存模型,详情请看我的另一篇博客:JVM内存模型

简单看一下JVM的内存结构:

其中,堆内存就是GC管理的主要区域(知识点,重点)

并且,为了实现分代回收,JVM又把堆内存分三代,新生代,老年代,持久代。如图所示:

3.4.1、新生代

新生代又分为Eden ,From Survivor ,TO Survivor:

绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。

Eden区是连续的内存空间,因此在其上分配内存极快。每次新生代的垃圾回收(又称Minor GC)一般采用copy算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少。

最初一次,当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区From (此时,TO 是空白的,两个Survivor总有一个是空白的);

下次Eden区满了,再执行一次Minor GC,将消亡的对象清理掉,将存活的对象复制到TO 中,然后清空Eden区同时也 将From 中消亡的对象清理掉,将存活的对象也复制到TO 区,然后清空From区;之后,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

如果没有填满,当两个存活区切换了几次(每进行一次MinorGC,都会在存活的对象做一个标记,加1,当标记的值大于15,HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold这一参数控制,进入老年代)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。

3.4.2、老年代

在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。

整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)

当老年代的空间不足时,会触发Major GC/Full GC,速度一般比Minor GC慢10倍以上。

3.4.3、持久代

在JDK8之前的HotSpot实现中,类的元数据如方法数据、方法信息(字节码,栈和变量大小)、运行时常量池、已确定的符号引用和虚方法表等被保存在永久代中,32位默认永久代的大小为64M,64位默认为85M,可以通过参数-XX:MaxPermSize进行设置。

GC不会在主程序运行期对永久区域进行清理,这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

所以虚拟机团队在JDK8的HotSpot中,把永久代从Java堆中移除了,并把类的元数据直接保存在本地内存区域(堆外内存),称之为元空间。

元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入java堆中. 这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。

但是我们依旧需要对元空间做一些限制,一般使用参数:-Xmx2g -Xms2g -Xmn1g -XX:+PrintGCDetails -XX:MaxMetaspaceSize=XXX,来进行最大元空间的限制,否则很可能因为被无止境使用而被OS Kill、。

三、垃圾回收器

在java中,虽然垃圾回收算法只有三种,也就是我们耳熟能详的标记清除算法(MS),复制算法(Copy),标记整理算法(MSC),但是由他们衍生出来的垃圾回收器却是很多的,从jdk1.8为界限,会将这几种常用的垃圾回收器做了总结和分类。

如下图所示:

看得出来,在前面这几种回收器中,新生代和老年代的回收算法不一样,所以经常搭配出现,我们重点说一下CMS和G1回收器。

注意:

对于Serial-Serial Old 和Parallel Scavenge-Parallel Old这两种垃圾回收器,新生代都采用复制回收算法,老年代采用标记整理算法,区别在于回收时采用一个还是多个线程,缺点也都很一致,就是会产生STW。

虽然CMS垃圾回收器比其他两种好,但是java8之前还算是默认使用的是PS-PO回收器。

1、CMS回收器

 CMS回收器:ParNew-CMS(ConcurrentMarkSweep)。

与上图说的那样,parNew是一种新生代垃圾回收器,而CMS是一种老年代垃圾回收器,两者常作为搭档应用。

从他的名字ConcurrentMarkSweep我们就可以顾名思义,它是一种“并发标记清除算法”,新生代采用复制算法,老年代采用标记清除算法

注意:

上面其他两种Serial Old和Parallel Old采用的是标记整理算法。

而标记清除算法是会产生内存碎片的,CMS怎么解决的呢?

解决这个问题的办法就是可以让CMS在进行一定次数的Full GC(标记清除)的时候进行一次标记整理算法,CMS提供了一下参数来控制:

-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5

也就是CMS在进行5次Full GC(标记清除)之后进行一次标记整理算法,从而可以控制老年带的碎片在一定数量以内,甚至可以配置CMS在每次Full GC的时候都进行内存的整理。
 

新生代采用复制算法,会暂停所有用户线程。

老年代采用CMS,而这种垃圾回收器的回收过程主要分成四个过程:

1、初始标记:

初始标记其实就是对被我们GC ROOT直接引用的对象做一个标记,在这个过程中将会触发一次STW(stop the word)机制,但是时间很短可以忽略。

2、并发标记:

在进行并发标记的过程中,我们的用户线程和CMS线程会一起执行。CMS所做的一件事情就是把堆里的所有引用对象全部找到并做标记。

但是在这个过程中可能会发生对象状态被改变的问题。
1、比如我的一个对象的引用链已经断开,变成了垃圾对象,但是CMS已经对他做过标记判断为非垃圾对象了怎么办?这就是在并发标记过程中产生的浮动垃圾(多标问题)
2、比如本来一个对象在CMS标记的过程中把他标记成了垃圾对象但是后来我们有引用了,结果在我们用的时候垃圾对象已经被干掉了,那我们是不是在引用这个对象的时候就会找不到这个垃圾对象。(漏标问题)

这时候我们的第五步就产生了。

3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;。

4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;

5、重新标记:

在这一步,CMS会触发STW机制,并修复并发标记状态已经改变的对象,但是这个过程会比较漫长。他利用三色标记和增量更新来解决我们的漏标问题。

三色标记算法:

1、标记GC Roots为黑色

2、标记GC Roots引用的对象为灰色

3、其他对象为白色

将所有灰色对象放入队列之中,将灰色对象标记为黑色,将它的引用对象标记为灰色,重复这个步骤,直到没有了灰色对象,最终所有黑色对象就是存货对象。

为了解决上述漏标问题,在并发标记过程中所有被置空或加上指针的引用对象,都姑且算作活对象(尽管又可能它是死的),

6、并发清除:

那这一步就很好理解了,所谓的并发清理其实就是对没有被做标记的对象进行一个清理回收,在这个过程中同样不会产生STW。

7.并发重置:

重置本次GC过程中的标记数据,等待下次CMS的触发。

CMS回收器实际上是为了缩减另两种STW时间很长的回收器,而设计的,它的STW时间在毫秒级别,一般最高不超过100ms,但是依旧干不过G1,G1的STW时间,号称不过10ms,那么他是怎么实现的呢?

java11中的ZGC更是号称不过1ms,更牛逼!

补充:由上图可知,这几种新生代和老年代是可以分别互换使用的但是我们一般不会去互换它来用,需要注意的是,CMS在使用的时候,会留有一个Serial Old用来做替补,当并发清除的时候,用户产生的新的垃圾在内存中装不下的时候,就会触发Serial Old停止用户线程,再进行清除。

2、G1回收器

2.1、简介

随着计算机软硬件的不断发展,我们用来存放对象的堆内存也随之越来越大,动辄数G甚至数十G,而这时候再使用串行和并行的回收方式,就显得不那么有效率,所以产生出来一个全新的垃圾回收方式——G1的回收方式。G1的牛逼之处在于,它和以往的回收算法大不相同,不能说没有关系,只能说毫不相干。。。

在jdk1.9之后,G1便是默认的垃圾回收器,用于替代CMS,而同时,一种回收器通吃新生代和老年代回收。

它主要有以下几个方面的改进:

1、G1可以设置STW的停顿时间,通过参数:-XX:MaxGCPauseMillis = N,默认250ms。

2、年轻代回收:STW,Parallel,Copy

3、老年代回收:Mostly-concurrent Marking(VS CMS),Increment Compaction。

几乎是一个实时回收算法(软实时)。

2.2、G1的内存布局

G1摒弃了以往的堆内存分代思想,而是将内存分为等大的区域块:利用参数 -XX:GCHeapRegionSize = N,默认2048个区域。并且每个区域不在固定,可以是Eden,也可以是Surviver也可以是Old,也就是说,这三个区域从此不再连续了,并且分配了一个Humongous区域(属于老年代)来存放那些大小超过一个区域的一半的超大对象,如图所示:

2.3、G1的回收过程

年轻代(Fully young GC):

G1在回收年轻代的时候,是会产生STW的,它不会回收整个堆,而是回收一个Collection Set(CS:回收区域集合)来进行回收,并且会估计整个Region的垃圾比例,优先回收垃圾占比高的Region。

但是这里必须考虑两个问题:

1、跨代引用(老年代对象持有年轻代引用)

2、不同Region之间互相引用

解决方法:

GC又将Region分成很多个卡片,并引入两个数据结构Card Table(用来记录卡片) 和 Remember Set(RS:被引用对象的Region用来记录引用对象的Card)。也就是说当两个Region有对象互相引用的时候,就会将引用对象的Card记录在另一个区域的RS里面,这样我们回收对象的时候,出现这种引用情况就不需要引用整个堆,而只需要扫描那个对应的Card就可以了,这是一个典型的“空换时”的概念。

回收过程:

STW开始

1、构建CS(Collection Set)

2、扫描GC Roots

3、更新Remember Set :排空Card Dirty Queue

4、找到跨域引用的对象

5、复制算法(Mark Copy)进行回收:清空Eden,连同一个Survivor复制到另一个Survivor里面去。

6、记录每个阶段时间,进行自行调优

7、记录Eden/Survivor数量和GC时间,根据暂停目标进行Eden数量调整,看看是提升吞吐量还是降低吞吐量。

老年代(OldGC):

当堆用量到达一定程度时触发,利用参数 -XX:InitiatingHeapOccupancyPercent 控制,默认45%,是一种并发标记回收方式,利用三色标记算法进行标记回收。

回收过程:

1、STW开始,先进行一次Fully young GC,这也是为什么G1能横跨整个堆内存无视区域的原因。

2、恢复应用线程

3、并发初始标记

4、再次STW

  • Remark重新标记,解决漏标问题
  • CleanUp,回收所有全空的区域。

5、恢复应用线程。

注意上面我们老年代回收的时候,是直接清空了所有空区域,并没有进行复制操作,所有不会压缩老年代,这是CMS的问题,但是G1当然解决了,这时候G1中第三种回收算法就起了作用。

Mixed GC (混合GC)

Mixed GC不一定发生,选择若干个Region进行,默认1/8个区域,过程和年轻代完全一样,因为需要拷贝。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值