C#中指针使用的相关问题

本文探讨 C#中指针使用的若干问题,并编写实例进行测试。

指针是对内存的直接操作,而 C#对开发人员隐藏了大部分基本内存管理,因为它使用了垃圾回收器和引用。

1 C#内存管理简介

首先简要的介绍一下 c#的内存管理机制:

c# GC 管理的是位于托管堆(managed heap)上的托管内存(managed memory)。 C#环境中,引用类型都存储在托管堆上,栈上存放其地址;值类型直接存储在线程栈上。 位于托管堆上的变量都是由 GC 自动处理内存释放的。

2 如何避免内存被垃圾回收?

2.1 为什么会有这个需求

  • 因为要用指针!
    1. 为了提高性能;
    2. 为了调用 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 关键字
{
static public void funInt(int* input, int* output, int len)
{//一个简单的调用函数,将输入的整数序列元素均乘以 10 后返回给输出序列

for (int i=0;i<len;i++)
output[i] = input[i]* 10;//此处的方括号相当于 *(P+i),它是 C#特殊的语法
}

static void Main(string[] args)
{

int* a = stackalloc int[10];//分配内存
int* b = stackalloc int[10];//分配内存
for (int i = 0; i < 10; i++)//初始化输入序列
a[i] = i;
funInt(a, b, 10);//调用指针操作函数
for (int i = 0; i < 10;i++ )
Console.WriteLine(*(b + i));
Console.ReadKey();
}
}

一个错误的写法

即使对 C#指针有了一定的了解,可能也会写出如下错误的代码:

static void Main(string[] args)
{

int[] a = { 0, 1, 2, 3 };
int[] b = { 0, 0, 0, 0 };
unsafe
{
int*[] pa=&a;//会提示语法错误:不可能将指针直接指向托管堆(managed heap)
int*[] pb=&b;//因为托管堆是被垃圾回收管理的,回收过程可能改变内存地址
funInt(pa, pb, 3);
for (int i = 0; i < 3; i++)
Console.WriteLine(*(pb + i));
}
Console.ReadKey();
}

3.2 使用 fixed 在堆中钉(pin)住一块内存

