GC运行机制

看过一些关于垃圾回收的资料 感觉有点乱 自己整理了一下

一:托管代码和非托管代码

托管代码(managedcode)

  由公共语言运行库环境(而不是直接由操作系统)执行的代码。托管代码应用程序可以获得公共语言运行库服务,例如自动垃圾回收、运行库类型检查和安全支持等。这些服务帮助提供独立于平台和语言的、统一的托管代码应用程序行为。

  托管代码是可以使用20多种支持Microsoft.NETFramework的高级语言编写的代码,它们包括:C#,J#,MicrosoftVisualBasic.NET,MicrosoftJScript.NET,以及C++。所有的语言共享统一的类库集合,并能被编码成为中间语言(IL)。运行库编译器(runtime-awareompiler)在托管执行环境下编译中间语言(IL)使之成为本地可 执行的代码,并使用数组边界和索引检查,异常处理,垃圾回收等手段确保类型的安全。

在托管执行环境中使用托管代码及其编译,可以避免许多典型的导致安全黑洞和不稳定程序的编程错误。同样,许多不可靠的设计也自动的被增强了安全性,例如类型安全检查,内存管理和释放无效对象。程序员可以花更多的精力关注程序的应用逻辑设计并可以减少代码的编写量。这就意味着更短的开发时间和更健 壮的程序。

非托管代码(unmanagedcode)

  在公共语言运行库环境的外部,由操作系统直接执行的代码。非托管代码必须提供自己的垃圾回收、类型检查、安全支持等服务;它与托管代码不同,后者从公共语言运行库中获得这些服务。

        托管的代码把有关内存管理(内存申请,内存释放,垃圾回收之类的)全部都是.net的CLR来管理,就是说使用托管的代码把底层的一些操作都封装起来了,不能直接进行内存的读取之类的和硬件相关的操作,优点就是比较安全,不会出现诸如内存泄露之类的问题,缺点也很明显,不能直接读取内存,性能上会有损失,使用起来有时也不够灵活。
非托管的刚好相反,可以直接进行硬件操作,性能比较高,但是对开发人员的要求也比较高。
最直观的就是c#不推荐使用指针,而c++就可以使用指针来直接读取内存;
c#使用垃圾回收,c++要手动的释放对象


问题:.net中托管代码的含义?什么是托管?托管是什么意思?

托管代码就是基于.net元数据格式的代码,运行于.net平台之上,所有的与操作系统的交换由.net来完成,就像是把这些功能委托给.net,所以称之为托管代码。非托管代码则反之。

二:托管资源和非托管资源

在.net编程环境中,系统的资源分为托管资源和非托管资源。

托管资源

与托管代码密切相关的是托管资源。托管资源是由公共语言运行的垃圾回收器进行分配和释放的数据。

常见的托管资源:像简单的int,string,float,DateTime等等,默认情况下,C#、VisualBasic和JScript.NET数据是托管资源,.net中超过80%的资源都是托管资源。不过,通过使用特殊的关键字,C#数据可以被标记为非托管资源。VisualC++数据在默认情况下是非托管数据,即使在使用/CLR开关时也不是托管的。

非托管资源

几种常见的非托管资 源:ApplicationContext,Brush,Component,ComponentDesigner,Container,Context,Cursor,FileStream,Font,Icon,Image,Matrix,Object,OdbcDataReader,OleDBDataReader,Pen,Regex,Socket,StreamWriter,Timer,Tooltip等等资源。

最常见的一类非托管资源就是包装操作系统资源的对象,例如文件,窗口或网络连接,对于这类资源虽然垃圾回收器可以跟踪封装非托管资源的对象的生存期,但它不了解具体如何清理这些资源。所以我们这时需要制定专门的规则,确保未托管的资源在回收类的一个实例时释放。

当然,微软已经为我们搭好了框架,就是这两个函数:Finalize和Dispose它们也代表了非托管清理的两种方式:自动和手动。  

在定义一个类时,可以使用这两种机制来自动释放未托管的资源。这些机制常常放在一起实现,因为每个机制都为问题提供了略为不同的解决方法。(后面会说到原因)

下面对这两种方式一一说明

Finalize:

它允许对象在垃圾回收器回收该对象使用的内存前适当清理其拥有的非托管资源。

