• 【CLR】解析CLR的托管堆和垃圾回收


    目录结构:

    contents structure [+]

    1. 为什么使用托管堆

    在应用程序中肯定要使用这样或是那样的资源,包括文件、内存缓冲区、屏幕空间、网络链接、数据库资源等等。在面向对象的编程环境中,每个对象都代表一种资源。

    下面介绍了一般资源的使用流程:
    a.调用IL的newobj,为代表资源的类型分配内存。
    b.初始化内存,设置资源的初始化状态并使资源可用。类型的实例化构造器负责设置初始状态。
    c.访问类型的成员来使用资源。
    d.摧毁资源的状态以进行清理。
    e.释放内存。

    最后一步是释放内存,想一想,如果是程序员手动管理内存(例如:原生的C++应用程序),那么程序员很有可能忘记释放不再需要的内存,或是使用已经释放掉的内存,这些内存方面的Bug比一般Bug更为严重,因为无法预判他们的后果。

    托管堆中提供了对对象内存回收的管理机制(GC),由GC来管理内存,可以极大程度的避免内存方面的Bug。GC只负责清理对象内存,不会回收任何物理内存(比如:文件、套接字、数据库连接等等。)在这些类中可以额外再调用一个方法(Dispose)进行资源清理。

    2. 从托管堆中分配资源

    CLR要求所有对象都从托管堆中分配,进程初始化时,CLR划出一个地址空间区域作为托管堆。
    下面的图展示了包含三个对象(A、B、C)的一个托管堆。

    如果要继续分配新对象,将放在NextObjPtr指针的位置,然后NextObjPtr指针往后移动(移动的长度就是当前对象所占用的字节数)以准备再次接收新分配的对象。

    3. 托管堆中的垃圾回收

    在上面我们已经知道了托管堆的资源分配流程,但是托管堆中的资源不可能一直这样分配下去,因为内存总是有限的,所以在托管堆中必定会有一种可以清理掉不再需要使用的对象的一种机制,这就是垃圾回收机制。

    3.1 垃圾回收算法

    如果托管堆发现内存空间不足,那么就会发生垃圾回收。在CLR中,将所有引用类型变量都称为“根”。

    CLR在开始GC时,首先会暂停GC中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态。然后CLR进入标记阶段,标记分为两类,可达对象和不可达对象。可达对象不能作为垃圾进行回收,不可达对象则相反。

    下面这张图片,展示了一个堆中的几个对象。其中A、C、D、F为应用程序的根直接引用的对象,其中D对象中的一个字段引用了对象H。其余都是不可达对象。

    通过这张图片,我们看出了B、E、G、I、J没有引用变量引向它们,所以它们是不可达对象。图片中A`C`D`F`代表的分别是A、C、D、F对象的根。

    接下来进行GC压缩阶段,在知道了那些对象可以保留下来,那些对象可以被删除后,就会压缩所有幸存下来的对象,使它们占用连续的内存空间,就是进行内存碎片化整理。

    在移动了堆中的对象后,引用幸存对象的根如果引用的地址还是原来的地址,而非移动后的地址,那么这显然是错误的。所以在压缩阶段,GC还要从每个根减去所引用对象在内存中的偏移字节数,这样就能保证每个根还是引用和之前一样的对象,只是对象在内存中变换了地址。

    下面这张图片就是完成压缩后的内存示意图:


    注意,静态字段引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段的引用某个集合对象,然后不停的向集合中添加数据项。静态字段使集合一直存在,集合使数据项一直存活。因此应该尽量避免使用静态字段。

    3.2 代

    CLR的GC是基于代的垃圾回收器,他有如下几点假设:
    a.对象越新,生存期越短。
    b.对象越老,生存期越长。
    c.回收堆的一部分,速度快于回收整个堆。

    托管堆在初始化时不包含对象。添加到堆的对象称为第0代对象。也就是说,第0代对象就是那些新构造的对象,垃圾回收器从未检查过他们。

    下面这张图片展示了一个新启动的应用程序堆示意图案例,它分配了5个对象(从A到E),过了一会,对象C和E就变得不可达。

    CLR初始化时为第0代对象选择一个预算容量。如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。假设对象A到E刚好用完第0代的空间,那么分配对象F就必须启动一次垃圾回收。垃圾回收器判断对象C和E是垃圾,所以会压缩D,使之与对象B相邻。在垃圾回收器中存活的对象(A,B和D)现在成为第1代对象。

    此时的堆内存如下:

    在一次垃圾回收过后,第0代对象就不包含任何对象了。新对象就会被分配到第0代中。
    假如应用程序继续分配对象F到K,随着程序继续的运行,对象B、H、J变得不可达,下面是内存示意图:

    现在假定分配新对象L会造成第0代超出预算,造成必须启动垃圾回收。开始垃圾回收时,垃圾回收器必须决定检查哪些代。CLR初始化时不仅仅会为第0代对象选择预算,还会为第1代对象选择预算。

    开始一次垃圾回收时,垃圾回收器还会检查第1代占用了多少内存。上面的案例中,第1代占用的内存远少于预算,所以垃圾回收器只会检查第0代中的对象。
    显然,忽略第1代中的对象检查能提升垃圾回收器的性能。但对性能有更大提升的就是不用遍历托管堆中的每个对象。如果根或对象引用了老一代的某个对象,垃圾回收器就可以忽略老对象内部的所有引用,能在更短时间内构造出可达对象图。

    Microsoft的测试表明,对第0代执行一次垃圾回收,所花的时间超不过1毫秒。

    基于代的垃圾回收器假设越老的对象存活的时间越长。也就是说,第1代对象在应用程序中很有可能是继续可达的,如果垃圾回收器检查第1代中的对象,很有可能找不到多少垃圾,回收不了多少内存。因此,对第1代进行垃圾回收很有可能是浪费时间。

    在这个案例中,经历了两次垃圾回收之后,第0代的幸存者被提升至第1代,第0代又空出来了。

    所有幸存下来的第0代对象都变成了第1代中的一部分。由于垃圾回收器没有检查第1代,所以对象B并没有被回收。同样,第0代中不包含任何对象,等待分配新对象,假设应用程序继续运行,分配对象L到对象O,并且应用程序中对象G、L、M变得不可达,此时内存示意图:

    假设分配对象P导致第0代超过预算,垃圾回收发生。由于第1代中的所有对象占据的内存仍然小于预算,所以垃圾回收只回收第0代,由于没有检查第1代中的对象,所以会忽略第1代中的不可达对象。堆示意图为:

    从几张图中可以看出,第1代正在缓慢增长,假设第1代的增长导致它的所有对象占用了全部预算。这时,应用程序继续运行,分配对象P到对象S,并且第0代对象达到了它的预算容量,其中对象P和对象R变得不可达,第1代中的对象K也变得不可达。此时的内存示意图为:

    当应用程序再次分配对象T时候,由于第0代满了,所以必须进行垃圾回收。但这一次垃圾回收器发现第1代占用了太多内存,以至于用完了预算。由于前几次对第0代进行回收时,第1代可能已经有许多对象变得不可达,所以垃圾回收器决定检查第1代和第0代中的所有对象。并且将第1代中的幸存对象提升到第2代中,此时的内存示意图为:


    托管堆只支持三代:第0代,第1代,第2代。CLR初始化时,会为每一代选择预算,然后CLR的垃圾回收时自动调节的,意味着垃圾回收器在执行垃圾回收的过程中了解应用程序的行为。例如,如果垃圾回收器发现在回收0代后存活下来的对象很少,就有可能减少第0代的预算,如果垃圾回收器发现在回收了第0代后,还有很多对象存活,就会增大第0代的预算。第1代、第2代与第0代同理,也会进行这样的调整。

    如果没有回收到足够的内存,垃圾回收器就会执行一次完整回收,如果还是不够,就会抛出OutOfMemoryException异常。

    3.3 垃圾回收模式

    CLR启动时会选择一个GC模式,进程终止前,该模式不会改变。有两个基本的GC模式:工作站和服务器。

    工作站:
    该模式针对客户端应用优化GC。该模式,GC假定机器上运行的其他应用程序都不会消耗太多的CPU资源。

    服务器:
    该模式针对服务器端应用程序优化GC。被优化的主要是吞吐量和资源利用,GC假定机器上没有运行其他应用程序,并且机器上的所有的CPU都可用来辅助完成GC.该模式造成托管堆被拆分为几个区域,每个CPU负责一个区域。开始垃圾回收时,垃圾回收器在每个CPU上都运行一个特殊线程;每个线程都和其他线程并发回收自己的区域。

    应用程序默认以“工作站”GC模式运行。寄宿了CLR服务器的应用程序可请求加载“服务器”GC模式,但如果服务器应用程序在单字节处理器计算机上运行,CLR将总是使用“工作站”GC模式。

    独立应用程序可以通过配置文件告诉CLR使用服务器回收器。配置文件要为应用程序添加一个gcServer元素。
    例如:

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

    应用程序运行时,可查询GCSettings类的只读Boolean属性IsServerGC来询问CLR它是否是“服务器”GC模式,

    Module Module1
        Sub Main()
            Dim b As Boolean = System.Runtime.GCSettings.IsServerGC
            Console.WriteLine("Application is running  with Server GC=" & b)
            Console.ReadLine()
        End Sub
    End Module

    除了这两种模式,GC还支持两种子模式:并发(默认)和非并发。

    在非并发模式中,如果一个线程分配对象造成第0代超出预算,GC首先挂起所有线程,然后判断那些对象是不可达的,然后再进行回收不可达对象和压缩内存(如果需要的话)。

    在并发方式中,垃圾回收器有一个额外的后台线程,她能在应用程序运行时并发标记对象。当进行垃圾回收时,在GC挂起所有线程后就能快速地从标记对象中,找出不可达对象,然后迅速进行垃圾回收。这样能缩短GC挂起线程的时间。

    如果不需要使用并发回收器,可创建包含gcConcurrent元素的应用程序配置文件。
    例如:

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

    通过配置文件进行配置的模式,是针对进程的。应用程序可以通过GCSettings类的GCLatencyMode属性对垃圾回收器进行某种程度的控制。下面列举GCLatencyModel枚举中的值:

    符号 说明
    Batch(“服务器”GC模式的默认值) 关闭并发GC
    Interactive("工作站"GC模式的默认值) 打开并发GC
    LowLatency 在短期的,时间敏感的操作中(比如动画绘制)使用这个延迟模式
    SustainedLowLatency 使用这个延迟模式,应用程序的大多数操作都不会长的GC暂停。只要有足够内存,它将禁止所有会造成阻塞的第2代回收动作。这种应用程序应该考虑安装更多的RAM来防止长的GC暂停。

    这里讲解一下LowLatency模式,一般用于执行一次短期的、时间敏感的操作,再将模式设回普通的Batch和Interactive。在模式设为LowLatency期间,垃圾回收器仍会全力避免任何第2代的回收。如果调用GC.Collect()或是WIndows系统报告内存低 仍然也会执行第2代的回收。

    在LowLatency模式中,应用程序抛出OutOfMemoryException异常的概率要大一些,所以处于该模式的时间应该尽量短,避免分配太多对象,避免分配大对象,

    3.4 垃圾回收触发条件

    CLR在检查到代中内存超过预算时,触发一次GC,尤其是在第0代中最频繁。除此之外,还有如下的条件:

    a.代码显式调用System.GC的静态Collect方法,
    Microsoft是强烈反对显示调用GC.Collect方法。

    b.Windows报告低内存的情况
    可使用Win32的函数的CreateMemoryResourceNotification和QueryMemoryResourceNotification监视系统的总体内存使用情况。如果Windows报告低内存,CLR将强制垃圾回收。
    例如:

        HANDLE hd= CreateMemoryResourceNotification(MEMORY_RESOURCE_NOTIFICATION_TYPE::HighMemoryResourceNotification);
        BOOL b1=true;
        PBOOL pb=&b1;
        BOOL b2= QueryMemoryResourceNotification(hd, pb);

    c.CLR正在卸载AppDomain
    CLR卸载时,CLR认为其中一切都不是根。可以使用AppDomain.CurrentDomain.IsFinalizingForUnload()方法判断AppDomain是否正在卸载。

    d.CLR正在关闭
    CLR进程正常终止时关闭,在关闭期间,CLR认为进程中一切都不是根。可以通过Environment.HasShutdownStarted判断是否正在关闭。

    3.5 强制垃圾回收

    System.GC类型允许应用程序允许对垃圾回收器对应用程序进行一些直接控制。例如,可读取GC.MaxGeneration属性来查询托管堆支持的最大代数;该属性总是返回2。

    还可调用GC类的Collect方法强制垃圾回收。可向方法传递一个代表最多回收几代的整数、一个GCCollectionMode以及指定阻塞(非并发)或后台(并发)回收一个Boolean值。
    下面是Collect重载方法的签名:

    public static void Collect();
    public static void Collect(int generation);
    public static void Collect(int generation, GCCollectionMode mode);
    public static void Collect(int generation, GCCollectionMode mode, bool blocking);

    在大多数时候,都应该避免直接调用Collect方法,最好让垃圾回收器自行斟酌。

    3.6 监视内存

    可在进程中调用几个方法来监视垃圾回收。GC类提供了以下几个静态方法,可调用他们来查看发生了多少次垃圾回收,或者托管堆中的对象使用了多少内存。

    Int32 CollectionCount(Int32 generation);
    Int32 GetTotalMemory(Boolean forceFullCollection);//forceFullCollection指示在返回前是否等待进行一次垃圾回收

    为了评估特定代码的性能,需要经常在代码块的前后调用这些方法,并且计算差值,如果数值太大,就应该花多点时间调整代码中的算法。

    安装.NET Framework时会自动安装一组性能计数器,为CLR的操作提供大量实时统计数据。这些数据可以通过Windows自带的PerfMon.exe工具或“系统监视器”Active X控件来查看。访问“系统监视器”最简单的方式就是运行PerfMon.exe,然后单击“+”工具栏按钮,然后会显示“添加计数器”对话框。为了监视CLR的监视回收器,选择“.NET CLR Memory.exe”性能对象,然后从下面实体列表中选择一个具体的应用程序。
    如图:

    4. 对包装了本机资源类型的处理

    大多数类型有内存就能正常工作,但有的类型除了内存还需要本机资源。例如,System.IO.FileStream类型需要打开一个文件,并保存文件的句柄,然后Read和Write方法用句柄操作文件。

    包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源的泄漏(GC对此是一无所知的),所以CLR提供了称为终结的机制,运行对象在判定为垃圾之后,但在对象内存被回收之前执行一些代码。任何包含本机资源的(文件、网络操作、套接字、互斥体)的类型都支持终结,CLR判定一个对象不可达时,对象将终结他自己,释放它包含的本机资源。

    终极基类System.Object提供了受保护的虚方法Finalize。垃圾回收器判定对象是垃圾后,会调用对象的Finalize方法(如果重写)。

    被视为垃圾的对象在垃圾回收器完成后才调用Finalize方法,所以这些对象的内存不是马上被回收的,因为Finalize方法可能要执行访问字段的代码。

    另外需要注意,Finalize方法的执行时间是控制不了的。CLR还不保证多个Finalize方法的调用顺序。所以,在Finalize方法中不要访问定义了Finalize方法的其他类型的对象,那些对象有可能已经被终结了。Finalize是为释放本机资源设计的,强烈建议不要重写Object的Finalize方法。

    4.1 对FileStream和StreamWriter类的特殊处理

    在日常中,对文件的操作比较多,这里就讲解一下使用FileStream和StreamWriter时的注意事项。
    首先看看FileStream类的使用案例:

                //创建要写入临时文件的字节
                String^ text="你好";
                UTF8Encoding^ utf8=gcnew UTF8Encoding();
                array<Byte>^dataArray=utf8->GetBytes(text);
    
                //创建临时文件
                String^ fileName="Temp.txt";
                FileStream^ fs=gcnew FileStream(fileName,FileMode::Create);
    
                //将字节写入文件
                fs->Write(dataArray,0,5);
    
                //删除文件
                File::Delete("Temp.txt");//引发IO异常

    这里案例中,有可能能正常工作,有可能不能正常工作。问题在于File的静态Delete方法试图删除一个已经打开的文件。
    FileStream对象操作的句柄资源在FileStream对象被终结的时候,句柄资源在Finalize方法中会被终结,这时候再次调用File的静态Delete方法,就不会报错。

    对象的Finalize方法调用时间是不确定的,所以如果类想要控制本机资源的生存期,那么就必须实现IDispose接口,重写Dispose方法。

    public interface IDispose{
        void Dispose();
    }

    如果类的字段实现了IDispose接口,那么类本身也应该实现IDispose接口,并且应该在类的Dispose方法中调用字段的Dispose方法。
    FileStream实现了Dispose接口,所以直接改为如下形式:

                //创建要写入临时文件的字节
                String^ text="你好";
                UTF8Encoding^ utf8=gcnew UTF8Encoding();
                array<Byte>^dataArray=utf8->GetBytes(text);
    
                //创建临时文件
                String^ fileName="Temp.txt";
                FileStream^ fs=gcnew FileStream(fileName,FileMode::Create);
    
                //将字节写入文件
                fs->Write(dataArray,0,5);
    
                //关闭文件
                fs->Dispose();
    
                //删除文件
                File::Delete("Temp.txt");

    这样程序就能正常执行了。

    如果在C#中使用using语句的话,那么能够自动调用对象的Dispose方法。
    上面讲解了FileStream,接下来讲解一下StreamWriter类:
    System.IO.FileStream 类型允许用户打开文件进行读写,该类型只支持字节的写入。如果想要写入字符和字符串那么可以使用System.IO.StreamWriter类。
    例如:

                //创建临时文件
                String^ fileName="Temp.txt";
    
                FileStream^ fs=gcnew FileStream(fileName,FileMode::Create);
    
                StreamWriter^ sw=gcnew StreamWriter(fs);
                String^ text="你好";
                sw->Write(text);
                
                //不要忘记调用这个Dispose函数
                sw->Dispose();
    
                //删除文件
                File::Delete("Temp.txt");

    StreamWriter接受一个Stream作为参数,一个StreamWriter在写入数据时,他会将数据缓存在自己的内部缓存区中,当缓存区满的时候才会将数据写入到Stream中。
    StreamWriter写入数据完毕后,应该调用Dispose方法。如果不调用Dispose方法的话,那么数据StreamWriter的数据就不会flush到Stream中。所以这里需要特别注意。

    4.2 终结的内部工作原理

    应用程序创建新对象时,new操作符从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放到一个终结列表中。终结列表是由垃圾回收器控制的一个内部数据结构,列表中每一项都指向一个对象-回收该对象的内存前应调用它的Finalize方法。

    下面的图展示了包含几个对象的堆,其中A、C、D、F对象可达,B、E、G、H、I、J对象不可达。其中对象C、E、F、I、J对象定义了Finalize方法。

    虽然System.Object定义了FInalize方法,但CLR知道忽略它。也就是说,如果该类型的Finalize方法是从System.Object继承的,就不认为这个对象是“可终结”的。类型必须是重写Object的Finalize方法,这个类型及其派生类型才被认为是“可终结”的。

    开始垃圾回收时候,B、E、G、H、I、J被认定是垃圾。垃圾回收器扫描终结列表以查找这些对象的引用。找到一个引用后,该引用会从终结列表中移除,并附加到freachable队列。freachable也是垃圾回收器的一种内部数据结构,其中的每个应用都代表其Finalize方法已准备好调用的一个对象。
    下面是垃圾回收后的示意图:

    从图中可以看出,B、G、H占用的内存已经被回收,因为他们没有Finalize方法。但对象E、I、J占用的内存暂时不能回收,因为他们的Finalize方法还没有调用。

    一个特殊的CLR线程专门调用Finalize方法,该线程可避免潜在的线程同步问题。freachable队列为空时,该线程将睡眠。但一旦队列中出现记录项后,线程就会被唤醒,将每一项从freachable中移除,并且每个对象的Finalize方法。由于该线程的特殊工作方式,Finalize中的代码不应该对执行代码的线程做出任何假设,例如,不要在Finalize方法中访问线程的本地存储。

    接下来或是说终结列表和freachable之间的交互,freachable是由f+reachable组成的,其中f代表finalization(终结);reachable代表可达的。所以说,freachabl中的记录项是可达的,不是垃圾。也就是说,当一个对象不可达时,垃圾回收器就把它视为垃圾。如果对象定义了Finalize方法,那么该对象首先会被放到终结列表中,当从终结列表中移动至freachable时,该对象不再认为是垃圾,不能回收它的内存。被视为垃圾的对象由不再变成垃圾,也就是对象复活了。

    当freachable队列完成记录时,此时的内存示意图:

    4.3 手动监视和控制对象的生存期

    CLR为每个AppDomain都提供了一个GC句柄表(GC Handle table),允许应用程序监视或手动控制对象的生存期。表中的每一项都包含了对托管堆中一个对象的引用,以及如何监视或控制对象的标志。

    为了控制或监视对象的生存期,可调用GChandle的静态Alloc方法并传递想控制/监视的对象的引用。还可传递一个GCHandleType,这是一个标志,指定了想如何控制/监视对象。
    GCHandleType是一个枚举类型,定义如下;

    public enum GCHandleType{
            Weak=0;                     //用于监视对象的存在
            WeakTrackResurrection=1     //用于监视对象的存在
            Normal=2;                   //用于控制对象的生存期
            Pinned=3;                   //用于控制对象的生存期
    }

    下面是各个值的含义:
    Weak
    该标志允许监视对象的生存期。具体地说,可检测垃圾回收器在什么时候判定该对象在应用程序中不可达。注意,此时对象的Finalize方法可能执行,也可能没有执行,对象可能还在内存中。
    WeakTrackResurrection
    该标志允许监视对象的生存期。具体地说,可检测垃圾回收器在什么时候判定该对象在应用的代码中不可达。注意:此时对象的Finalize方法(如果有的话)已经执行,对象的内存已经回收。
    Normal
    该标志允许控制对象的生存期。具体地说,是告诉垃圾回收器:即使应用程序中没有变量应用该对象,该对象也必须留在内存中。垃圾回收时,该对象的内存可以压缩(移动)。不向Alloc方法传递任何GCHandleType标志,就默认使用GCHandleType.Normal。
    Pinned
    该标志允许控制对象的生存期。具体地说,是告诉垃圾回收器:即使应用程序中没有变量引用该对象,该对象也必须留在内存中。垃圾回收发生时,该对象的内存不能压缩(移动)。需要将内存地址交给本机代码使用时,这个功能很好用。本机代码知道GC不会移动对象,所以能放心地向托管堆的这个内存写入。

    下面展示了垃圾回收器如何使用GC句柄表,当垃圾回收发生时,垃圾回收器的行为如下:
    1.垃圾回收器标记所有可达的对象。然后,垃圾回收器扫描GC句柄表:所有Normal或Pinned对象都被看成是根,同时标记这些对象(包括这些对象通过他们字段应用的对象)。
    2.垃圾回收器扫描GC句柄表,查找所有Weak记录项。如果一个Weak记录项引用了未标记的对象,该引用标识的就是不可达对象(垃圾),该记录项引用的值该为null。
    3.垃圾回收器扫描终结列表。在列表中,对未标记对象的引用标识的是不可达对象,这些引用从终结列表移至freachable队列。这时对象会被标记,因为对象又变成可达的了。
    4.垃圾回收器扫描GC句柄表,查找所有WeakTrackResurrection记录项。如果一个WeakTrackResurrection记录项引用了未标记的对象(它现在是由freachable队列中的记录项引用的),该引用标识的就是不可达对象(垃圾),该记录项的引用值改为null。
    5.垃圾回收器对内存进行压缩,填补不可达对象留下的内存“空洞”,这其实就是一个碎片整理的过程。Pinned对象不会压缩(移动),垃圾回收器会移动它周围的其他对象。

    GC句柄表一般在用于和本机代码交互时。

  • 相关阅读:
    今天特别忙
    代码重构十
    周末,悠哉的一天
    周六,游戏的一天
    代码重构九
    微信公众号网页上点击放大图片浏览,解决方案
    thinkphp 百度地图Api坐标计算 A坐标距离B坐标多少公里 并按照距离近的排序 坐标排序 外部字段排序
    php 中的关系运算符
    jquery 倒计时
    数组排序,
  • 原文地址:https://www.cnblogs.com/HDK2016/p/9203788.html
Copyright © 2020-2023  润新知