内存和指针
内存管理
与C/C++相比,C#和java这样的语言最大的好处是带有垃圾回收机制,这使得编码人员无序再过多关注内存管理,垃圾回收器可以较好的完成这项工作,不过,了解内存管理机制会帮助设计出性能好、稳定性高的程序。
值数据类型
Windows(现代操作系统)使用一个虚拟寻址系统,该系统把程序可用的内存地址映射到硬件内存中的实际地址上,这些任务完全由Windows在后台管理。其实际结果是32位处理器上的每个进程都可以使用4GB的内存——无论计算机上实际有多少物理内存(在64位处理器上是2^64)。
这个4GB的内存实际上包含了程序的所有部分,包括可执行代码、代码加载的所有DLL,以及程序运行时使用的所有变量的内容。这个4GB的内存称为虚拟地址空间,或虚拟内存,简称为内存。
4GB中的每个存储单元都是从0开始往上排序的。要访问存储在内存的某个空间中的一个值,就需要提供表示该存储单元的数字。在任何复杂的高级语言中,如C#、VB、CH和 Java,编译器负责把人们可以理解的变量名转换为处理器可以理解的内存地址。
在处理器的虚拟内存中,有一个区域称为栈。栈存储不是对象成员的值数据类型。另外,在调用一个方法时,也使用栈存储传递给方法的所有参数的副本。
变量作用域:栈变量只在作用域内生存,超出了作用域就会被释放,作用域内可以嵌套子作用域。
栈指针(操作系统维护的一个变量)表示栈中下一个空闲存储单元的地址。程序第一次开始运行时,栈指针指向为栈保留的内存块末尾。栈是向下填充的,即从高内存地址向低内存地址填充。当数据入栈后,栈指针就会随之调整,以始终指向下一个空闲存储单元。
基本数据类型和struct都属于值类型。
引用数据类型
堆是程序内存空间中的一个区域,被垃圾回收器管理,对象的创建和释放都在此进行。
堆内存是向上分配的。
堆中的数据一直存活到程序结束,或被垃圾回收器回收为止(该数据不被任何变量引用)。
引用变量需要能代表整个内存的地址,一般占用(计算机位数/8)个字节。
垃圾回收(托管资源)
在堆中,对象被回收之后原来的位置就会释放,当很多不连续的对象被回收之后,内存中会留下许多“空洞”,垃圾回收器会做一些操作将剩余的存活对象移动到堆的一端,形成一个连续的内存块,同时更新这些对象的所有引用地址,这个过程叫做压缩。
代码中可以使用System.GC.Collect()强迫垃圾回收器运行,如果有大量对象同时取消引用,就适合用这个方式,但是垃圾回收器也不能保证再一次回收中清除所有未被引用的对象。
第0代:堆的一个区域,所有新创建的对象都在此区域。
第1代:堆的一个区域,在第一次垃圾回收后,仍然存活的对象移动到此区域,此时第0代已清空。
依次类推,第2代是第1代的垃圾回收过程中仍然存活的对象的存放地点。
大对象堆:当对象大小大于85000字节时,对象将会放在此堆上。大对象堆上不执行压缩操作,因为移动它们较慢。
释放非托管资源
垃圾回收器的出现意味着,通常不需要担心不再需要的对象,只要让这些对象的所有引用都超出作用域,并允许垃圾回收器在需要时释放内存即可。但是,垃圾回收器不知道如何释放非托管的资源(例如文件句柄、网络连接和数据库连接)。托管类在封装对非托管资源的直接或间接引用时,需要制定专门的规则,确保非托管的资源在回收类的一个实例时释放。
下面有两种方法释放非托管资源。
析构函数
与构造函数相反,析构函数是在垃圾回收器销毁对象之前执行(时机不确定),它也叫做终结器(finalizer),编译器实际上会重写父类的Finalize()方法,并调用父类的Finalize()方法。
与C++析构函数相比,C#析构函数的问题是它们的不确定性。在销毁C+对象时,其析构函数会立即运行。但由于C#垃圾回收器的工作方式,无法确定C#对象的析构函数何时执行。所以,不能在析构函数中放置需要在某一时刻运行的代码,也不应寄望于析构函数会以特定顺序对不同类的实例调用。如果对象占用了宝贵而重要的资源,应尽快释放这些资源。
另一个问题是C#析构函数的实现会延迟对象最终从内存中删除的时间。没有析构函数的对象会在垃圾回收器的一次处理中从内存中删除,但有析构函数的对象需要两次处理才能销毁:
第一次调用析构函数时,没有删除对象,第二次调用才真正删除对象。另外,运行库使用一个线程来执行所有对象的Finalize()方法。如果频繁使用析构函数,而且使用它们执行长时间的清理任务,对性能的影响就会非常显著。
class TestClass
{
public TestClass(){ }
~TestClass(){ }
}
IDisposable
推荐使用System.IDisposable接口代替析构函数。它声明了一个Dispose()方法,显示的释放由对象直接使用的所有非托管资源。
使用try-finally块确保资源释放。
使用using语句也可以确保资源的正确释放。
class TestClass:IDisposable
{
public TestClass(){ }
#region IDisposable Support
private bool disposedValue = false; // 要检测冗余调用
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// TODO: 释放托管状态(托管对象)。
}
// TODO: 释放未托管的资源(未托管的对象)并在以下内容中替代终结器。
// TODO: 将大型字段设置为 null。
disposedValue = true;
}
}
// TODO: 仅当以上 Dispose(bool disposing) 拥有用于释放未托管资源的代码时才替代终结器。
// ~TestClass()
// {
// // 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
// Dispose(false);
// }
// 添加此代码以正确实现可处置模式。
public void Dispose()
{
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
Dispose(true);
// TODO: 如果在以上内容中替代了终结器,则取消注释以下行。
// GC.SuppressFinalize(this);
}
#endregion
}
static void Main(string[] args)
{
TestClass tc = new TestClass();
try
{
Type t = tc.GetType();
Console.WriteLine(t);
}
finally
{
tc.Dispose();
}
using(TestClass t=new TestClass())
{
Console.WriteLine(t.GetType());
}
Console.ReadKey();
}
不安全代码
指针
这里的指针就是类似C/C++的指针,它指向一块内存地址。
使用unsafe关键字来标识可以使用指针的位置,可以标识方法、方法中的代码块、类/结构、类/结构的成员,但局部变量不可以直接标记unsafe,将它包含在unsafe的代码块中。
编译不安全代码:可以找到项目属性中生成页面,勾选允许不安全代码;或者使用编译器选项csc /unsafe xx.cs。
踩坑点:与C/C++不同,C#语句中的“int*pX, pY;”对应于C/C++语句中的“int *pX, *pY;”。在C#中,*符号与类型相关,而与变量名无关。
var代表变量
&var 表示取地址,把一个值类型转换为指针类型,也叫寻址运算符。
*var 表示 获取地址存放的内容,将指针类型转换为值类型,也叫间接寻址运算符(取消引用运算符)。
指针运算中,&和*是一对相反作用的运算符。
可以把指针声明为任意一种值类型——即任何预定义的基本类型uint、int和 byte等,也可以声明为一个结构,还可以声明为void*。但是不能把指针声明为一个类或数组,这么做会使垃圾回收器出现问题。为了正常工作,垃圾回收器需要知道在堆上创建了什么类的实例,它们在什么地方。但如果代码开始使用指针处理类,就很容易破坏堆中.NET运行库为垃圾回收器维护的与类相关的信息。在这里,垃圾回收器可以访问的任何数据类型称为托管类型,而指针只能声明为非托管类型,因为垃圾回收器不能处理它们。
指针实际上存储了一个表示地址的整数,任何指针中的地址都可以和任何整数类型之间相互转换。指针到整数类型的转换必须是显式指定的,隐式的转换是不允许的。
static void Main(string[] args)
{
unsafe
{
int* pX;
int a = 0;
pX = &a;
Console.WriteLine((long)pX);
byte b = 8;
byte* pB = &b;
//危险操作,不会得到一个有意义的值
double* pD = (double*)pB;
Console.WriteLine(*pD);
}
Console.ReadKey();
}
指针可以进行加减整数运算,假如有一个int型的指针,给它+1后,它指向的地址会移动4个字节,如果是一个long型的指针,+1则会移动8个字节,各种类型的指针能够自适应移动的字节数(除了void指针)。
可以使用+、-、+=、-=、++、--操作(除了void指针),运算符右边必须是long或ulong(能隐式转换也可)。
一般来说,给类型为T的指针加上数值X,若指针的值为P,则得到的结果是P+X*(sizeof(T))。使用这条规则时要小心。如果给定类型的连续值存储在连续的存储单元中,指针加法就允许在存储单元之间移动指针。但如果类型是byte或char,其总字节数不是4的倍数,连续值就不是默认地存储在连续的存储单元中。
int存储在32位处理器中栈的连续空间上,但并不是所有的数据类型都会存储在连续的空间中。原因是32位处理器最擅长于在4个字节的内存块中检索数据,这种计算机上的内存会分解为4个字节的块,在Windows上,每个块也称为DWORD(双字,一个字2个字节)。这是从内存中获取 DWORD 的最高效的方式,跨越 DWORD边界存储数据通常会降低硬件的性能。因此,.NET运行库通常会给某些数据类型填充一些空间,使它们占用的内存是4的倍数。
例如,short数据占用两个字节,但如果把一个 short放在栈中,栈指针仍会向下移动4个字节,而不是两个字节,这样,下一个存储在栈中的变量就仍从DWORD的边界开始存储。
下面定义了int,byte和double类型,各占4,4,8字节。
如果两个指针都指向相同的数据类型,可以把一个指针从另一个指针中减去。此时,结果是一个long,其值是指针值的差被该数据类型所占用的字节数整除的结果。
static void Main(string[] args)
{
unsafe
{
uint u = 3;
byte by = 8;
double dd = 10.0;
uint* pUint = &u;
byte* pByte = &by;
double* pDou = ⅆ
Console.WriteLine("地址操作前");
Console.WriteLine((long)pUint);
Console.WriteLine((long)pByte);
Console.WriteLine((long)pDou);
++pUint;//+4byte
pByte -= 3;//-3byte
pDou += 4;//+32byte
Console.WriteLine("地址操作后");
Console.WriteLine((long)pUint);
Console.WriteLine((long)pByte);
Console.WriteLine((long)pDou);
Console.WriteLine();
Console.WriteLine("指针相减");
double* pD1 = (double*)12336364;
double* pD2 = (double*)12336332;
Console.WriteLine(pD1-pD2);
}
Console.ReadKey();
}
sizeof,可以使用它来确定值类型占用的字节数。
static void Main(string[] args)
{
Console.WriteLine("int 类型占{0}个字节", sizeof(int));
Console.WriteLine("uint 类型占{0}个字节", sizeof(uint));
Console.WriteLine("short 类型占{0}个字节", sizeof(short));
Console.WriteLine("ushort 类型占{0}个字节", sizeof(ushort));
Console.WriteLine("byte 类型占{0}个字节", sizeof(byte));
Console.WriteLine("sbyte 类型占{0}个字节", sizeof(sbyte));
Console.WriteLine("long 类型占{0}个字节", sizeof(long));
Console.WriteLine("ulong 类型占{0}个字节", sizeof(ulong));
Console.WriteLine("double 类型占{0}个字节", sizeof(double));
Console.WriteLine("float 类型占{0}个字节", sizeof(float));
Console.WriteLine("bool 类型占{0}个字节", sizeof(bool));
Console.ReadKey();
}
结构的指针,结构不能包含任何引用类型才可使用指针。
struct MyStruct
{
public long X;
public float F;
public override string ToString()
{
return $"[X:{X},F:{F}]";
}
}
static void Main(string[] args)
{
unsafe
{
MyStruct* myStruct;
MyStruct ms = new MyStruct();
myStruct = &ms;
(*myStruct).X = 5;
(*myStruct).F = 7.9f;
Console.WriteLine(*myStruct);
//指针成员访问运算符
myStruct->X = 4;
myStruct->F = 4.5f;
Console.WriteLine(*myStruct);
}
Console.ReadKey();
}
有些情况下,类中包含很多值类型,我们想给这些值类型使用指针。但是直接使用指针会出现编译错误,因为对象分配在堆上,对象里的值类型也在堆上,垃圾回收器有时会移动对象,这样如果使用了指针,那么指针那时就指向了一个错误地址,解决方法是使用fixed关键字,它告诉垃圾回收器这些对象在垃圾回收时不能移动。
class MyClass
{
public long X;
public float F;
public override string ToString()
{
return $"[X:{X},F:{F}]";
}
}
static void Main(string[] args)
{
MyClass mc = new MyClass();
MyClass mc1 = new MyClass();
unsafe
{
//在执行fixed块中的代码时,不能移动mc和mc1对象
fixed (long* pL = &(mc.X), pL2 = &(mc1.X))
{
//fixed可以嵌套使用
fixed(float* pF = &(mc.F))
{
Console.WriteLine(*pF);
}
}
}
Console.ReadKey();
}
使用指针提高性能
在栈中创建高性能、低系统开销的数组。
一般的数组存储在堆上,这会增加系统开销。有时,我们希望创建一个使用时间比较短的高性能数组,不希望有引用对象的系统开销。而使用指针就可以做到,但指针只对于一维数组比较简单。
为了创建一个高性能的数组,需要使用一个关键字:stackalloc。stackalloc命令指示.NET运行库在栈上分配一定量的内存。在调用stackalloc命令时,需要提供两条信息:
1.要存储的数据类型
2.需要存储的数据个数
下面给出了一个例子:
static void Main(string[] args)
{
unsafe
{
decimal* pMoneys = stackalloc decimal[10];
*pMoneys = 99.0m;
double* pNums = stackalloc double[10];
for (int i = 0; i < 10; i++)
{
*(pNums + i) = i * 2.5d;
}
//编译器会编译为*(pNums + 5)
pNums[5] = 9.0d;
//危险!下面的语句不会检查下标越界,普通数组会创建一个Syste.mArray对象,但是stackalloc分配的数组不会生成这个对象。
pNums[100] = 6.0d;
for (int i = 0; i < 10; i++)
{
Console.Write(*(pNums + i));
Console.Write(" ");
}
}
Console.ReadKey();
}