默认情况下,Finalize方法不执行任何操作。如果您要让垃圾回收器在回收对象的内存之前对对象执行清理操作,您必须在类中重写Finalize方法。然而大家都可以发现在实际的编程中根本无法override方法Finalize(),在C#中,可以通过析构函数自动生成Finalize方法和对基类的Finalize方法的调用。  

例如:

  C#code

~MyClass()

{

//Performsomecleanupoperationshere.

}

该代码隐式翻译为下面的代码。

Protected overridevoid Finalize()

{

try{

//Performsomecleanupoperationshere.

}finally{base.Finalize();}

}

由于Finalize是由GC负责调用,所以可以说是一种自动的释放方式。但是这里面要注意几个问题:

第一,资源释放不及时:由于垃圾回收器的运行规则,所以无法确定GC何时会运作,对象会被提升到更老的“代”,因此可能很长的一段时间里使对象和此对象的关联对象(例如对象引用的其他没有实现Finalize方法的对象以及类似数据库连接之类的资源)都不能得到及时的释放,这对于一些关键资源而言是非常要命的,而且这会增加内存压力。

所以不能在析构函数中放置需要在某一时刻运行的代码,如果对象占用了宝贵而重要的资源,应尽可能快地释放这些资源,此时就不能等待垃圾收集器来释放了.可见,这种“自动”释放资源的方法并不能满足我们的需要,因为我们不能显示的调用它(只能由GC调用),而且会产生依赖型问题。我们需要更准确的控制资源的释放。而Dispose()可以解决这个问题。

第二,负责调用Finalize的线程并不保证各个对象的Finalize的调用顺序,而有时候对象的销毁可能有顺序性,所以这时会引起异常:由于负责调用Finalize的线程并不保证各个对象的Finalize的调用顺序,这可能会带来微妙的依赖性问题。如果你在对象a的Finalize中引用了对象b,而a和b两者都实现了Finalize,那么如果b的Finalize先被调用的话,随后在调用a的Finalize时就会出现问题,因为它引用了一个已经被释放的资源。因此,在Finalize方法中应该尽量避免引用其他实现了Finalize方法的对象。

第三:实现Finalize方法或析构函数对性能可能会有性能负面影响。一个简单的理由如下:用Finalize方法回收对象使用的内存需要至少两次垃圾回收。(后面讲垃圾回收的原理时会说到)

 

IDisposable接口

IDisposable接口为释放未托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾函数器相关的问题。IDisposable接口声明了一个方法 Dispose(),它不带参数,返回void,

