Table of Contents
本文探讨 C#中指针使用的若干问题,并编写实例进行测试。
指针是对内存的直接操作,而 C#对开发人员隐藏了大部分基本内存管理,因为它使用了垃圾回收器和引用。
1 C#内存管理简介
首先简要的介绍一下 c#的内存管理机制:
c# GC 管理的是位于托管堆(managed heap)上的托管内存(managed memory)。 C#环境中,引用类型都存储在托管堆上,栈上存放其地址;值类型直接存储在线程栈上。 位于托管堆上的变量都是由 GC 自动处理内存释放的。
2 如何避免内存被垃圾回收?
2.1 为什么会有这个需求
- 因为要用指针!
- 为了提高性能;
- 为了调用 C 语言 dll 等其他库;
这些情况下,需要使用指针。
- 因为 C#语言设计机制
我们知道,指针是“指向”另一个对象的复合数据类型,它存放了另一个对象的内存地址。在 C#中使用指针时,指针不能指向托管堆上的内存,也就是说不能将位于托管堆上的变量地址赋值给指针。那么什么样的变量会被 C#放置在托管堆上呢?答案是所有的引用型变量。
- 对于引用型变量 GC 会改变变量的内存地址(垃圾回收过程中的托管堆压缩操作),而且 GC 执行时机难确定,如果使用指针,其存储的地址就不再是原来的对象了。因此,如果需要使用指针,如\*int data,这个变量就必须脱离 GC 的管控,也就是需要用户来处理变量内存的 申请和释放 。
- 对于值类型变量 GC 不对其内存进行管理,也不需要用户释放,它会在线程退出时自动被释放(因为使用的是栈)。因此对值类型变量使用指针比较容易(指针也随着代码块的终止而释放)。
- 关于引用类型的补充
GC 针对引用类型的变量,对其内存进行回收,但并不是说只要一个变量是引用型的,GC 就能正确地释放它所占用的资源了。这是因为,对象可能会持有一些特殊的资源(其实是非托管资源),比如磁盘文件、TCP 连接、通信端口、数据库连接等等,GC 这些对象时,它们只是被简单的覆盖掉,而资源却没有释放。有两种方法来正确释放这些非托管资源:
- 声明一个析构函数
- 在类中实现 System.IDisposable 接口
2.2 方法论
根据 C#内存管理方式,自然能想到这样两类方法:第一类,不将变量放在托管堆上;第二类,变量仍位于托管堆上,但不让 GC 自动回收它。
3 C#内存使用总结
c# 中提供了一些接口,完成托管和非托管之间的转换,以及对这部分内存的操作。基本上有以下几种:
3.1 使用 stackalloc 在栈中分配内存
这显然属于第一类方法,该方法使用应注意:
- 由于涉及指针类型,stackalloc 要求 unsafe 关键字
- stackalloc 仅在局部变量的初始值设定中有效
- 内存块的生存期受定义它的方法的生存期的限制(没有在方法返回之前释放内存的途径)
- stackalloc 创建的是位于栈上的高性能数组,性能高的主要原因是它避免了 GC 操作的性能开销
- 不会对数组进行边界检查,访问位置如果超出边界,也不会报错
下面的代码给出了一个简单的例子:
unsafe class Program//使用指针需要用 unsafe 关键字 |
一个错误的写法
即使对 C#指针有了一定的了解,可能也会写出如下错误的代码:
static void Main(string[] args) |
3.2 使用 fixed 在堆中钉(pin)住一块内存
因为钉住的内存在堆上,这属于第二类方法。结合语句给出方法的使用注意事项 (C#中,class, string, 数组等都是引用类型,fixed 让这些实例化对象的地址固定住不动,让 GC 特殊处理这些对象): fixed 的方法将需要固定内存地址的操作都放在它的语块当中,因此不需要显示地 unpinning 被钉住的对象。这使得对象的 unpinning 过程相对可靠和快速,有利于 GC 的执行。
static unsafe void TestFixed() |
将上面 stackalloc 的例子用 fixed 来写,应该是这样的:
int[] a = {0,1,2,3}; |
3.3 GCHandleType.Pinned
这种方法跟 fixed 很像,它们都是在托管堆上钉住部分内存,让 GC 不对其自动回收。 这种方法会影响垃圾回收的效率,必要时使用 Free 及时清除这部分内存。 CLR 支持多种钉住目标的方法,但只有 GCHandleType.Pinned 这种方法是将细节直接暴露给用户的。
int[] a = {0,1,2,3}; |
另外,GCHandleType.Pinned 的方法并不需要 unsafe 关键字,也就是说上面的代码只需要将使用到指针的部分用 unsafe 修饰,而不是整个 main 函数。
3.4 StructLayout 可以模拟 C 中的 union
StructLayout 特性 允许我们控制 Structure 和 class 元素在内存中的排列方式,以及当这些元素被传递给外部 DLL 时,运行库排列这些元素的方式。 当使用的是结构体时,内存在堆栈上;而当使用 class 时,内存在托管堆上。但无论如何最终要变为指针的内存都是托管内存。 Marshal 类提供了托管内存和非托管内存相互转化的方法。
[StructLayout(LayoutKind.Sequential)] |
从上面的例子中可以看出关键的两个函数是 Marshal.AllocHGlobal 和 Marshal.FreeHGlobal,从函数名就能看出,应该使用了托管堆上的内存。实际上,第一个函数的作用就是通过使用指定的字节数,从进程的非托管内存中分配内存。
Marshal 是一个很强大的类,它提供了一个方法集合,这些方法用于分配非托管内存、复制非托管内存块、将托管类型转换为非托管类型,此外还提供了在与非托管代码交互时使用的其他杂项方法。 鉴于 Marshal 的强大能力,我们其实不需要使用 StructLayout 也能完成上述例子的功能。 StructLayout 更为有用的地方是用于字节数组和结构体/类实例间的相互转化。
再继续往下前,补充说明一点,从 stackalloc 部分给出的完整代码中,可以看到,目前所调用的函数还不是来自第三方 dll。所用的指针其实都是 C#的指针,如果仅是这样的话,并不需要绕这么大个圈,可以这样实现(略去重复代码):
static void Main(string[] args) |
甚至根本就用不到 Structlayout,直接用 C#指针。
3.5 灵活使用 Marshal 类
上面的例子也可以这样实现:
int[] a = { 0, 1, 2, 3 }; |
3.6 使用 Dispose 模式管理非托管内存
这需要对 Dispose 模式有一定的了解。但其本质还是比较简单的,不过是加强了上述的方法的实现。 在一些较为规范的代码中,可以看到 Dispose 模式,并不是说非这么用,而是一般都建议用 Dispose 模式来管理非托管内存,因为这样做有以下好处:1. 用户可以显示执行 dispose 来释放内存;2. 即使用户没有执行 dispose 不释放,当对象被 GC 回收时(CLR 做垃圾回收时会调用 类 的析构函数),也会收回内存;3. 可以使用 using 关键字。
其中,前两条是需要编写类的析构函数和 Dispose 函数来实现的,第三条则是所有实现了 IDispose 接口的类都具有的特性。(即使不继承 IDisposable 类,也可以用前两条)。
public unsafe class aIntarray : IDisposable |
4 参考链接
Date:
Created: 2016-10-20 四 23:04