因为钉住的内存在堆上,这属于第二类方法。结合语句给出方法的使用注意事项 (C#中,class, string, 数组等都是引用类型,fixed 让这些实例化对象的地址固定住不动,让 GC 特殊处理这些对象): fixed 的方法将需要固定内存地址的操作都放在它的语块当中,因此不需要显示地 unpinning 被钉住的对象。这使得对象的 unpinning 过程相对可靠和快速,有利于 GC 的执行。

static unsafe void TestFixed()
{

Point point = new Point();
double[] arr = { 0, 1.5, 2.3, 3.4, 4.0, 5.9 };
string str = "Hello World";

// The following two assignments are equivalent. Each assigns the address
// of the first element in array arr to pointer p.

// You can initialize a pointer by using an array.
fixed (double* p = arr) { /*...*/ }

// You can initialize a pointer by using the address of a variable.
fixed (double* p = &arr[0]) { /*...*/ }

// The following assignment initializes p by using a string.
fixed (char* p = str) { /*...*/ }

// The following assignment is not valid, because str[0] is a char,
// which is a value, not a variable.
//fixed (char* p = &str[0]) { /*...*/ }

// You can initialize a pointer by using the address of a variable, such
// as point.x or arr[5].
fixed (int* p1 = &point.x)
{
fixed (double* p2 = &arr[5])
{
// Do something with p1 and p2.
}
}
}

将上面 stackalloc 的例子用 fixed 来写,应该是这样的:

int[] a = {0,1,2,3};
int[] b = {0,0,0,0};
fixed (int* pa = a, pb = b)//C#中的 int* pX, pY; 相当于 C++中的 int *pX, *pY;
{
funInt(pa, pb, 4);
for (int i = 0; i < 4; i++)
Console.WriteLine(*(pb + i));
Console.ReadKey();
}

3.3 GCHandleType.Pinned

这种方法跟 fixed 很像,它们都是在托管堆上钉住部分内存,让 GC 不对其自动回收。 这种方法会影响垃圾回收的效率,必要时使用 Free 及时清除这部分内存。 CLR 支持多种钉住目标的方法,但只有 GCHandleType.Pinned 这种方法是将细节直接暴露给用户的。

int[] a = {0,1,2,3};
int[] b = {0,0,0,0};
GCHandle ha = GCHandle.Alloc(a, GCHandleType.Pinned);
GCHandle hb = GCHandle.Alloc(b, GCHandleType.Pinned);
IntPtr pa = ha.AddrOfPinnedObject();
IntPtr pb = hb.AddrOfPinnedObject();
unsafe {
funInt((int *)pa, (int *)pb, 4);
for (int i = 0; i < 4; i++)
Console.WriteLine(*((int*)pb + i));
}
ha.Free();
hb.Free();
Console.ReadKey()

另外,GCHandleType.Pinned 的方法并不需要 unsafe 关键字,也就是说上面的代码只需要将使用到指针的部分用 unsafe 修饰,而不是整个 main 函数。

3.4 StructLayout 可以模拟 C 中的 union

StructLayout 特性 允许我们控制 Structure 和 class 元素在内存中的排列方式,以及当这些元素被传递给外部 DLL 时,运行库排列这些元素的方式。 当使用的是结构体时,内存在堆栈上;而当使用 class 时,内存在托管堆上。但无论如何最终要变为指针的内存都是托管内存。 Marshal 类提供了托管内存和非托管内存相互转化的方法。

[StructLayout(LayoutKind.Sequential)]
struct Sin
{
public int a;
public int b;
public int c;
}

[StructLayout(LayoutKind.Sequential)]
struct Sout
{
public int a;
public int b;
public int c;
}

static void Main(string[] args)
{

Sin sin = new Sin();
Sout sout = new Sout();
sin.a = 0; sin.b = 1; sin.c = 2;
IntPtr pa = Marshal.AllocHGlobal(Marshal.SizeOf(sin));
IntPtr pb = Marshal.AllocHGlobal(Marshal.SizeOf(sout));
Marshal.StructureToPtr(sin, pa, false);
unsafe
{
funInt((int*)pa, (int*)pb, 3);
for (int i = 0; i < 3; i++)
Console.WriteLine(*((int*)pb + i));
}
Marshal.FreeHGlobal(pa);
Marshal.FreeHGlobal(pb);
Console.ReadKey();
}

从上面的例子中可以看出关键的两个函数是 Marshal.AllocHGlobal 和 Marshal.FreeHGlobal,从函数名就能看出,应该使用了托管堆上的内存。实际上,第一个函数的作用就是通过使用指定的字节数,从进程的非托管内存中分配内存。

Marshal 是一个很强大的类,它提供了一个方法集合,这些方法用于分配非托管内存、复制非托管内存块、将托管类型转换为非托管类型,此外还提供了在与非托管代码交互时使用的其他杂项方法。 鉴于 Marshal 的强大能力,我们其实不需要使用 StructLayout 也能完成上述例子的功能。 StructLayout 更为有用的地方是用于字节数组和结构体/类实例间的相互转化。

再继续往下前,补充说明一点,从 stackalloc 部分给出的完整代码中,可以看到,目前所调用的函数还不是来自第三方 dll。所用的指针其实都是 C#的指针,如果仅是这样的话,并不需要绕这么大个圈,可以这样实现(略去重复代码):

static void Main(string[] args)
{

Sin sin = new Sin();
Sout sout = new Sout();
sin.a = 0; sin.b = 1; sin.c = 2;
unsafe
{
Sin* pa=&sin;
Sout* pb=&sout;
funInt((int*)pa, (int*)pb, 3);
for (int i = 0; i < 3; i++)
Console.WriteLine(*((int*)pb + i));
}
Console.ReadKey();
}

甚至根本就用不到 Structlayout,直接用 C#指针。

3.5 灵活使用 Marshal 类

上面的例子也可以这样实现:

int[] a = { 0, 1, 2, 3 };
int[] b = { 0, 0, 0, 0 };

IntPtr pa = Marshal.AllocHGlobal(4);
IntPtr pb = Marshal.AllocHGlobal(4);
Marshal.Copy(a, 0, pa, 4);
Marshal.Copy(b, 0, pb, 4);
unsafe
{
funInt((int*)pa, (int*)pb, 3);
for (int i = 0; i < 3; i++)
{
Console.WriteLine(*((int*)pb + i));
}
}
Marshal.FreeHGlobal(pa);
Marshal.FreeHGlobal(pb);
Console.ReadKey();

3.6 使用 Dispose 模式管理非托管内存

这需要对 Dispose 模式有一定的了解。但其本质还是比较简单的,不过是加强了上述的方法的实现。 在一些较为规范的代码中,可以看到 Dispose 模式,并不是说非这么用,而是一般都建议用 Dispose 模式来管理非托管内存,因为这样做有以下好处:1. 用户可以显示执行 dispose 来释放内存;2. 即使用户没有执行 dispose 不释放,当对象被 GC 回收时(CLR 做垃圾回收时会调用 的析构函数),也会收回内存;3. 可以使用 using 关键字。

其中,前两条是需要编写类的析构函数和 Dispose 函数来实现的,第三条则是所有实现了 IDispose 接口的类都具有的特性。(即使不继承 IDisposable 类,也可以用前两条)。

      public unsafe class aIntarray : IDisposable
{
public int* Handle;
private bool _disposed = false;

public aIntarray(int size)
{

Handle = (int*) System.Runtime.InteropServices.Marshal.AllocHGlobal(size*4);
}

public void Dispose()
{

if (!_disposed) {
cleanUp();
GC.SuppressFinalize(this);}//用户显式执行垃圾回收
}

protected virtual void cleanUp()
{

if (Handle != null) {
System.Runtime.InteropServices.Marshal.FreeHGlobal((IntPtr)Handle);}
_disposed = true;
}

~aIntarray()
{//但用户没有做垃圾回收时,CLR 会在需要的时候主动执行析构函数
   cleanUp();
  }
}

static void Main(string[] args)
{

int len=5;
unsafe
{
using (aIntarray a = new aIntarray(len), b = new aIntarray(len))
{
int* pa = (int*)a.Handle;
int* pb = (int*)b.Handle;
for (int i = 0; i < len; i++)
*(pa + i) = i;//初始化输入序列
funInt(pa, pb, len);
for (int i = 0; i < len; i++)
Console.WriteLine(*(pb + i));
}
}
Console.ReadKey();
}

Date: <2016-09-26 一 16:20>

Author: ziyuan

Created: 2016-10-20 四 23:04

Emacs 25.1.50.1 (Org mode 8.2.10)

Validate

热评文章