• dotnet学习笔记


    转眼作程序员已经四年了,自己也算是这一行的老人了。回头看看,自己做过的东西不算少,从应用程序到内核驱动,从普通程序到Web应用,从Windows到Linux。市面上流行的开发工具语言也都用过,VC,VB,DELPHI,BCB,JBUILDER,ASP,JSP。不过扪心自问,那些是自己专长的呢?好像没有,找工作的时候也是项目作了不少,算得上精通的,好像没有。.NET以前也接触过,但是没有深入,前一段时间由于一段机缘,对.NET产生了喜欢之情,好强大,C#也是很规整的OO语言。再加上自己对WIN平台的多年的感情,决定投入到轰轰烈烈的.NET事业中去。书看过了需要消化和实践,自己感觉把理解的东西写出来是很好的一种消化方法,就萌生了把自己看得东西写成一套学习笔记的想法。这篇是开篇,抛砖引玉,希望各位.NET界的师兄元老们多多指点,希望和和我一样立志于学习应用.NET技术的朋友们一起讨论,一起提高。

    .NET算是集现有开发平台语言之大成了,也提出了不少新概念。装箱(boxing)和拆箱(unboxing)应该是一个了。

    .NET的所有类型都是由基类System.Object继承过来的,包括最常用的基础类型:int, byte, short,bool等等,就是说所有的事物都是对象。但这样造成了及底的效率,比如简单的两个数相加,bool取反都会导致从堆(Heap)中分配内存。怎样解决这个问题呢?.NET把类型分成了两类:值型和引用型。

    值型在栈中分配内存,它们在声明的同时就初始化,以确保数据不为NULL。例如:

    byte b = 33;

    上边的声明就在栈中分配了8位的内存,并将变量初始化为8。.NET的值型包括枚举(enum),结构(structure)和基本类型(int, float, short等)。值型不需要Garbage Collection来回收占用的内存。超出了作用范围后,系统会自动释放。

    引用型就完全类似于c++或者java中的类了,在堆中分配内存,初始化为null。引用型是需要Garbage Collection来回收内存的。

    既然值型也是从System.Object中继承过来的,那么这样一句话就应该是合法的:

    int n=3;

    System.Object obj = n;

    正如上面所说,n这个变量的内存应该是在栈中分配内存,而obj则应该在栈中分配内存。这时候系统都作了哪些工作呢?系统在堆中分配了一个对象obj,并将n的值复制给它。这就叫做装箱(boxing。这时候n 和obj就是两个没有关联的对象了,继续运行如下的代码:

    obj = 9;

    Console.WriteLine( “{0}{1}”, n, obj );

    得到的结果就是:

    39

    简单的说装箱就是隐式的将一个值型转换为引用型对象。

    和装箱对应的就是拆箱了,拆箱(unboxing就是将一个引用型对象转换成任意的值型。与装箱不同,拆箱是显示的操作。如下面的代码:

    int i=0;

    System.Object obj = i;

    int j=(int)obj;

    由此可见.NET的类型系统是统一的类型系统,因为不管是值型还是引用型都被看作对象,这样就允许我们可以使用一致的方式跨代码处理类型。看一看下面的一段代码更能清楚地了解装箱的好处:

    ArrayList ar = new ArrayList();

    Date dt = new Date();

    ar.Add( dt );

     

    int n = 10;

    ar.Add( n );

    正如最好两行代码,我们不用显示的分配一个System.Object,或者做强制的类型转换。只要用我们最常用到的int就可以和其他的引用型对象一样处理了。在最后一行代码中,当n加入到数组中时,它已经被自动的装箱了。

    .NET Framework给我们提供了良好的开发平台。有很好的类库,可以跨语言,跨平台等等。但是他的内部实现细节是怎样的呢?.NET编译出来的exe文件并不是机器码,它是怎样和CLR结合起来的呢?下面就让我们揭开这个小秘密。

    首先做一个简单的.NET应用,把它编译成EXE文件。然后用Visual Studio 6.0带的工具Depends把它打开。如下图:

     

    这里可以看到一个很奇怪的现象,我的.net应用程序只直接依赖于一个dll – MSCOREE.DLL。而且这个DLL输出的这么多函数中也只用到了一个_CorExeMain。我的这个APPENDLINE.EXE中还用到了自己做的一个.NET组件,在这里也看不到。

    再让我们借助一些PE察看工具来分析一下这个EXE文件。这一步得到的结果是什么呢?在这个PE文件的入口函数上可以看到一条唯一的汇编语句:

    JMP  DS:_CorExeMain

    经过上面的步骤,我们可以明确的断定所有的.net编译后的EXE文件一旦运行,就执行MSCOREE.DLL输出的一个函数_CorExeMain。而在开发过程中我们使用到的一些外部组件、控件由于是被.net编译器编译成了中间代码,在Depends中是无法看到的,所有使用外部组件的过程全部由CLR处理。

    幸运的是微软这次公布了一个CLI实现的代码,我们可以看个究竟。我下载的代码中没有找到_CorExeMain,只找到了一个_CorExeMain2。参数有5个:

    PBYTE   pUnmappedPE,                // -> memory mapped code

        DWORD   cUnmappedPE,                // Size of memory mapped code

        LPWSTR  pImageNameIn,               // -> Executable Name

        LPWSTR  pLoadersFileName,           // -> Loaders Name

        LPWSTR  pCmdLine

    一目了然,pUnmappedPE和cUnmappedPE就是.net编译后的中间代码的内存缓冲和长度。pImageNameIn就是这个EXE的名字,pLoadersFileName是载入者的名字,pCmdLine就是命令参数。有了这几个参数我们就可以猜到.net将中间代码编译后放入到PE文件中,再加入一段代码直接执行MSCOREE.DLL的_CorExeMain,参数就是中间代码的内存指针。JIT就把这段中间代码编译成机器代码执行。

    _CorExeMain2代码不长,简单总结一些,做了如下的5步工作:

    1. 1.         验证签名。
    2. 2.         初始化CLR环境
    3. 3.         创建了一个代表EXE文件的PEFile对象
    4. 4.         使用SystemDomain的静态方法执行这个PEFile。
    5. 5.         执行后,做一些清除工作,退出。

     

    很明显,上面5个步骤里最重要,最关键的就是第4步了,究竟做了什么呢?有兴趣的朋友可以去看微软的CLI代码,我在以后的文章中也会进一步分析。

    大部分开发技术和环境都定义了自己的代码执行和资源所有的范围。操作系统是以进程为范围的,IIS, ASP,JSP是以一个虚拟目录(Virtual Directory)为范围的。而.NET的公共语言运行时(CLR)是以应用程序域(AppDomain)为基础范围的。

    程序域基本和进程的概念相同,是代码运行和资源访问的限制区域。普通的win32应用的资源和地址空间是在进程内共享的。.NET中所有的对象和资源只能在AppDomain的范围中访问,对象不能在其他的AppDomain中访问。这样的做法保证了应用运行的安全性,防止有意和无意的代码破坏,也能防止由于某个AppDomain的崩溃导致其他AppDomain的崩溃。不过这个前提是保证代码是托管的(Managed)。如果是非托管的代码,比如使用指针,理论上就可以访问整个进程的内存空间,就可能导致无法预料的事情发生。AppDomain比进程来说消耗资源要少一些。每一个进程操作系统要分配一个内核对象,分配地址空间。不过相对于进程间的互相访问(也要使用一些比较特殊的方式来实现,如Windows消息,内存映射等)来说,AppDomain之间的互相访问要困难一些。不过AppDomain是以进程为宿主的。一个进程可以有多个AppDomain。

    使用.NET提供的AppDomain类,我们可以创建自己的AppDomain。如下面的代码示例:

    代码1

    namespace ConsoleApplication1

    {

         /// <summary>

         /// Summary description for Class1.

         /// </summary>

         class Class1

         {

             /// <summary>

             /// The main entry point for the application.

             /// </summary>

             [STAThread]

             static void Main(string[] args)

             {

                  //

                  // TODO: Add code to start application here

                  //

                  Console.WriteLine( "CurrentDomain Name is: {0}", AppDomain.CurrentDomain.FriendlyName );

     

                  AppDomain newDomain = AppDomain.CreateDomain( "New Domain", null, null );

                  newDomain.ExecuteAssembly( "ConsoleApplication2.exe" );

             }

         }

    }

    使用AppDomain类的静态变量CurrentDomain可以得到当前AppDomain的引用。使用静态函数CreateDomain可以创建新的AppDomain。ExecuteAssembly可以执行某个程序集。上面代码中的两个AppDomain:CurrentDomain和newDomain就在同一个进程中,这一点可以通过Windows任务管理器察看进程数得到,运行时系统进程数只增加了一。

    程序集(Assembly)类似于普通程序的模块,可以是一个或者多个Exe和Dll文件。Assembly有私有和全局之分,私有的Assebmly只要放在程序的home目录下就可以了。公共Assembly则必须要有强名称,利用.NET提供的工具安装到系统中。

    每个AppDomain都有自己的Assembly的副本,不管他们是不是在同一个进程内。如下图:

    这个并不是绝对的,用户可以通过设置CLR来确定是否进程内的AppDomain可以共享某一个程序集,这样就节省了资源,提高了速度,但同时就丧失了安全性。

    Assembly一旦载入是不能被卸载的,除非它所在的程序域被卸载。我们可以通过编程来卸载AppDomain。就代码1中的newDomain,我们就可以使用AppDomain.Unload(newDomain)的语句来卸载它。

    我们平常写程序很少自己去写资源管理的,除非写非常大型的应用程序,或者大公司自己的sdk。看到过PGP源代码的一定知道,PGP的SDK就实现了自己的内存管理。自己管理内存烦恼实在多多,忘记释放了,释放了又再次访问的bug层出不穷,这种bug又非常难查。普通的逻辑bug,简单测试发现程序没有按照预想的运行就可以找到。但是内存的问题却很难发现。过去很多公司也为解决这方面的问题作过很大努力,比如Compuware的BoundsChecker,Rational的Purify。这些工具使用起来也很困难,经常找到的点都是你用的开发库的代码。现在.NET提供了全套的资源管理(无用资源回收:Garbage Collection,简称GC),能够让我们从中解脱出来,把精力用在自己应该解决的业务问题上去。当然它不是万能的,我们最好多了解他的原理,以便我们可以更好的享用它。

    系统资源不是无限的,内存用完要释放,文件,网络连接都是系统资源,用完之后也要释放。在面向对象的系统中,所有的东西都是对象,所以使用任何资源都要从系统分配内存,最后释放它。使用资源的过程无外乎五个步骤:

    1. 1.         为代表资源的类型分配内存;
    2. 2.         初始化资源状态,请求非内存系统资源(如打开文件,建立网络连接等);
    3. 3.         通过访问类型的实例(对象)及其成员变量、方法等来访问资源;(可能多次)
    4. 4.         清空资源状态,释放系统资源(如关闭文件,关闭网络连接等);
    5. 5.         释放内存

    我们遇到的内存问题一般都在上面的五个步骤中,.NET提供的无用单元回收(GC)机制基本上都可以解决这些问题。不过GC是不知道如何清空资源状态和释放系统资源的(即上面的第四步),这就要利用到Finalize方法,这个我们后面讨论。当然,大部分对象,如字符串等是无需这第四步的。

    CLR实现了一个托管堆(Managed Heap),要求所有的资源都必须从这个堆中分配,并且无需释放。下面就详细说明对象是如何在堆中分配,GC又是如何做无用单元回收的。

    CLR在进程初始化时,保留一块连续的内存,这块连续的内存就是托管堆。CLR同时为托管堆维护一个指针,这个指针永远指向下一个可以分配的内存空间,我们这里叫NextObjPtr。

    当程序适用new创建一个对象时,new首先确认堆中是否有足够的内存空间,如果有的话,则为对象分配空间,调用对象的构造函数,返回分配空间的地址,接着NextObjPtr指向剩余空间的地址,即下一个可以分配的地址。如下图:

     

    图中虚线是NextObjPtr的起始地址,分配对象C成功并返回地址后,NextObjPtr移到实线的位置。

    再让我们看看普通应用程序的堆内存分配方式。普通的内存分配方式维护一个空闲内存列表,系统首先遍历空闲空间列表,找到一个足够大的空间,然后将其拆分出足够的空间分配,然后再将剩下的空间加入到空闲空间列表。在历史上有很多的实现进程堆内存分配的算法,比如比较著名的二分法等等。但是比较来看,.NET的内存分配方法要快的多。

    不过内存不是无限的,堆的空间分配光了怎么办?CLR在分配内存的时候,如果发现堆中的空闲空间不足时,就会启动无用空间回收。GC将堆中不再被使用的对象占用的内存释放掉,然后将堆整理,使其剩下连续的空间以待分配,当然如果没有可以释放的对象或者释放后内存还是不够的话,就抛出OutOfMemoryException异常。GC是如何断定一个对象不再被使用了呢?

    每一个应用都有一组根,这些根包括了标示托管堆中一组对象的存储单元。被认为是根的对象包括:

    1. 1.         所有的全局和静态对象;
    2. 2.         一个线成栈中的局部对象和函数的参数;
    3. 3.         任何CPU寄存器包含的指针指向的对象;

    上面根的列表由JIT和CLR维护并且GC可以访问。

    开始无用单元回收后GC就开始遍历根,找到第一个根指向的对象,沿着这个对象向下找,找到这个对象所有引用的对象,以及引用之引用,并将其放入到一个集合中。这一个完成后,就接着找下一个根。一旦GC发现某个对象已经在集合中,就停止这个分支的搜寻以防止重复和引用死循环。

    完成后,GC就有了一个所有根可以访问到的对象的集合,这个集合中没有的对象就认为是无用的。如下图:

     

    GC的集合包括了对象A、B、D、F,而对象C、E、G就是无用对象,GC将释放其资源。GC接着遍历堆中的所有对象,释放无用对象,并将有用对象向内存的地位移动(据说使用的memcpy),以保证空闲空间的连续性。NextObjPtr就被指向空闲空间的开始地址。这样做会使一些对象的引用地址失效,GC负责更正这些指针。回收后的堆如下图:

     

    这一次回收所作的工作不可谓不复杂,消耗的CPU时间也是很多。不过还好,它不是时时刻刻都在运行,而是只在堆满了之后才回收(实际上是Generation 0满了之后,Generation我将在接下来的文章讨论),其他分配内存的时候还是非常快的。而且.NET提供丰富的设置来提高无用单元回收的效率。

    我将在下一节讨论Finalization和Generation,强引用,弱引用。

    l         Finalize

    在上一篇文章中我分配使用资源一共五步,我们已经知道了GC是如何释放无用对象的内存了。但是它怎么实现第四步清空资源使用状态、释放利用到的一些非内存的系统资源呢?.NET引入了Finalize来完成这个任务。

    GC在无用单元回收时一旦发现某个对象有Finalize方法,便调用它。所以我们的Finalize方法一定要尽量少做事情,以提高内存回收的速度。另外,在Finalize方法中不要有任何的线程同步等操作,以防止GC线程被挂起。

    我们可以用两种方法来写自己的Finalize方法。一种就是显示的实现,如下面的代码:

    代码1

    public class SomeClass

    {

       public SomeClass()

       {

       }

       protected override void Finalize()

       {

          Console.WriteLine(“Finalizing…”);

       }

    }

    使用这种方法时要注意一点,.NET不会帮你做调用基类的Finalize方法。如果需要调用基类的Finalize方法,需要显示的调用。如下面代码:

    代码2

    public class SomeClass

    {

       public SomeClass()

       {

       }

       protected override void Finalize()

       {

          Console.WriteLine(“Finalizing…”);

          base.Finalize(); // 调用基类Finalize方法

       }

    }

    另外一种方法就是析构函数。C#中的析构函数不同于C++。我们看下面的代码:

    代码3

    public class SomeClass

    {

       public SomeClass()

       {

       }

       ~SomeClass()

       {

          Console.WriteLine(“Finalizing…”);

       }

    }

    它等同于代码2。

    使用Finalize方法要特别小心。因为使用Finalize方法的对象要比普通的对象花时间;GC也要花更多的时间来回收。而且CLR并不能保证调用Finalize的顺序,所以如果对象间有关联(比如一个成员变量先被Finalize了,如果在Finalize方法里还使用它,就会出错),就会更麻烦。

    GC是如何实现Finalize的呢?GC维护了两个队列,Finalization队列和Freachable队列。在托管堆分配对象的时候,GC如果发现这个对象实现了一个Finalize方法,就把它加到Finalization队列。如图:

     

    当托管堆的内存不足的时候,GC开始对堆进行回收。GC回收一个对象前,先检查Finalization队列中是否有这个对象的指针,如果有,就将其放入Freachable队列。Freachable队列被认为是根(root)的一部分,所以GC不会对其作回收。GC第一次回收后,堆如下图:

     

    对象G和对象E不在根的范围之内,被回收。对象F和对象C由于需要Finalize被放入到Freachable队列,这个队列被认为是根的一部分,所以这是对象F和对象C就复活了,没有被GC回收。Freachable队列中的对象的Finalize方法被一个特殊的线程执行。这个线程平时处于非活动状态,一旦Freachable队列不再为空,它就醒过来,一一执行这个队列中对象中的Finalize方法。执行过后如下图:

     

    这时对象F和对象C不再是根的一部分,如果此时GC进行回收,将会被认作无用对象进行回收,回收后如下图:

     

    上面简单描述了Finalize作用及其内部的工作原理。下面来说一下Generation。

    l         Generation

    每次都对整个对进行搜索,压缩是非常耗时的。微软总结了一些过去的开发中出现的现象,其中有一条就是,越是新的对象,越是最快被丢弃不再使用。微软根据这个经验在内存回收中引入了Generation的概念,我此处暂时将其翻译成代。托管堆开始的时候是空的,程序启动开始在其中分配对象,这时候的对象就是第0代(Generation 0)对象。如下图:

     

    接下来,到托管堆空间不足,GC进行了第一次回收,剩下的没有被回收的对象就升为第一代,之后再新分配的对象就是第0代(图甲)。再之后GC再进行回收的话只回收第0代,未被回收的第0代升级为第一代,原来的第一代升级为第0代(图乙)。

     

    GC缺省的代(Generation)最高就是2,升级到第二代就不会再升级了。那什么时候GC回收第一,第二代呢?当GC回收完第0代后,发现内存空间还不够,就会回收第一代,回收完第一代,还不够,就回收第二代。

    这一篇也写了不少了,所以下一篇再继续,下一篇写WeakReference和如何在自己的代码中控制GC的动作。

    这篇文章接着上一次的来,继续讨论无用资源回收的其它一些话题。

    l         WeakReference(弱引用)

    我们平常用的都是对象的强引用,如果有强引用存在,GC是不会回收对象的。我们能不能同时保持对对象的引用,而又可以让GC需要的时候回收这个对象呢?.NET中提供了WeakReference来实现。弱引用使用起来很简单,看下面的代码:

    代码1

    Object obj = new Object();

    WeakReference wref = new WeakReference( obj );

    obj = null;

    第一行代码新建了一个新的对象,这里叫它对象A,obj是对对象A的强引用。接着第二行代码新建了一个弱引用对象,参数就是对象A的强引用,第三行代码释放掉对对象A的强引用。这时如果GC进行回收,对象A就会被回收。

    怎样在取得对象A的强引用呢?很简单,请看代码2:

    代码2

    Object obj2 = wref.Target;

    if( obj2 != null )

    {

       … // 做你想做的事吧。

    }

    else

    {

       …// 对象已经被回收,如果要用必须新建一个。

    }

    只要显示的将弱引用的Target属性附值就会得到弱引用所代表对象的一个强引用。不过在使用对象之前要对其可用性进行检查,因为它可能已经被回收了。如果你得到的是null(VB.NET下为Nothing),表明对象已经被回收,不能再用了,需要重新分配一个。如果不是null,就可以放心大胆的用了。

    接下来让我们看WeakReference的另外一个版本,请看代码3:

    代码3

    // public WeakReference(

    //   object target,

    //   bool trackResurrection

    //);

    Object obj1 = new Object();

    Object obj2 = new Object();

    WeakReference wref1 = new WeakReference( obj1, false );

    WeakReference wref2 = new WeakReference( obj2, true );

    WeakReference的另外一个版本有两个参数,第一个参数和我们前面用的版本的一样。第二个参数让我们看一下他的原型,bool trackResurrection,跟踪复活,是个bool型,就是是否跟踪复活。前面的文章中我提到过需要Finalize的对象在最终释放前会有一次复活,我们大概可以猜到第二个参数表示的意思了。如果我们第二个参数给false,这个弱引用就是一个short weak reference(短弱引用),当GC回收时,发现根中没有这个对象的引用了,就认为这个对象无用,这时短弱引用对这个对象的跟踪到此为止,弱引用的Target被设置为null。前面的一个参数的构造函数版本新建的弱引用为短弱引用。如果第二个参数给true,这个弱引用就是一个long weak reference(长弱引用)。在对象的Finalize方法没有被执行以前,Target都可用。不过这是对象的某些成员变量也许已经被回收,所以使用起来要想当小心。

    现在让我们看看WeakReference是如何实现的。很显然WeakReference不能直接的引用目标对象,WeakReference的Target属性的get/set是两个函数,从某处查到目标对象的引用返回,而不是我们最常用写的那样直接返回或者设置一个私有变量。GC维护了两个列表来跟踪两种弱引用的目标对象,在一个WeakReference对象创建时,它在相应的列表中找到一个位置,将目标对象的引用放入,很显然,这两个列表不是根的一部分。在GC进行内存回收的时候,如果要回收某一个对象,会检查弱引用的列表,如果保存着这个对象的引用,则将其设为null。

    l         控制GC行为

    .NET提供了System.GC类来控制GC的行为,GC只提供静态方法,无需也不能(GC的构造方法被做成私有)创建它的实例。

    GC类提供的最主要一个方法就是Collect,它使自己控制内存回收成为可能。Collect方法有两种版本,void Collect(); 和 void Collect(int);。第二个版本的Collect提供一个参数,让你选择是回收那一代(Generation)以及比其年轻者的对象,也就是说GC.Collect(0)只回收第0代的对象,而GC.Collect(1)则是要回收第0代和第一代的对象。Collect()则是回收所有对象,等同于GC.Collection(GC.MaxGeneration)。MaxGeneration是GC唯一的一个属性,它给出GC的最高代。

    GC类提供了另外一个方法来获取某个对象的代值,GetGeneration。代码4给出了一段例子代码,可以让我们更好的理解Generation和GC提供的这两个方法。请看代码4:

    代码4

    class GCDemoClass

         {

             ~GCDemoClass()

             {

                  Console.WriteLine( "Demo Class Finalizing..." );

             }

    }

    static void Main(string[] args)

             {

                  GCDemoClass inst = new GCDemoClass();

     

                  Console.WriteLine( "Generation of demo object:{0} ",  GC.GetGeneration( inst ) );

     

                  GC.Collect();

     

                  Console.WriteLine( "Generation of demo object:{0} ",  GC.GetGeneration( inst ) );

     

                  GC.Collect();

     

                  Console.WriteLine( "Generation of demo object:{0} ",  GC.GetGeneration( inst ) );

     

                  inst = null;

                 

                  GC.Collect( 0 );

                  Console.WriteLine( " After collect generation 0 ..." );

     

                  GC.Collect( 1 );

                  Console.WriteLine( " After collect generation 1 ... " );

     

                  GC.Collect( 2 );

                  Console.WriteLine( " After collect generation 2 ... " );

     

                  Console.ReadLine();

             }

    GCDemoClass实现了一个析构函数,根据我前面文章提到的,编译器会将其变为Finalize方法,在正式回收这个类的实例的内存之前调用。我们把new GCDemoClass()新建的对象叫做对象A,这时它是第0代对象,由于inst保存着一个它的强引用,所以前两次的Collect不会将对象A回收,而随着Collect,对象A的Generation也随之增加,第二次Collect后,对象A成为第二代对象。接下来,inst = null 放弃了对对象A的强引用,但是由于为第二代对象所以Collect(0)和Collect(1)都没有将其回收。终于,Collect(2)被执行,对象A别回收,同时其Finalize方法也被调用。这段代码执行的结果如下:

     

    GC还提供了其他的一些方法,这里就不再讨论了,大家可以去看MSDN。

  • 相关阅读:
    软件测试第三次作业
    第一次实验 Junit简单test三角形的小程序
    软件测试[2]falut error failure 的区别与理解
    java中使用jxl的jar包处理excel的复制,更新等问题。
    java中== 和 .equals()的区别
    c# 规格说明书
    c#第九课 linq stream(2)
    c# 第八课 linq stream
    c# 第七课 NET 框架的正则表达式类
    矩阵模版(新)
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/1805077.html
Copyright © 2020-2023  润新知