• C# 内存管理和指针 (13)


    本章要点

    • 运行库在栈和堆上分配空间
    • 垃圾回收
    • 使用析构函数 和 SYstem.IDisposable 接口来释放非托管的资源
    • C#中使用指针的语法
    • 使用指针实现基于栈的高性能数组

    值类型数据

    程序第一次开始运行时,栈指针指向为栈保留的内存块末尾。栈实际是从高内存地址向低内存地址填充的,向下填充。当数据入栈后,栈指针就会随之调整,以始终指向下一个空间存储单元。

    引用数据类型

    虽然栈有非常高的性能,但它没有灵活到可以用于所有的变量(引用类型)。引用类型,用 new 运算符请求分配存储空间,存放托管堆的。

    Custormer arabel = new Custormer();

    arabel  仅是一个引用。占用 4个字节空间。

    当一个引用变量超出作用域时,它会从栈中删除,但引用的对象数据仍保留在堆中,一直到垃圾回收器删除它 或 程序终止,它才会被删除。

    垃圾回收

    在垃圾回收器运行时,它会从堆中删除不在引用所有对象。在完成删除操作后,堆会立即把对象分散开来,与已经释放的内存混合一起。

    托管堆,在其新对象分配内存就称为一个很难处理的过程,运行库必须搜索整个堆,才能找到足够大的内存块来存储每个新对象。

    垃圾回收器释放了所有的对象,就会把其他对象移动回堆的端部,再次形成一个连续的内存块。因此,堆可以继续像栈那样确定在什么地方存储新对象。当然移动对象时,这些对象的所有引用都需要用正取的新地址来更新,但垃圾回收器也会处理更新问题。

    垃圾回收器的压缩操作是托管的堆与非托管的旧堆的区别所在。使用托管的堆,只需要读取指针的值即可,而不需要遍历地址的链表,来查找一个地方来放置新数据。

    调用 System.GC.Collect() 方法,强迫来及回收器在代码的某个地方运行,System.GC 类是一个表示垃圾回收器的 .NET 类,Collect() 方法 启动一个来及回收过程。GC类适用的场合很少,例如,代码中有大量的对象刚刚取消引用,就适合调用垃圾回收器。

    垃圾回收器的逻辑不能保证在一次垃圾收集过程中,所有未引用的对象都从堆中删除。

          创建对象时, 会把这些对象放在托管堆上。 堆的第一部分称为第0代。 创建新对象时, 会把它们移动到堆的这个部分中。 因此, 这里驻留了最新的对象。对象会继续放在这个部分, 直到垃圾回收过程第一次进行回收。 这个清理过程之后仍保留的对象会被压缩, 然后移动到堆的下一部分上或世代部分 一 第1代对应的部分。

          此时, 第0代对应的部分为空, 所有的新对象都再次放在这一部分上。 在垃圾回收过程中遗留下来的旧对象放在第1代对应的部分上。 老对象的这种移动会再次发生。接着重复下一次回收过程。这意味着 第1代中在垃圾回收过程中遗留下来的对象会移动到堆的第2代, 位于第0代的对象会移动到第1代, 第0代仍用于放置新对象。

          在给对象分配内存空间时,如果超出了第0代对应的部分的容量,或者调用 GC.Collect() 方法,就会进行垃圾回收。

         这个过程极大地提高了应用程序的性能。一般而言,最新的对象通常是可以回收的对象,而且可能也会回收比较新的对象。如果这些对象在堆中的位置是相邻的,垃圾回收过程就会更快。另外,相关的对象相邻放置也会使程序执行得更快。

         在.NET中,垃圾回收提高性能的另一个领域是架构处理堆上较大的对象的方式。在.NET下,较大对象有自己的托管堆,称为大对象堆。使用大于85000个字节的对象时,它们就会放在这个特殊的堆上,而不是主堆上。.NET 应用程序不知道两者的区别,因为这是 自动完成的。其原因是在堆上压缩大对象时比较昂贵的,因此驻留在大对象堆上的对象不执行压缩过程。

         在进一步改进垃圾回收过程后,第二代和大对象堆上的回收现在放在后台线程上进行。这表示,应用程序线程仅会为第0代和第1代的回收而阻塞,减少了总暂停时间,对于大型服务器应用程序尤其如此。服务器好工作站默认打开这个功能。要关闭该功能,可以在配置文件中把<gcConcurrent>元素设置为false。

       有助于提高应用程序性能的另一个优化是垃圾回收的平衡,它专用于服务器的垃圾回收,服务器一般有一个线程池,执行大致相同的工作。内存分配在所有线程上都是类似的。对于服务器,每个逻辑服务器都有一个垃圾回收堆。其中一个堆用尽了内存,触发了垃圾回收过程时,所有其他队也可能得益于垃圾的回收。如果一个线程使用的内存远远多于其他线程,导致垃圾回收,其他线程可能不需要垃圾回收,这就不是很高效。垃圾回收过程会平衡这些堆-----小对象堆和大对象堆。进行这个平衡过程,可以减少不必要的回收。

         为了利用包含大量内存的硬件,垃圾回收过程添加了GCSettings.LatencyMode 属性。把这个属性设置为 GCLatencyMode枚举的一个值,可以控制垃圾回收器进行回收的方式。

    GCLatencyMode的设置

    Batch 禁用并发设置,把来及回收设置为最大吞吐量。这会重写配置设置
    Interactive 默认行为
    LowLatency 保守的垃圾回收。只有系统存在内存压力时,才进行完整的回收。只应用于较短时间,执行特定的操作
    SustainedLowLatency 只有系统存在内存压力时,才进行完整的内存块回收

         LowLatency 设置使用的时间应为最小值,分配的内存量应尽可能小。如果不小心,就可能出现溢出内存错误。

         为了使用64位机器的高内存量,添加了<gcAllorVeryLargeObjects>配置配置。它允许创建大于2GB的对象。这对32位机器没有影响,32位机器仍有2GB的限制。

    释放非托管的资源

    那些对象所有引用只要超出作用域,并允许垃圾回收时,它就会在需要时,释放内存。但是垃圾回收器不知道如何释放非托管的资源。在定义类时,需要实现两种机制,来实现垃圾回收。

    • 声明析构函数(或终结器),作为类的一个成员
    • 在类中实现 System.IDisposable 接口

    析构函数

    在底层的.NET体系结构中,这些函数称为终结器(finalizer)。在C#中定义析构函数时,编译器发送给程序集的实际上是Finalize()方法。它不会影响源代码,但如果需要查看程序集的内容,就应知道这个事实。

    class StaticClass
    {
        public StaticClass()
        {
        }
    
        // 析构函数
        ~StaticClass()
        {
            
        }
    }

    C#编译器在编译析构函数时,它会隐式地把析构函数的代码编译为等价 Finalize() 方法的代码,从而确保执行父类的 Finalize() 方法。下面列出了等价于编译器为 MyClass析构函数生成的IL的c#代码

    class StaticClass
    {
        public StaticClass()
        {
        }
    
        // 析构函数
        protected override void Finalize()
        {
            return base.Finalize();
        }
    }

    由于使用C#时垃圾回收器的工作方式,无法确定C#对象的析构函数何时执行,所以不能在析构函数中放置需要在某一时刻运行的代码,也不应寄望于析构函数会以特定顺序对不同类的的实例调用。如果对象占用了宝贵而重要的资源,应尽快释放这些资源,此时就不能等待垃圾回收器来释放了。

    C#析构函数的实现会延迟对象最终从内存删除的时间,没有析构函数的对象会在垃圾回收器的一次处理中从内存中删除,但有析构函数的对象需要两次处理才能销毁:第一次调用析构函数时,没有删除对象,第二次调用才真正删除对象。另外,运行库使用一个线程来执行所有对象的 Finalize() 方法。如果频繁使用析构函数,而且使用它们执行长时间的清理任务,对性能影响就会非常显著。

    IDisposeable接口

    在C#中,推荐使用 System.IDisposeable 接口替代析构函数。IDisposeable 接口定义了一种模式(具有语言级的支持),该模式为释放非托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾回收器相关的问题。IDisposeable 接口声明了一个 Dispose() 方法,它不带参数,返回 void。 

    class MyClass : IDisposable
    {
        public void Dispose()
        {
            
        }
    }

    Dispose() 方法的实现代码显式地释放由对象直接使用的所有非托管资源,并在所有也实现 IDisposeable 接口的封装对象上调用 Dispose() 方法。这样,Dispose() 方法为和是释放非托管资源提供了精确的控制。

     MyClass myClass = new MyClass();
     myClass.Dispose();

    还可以使用 using 关键字。

    C#提供一种语法,确保在实现IDisposeable接口的对象的引用超出作用域时,在该对象上自动调用Dispose()方法。 该关键字在完全不同的环境下,它与名称空间没有关系。

    using ( MyClass myClass = new MyClass() )
    {
        
    }

    它与try块生成等价的IL代码:

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

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

    对于某些类,使用Close()方法要比 Dispose() 方法更富有逻辑性。如处理文件或数据库连接时,就是这样。Close()方法调用Dispose()方法。这种方法在类的使用上比较清晰,还支持C#提供的using语句。

    实现IDisposeable接口 和 析构函数

    一般情况下, 最好的方法是实现两种机制,获得两种机制的优点,克服其缺点。假定大多数程序员都能正确调用 Dispose() 方法,同事把实现析构函数作为一种安全机制,以防没有调用Dispose()方法。

    using System;
    
    public class ResourceHolder : IDisposable
    {
        // 成员变量表示对象是否已被清理,并确保不试图多次清理成员变量。
       private bool isDisposed = false;
    
       public void Dispose() 
       {
          Dispose(true);
            // GC类表示垃圾回收器
            // SuppressFinalize方法则告诉垃圾回收器有一个类不再需要调用其析构函数了,因为Dispose()方法已经完成了所有需要的清理工作,所以析构函数不需要做任何工作。调用SuppressFinalize()方法就意味着垃圾回收器认为这个对象根本没有析构函数。
            GC.SuppressFinalize(this); 
       }
    
        // 重载 Dispose方法
       protected virtual void Dispose(bool disposing) 
       {
          if (!isDisposed) 
          {
             if (disposing) 
             {
                // Cleanup managed objects by calling their 
                // Dispose() methods.
             }
             // Cleanup unmanaged objects
          }
          isDisposed = true;
       }
    
       ~ResourceHolder()
       {
          Dispose (false);
       }
    
       public void SomeMethod() 
       {
            // 确保在执行实例方法之前,测试对象是否已清理
            // 这个方法不是线程安全的,需要调用者确保在同一时刻只有一个线程调用方法。要求使用者进行同步是一个合理的假定。
          // Ensure object not already disposed before execution of any method
          if(isDisposed) 
          {
             throw new ObjectDisposedException("ResourceHolder");
          }
    
          // method implementation…
       }
    }

    不安全的代码

    有时也需要直接访问内存。例如,由于性能问题,要在外部(非.NET环境)的DLL中访问一个函数,该函数需要把一个指针当做参数来传递(许多Windows API函数就是这样)。

    用指针访问内存

    引用就是一个类型安全的指针。引用表示对象和数组的变量实际上存储相应数据(被引用者)的内存地址。指针只是一个以与引用相同的方式存储地址的变量。其区别是C#不允许直接访问引用变量中包含的地址。有了引用后,从语法上看,变量就可以存储引用的实际内容。

    C#引用主要用于C#语言易于使用,防止用户无意中执行某些破坏内存中内容的操作。另一方面,使用指针,就可以访问实际内存地址,执行新类型的操作。例如,给地址加上4个字节,就可以查看甚至修改存储在新地址中的数据。

    下面是使用指针的两个主要原因:

    • 向后兼容性----------尽管.NET运行库提供了需要工具,但仍可以调用本地的 Windows API 函数。对于某些操作这可能是完成任务的唯一方式。这些API函数都是用C++或C#语言编写的,通常要求把指针作为其参数。但许多情况下,还可以使用 DllImport 声明,以避免使用指针,例如,使用System.IntPtry 类型。
    • 性能        ----------在一些情况下,速度是最重要的,而指针可以提供最优性能。假定用户最多自己在做什么,就可以确保以最搞笑的方式访问或处理数据。但是,注意在代码的其他区域中,不使用指针,也可以对性能进行必要的改进。使用代码配置文件,查找代码中的瓶颈,VS中就包含一个代码配置文件。

    使用指针,必须授予代码运行库的代码访问安全机制的高级别信任,否则就不能执行它。在默认的代码访问安全策略中,只有代码运行在本地计算机上,这才是可能的。如果代码必须运行在远程地点,如Internet,用户就必须给代码授予额外的许可,代码才能工作。除非用户信任你和代码,否则他们不会授予这些许可。

    指针无法通过 CLR(.NET 公共语言运行库)内存类型安全检查。

    用unsafe关键字编写不安全代码

    因为使用指针会带来相关的风险,所以C#只允许特别标记的代码块中使用指针。标记代码所用的关键字是unsafe。

    unsafe int GetSomeNumber()
    {
        
    }

    任何方法都可以标记为 unsafe -----无论该方法是否应用了其他修饰符(例如,静态方法、虚方法等)。unsafe 修饰符还会应用到方法的参数上,允许把指针用做参数。还可以把整个类或结构标记为 unsafe ,这表示假设所有的成员都是不安全的。

    unsafe class MyClass()
    {
        
    }
    
    class MyClass
    {
        unsafe int* pX;
    }
    
    // 把代码块标记为 unsafe
    void MyMethod
    {
        unsafe
        {
            
        }
    }

    注意,它不能把局部变量标记为 unsafe 。

    如果要使用不安全的局部变量,就需要在不安全的方法或语句块中声明和使用它。在使用指针前还有一步要完成。C#编译器会拒绝不安全的代码,除非告诉编译器代码包含不安全的代码块。标记所用的关键字 unsafe 。因为要编译包含不安全代码块的文件 MySource.cs ( 假定没有其他编译器选项),就要使用下述命令:

    csc /unsafe MySource.cs 
    或者
    csc -unsafe MySource.cs 

    如果使用 vs 可以在项目属性窗口的 Build 选项卡中 找到 编译器不安全代码的选项。

    新版本的VS

    指针的语法

    把代码块标记为 unsafe 后,就可以使用下面的语法声明指针。

      // 在指针变量名的前面使用前缀来表示这些变量是指针。
      // 符号 * 表示声明一个指针,换言之,就是存储特定类型的变量的地址。
    
      // 整数型指针
     int* pWidth, pHeight;
      // double 型指针
     double* pResult;
      // 字节型的数据指针
     byte*[] pFlags;

    C++开发人员要注意与C#中的语法差异。C#语句中的 "int*pX,pY;" 对应 C++ 语句中的 "int *pX, *pY;" 在c#中 * 符号与类型相关,而与变量名无关。

    unsafe
    {
        int x = 10;
        int* pX, pY;
        pX = &x;
        pY = pX;
        *pY = 20;
        System.Console.WriteLine(x);
    }
    • & 表示 "取地址",并把一个值数据类型转换为指针,例如,int 转换为 *int 。这个运算符称为 寻址运算符。
    • * 表示 "获取地址的内容", 把一个指针转换为值数据类型(例如 *float 转换为 float)。这个运算符称为 "间接寻址运算符" (有时称为 "取消引用运算符" )。

    首先声明一个整数x,其值是10。接着声明两个整数指针pX和pY。然后把pX设置为指向x(换言之,把pX的内容设置为x的地址)。然后把pX的值赋值予pY,所以pY也指向x。最后,在语句 *pY = 20 中,把值 20 赋予 pY 指向的地址包含的内容。实际上把 x 的内容改为20,因为 pY 指向 x。 注意, pY 和 x 之家没有任何关系,只是此时 pY 指向 存储 x 的存储单元。

    进一步理解这个过程。假定 x 存储在栈的存储单元 0x12F8C4 ~ 0x12F8C7 ,既有4个存储单元,因为一个int占用4个字节。因为栈向下分配内存,所以变量pX存储在 0x12F8C0 ~ 0x12F8C3 的位置上,pY存储在 0x12F8BC ~ 0x12F8BF 的位置上。注意,pX 和 pY 也分别占用 4 个字节。这不是因为 int 占用 4个 字节,而是因为在32位处理器上,需要用4个字节存储一个地址。利用这些地址,在执行完上述代码后,栈如以下图所示

    这个示例使用int来说明该过程,其中int存储在32位处理器中栈的连续空间上,但并不是所有的数据类型都回存储在连续的空间中,原因是32位处理器最擅长于在4个字节的内存块中检索数据。这中计算机上的内存会分解位4个字节块,在Windows上,每个块有事称为 DWORD,因为这是32位无符号int数在.NET出现之前的名字。这是从内存获取 DWORD 的最高效的方式-----跨越 DWORD 边界存储数据通常会降低硬件的性能。因此, .NET 运行库通常会给某些数据类型填充一些空间,使它们占用的内存是4个倍数。例如 ,short 数据占用两个字节,但如果把一个short 放在栈中,栈指针仍会向下移动4个字节,而不是两个字节,这样,下一个存储在栈中的变量就仍从 DWORD 的边界开始存储。

    可以把指针声明为任意一种值类型---任何预定义的类型 uint、int 和 byte ,也可以声明 一个结构。 但不能把指针声明为一个 类 或 数组,因为这么做会使垃圾回收器出现问题。为了正常工作,垃圾回收器需要做的在堆上创建了什么类的实例,它们在什么地方。但如果代码开始使用指针处理类,就很容易破坏堆中 .NET 运行库为垃圾回收器维护的与类相关的信息。在这里,垃圾回收器可以访问的任何数据类型称为托管类型,而指针只能声明为非托管类型,因为垃圾回收器不能处理它们。

    指针强制转换为整数类型

    指针实际上存储了一个表示地址的整数,因此任何指针中的地址都可以和任何整数类型之间相互转换。指针到整数类型的转换必须是显式指定的,隐式的转换是不允许的。

     unsafe
     {
         // 把指针 pX 中包含的地址强制转换为一个 uint ,存储在变量 y 中。接着把 y 强制转换回一个 int* ,存储在新变量 pD 中。因此 pD 也指向 x 的值。
         int x = 10;
         int* pX, pY;
         pX = &x;
         pY = pX;
         *pY = 20;
         uint y = (uint)pX;
         int* pD = (int*)y;
    
         Console.WriteLine(y + "   " + (int)*pD );
     }
     

    把指针的值强制转换为整数类型的主要目的是显式它。

    可以把一个指针强制转换为任何整数类型,但是因为在32位系统上,一个地址占用4个字节,把指针强制转换为了除了 uint、long 或 ulong 之外的数据类型,肯定会导致溢出错误(int数也可能导致这个问题,因为它的取值范围是 -20亿 ~ 20亿 ,而地址的取值范围是 0 ~ 40亿)。C#用于64位处理器时,一个地址占用8个字节。因此在这样的系统上,把指针强制转换为非 ulong 类型,就可能导致溢出错误。

    指针转换时,发生溢出时,即使使用 checked 关键字,也不会抛出异常。 因为 .NET运行库假定,如果使用指针,就必须知道自己要做什么,不必担心可能出现的溢出。

    指针类型之间的强制转换

    可以在指向不同类型的指针之间进行显示的转换。

    byte aByte = 8;
    byte* pByte = &aByte;
    double* pDouble = (double*) pByte;
    Console.WriteLine((double)*pDouble);
    Console.WriteLine((double)*pByte);

    如果要查找指针pDouble指向的 double 值,就会查找包含 1个 byte(aByte)的内存,和一些其他内存,并把它当做包含一个 double 值的内存区域来对待----这不会得到一个有意义的值。但是,可以在类型之间转换,实现 C union 类型的等价形式,或者把指针强制转换为其他类型,例如把指针转换为 sbyte ,检查内存的单个字节。

    void 指针

    如果不希望指定它指向的数据类型,可以把指针声明为 void :

    unsafe
    {
        int* pointerToInt;
        void* pointerToVoid;
        pointerToVoid = (void*) pointerToInt;
    }

    void指针的主要用途是调用需要 void* 参数的API函数。在C#语言中,使用 void 指针的情况并不是很多。特殊情况下, 如果试图使用 * 运算符取消引用 void 指针,编译器就会标记一个错误。

    指针算术的运算

    可以给指针加减整数。例如假定有一个 int 指针,要在其值加1。编译器会假定我们要查找int后面的存储单元,因此会给该值加上4个字节,即加上一个int占用的字节数。如果这是一个double指针,加1就表示指针的值加上8个字节,即一个double占用的字节数。只有指针指向 byte 或 sbyte (都是1个字节时),才会给该指针的值加1。

    可以对指针使用运算符+、-、+=、-=、++和--,这是运算符右边的变量必须是 long 或 ulong 类型。

    不允许对 void 指针执行算术运算。

    unsafe
    {
        uint u = 3;
        uint u2 = 4;
        uint* pUint = &u;
        Console.WriteLine((uint)pUint + "    " + (uint)&u + "    " + (uint)*pUint);
        pUint -= 1;
        Console.WriteLine("{0}    {1}    {2}", (uint)pUint, (uint)&u2, (uint)*pUint);
    }

    一般规则是,给类型为T的指针加上数值X,其中指针的值为P,则得到的结果是 P + X *(siezeof(T))。 使用这条规则是要小心。如果给定类型的连续值存储在连续的存储单元中,指针加法就允许在存储单元之间移动指针。但如果类型是 byte 或 char,其总字节数不是4的倍数,连续值就不是默认地存储在连续的存储单元中。

     如果两个指针都指向相同的数据类型,则也可以把一个指针从另一个指针中减去。此时,结果是一个long,其值是指针值的差被该数据类型所占用的字节数整除的结果。

     double d1 = 11;
     double* pD1 = &d1;
     double* pD2 = pD1 - 8;
     Console.WriteLine("{0}      {1}", (uint)pD1, (uint)pD2);
     // 返回数据类型占用
     long mL = pD1 - pD2;
     Console.WriteLine(mL);

    sizeof 运算符

    size运算符,返回该类型占用的字节数。参数是数据类型的名称。

    int size = sizeof(double);
    Console.WriteLine(size);

    对自己定义的结构使用 sizeof,但此时得到的结果取决于结构中的字段类型。

    结构指针:指针成员运算符

    结构指针的工作方式与预定义值类型的指针的工作方式完全相同。但是这有一个条件:结构不能包含任何引用类型,这是因为前面介绍的一个限制-----指针不能指向任何引用类型。为了避免这种情况,如果创建一个指针,它指向包含任何引用类型的任何结构,编译器就会标记错误。

    unsafe
    {
        MyStruct myStruct = new MyStruct();
        MyStruct* pStruct = &myStruct;
        
        // 通过指针访问成员值
        (*pStruct).X = 100;
        (*pStruct).F = 10F;
        Console.WriteLine("{0} {1}", myStruct.X, myStruct.F);
    
        // C# 提供指针成员访问运算符,简化 写法
        pStruct -> X = 20;
        pStruct -> F = 12F;
        Console.WriteLine("{0} {1}",myStruct.X,myStruct.F);
    }

    成员运算符,C++ 和 C# 作用是一样的。

    也可以

    MyStruct myStruct = new MyStruct();
    MyStruct* pStruct = &myStruct;
    
    long* pL = &(myStruct.X);
    float* pF = &(pStruct->F);

    类成员指针

    MyClass myClass = new MyClass();
    myClass.L = 100;
    myClass.F = 10f;
    long* pL = &(myClass.L);
    float* pF = &(myClass.F);

    尽管 L 和 F 都是非托管类型,但它们嵌入在第一个对象中,这个对象存储在堆上。在垃圾回收的过程中,垃圾回收器会把MyObject移动到内存的一个新单元上,这样 pL 和 pF 就会指向错误的存储地址。由于存在这个问题,因此编译器不允许以这种方式把托管类型的成员的地址分配给指针。

    解决办法用 fixed 关键字,它会告诉垃圾回收器,可能有引用某些对象的成员的指针,所以这些对象不能移动。

    MyClass myClass = new MyClass();
    myClass.L = 100;
    myClass.F = 10f;
    
    fixed (long* pL = &(myClass.L))
    fixed (float* pF = &(myClass.F))
    {
        
    }

    在关键字 fixed 后面的圆括号中,定义和初始化指针变量。这个指针变量的作用域是花括号标识的 fixed 块。这样, 垃圾回收器就知道,在执行 fixed 块中的代码时,不能移动 myClass 对象。

    MyClass myClass = new MyClass();
    fixed (long* pObject = &(myClass.L))
    {
        
    }
    
    fixed (long* pObject1 = &(myClass.L))
    fixed (float* pObject2 = &(myClass.F))
    {
        
    }
    
    fixed (long* pObject1 = &(myClass.L))
    {
        fixed (float* pObject2 = &(myClass.F))
        {
    
        }
    }
    
    fixed (long* pObject1 = &(myClass.L), pObject2 = &(myClass.L2))
    {
    
    }

    栈的内存块始终占用的字节数总是4的倍数。

    使用指针优化性能

    创建基于栈的数组

    C#很容易支持数组的处理,但也有一个缺点,这些数组都是对象,它们是 Syste.Array 的实例。因此数组存储在堆上,这会增加系统开销。有时,我们希望创建一个使用事件比较短的高性能数组,不希望有引用对象的系统开销。而使指针就可以做到,但指针只对于一维数组比较简单。

    为了创建一个高性能的数组,需要使用另一个关键字 stackalloc。stackalloc 命令指示 .NET 运行库在栈上分配一定量的内存。在调用 stackalloc 命令时,需要为它提供两条信息:

    • 要存储的数据类型
    • 需要存储的数据项数
     // 分配足够的内存,存储10个 decimal
     decimal* pDiDecimals = stackalloc decimal[10];
    
     // 上面命令只分配栈内存。它不会试图把内存初始化为任何默认值,正好符合我们的目的。
     // 因为要创建一个高性能的数组,给它不必要地初始化相应值会降低性能。

    stackalloc 后面紧跟要存储的数据类型名(该数据类型必须是一个值类型),之后把需要的项数放在方括号中。分配的字节数是项数乘以sizeof(数据类型)。在这里,使用方括号表示这个一个数组。如果给20个 double 数分配存储单元,就得到了一个有20个元素的double数组,最简单的数组类型是逐个存储元素的内存块。

    stackalloc 总是返回分配数据类型的指针,它指向新分配内存块的顶部。要使用这个内存块,可以取消对已返回指针的引用。

     // 分配20个double内存
     double* pDoubles = stackalloc double[20];
     // 设置第一个元素 3.0
     *pDoubles = 3.0;
     // 访问第二个元素,直接加 1
     *(pDoubles + 1) = 4.0;
     // 那么就可以用表达式 *(pDoubles + X) 访问数组中下标为 X 的元素,一种访问数组中元素的方式。
     // C# 提供另一种语法 p[X] 它会自动被编译器解释为 *(p + X) 。
    
     for (int i = 0; i < 2; i++)
     {
         Console.WriteLine(*(pDoubles + i));
     }
    
     pDoubles[0] = 5.0;
     pDoubles[1] = 8.4;
    
     for (int i = 0; i < 2; i++)
     {
         Console.WriteLine(pDoubles[i]);
     }

    数组的语法应用于指针并不是新东西,它是C 和 C++ 语言的基础部分。

    高性能的数组可以用与一般C#数组相同的方式访问,但也有不同。

     double[] doublesArray = new double[20];
     // 会出现索引越界错误,但如果使用 stackalloc 就不会
     doublesArray[50] = 3.0;
    
     double* pDoubles = stackalloc double[20];
     pDoubles[50] = 1.0;

    上面代码分配了 20个double类型数的内存,接着把sizeof(double)存储单元的位置加上50*sizeof(double)个存储单元,来保存双精度值。但这个存储单元超出刚才为double数分配的内存区域。谁也不知道这个地址存储了什么数据。最好是只使用某个当前未使用的内存,但所重写的存储单元也有可能是在栈上用于存储其他变量,或者是某个正在执行的方法的返回地址。因此,使用指针获得高性能的同时,也会出现以一些代价,需要确保自己知道在做什么,否则就会抛出非常古怪的运行错误。

    QuickArray示例

    unsafe
    {
        int size = 20;
        long* pArray = stackalloc long[size];
        for (int i = 0; i < size; i++)
        {
            pArray[i] = i * i;
        }
    
        for (int i = 0; i < size; i++)
        {
            Console.WriteLine("{0} {1}", i, pArray[i]);
        }
    }
  • 相关阅读:
    Ubuntu 开机启动是出现 grub rescue 解决办法
    OpenvSwitch Port Mirror in OpenStack Neutron
    Protobuf 在Ubuntu 14上的编译与使用
    Linux screen 常用命令
    OpenStack/devstack with Neutron on Ubuntu 14 (1)
    osprofiler在openstack Cinder里的使用
    基于SSH协议的端口转发
    HA 技术简介
    Socket Receive数据一次性接收不全的问题
    【转】本文对C#虚函数virtual进行详解。
  • 原文地址:https://www.cnblogs.com/z888/p/5886913.html
Copyright © 2020-2023  润新知