• 垃圾回收机制


    在介绍GC前,有必要对.net中CLR管理内存区域做简要介绍:

      1、 堆栈:用于分配值类型实例。堆栈主要操作系统管理,而不受垃圾收集器的控制,当值类型实例所在方法结束时,其存储单位自动释放。栈的执行效率高,但存储容量有限。

      2 、GC堆:用于分配小对象实例。如果引用类型对象实例的大小小于85000字节,实例将被配置在GC堆上,当有内存分配或者回收时,垃圾收集器可能会对GC堆进行压缩。

      3、 LOH:large object heap,用于分配大对象实例。如果引用类型对象的实例的大小不小于85000字节时,该实例将被分配到LOH堆上,而LOH堆不会被压缩,而且只在完全GC回收时被回收。

    在面向对象的环境中,每个类型都代表可供程序使用的一种资源,要使用这些资源,必须为代表资源的类型分配内存,以下是访问一个资源所需的步骤:

    1 调用IL指令newobj,为代表资源的类型分配内存(一般使用c#new操作符来完成);

    2 初始化内存,设置资源的初始化状态并使资源可用。类型的实例构造器负责设置初始化状态;

    3 访问类型的成员来使用资源(有必要可用重复);

    4 摧毁资源的状态以进行清理;(大多数时候不需要,一般只有包含了本机资源—文件、套接字、数据库连接等的类型才需要特殊清理,dispose或finalize)

    5 释放内存。垃圾回收器独自负责这一步。

    NextObjPtr:该指针指向下一个对象在堆中的分配位置;刚开始时,NextObjPtr设为地址空间区域的基地址。

    C#的new操作符导致CLR执行以下步骤:

    1 计算类型的字段所需的字节数;

    2 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步快索引。对应32位应用程序,这两个字段各自需要32位,所以每个对象需要增加8个字节。对应64位应用程序,两个字段各自需要64位,所以每个对象需要增加16字节。

    3 CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在NextObjPtr指针指向地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象引用。就在返回这个引用之前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。

    局部性原理:cpu访问存储器时,无论存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

    对于托管堆,分配对象只需要在指针上加一个值,速度相当快。在许多应用程序中,差不多同时分配的对象彼此间有较强的联系,而且经常差不多在同一时间访问。由于托管堆在内存中连续分配这些对象,所以会因为引用的“局部化”locality而获得性能上的提升。具体来说,这意味着进程的工作集会非常小,应用程序只需使用很少的内存,从而提高了速度。还意味着代码使用的对象可以全部驻留在CPU的缓存中,结果是应用程序能以惊人的速度访问这些对象,因为cpu在只需大多数操作时,不会因为“缓存未命中”cache miss而被迫访问较慢的RAM。

    判断对象是否存活

      既然要清理垃圾,那么必然要明白什么是垃圾吧,垃圾的理解:一个对象成为“垃圾”表示该对象不被任何其他对象所引用。因此GC必须采用一定的算法在托管堆中遍历所有对象,最终形成一个可达对象图,而不可达的对象将成为被释放的垃圾对象等待收集。

      在明白了什么是垃圾后,肯定会对GC如何回收垃圾提出疑问。.net平台下,每个应用程序都有一组根(指针),它指向托管堆中的存储位置,由JIT编译器和CLR运行时维护根指针列表,主要包括全局变量、静态变量、局部变量和寄存器指针等。GC正是通过根指针列表来获得托管堆中的对象图,其中定义了应用程序根引用的托管堆中的对象,当GC启动时,它假设所有对象都是可回收的垃圾,开始遍历所有的根,将根引用的对象标记为可达对象添加到可达对象图中,在遍历过程中,如果根引用的对象还引用着其他对象,则该对象也被添加到可达对象图中,依次类推,GC通过根列表的递归遍历,将能找到所有可达对象,并形成一个可达对象图。同时那些不可达对象则被认为是可回收对象,GC接着运行垃圾收集进程来释放垃圾对象的内存空间。这种收集算法称为:标记和清除收集算法。

    引用计数算法:

    对象生存期的管理,有的系统采用的是某种引用计数算法。在这类系统中,堆上的每个对象都维护这一个内存字段来统计程序中多少“部分”正在使用对象,随着每一个“部分”到达代码中某个不再需要对象的地方,就递减对象的计数字段,直至计数字段变为0,对象就可以从内存中删除了。引用计数算法最大的问题是处理不好循环引用。

    引用跟踪算法:

    鉴于引用计数算法存在的问题,CLR改为使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量都成为根。CLR开始GC时,首先暂停进程中的所有线程,这样可以防止线程在CLR检查期间访问对象并更改其状态。然后CLR进入GC标记阶段,其中,CLR遍历堆中所有的对象,将同步块索引字段中的一位设为0,表明所有对象都应删除。然后CLR检查所有的活动根,查看它们引用了哪些对象。如果一个根包含null,CLR忽略这个根并继续检查下个根。任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1.一个对象被标记后,CLR会检查那个对象中的根,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段,这就避免了因为循环引用而产生的死循环了。

    检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,引用这说明至少有一个对象在引用它,该对象时可达的reachable。CLR在检查完毕后,进入GC压缩compact阶段。压缩幸存的对象,使他们占用联系的内存空间。好处有:1 幸存对象在内存中紧挨着,恢复了引用的“局部化”,减小了应用程序的工作集,提升访问这些对象时的性能;2 压缩也可造成可用空间也全部是连续的,这段地址空间得到解放,允许其他对象入住,意味着压缩托管堆也解决了本机(原生)堆的空间碎片化问题。压缩后,引用幸存对象的根现在引用的还是对象最初在内存中的位置,所以CLR还要从每个根减去所引用的对象在内存中偏移的字节数,这样就能保证每个根的引用还是和之前一样的对象,只是对象在内存中变换了位置。

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

    CLR的GC是基于代的垃圾回收器,对于大多数应用程序:

    对象越新,生存期越短;

    对象越老,生存期越长;

    回收堆的一部分,速度快于回收整个堆。

    添加到堆的新对象会分配到第0代中,如果超过预算,就必须启动一次垃圾回收,压缩幸存的对象成为第1代对象,一次垃圾回收后,第0代就不包含任何对象了。如果再有新对象,还是会分配到第0代中。当系统在分配新对象时如果第0代超出预算,造成必须启动垃圾回收。开始垃圾回收时,GC必须决定检查哪些代,CLR初始化时会为第0代对象选择预算,事实上,它还必须为第1代选择预算。开始垃圾回收时,根据越新的对象活的越短。因此第0代包含更多的垃圾,能回收更多的内存,CLR经过计算,如果第1代占用的内存远少于预算,GC只检查第0代的对象,忽略第1代中的对象,加快了垃圾回收速度,提升GC的性能,但对性能更大的提升作用是不必遍历托管堆中的每个对象,如果根或者对象引用了老一代的某个对象,GC就可以忽略老对象内部的所有引用,能在更短的时间内构造号可达对象图。当然老对象的字段也有可能引用新对象,为了确保对老对象的已更新字段进行检查,GC利用了JIT编译器内部的一个机制,该机制在对象的引用字段发生变化是,会设置一个对应的位标志,这样,GC就知道自上一次垃圾回收以来,那行老对象已被写入,只有字段发生变化的老对象才需检查是否引用了第0代中的任何新对象。

    在经过一次次的垃圾回收后,第0代的幸存者提升为第1代,第1代的幸存者提升至第2代。有可能虽然在多次垃圾回收后,但只有第1代超出预算时才会检查第1代中的对象。托管堆只支持三代。CLR初始化时,会为每一代选择预算,然而,CLR的垃圾回收器是自调节的。如果GC发现回收0代后存活下来的对象很少,就可能减小第0代的预算,分配空间减少意味着垃圾回收将更频繁的发生,但GC每次做的事情也减少了,减小了进程的工作集。

    CLR将大小为85000字节或更大的对象称为大对象,它不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。大对象总是第2代。所以只能为需要长时间存活的资源创建大对象。分配短时间存活的大对象会导致第2代被更频繁的回收,损害性能。

    垃圾收集器将托管堆中对象分为三代:0、1和2,在CLR初始化时,会选择为三代设置不同的阙值容量,一般为:第0代大约为256KB,第1代2MB,第2代10MB。容量越大效率越低,而GC收集器会自动调节其阙值容量来提升执行效率。在CLR初始化后,首先添加到托管堆中的对象都被定位第0代对象,当有垃圾回收执行时,未被回收的对象代龄将提升一级,变成第1代对象,而后新建对象仍未第0代对象。代龄越小表示对象越新,通常情况下其生命周期也最短,因此GC总是先收集第0代的不可达对象内存。

    随着对象的不断创建,垃圾收集再次启动时则只会检查0代对象并回收0代垃圾对象。而1代对象由于未达到1代容量阙值,则不会进行垃圾回收操作,从而有效地提高了垃圾收集的效率,而这也是代龄机制在垃圾回收中的性能优化作用。当第0代对象释放的内存不足以创建新的对象,同时1代对象的体积也超出了容量阙值是,垃圾收集器将同时对0代和1代对象进行垃圾回收。回收之后,未被回收的1代对象变化2级对象,未被回收的0代对象升级为1代对象,而后新建的对象仍为第0代对象。

    注:微软强烈建议不要通过GC.Collect方法来强制执行垃圾收集,这样会妨碍GC本身的工作方式,通过Collect会使对象代龄不断提升,扰乱应用程序的内存使用。只有在明确知道有大量对象停止引用时,才考虑使用GC.Collect方法来调用收集器。

    什么时候进行垃圾回收

    垃圾回收一般在下列情况下进行:

    1 内存不足溢出时,更确切的应该说是第0代对象充满时。

    2 调用GC.Collect方法强制执行垃圾回收。(一般不要执行此方法)

    3 Windows报告内存不足时,CLR将强制执行垃圾回收。

    4 CLR卸载AppDomain时,GC将对所有代龄的对象执行垃圾回收。

    5 CLR正在关闭。CLR在进程正常终止时关闭。关闭期间,CLR任务进程中一切都不是根。对象有机会进行资源清理,但CLR不会试图压缩或释放内存。整个进程都要终止了,Windows将回收进程的全部内存。

    6 其他情况,如物理内存不足,超出短期存活代的内存段门限,运行主机拒绝分配内存等。

    垃圾回收模式

    CLR启动时会选择一个GC模式,进程终止前该模式不会改变。两种模式:

    工作站:

    该模式针对客户端应用程序优化GC。GC造成的延时很低,应用程序线程挂起的时间很短,避免使用户感到焦虑。在该模式中,GC假定机器上运行的其他应用程序都不会消耗太多的CPU资源。

    服务器:

    该模式针对服务器端应用程序优化GC。被优化的主要是吞吐量和资源利用。GC假定当前应用程序是服务器上唯一的应用程序,该模式会导致托管堆被分隔为多个部分,每一个CPU一份,并且这些部分是可以并行执行的。

    默认情况下应用程序运行在工作站模式,并且支持同时收集(Asp.net和Sqlserver 默认采用服务器模式),如果服务器应用程序运行在单处理上,那么GC会采用工作站模式并且不支持同时收集。

    GC还支持两种子模式:并发(默认)和非并发。在并发方式中,GC有一个额外的后台线程,在应用程序运行期间并发的标记对象,当因为分配对象造成第0代超出运算时,GC挂起所有线程,如果判断回收0代或1代,GC如常进行。如果需要回收第2代,GC会增大第0代的大小(超过其运算)。在应用程序运行期间,GC运行一个普通优先级的后台线程查找不可达对象,找到之后,GC再次挂起所有线程时,判断是否要压缩(移动)内存。如决定压缩,内存会进行压缩,根引用会被修正,然后应用程序恢复运行。此次GC花费时间比平常少,因为不可达对象集合已构造好。但GC也可能决定不压缩内存,实际上,GC更倾向于选择不压缩。可用内存多,GC不压缩,这样有利于增强性能,但会增大应用程序的工作集。使用并发GC,应用程序消耗的内存通常比使用非并发GC的多。

    GC模式是针对进程进行配置的,进程运行期间不能更改。但应用程序可以使用GCSettings类的GCLatencyMode属性对GC进行某种程度的控制。

    LowLatency模式一般用它执行一次短期的、时间敏感的操作,再讲模式设置成普通的Batch或Interactive。LowLatency期间,GC会全力避免回收任何第2代对象,因为那样花费的时间比较多。当然调用GC.Collect()或Windows告诉CLR系统内存低时,仍会回收第2代。该模式中,应用程序抛出OutOfMemoryException的几率大些,因此该模式的时间应尽量短,避免分配太多对象,避免分配大对象。

    GCLatencyMode oldMode = GCSettings.LatencyMode;
    
                System.Runtime.CompilerServices.RuntimeHelpers.PrepareConstrainedRegions();
    
                try
    
                {
    
                    GCSettings.LatencyMode = GCLatencyMode.LowLatency;
    
                    //run code
    
                }
    
                finally
    
                {
    
                    GCSettings.LatencyMode = oldMode;
    
                }
    View Code

    终结的内部工作原理:

    当包含本机资源的对象被GC时,GC会回收对象在托管堆上的内存,但是这会造成本机内存资源的泄露,是不允许的。如果需要清除非托管的包含本机资源的对象,CLR提供了终结机制,允许非托管对象在CLR判定为不可达、被判定为垃圾并且进行回收之前,执行终结机制,调用回收机制自己终结自己,释放本机资源。

    终极基类System.Object定义一个虚拟方法finalize,允许对象在被CLR判定为垃圾时,调用对象的Finalize方法。

    应用程序创建新对象,new操作符会从堆中分配内存。如果对象定义了Finalize方法,那么该类型的实例构造器在被调用之前,会将指向该对象的指针存放到一个终结列表finalization list中,终结列表是GC控制的一个内部的数据结构,列表中每一项都指向一个对象,回收该对象的内存前应调用它的Finalize方法。当对象进行GC时,如果GC认为终结对象是垃圾时,会将对象从终结列表中移出,同时移入到freachable队列中,freachable队列也是GC的一种内部数据结构,队列中的每个引用都代表其Finalize方法已准备好调用的一个对象。一个特殊的高优先级的CLR线程专门调用Finalize方法,可避免潜在的线程同步问题,由于该线程的特殊性,finalize中不要访问线程的本地存储。

    当一个对象不可达时,GC把它视为垃圾,该对象从终结列表移至freachable队列中,对象不再认为是垃圾,不能回收它的内存。GC标记freachable对象是,将递归标记对象中的引用类型所引用的对象,所以这些对象也必须复活以便在回收过程中存活,之后GC才结束垃圾标识,此过程中,一些原本认为是垃圾的对象复活了,然后GC压缩移动可回收内存,将复活的对象提升到老的一代。现在,特殊的终结现场清空freachable队列,执行每个对象的finalize方法。下一次对老一代进行垃圾回收时,会发现已终结的对象成为真正的垃圾,因为没有应用程序指向它们,freachable队列也不在指向它们。所以这些对象的内存会直接回收。整个过程,可终结对象需要执行两次垃圾回收才能是否占用的内存。在实际应用中,由于对象可能被提升至另一代,所以可能不止进行两次垃圾回收。

    对于非托管资源,需要开发者手动清理,方法主要有:Finalize方法和Dispose方法。

    Finalize:

    Finalize方法又称为终止化操作:通过对自定义类型实现一个Finalize方法来释放非托管资源,而终止化操作在对象的内存回收之前通过调用Finalize方法来释放资源。在析构函数中重写Finalize方法,当垃圾管理器启动时,对于判定为可回收的垃圾对象,GC会自动执行其Finalize方法清理非托管资源。

    protected override void Finalize()
    
            {
    
                try
    
                {
    
                    //执行自定义资源清理操作
    
                }
    
                finally
    
                {
    
                    base.Finalize();
    
                }
    
            }
    View Code

    Finalize的缺点是:

    终止化操作的时间无法控制,执行顺序也不能保证。

    Finalize方法会极大的损失性能,GC使用一个终止话队列的内部结构来跟踪具有Finalize方法的对象。

    重写finalize方法的类型对象,其引用类型对象的代龄将被提升,带来内存压力。

    Dispose:

    Dispose模式的实现是:定义的类型必须实现System.IDisposable接口,该接口中定义了一个公有无参数的Dispose方法,程序设计者可以在Dispose方法中实现对非托管资源的清理工作。

    下面编写一个项目中遇到使用Dispose方法的例子,功能是在套接字使用完毕后释放资源

    public class SocketConnection : IDisposable
    
        {
    
            //逻辑操作
    
            //.....................
    
     
    
            //实现Dispose
    
            public void Dispose()
    
            {
    
                try
    
                {
    
                    this.ClientSock.Shutdown(SocketShutdown.Both);
    
                    this.ClientSock.Close();
    
                    this.Server = null;
    
                }
    
                catch (Exception ex)
    
                { }
    
            }
    
        }
    View Code

    总结:

    在.net中,在堆栈上分配的资源在调用结束后,其内存自动会释放。

    托管堆中的资源,由CLR的垃圾管理器进行清理操作。

    对于非托管资源,必须由程序设计者进行操作,而对于Finalize和Dispose,最好采用Dispose方法。

    Java引用

    Java1.2之前引用的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

    Java1.2之后,强引用strong reference、软引用soft reference、弱引用weak reference、虚引用phantom reference。

    强引用:类似Object obj = new Object(),只要强引用还存在,GC永远不会回收掉被引用的对象。

    软引用:描述一些还有用但并非必需的对象。对应软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

    弱引用用来描述非必须的对象的,它的强度比软引用更弱一些,倍弱引用关联的对象只能生存到下次GC发生之前。

    虚引用称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也不发通过虚引用来取得一个对象实例,唯一目的是能在这个对象被GC时收到一个系统通知。

    永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

    垃圾收集算法:

    标记-清除算法Mark-Sweep:

    问题有:效率问题,标记和清除的效率都不高。

    空间问题,标记清除后会产生大量不连续的内存碎片,导致以后程序需要分配大对象时,无法找到足够连续的内存而不得不提起触发另一次GC。

    复制算法Copying:

    将内存分为大小相等的两块,每次只使用 其中一块,用完时将存活的对象复制到另外一块上,再把已使用的内存空间一次清理掉。代价是:内存缩小了原来的一半。现代商业的一般做法是分配一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor。HotSpot默认比例为8:1。

    标记-整理算法:过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行整理,而是让所有存活对象都向一端移动。

    分代收集算法Generational Collection:

    将java堆分为新生代和老年代,根据各代的特点采用适当的收集算法。新生代中,每次都有大批对象死去,选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高、没有额外空间对它进行分配担保,采用标记-清理或者标记-整理算法。

    收集器

    在讨论垃圾收集器的上下文语境中:

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

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

    Serial收集器:

    最基本、发展历史最悠久的收集器,它是一个单线程收集器。单线程的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在进行GC时,必须暂停其他所有的工作线程,直到它收集结束。它依然是虚拟机运行在client模式下的默认新生代收集器。简单而高效。

    ParNew收集器:

    其实就是Serial收集器的多线程版本。它是许多运行在server模式下的虚拟机中首选的新生代收集器,其中一个与性能无关的重要原因是,除了serial收集器外,目前只有它能与CMS收集器配合工作。

    Parallel Scavenge收集器:

    特点是关注点不一样,它关注点是达到一个可控制的吞吐量。吞吐量值cpu用于运行用户代码的时间与cpu总消耗时间的比值。停顿时间越短越适合需要与用户交互的程序,提升用户体验,而高吞吐量则可以高效率地利用cpu时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

    Parallel Old收集器

    Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。吞吐量优先。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old收集器。

    CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

    整个过程分为4个步骤:

    初始标记、并发标记、重新标记、并发清除,其中初始标记、重新标记这两个步骤仍需要“stop the world”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。由于整个过程中耗时最长的并发标记和并发清除过程收集器都可以与用户线程一起工作,所以总体来说,CMS收集器的内存回收过程是与用户现场一起并发执行的。

    CMS的缺点:

    CMS收集器对CPU资源非常敏感。并发阶段,虽然不会导致用户的线程停顿,但是因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

    CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次FULL GC的产生。

    CMS收集器的标记-清除算法,会有大量空间碎片产生,空间碎片过多时,可能会给大对象分配带来很大的麻烦。

    G1收集器

    G1 Garbage-First收集器是一款面向服务端的垃圾收集器。

    有如下特点:

    并行与并发

    分代收集

    空间整合:整体来看是基于标记-整理,从局部来看是具有复制算法实现的。

    可预测的停顿

    使用G1收集器时,它将整个java堆划分为多个大小相等的独立区域,虽然还保留有新生代和老年代的概念,但已不再是物理隔离的了,它们都是一部分Region的集合。之所以是可预测的,是因为它可以有计划的避免在整个java堆上进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优秀列表,每次根据允许的收集时间,优先回收价值最大的Region,这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

    G1收集器的步骤:

    初始标记-并发标记-最终标记-筛选回收。

    Java内存分配与回收策略:

    对象优先在Eden分配

    大对象直接进入老年代

    长期存活的对象将进入老年代

  • 相关阅读:
    SQL Server 调优系列基础篇
    SQL Server 调优系列基础篇
    SQL Server 调优系列基础篇
    企业应用架构 客户端 / 服务器
    SQL Server 调优系列基础篇
    SQL Server 调优系列基础篇
    SQL Server 调优系列基础篇
    HL7 2.6 解析(XML)
    .Net程序调试与追踪的一些方法
    实现算法2.17的程序
  • 原文地址:https://www.cnblogs.com/ysyn/p/8036405.html
Copyright © 2020-2023  润新知