示例:Myclass的方法Dispose()的执行代码如下:

  C#code

  Class Myclass:IDisposable{ public void Dispose(){//implementation}}

  Dispose()的执行代码显式释放由对象直接使用的所有未托管资源,并在所有实现IDisposable接口的封装对象上调用Dispose()。这样,Dispose()方法在释放未托管资源时提供了精确的控制

假定有一个类ResourceGobbler,它使用某些外部资源,且执行IDisposable接口。如果要实例化这个类的实例,使用它,然后释放它,就可以使用下面的代码:

  C#code

ResourceGobblertheInstance=new ResourceGobbler();

//这里是theInstance对象的使用过程代码

theInstance.Dispose();//主动释放

  如果在处理过程中出现异常,这段代码就没有释放theInstance使用的资源,所以应使用try块,编写下面的代码:

  C#code

   ResourceGobbler theInstance=null;try{  theInstance=newResourceGobbler();

//这里是theInstance对象的使用过程代码

}finally{ if(theInstance!=null)theInstance.Dispose();}

  即使在处理过程中出现了异常,这个版本也可以确保总是在theInstance上调用Dispose(),总是释放由theInstance使 用的资源。但是,如果总是要重复这样的结构,代码就很容易被混淆。C#提供了一种语法,可以确保在引用超出作用域时,在对象上自动调用Dispose() (但不是Close())。该语法使用了using关键字来完成这一工作——但目前,在完全不同的环境下,它与命名空间没有关系。下面的代码生成与try 块相对应的IL代码:

  C#code

using(ResourceGobblertheInstance=newResourceGobbler()){

//这里是theInstance对象的使用过程

}

  using语句的后面是一对圆括号,其中是引用变量的声明和实例化,该语句使变量放在随附的复合语句中。另外,在变量超出作用域时,即使出现异常,也会自动调用其Dispose()方法。如果已经使用try块来捕获其他异常,就会比较清晰,如果避免使用using语句,仅在已有的try块的finally子句中调用Dispose(),还可以避免进行额外的缩进。

非托管资源自己提供的Close方法或者自己提供Close方法。

Close与Dispose这两种方法的区别在于,调用完了对象的Close方法后,此对象有可能被重新进行使用;而Dispose方法来说,此对象所占有的资源需要被标记为无用了,也就是此对象被销毁了,不能再被使用。例如,常见SqlConnection这个类,当调用完Close方法后,可以通过 Open重新打开数据库连接,当彻底不用这个对象了就可以调用Dispose方法来标记此对象无用,等待GC回收。

总结:前面的章节讨论了类所使用的释放未托管资源的两种方式:

  利用析构函数,但析构函数的执行是不确定的,而且,由于垃圾收集器的工作方式,它会给运行库增加不可接受的系统开销。

  IDisposable接口提供了一种机制,允许类的用户控制释放资源的时间,但需要确保执行Dispose()。

一般情况下,最好的方法是执行这两种机制,获得这两种机制的优点,克服其缺点。

C#code

Public class ResourceHolder:IDisposable

{

private System.IO.FileStream fs = newSystem.IO.FileStream("test.txt", System.IO.FileMode.Create);

Private bool isDispose=false;

// 显示调用的Dispose方法

Public void Dispose()

{

Dispose(true);

//告诉GC不需要再调用Finalize方法, 因为资源已经被显示清理

GC.SuppressFinalize(this);

}

//实际的清除方法

Protected virtual void Dispose(booldisposing){

if(isDisposed){retrun;}

//由于Dispose方法可能被多线程调用,所以加锁以确保线程安全

lock (this) {

if(isDisposed){retrun;}

//说明对象的Finalize方法并没有被执行,在这里可以安全的引用其他对象

if(disposing){

//这里执行清除托管对象的操作.

}

//这里执行清除非托管对象的操作

if (fs !=null) {

fs.Dispose();

fs = null; //标识资源已经清理,避免多次释放

}

isDisposed=true;

}

}

//析构函 数

~ResourceHolder(){

Dispose(false);

}

}

  可以看出,Dispose()有第二个protected重载方法,它带一个bool参数,这是真正完成清理工作的方法。Dispose(bool)由析构函数和IDisposable.Dispose()调用。这个方式的重点是确保所有的清理代码都放在一个地方。

  传递给Dispose(bool)的参数表示Dispose(bool)是由析构函数调用,还是由IDisposable.Dispose()调用——Dispose(bool)不应从代码的其他地方调用,其原因是:

  ●如果客户调用IDisposable.Dispose(),该客户就指定应清理所有与该对象相关的资源,包括托管和非托管的资源。

  ●如果调用了析构函数,在原则上,所有的资源仍需要清理。但是在这种情况下,析构函数必须由垃圾收集器调用,而且不应访问其他托管的对象,因为我们不再能确定它们的状态了。在这种情况下,最好清理已知的未托管资源,希望引用的托管对象还有析构函数,执行自己的清理过程。

  isDispose成员变量表示对象是否已被删除,并允许确保不多次删除成员变量。这个简单的方法不是线程安全的,需要调用者确保在同一时刻只有一个线程调用方法。要求客户进行同步是一个合理的假定,在整个.NET类库中反复使用了这个假定(例如在集合类中)。最 后,IDisposable.Dispose()包含一个对System.GC.SuppressFinalize()方法的调用。SuppressFinalize()方法则告诉垃圾收集器有一个类不再需要调用其析构函数了。因为Dispose()已经完成了所有需要的清理工作,所以析构函数不需要做任何工作。调用SuppressFinalize()就意味着垃圾收集器认为这个对象根本没有析构函数.

另外还有一点需要说明:如果DisposePattern类是派生自基类B,而B是一个实现了Dispose的类,那么DisposePattern中只需要override基类B的带

参的Dispose方法即可,而不需要重写无参的Dispose和Finalize方法,此时Dispose的实现为:

classDerivedClass : DisposePattern

{

protected override void Dispose(bool disposing)

{

lock (this)

{

try

{

//清理自己的非托管资源,

//实现模式与DisposePattern相同

}

finally

{ base.Dispose(disposing); }

}

}

当然,如果DerivedClass本身没有什么资源需要清理,那么就不需要重写Dispose方法了,正如我们平时做的一些对话框,虽然都是继承于

System.Windows.Forms.Form,但我们常常不需要去重写基类Form的Dispose方法,因为本身没有什么非托管的咚咚需要释放。

 

三:资源分配及资源释放

两种资源:.Net所指的托管只是针对内存这一个方面,并不是对于所有的资源;因此对于Stream,数据库的连接,GDI+的相关对象,还有Com对象等等,这些资源并不是受到.Net管理而统称为非托管资源。而对于托管资源的释放和回收,系统提供了GC-GarbageCollector,至于其他资源则需要手动进行释放(第二节已经说明)。

当一个进程初始化之后,CLR会保留一段连续的空白内存空间(虚拟地址空间,在运行中会映射到物理内存地址中),分为“托管堆”和“栈”两部分,栈用于存储值类型数据,它会在方法执行结束后自动销毁其中引用的值类型变量,这一部分不属于垃圾收集的范围。托管堆用于引用类型的变量存储,是垃圾收集的关键阵地。

托管堆是一段连续的地址空间,其中所分配出去的空间呈现出类似数组形态的队列结构.

NextObjPtr是托管堆所维护的一个指针,指示下一个对象分配的内存起始地址,它会随着内存的分配而不断移动(当然也会随着内存垃圾回收而发生移动),永远指向下一个空闲的地址。最初的时候,这个指针指向托管堆的起始位置。

了解了以上基础概念之后我们开始讨论对象分配和垃圾回收的过程:

1:通过new操作符创建对象时判断托管堆中空间大小是否能够放得下对象:

应用程序使用new操作符创建一个新对象时,托管堆首先要检测托管堆剩余空间能否放得下这个对象,如果能放得下,就把NextObjPtr指针指向这个对象,然后调用对象的构造函数,new操作符返回对象的地址,之后,重置NextObjPtr指针使其指向托管堆下一个对象分配的位置。

如果不能放下新对象,这时候就会启动垃圾回收器了。

2: 垃圾回收

在介绍垃圾回收器回收机制之前先引入一下代的概念:

“代”是垃圾回收器提升性能的一种实现机制。“代”的意思是:新创建的对象是年轻一代,而在回收操作发生之前没有被回收掉的对象是较老的对象。将对象分成几代可以允许垃圾回收器只回收某一代的对象,而不是回收所有对象。

.NET将堆分成3个代龄区域: Gen 0、Gen 1、Gen 2

CLR初始化后的第一批被创建的对象被列为0代对象。CLR会为0代对象设定一个容量限制,当创建的对象大小超过这个设定的容量上限时,GC就会开始工作,工作的范围是0代对象所处的内存区域,然后开始搜寻垃圾对象,并释放内存。当GC工作结束后,幸存的对象将被列为第1代对象而保留在第1代对象的区域内。此后新创建的对象将被列为新的一批0代对象,直到0代的内存区域再次被填满,然后会针对0代对象区域进行新一轮的垃圾收集,之后这些0代对象又会列为第1代对象,并入第1代区域内。第1代区域起初也会被设上一个容量限制值,等到第1代对象大小超过了这个限制之后,GC就会扩大战场,对第1代区域也做一次垃圾收集,之后,又一次幸存下来的对象将会提升一个代龄,成为第2代对象。

可见,有一些对象虽然符合垃圾的所有条件,但它们如果是第1代(甚至是第2代老臣)对象,并且第1代的分配量还小于被设定的限制值时,这些垃圾对象就不会被GC发现,并且可以继续存活下去。另外,GC还会在工作过程中汲取经验,根据应用程序的特点而自动调整每代对象区域的容量,从而可以更高效的工作。

2.1 暂时挂起所有线程

2.2:垃圾判定,检测扫描每一个线程栈上可回收对象

垃圾:要进行垃圾收集,首先要知道什么是垃圾。.Net的判断很简单,只要判定此对象或者其包含的子对象没有任何引用是有效的,那么系统就认为它是垃圾。

垃圾判断条件:每个应用程序都有一组根对象,GC通过遍历应用程序中的“根”来寻找垃圾(我们可以认为根是一个指向引用类型对象内存地址的指针可能是指向托管堆中对象的存储地址,也可能是null.例如,所有的全局和静态对象指针是应用程序的根对象,另外在线程栈上的局部变量/参数也是应用程序的根对象,还有CPU寄存器中的指向托管堆的对象也是根对象。存活的根对象列表由JIT(just-in-time)编译器和clr维护,垃圾回收器可以访问这些根对象的。)。如果一个对象没有了根,就是它不再被任何位置所引用,那么它就是垃圾的候选者了。

过程:当垃圾回收器开始运行,垃圾回收器开始遍历根对象并构建一个由所有和根对象之间有引用关系对象构成的图。

图2显示,托管堆上应用程序的根对象是A,C,D和F,这几个对象就是图的一部分,然后对象D引用了对象H,那么对象H也被添加到图中;垃圾回收器会循环遍历所有可达对象。

右图为托管堆上的对象

垃圾回收器会挨个遍历根对象和引用对象。如果垃圾回收器发现一个对象已经在图中就会换一个路径继续遍历。这样做有两个目的:一是提高性能,二是避免无限循环。

所有的根对象都检查完之后,垃圾回收器的图中就有了应用程序中所有的可达对象。托管堆上所有不在这个图上的对象就是要做回收的垃圾对象了。

内存中的垃圾分为两种,一种是需要调用对象的析构函数,另一种是不需要调用的。GC对于前者的回收需要通过两步完成,第一步是调用对象的析构函数,第二步是回收内存,但是要注意这两步不是在GC一次轮循完成,即需要两次轮循;相对于后者,则只是回收内存而已。

2.3:移动对象

构建好可达对象图之后垃圾回收器开始线性的遍历托管堆,找到连续垃圾对象块(可以认为是空闲内存)。然后垃圾回收器将非垃圾对象移动到一起(使用c语言中的memcpy函数),覆盖所有的内存碎片。当然,移动对象时要禁用所有对象的指针(因为他们都可能是错误的了)。

2.4重置对象指针

因此垃圾回收器必须修改应用程序的根对象使他们指向对象的新内存地址。此外,如果某个对象包含另一个对象的指针,垃圾回收器也要负责修改引用。

2.5 重置NextObjPtr

2.6 恢复线程执行

下图显示了一次回收之后的托管堆。
回收之后的托管堆

如图所示在回收之后,所有的垃圾对象都被标识出来,而所有的非垃圾对象被移动到一起。所有的非垃圾对象的指针也被修改成移动后的内存地址,NextObjPtr指向最后一个非垃圾对象的后面。

 

3:这时候new操作符就可以继续成功的创建对象了。

 

在2.2步骤中说过内存中的垃圾分为两种,一种是需要调用对象的析构函数,另一种是不需要调用的。以上步骤是不需要调用析构函数的,下面我们讨论一下需要调用析构函数的垃圾的回收过程。

GC调用Finalize方法的内部实现

~ClassName() {//释放你的非托管资源}

分为四步:

1:把实现了Finalize函数的对象插入finalization终结队列里:比如类A中实现了Finalize函数,在A的一个对象a被创建时(准确的说应该是构造函数被调用之前),它的指针被插入到一个finalization终结队列里(终结队列是由垃圾回收器控制的内部数据结构。此队列中的每一个对象都在等待执行他们的Finalize方法,每一个对象在回收时都需要调用它们的Finalize方法。)。

2:扫描finalization终结队列并插入到freachable队列:在GC运行时,GC将扫描finalization终结队列中的对象指针,如果此时a已经是垃圾对象的话,它会被移入一个freachable队列中(Freachable队列是另一个由垃圾回收器控制的内部数据结构,在Freachable队列中的每一个对象的Finalize方法将执行。)

3:调用线程遍历freachable队列并执行Finalize释放非托管资源,之后清空freachable队列:最后GC会调用一个高优先级线程(当Freachable队列为空时,这个线程会休眠,当队列中有对象时,线程被唤醒),这个线程专门负责遍历freachable队列并调用队列中所有对象的Finalize方法,并移除队列中的对象。至此,对象a中的非托管资源才得到了释放(当然前提是你正确实现了它的Finalize方法)

4:GC再次启动时释放对象所占内存:a所占用的内存资源则必需等到下一次GC才能得到释放,所以一个实现了Finalize方法的对象必需等两次GC才能被完全释放。

图例:下图显示的堆上包含几个对象,其中一些对象是跟对象,一些对象不是。当对象C、E、F、I和J创建时,系统会检测这些对象实现了Finalize方法,并将它们的指针放到终结队列中。

图1

对象B、E、G、H、I和J被标记为垃圾。垃圾回收器扫描终结队列找到这些对象的指针。当发现对象指针时,指针会被移动到Freachable队列。垃圾回收之后,托管堆如图2所示。你可以看到对象B、G、H已经被回收了,因为这几个对象没有Finalize方法。然而对象E、I、J还没有被回收掉,因为他们的Finalize方法还没有执行。

图2

然后执行队列中每一个对象的Finalize方法,清空freachable队列。

再次启动垃圾回收之后,实现Finalize方法的对象才被真正的回收。

 

 

四:GC方式
    有WorkstationGC with Concurrent GC off、 Workstation GC withConcurrent GC on、Server GC 3种
    Workstation GC with Concurrent GC off: 用于单CPU机器实现高吞吐量,采用一系列策略观察内存分配以及每次GC的状况,动态调整GC策略,尽可能使程序随着运行时状态的变化实现高效的GC操 作,但进行GC时会冻结所有线程
    Workstation GC with Concurrent GC on: 用于响应时间非常重要的交互式程序,例如流媒体的播放等(如果一次full GC导致应用程序中断几秒、十几秒时间,用户将无法忍受)。这种方式利用多CPU对full GC进行并行处理,不是整个full GC期间冻结所有线程,而是将full GC切分成多次很短的时间对线程进行冻结,在线程冻结时间之外,应用程序仍然可以正常运行,进行内存分配,这主要通过将Gen 0 heap size设置的比non-concurrent GC大很多而实现,使得GC操作时线程仍然能够在Gen 0 heap中进行内存分配,但如果Gen 0 heap用完后GC仍然没有结束,线程仍然会出现阻塞。这种方式付出的代价是working set和GC所需时间比non-concurrentGC要大一些
    Server GC: 用于多CPU机器的服务器应用程序实现高吞吐量和伸缩性,充分利用服务器的大内存。.NET为每个CPU创建一组heap(包括Gen 0, 1, 2和LOH)和一个GC线程,每个CPU可以独立的为相应的heap执行GC操作,而其他CPU则正常执行处理。最佳的应用场景是多线程之间内存结构基本 相同,执行的工作相同或类似

    单CPU机器上只能使用workstationGC,默认情况下为Workstation GC with Concurrent GC on方式,单CPU机器上配置为Server GC无效,仍然使用workstation GC;多CPU服务器上的ASP.NET默认使用Server GC方式,Server GC时不能使用concurrent方式
    concurrent GC可以用于单CPU机器,它与CPU数量无关
    对于ASP.NET程序应当尽量保证一个CPU仅对应一个GC线程,防止同一个CPU上面多个GC线程之间的冲突造成性能问题。如果使用了Web Garden则应当使用Workstation GC with Concurrent GC off。WebGarden为了提高吞吐量会导致多出几倍的内存使用,每个work process的内存有很多重复部分,Web Garden的最佳应用场景是多个进程之间使用一个共享的resource pool,避免内存的重复并尽可能的提高吞吐量。在这一点上Server GC应当与Web Garden类似,但Web Garden在多个进程中,而Server GC是在同一个进程中通过多线程实现,目前没有发现Server GC方面深入一些的资料,很多东西只能根据现有资料做一些猜想
    为workstation GC禁用concurrent GC:

<configuration>
    
<runtime>
        
<gcConcurrent enabled="false"/>
    
</runtime>
</configuration>

    启用Server GC:

<configuration>
    
<runtime>
        
<gcServer enabled=“true"/>
    
</runtime>
</configuration>

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值