.NET 内存管理与非安全代码深度解析
1. .NET 中的 GC 类
在 .NET 开发中,开发者可借助 GC 类作为垃圾回收器(Garbage Collector,简称 GC)的接口。利用该类,能够对垃圾回收操作进行操控。不过,通常情况下,垃圾回收在不受开发者干预时能更好地运行。以下是 GC 类的静态成员列表:
| 成员名称 | 描述 |
| — | — |
| MaxGeneration | 获取系统当前支持的最大代数。 |
| AddMemoryPressure | 强制 GC 识别非托管资源的内存分配。 |
| Collect | 对指定代数执行垃圾回收。有两种重载形式:无参数时对所有代数进行回收;带一个参数时指定要回收的最老代数。 |
| CollectionCount | 返回指定代数的垃圾回收周期数。 |
| GetGeneration | 返回指定对象的代数。 |
| GetTotalMemory | 返回当前为托管堆分配的总字节数。布尔参数设为 true 时,该方法在系统执行垃圾回收和对象终结操作时,会等待一小段时间再返回结果。 |
| KeepAlive | 引用指定对象,使其在当前例程开始到调用该方法的位置期间不被垃圾回收。 |
| RemoveMemoryPressure | 移除为非托管资源预留的部分内存压力。 |
| ReRegisterForFinalize | 为复活的对象重新关联终结器。 |
| SuppressFinalize | 取消指定对象的未来终结操作。 |
| WaitForPendingFinalizers | 挂起线程,直到终结队列清空。 |
2. 非安全代码概述
非安全代码能够访问超出公共语言运行时(Common Language Runtime,简称 CLR)控制范围的非托管内存,而安全代码只能访问由 CLR 在 GC 支持下管理的托管堆。使用托管堆的代码本质上更安全,因为 CLR 会自动释放未使用的对象,进行类型检查和其他内存管理操作,让开发者能更专注于应用程序开发,间接提高用户的生产力和满意度。
在非安全代码中,会使用指向非托管内存的指针。这些指针与非托管内存一样,不受 CLR 管理,指向非托管内存中的固定位置,而引用类型指向托管内存中的可移动位置。CLR 管理引用类型,开发者无需手动释放引用类型分配的内存。在 C 和 C++ 应用开发中,开发者需特别关注内存管理操作,但不当的指针管理常导致非托管环境中的非安全代码问题,如内存泄漏、无效内存访问、指针删除错误和边界错误等。将指针管理和操作封装在引用类型中,使托管代码更加安全。不过,在特定情况下,仍有必要编写非安全代码,以下是一些适用场景:
- 非托管代码通常严重依赖指针,在向 C# 代码迁移时,非安全代码是一种可选方案。
- 实现某些软件算法时,根据设计需要使用指针,可能需要非安全代码。
- 调用非托管函数时,可能需要函数指针。
- 处理二进制内存中的数据结构时,指针可能是必要的。
- 在某些情况下,非托管指针可提高代码的性能和效率。
3. 非安全代码的调用与互操作性
有时开发者需要在托管应用中调用非托管代码。尽管 .NET Framework 的类库(FCL)几乎涵盖了开发 .NET 应用所需的所有代码,但仍可能需要调用操作系统库中的函数(API)以实现 FCL 未提供的功能。此外,一些专有软件或商业软件可能没有托管代码版本。同样,也可以在非托管模块中调用托管代码,如回调。
平台调用服务(PInvoke)在托管执行和非托管执行之间搭建了双向桥梁。在托管代码中,PInvoke 负责定位、加载和执行非托管模块中的函数。大多数跨平台调用由互操作性封送器进行封送处理,它负责在非托管和托管环境的可识别格式之间转换参数和返回类型。幸运的是,并非所有类型都需要转换,避免了不必要的开销。
还可以在托管代码和包含非托管代码的 COM 组件之间建立桥梁。运行时可调用包装器(Runtime Callable Wrapper,简称 RCW)简化了在托管代码中调用 COM 组件的过程,COM 可调用包装器(COM Callable Wrapper,简称 CCW)使托管组件看起来像 COM 组件,从而间接让 COM 客户端可以访问托管组件。COM 组件也可通过 PInvoke 访问,但 CCW 和 RCW 更实用,在大多数场景中是推荐的解决方案。
4. 非安全代码的限制与编译选项
非安全代码无法获得批准,因为它不受代码访问安全的约束,不进行类型检查以防止缓冲区溢出攻击等操作,代码可靠性难以确定。因此,在托管代码中调用非安全代码需要非常高的权限。包含非安全代码的托管应用必须使用 /unsafe 选项进行编译。在 Microsoft Visual Studio 2005 中,可通过以下步骤设置:
1. 从解决方案资源管理器中打开项目的上下文菜单,选择“属性”。
2. 在“生成”选项卡中,勾选“允许使用不安全代码”选项。
5. “unsafe”关键字的使用
“unsafe”关键字用于界定非安全代码的上下文,避免意外插入非安全代码。处于该上下文中的代码可以声明和使用非托管指针。该关键字可应用于类、结构体、接口或委托,当应用于某个类型时,该类型的所有成员都被视为非安全的,也可应用于类型的特定成员。例如:
public struct ZStruct {
public unsafe int *fielda;
public unsafe double *fieldb;
}
public unsafe struct ZStruct {
public int *fielda;
public double *fieldb;
}
也可以通过“unsafe”语句创建非安全代码块,块内的所有代码都将在非安全上下文中执行。示例如下:
public static void Main(){
int number = 296;
byte b;
unsafe {
b = MethodA(&number);
}
Console.WriteLine(b);
}
public unsafe static byte MethodA(int *pI) {
byte *temp = (byte*) pI;
return *temp;
}
基类的非安全特性不会被派生类继承,除非派生类显式指定为“unsafe”,否则它是安全的。在非安全上下文中,派生类可以使用基类中可访问的非安全成员。例如:
public unsafe class ZClass {
protected int *fielda;
}
public class YClass: ZClass {
protected int *fieldb; // 编译错误
}
要解决上述编译错误,需在
fieldb
前显式添加“unsafe”关键字。
6. 指针的使用
非安全代码主要涉及直接访问指针,指针指向内存中的固定位置,可用于传统的指针操作,如间接寻址、指针计算等。指针不受 GC 控制,必要时开发者需自行管理其生命周期。
C# 不会自动暴露指针,需要在非安全上下文中才能使用。在该语言中,指针通常通过引用进行操作,引用将指针转换为托管堆内存中的引用,该引用和关联的内存由 GC 管理且可能会移动,因此引用不能直接用于操作指针。
指针的声明语法如下:
typenonmanagé* identificateur;
typenonmanagé* identificateur = initialiseur;
可以在同一语句中声明多个指针,用分号分隔。注意,其语法与 C 或 C++ 略有不同,例如:
int *pA, pB, pC; // C++: int *pA, *pB, *pC;
非托管类型(托管类型的子集)包括 sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool 和 enum 等,像 string 等某些托管类型不在此列。可以为用户定义的结构体创建指针,前提是结构体的所有字段都是非托管类型。指针类型不继承自
System.Object
,因此不能与
System.Object
进行转换。
空指针(void 指针)是允许的,但非常危险。它是无类型指针,可模拟任何其他类型的指针,任何类型的指针都可隐式转换为空指针,这种不可预测性使其极不安全。除空指针外,指针在类型层面有不同程度的安全性。虽然不同类型的指针之间不能进行隐式转换,但大多数情况下允许显式转换。例如:
int val = 5;
float* pA = &val; // 编译错误
可以使用值的地址或另一个指针来初始化指针,示例如下:
public unsafe static void Main() {
int ival = 5;
int *p1 = &ival; // 间接寻址
int *p2 = p1; // 指针赋值
}
以下是指针使用中各符号的含义:
| 符号 | 描述 |
| — | — |
| 指针声明 (
) | 用于声明新的指针变量,如
int *pA;
|
| 间接寻址 (
) | 对指针进行间接寻址操作,返回指针所指内存地址存储的值。例如:
int val = 5; int *pA = &val; Console.WriteLine(*pA);
会输出 5。但不能对空指针进行间接寻址。 |
| 取地址 (&) | 返回变量的内存地址,用于初始化指针,如
int *pA = &val;
|
| 成员访问 (->) | 对位于内存位置的类型成员进行间接寻址。例如,对于结构体
ZStruct
和其成员
fielda
,可使用
ZStruct obj = new ZStruct(5); ZStruct *pObj = &obj; int val1 = pObj->fielda;
访问成员,也可用
int val2 = (*pObj).fielda;
实现相同功能。 |
| 指针元素 ([]) | 表示从指针内存地址开始的偏移量。例如,
p[2]
表示偏移量为 2,偏移量根据指针类型的大小递增。若
p
是
int
类型指针,
p[2]
表示偏移 8 个字节。假设
ZStruct
包含两个连续的
int
字段
fielda
和
fieldb
,可通过
ZStruct obj = new ZStruct(5); int *pA = &obj.fielda; Console.WriteLine(pA[1]);
访问
fieldb
。 |
| 指针的指针 (**) | 指向包含另一个指针地址的内存位置。可以使用双星号
**
对其进行间接寻址,也可分多次使用单星号进行操作。例如:
int val = 5;
int *pA = &val;
int **ppA = &pA;
Console.WriteLine((int)ppA); // 输出 ppA 存储的地址(即 pA 的地址)
Console.WriteLine((int)*ppA); // 输出 pA 存储的地址
Console.WriteLine((int)**ppA); // 输出 pA 所指地址存储的值(即 5)
``` |
综上所述,在 .NET 开发中,合理运用 GC 类和谨慎使用非安全代码及指针,能帮助开发者更好地管理内存和实现特定功能,但同时也需要充分了解其潜在风险并采取相应的防范措施。
#### 7. 指针操作示例与流程分析
为了更清晰地理解指针的操作,下面通过一个更复杂的示例来详细说明。假设我们要实现一个简单的数组反转功能,使用指针来完成这个任务。以下是具体的代码实现:
```csharp
using System;
class Program
{
public unsafe static void ReverseArray(int* arr, int length)
{
int* left = arr;
int* right = arr + length - 1;
while (left < right)
{
int temp = *left;
*left = *right;
*right = temp;
left++;
right--;
}
}
public unsafe static void Main()
{
int[] array = { 1, 2, 3, 4, 5 };
fixed (int* arrPtr = array)
{
ReverseArray(arrPtr, array.Length);
}
foreach (int num in array)
{
Console.Write(num + " ");
}
Console.WriteLine();
}
}
下面是这个示例的流程分析:
1.
定义反转函数
:
ReverseArray
函数接受一个
int
类型的指针
arr
和数组的长度
length
作为参数。
- 初始化两个指针
left
和
right
,分别指向数组的起始位置和末尾位置。
- 使用
while
循环,只要
left
指针小于
right
指针,就交换它们所指向的值。
- 每次交换后,
left
指针向后移动一位,
right
指针向前移动一位。
2.
主函数中调用
:
- 创建一个整数数组
array
。
- 使用
fixed
语句固定数组,获取数组的指针
arrPtr
。
- 调用
ReverseArray
函数,传入数组指针和数组长度。
3.
输出结果
:使用
foreach
循环遍历反转后的数组,并输出每个元素。
这个示例展示了如何使用指针进行数组操作,同时也体现了指针在处理内存时的灵活性。
8. 非安全代码的性能考量
在某些情况下,非安全代码可以显著提高性能,但这并不意味着可以随意使用。下面是使用非安全代码提高性能的一些场景和注意事项:
场景
:
-
内存密集型操作
:当需要处理大量数据时,使用非安全代码可以减少内存分配和垃圾回收的开销。例如,在图像处理、数据压缩等领域,直接操作内存可以提高处理速度。
-
底层系统交互
:与操作系统或硬件进行交互时,非安全代码可以直接访问底层资源,避免了托管代码的中间层开销。
注意事项
:
-
代码复杂度
:非安全代码通常比安全代码更复杂,容易引入错误。在编写非安全代码时,需要仔细考虑边界条件和内存管理,以避免出现内存泄漏、访问越界等问题。
-
可维护性
:非安全代码的可读性和可维护性较差,对于团队开发来说,可能会增加开发和维护的难度。因此,在使用非安全代码时,需要权衡性能提升和可维护性之间的关系。
9. 非安全代码的调试与测试
由于非安全代码的特殊性,调试和测试非安全代码需要额外的注意。以下是一些调试和测试非安全代码的建议:
调试
:
-
使用调试工具
:利用 Visual Studio 等开发工具的调试功能,设置断点、查看变量值和内存状态。在调试非安全代码时,需要特别注意指针的指向和内存的使用情况。
-
检查边界条件
:非安全代码容易出现访问越界等问题,因此在调试时需要仔细检查边界条件,确保指针不会超出合法范围。
测试
:
-
单元测试
:编写单元测试用例,对非安全代码的各个功能进行测试。在测试时,需要考虑各种可能的输入和边界情况,确保代码的正确性。
-
压力测试
:进行压力测试,模拟大量数据和高并发场景,检查非安全代码在极端情况下的性能和稳定性。
10. 总结
非安全代码在 .NET 开发中是一把双刃剑,它可以提供对非托管内存的直接访问,从而实现一些特定的功能和提高性能,但同时也带来了安全和维护方面的挑战。在使用非安全代码时,需要遵循以下原则:
- 谨慎使用 :非安全代码应该作为最后的手段,只有在确实需要时才使用。在大多数情况下,尽量使用安全代码来完成开发任务。
- 充分测试 :在使用非安全代码之前,需要进行充分的测试和调试,确保代码的正确性和稳定性。
- 遵循规范 :编写非安全代码时,需要遵循相关的规范和最佳实践,避免出现常见的错误和安全隐患。
通过合理运用非安全代码和指针,开发者可以在保证代码安全和可维护性的前提下,实现更高效的内存管理和更强大的功能。同时,不断学习和掌握非安全代码的使用技巧,也有助于提升开发者的技术水平和解决问题的能力。
以下是一个总结非安全代码使用要点的表格:
| 要点 | 说明 |
| — | — |
| 使用场景 | 非托管代码迁移、特定算法实现、底层系统交互等 |
| 性能考量 | 提高内存密集型操作和底层交互性能,但增加代码复杂度和降低可维护性 |
| 调试测试 | 使用调试工具,检查边界条件;进行单元测试和压力测试 |
| 使用原则 | 谨慎使用,充分测试,遵循规范 |
通过以上内容,我们对 .NET 中的内存管理、非安全代码和指针的使用有了更深入的了解。在实际开发中,需要根据具体需求和场景,合理选择使用安全代码和非安全代码,以达到最佳的开发效果。
超级会员免费看
1万+

被折叠的 条评论
为什么被折叠?



