GC 基本运作方式
托管进程存在两种内存堆(本机堆和托管堆),本机内存堆(Hative Heap)是由VirtualAlloc 这个Windows Api 分配,由操作系统和CLR使用,用户非托管代码所需要的内存。CLR在托管堆上为所有.NET对象分配内存,也被称为GC堆,因为其中的所有对象均会受到垃圾回收机制的控制。
托管堆也分为小对象堆和大对象堆(LOH),两者各拥有自己的内存段(segment)。内存段大小根据硬件环境决定,并且都可以拥有多个内存段。
小对象的内存段分为(0-2)三代,且0和1代总是位于同一段内存中,2代则可能跨越多段内存,LOH也可以跨越多段内存,因此0和1代堆的内存段被称为暂时段。
一开始内存堆如下图所示,分别标记为A和B,内存地址从左到右由小变大。小对象堆由A段内存构成,LOH拥有B段内存,第2代和第1代堆只占有开头的一点内存,因为它们还都是空的。
如果对象小于85000字节,CLR会把它分配在小对象堆中的第0代,通常紧挨着当前已用内存空间往后分配。因此.NET内存分配过程非常迅速。如果快速分配失败,对象就可能第0代的任意位置,只要能容纳的下就行。如果扩大内存堆时超越了内存段的边界,则会触发垃圾回收过程。
对象总是诞生于第0代内存堆,只要对象保持存活,每当发生垃圾回收时,GC都会把它提升一代。第0代和第1代内存堆的垃圾回收通常称为瞬时回收。
当发生垃圾回收时,可能会进行碎片整理,也就是GC把对象物理迁移到一个新的位置中去,以便内存段中的空闲内存能够连续起来以备使用。如果未发生碎片整理,那就只需要重新调整各个内存的边界即可。内存就会变成
位置没有变过,但各代内存堆的边界已经发生了变化。
每一代内存堆都有可能发生碎片整理。因为GC必修修正所有对象的引用,使他们指向新的位置,所以碎片整理的开销相对较大,还有可能需要暂停所有托管线程。所以垃圾回收只有在划算时才会进行碎片整理,判断依据是一些内部指标。
如果对象达到了对2代内存堆,他就会一直留在那里知道终结。这并不意味着第2代内存堆会一直变大。如果第2代内存堆中的对象都总结了,整个内存段也没有存活的对象了,垃圾回收器会把整个内存段交还给操作系统,或者作为其他几代内存的附加段,在进行完全垃圾回收时,就可能发生第2代内存堆的回收。
那么“存活”是指什么?如果GC能够通过任一已知的GC根对象,沿着层层引用访问到某个对象,那他就是存活的。GC根对象可以是程序中的静态变量,或者某个线程的堆正被正在运行的方法张勇(或者局部变量)或是终结器队列。如果对象没有受到GC根对象的引用,但是它属于第2代内存堆中,那么只有完全垃圾回收才会被清理。
如果第0代占满一个内存段,而且垃圾回收也无法通过整理获取足够的内存,那么Gc会分配一个新的内存段。新的内存段用于容纳第1代和0代堆,老的内存段会变成2代内存堆。老的0代则变为1代,1代变2代,这样则不需要对数据进行复制
此时内存如下:
如果第2代继续变大,就可能会跨越多个内存段。LOH同样可以跨越多个内存段。无论存在多少个内存段,第0代和第1代总是位于同一个内存段中。如果想找出内存中有哪些对象是存活的,这个应该可以用上。
LOH堆则遵从另外一套回收规则。大于85000 字节的对象将自动在LOH中分配内存,且没有什么代的模式。超过这个尺寸的一般是数组和字符串了。处于性能考虑,在垃圾回收期间LOH不会自动进行碎片整理,但是必要时可以人为发起碎片整理(varsion >=4.5.1)。和2代一样如果内存不再有用,就有可能会被用于其他内存堆,不过理想状态下根本不愿意LOH的内存被回收掉。
在LOH中,垃圾回收器用一张空闲内存列表来确定对象的存放位置。
如果在调试器中看到LOH中的对象,可能有的LOH都小于85000字节,而且可能还有对象的大小是小于已分配值的,这些对象通常都是CLR分配出去的,可以不予理睬。
垃圾回收是针对某一代及其以下几代内存堆进行的。如果回收了第1代,则同时会回收第0代。如果回收了第2代,则所有内存都会回收,包括LOH。如果发生第0代和第1代的内存回收,则回收期间程序会暂停。对于第0代垃圾回收而言,有部分回收是在后台线程中进行的,这主要根据配置参数而定。
垃圾回收分为4个阶段。
1. 挂起-在垃圾回收发生前,所有托管线程都被强行终止。
2. 标记-从GC根对象开始,垃圾回收会沿着所有对象引用进行遍历并把所有对象记录下来。
3. 碎片整理-将对象重新紧挨着存放并更新所有引用,以便减少内存碎片。在小对象堆中,碎片整理会按需进行,无法控制。在LOH中,碎片整理不会自动进行,但你可以在必要时通知垃圾回收器来上一次。
4. 恢复-托管线程恢复运行
在标记阶段并不需要遍历内存堆中的所有对象,只要访问那些需要回收的部分即可。0代只访问0代,1代则只访问1和0代。2代则是完全回收,会遍历内存中所有存活的对象,这个过程的开销非常大。高代内存中的对象可能会是低代内存堆的根对象,这样会遍历一部分高代对象,但是开销还是小于高代内存的垃圾完全回收。
总结
-
垃圾回收过程的耗时完全取决于所设计的“代”内存堆中的对象数量,而不是分配的对象数量。即使你分配了1棵包含100w个对象的树,只要在下一次垃圾回收之前把根对象的引用解除,这100w个对象就不会增加垃圾回收的耗时。
-
垃圾回收的频率取决于所涉及的“代”内存堆中已被占用的内存的大小。分配的内存超过阈值,就会发生该代的垃圾回收。这个阈值时会持续变化的,GC会根据进程的执行情况进行调整。如果某“代”回收只够划算(提升了很多对象所处的”代“)那垃圾回收就会比较频繁。当所有可用内存(计算机内存)少于某个阈值,为了减少内存堆的大小,也会进行频繁回收。
垃圾回收的配置暂时不说
首要规则
垃圾回收器,存在一条高性能编码规则(垃圾回收就是按照这条规则设计的):
只对第0代内存堆中的对象进行垃圾回收
对象的生存期应该尽可能短暂,这样垃圾回收就不会区触及它们。如果做不到转瞬即逝,那就让对象尽快提升到2代内存堆并永远留在那里,再也不会被回收。
这就是意味着需要一直保持一个对长久存活对象的引用,通常这也意味着要把可重用的对象进行池化(polling),特别时LOH中的所有对象。
内存堆的代数越高,垃圾回收成本越高。应该把内存堆分为0代和2代来处理,第1代可以是说时第2代的一种缓存区。
缩短对象的生命周期
对象的作用域越小,在垃圾回收时就越没有被提升到下一代。一般来说对象在使用前不应该被分配内存。除非创建对象的开销太大,需要提前创建才不至于影响到其他操作的执行。
另外在使用对象时,应确保对象尽快的离开作用域。对于局部变量而言,可能时最后一次局部使用之后,甚至可以在方法结束之前。你可以用成堆“{}”在语法上缩小作用域,但很可没有什么实际效果,所以尽量缩短使用间隔。
如果一个对的引用时一个长时间存活的对象成员,有时你得把这个引用显示的设置为null。
减少对象树的深度
因为GC会沿着对象进行引用。
减少对象间的引用
比如有个第2代的内存堆中有个对象包含了对第0代内存堆对像的引用,这样回收0代时,总有一部分第2代内存堆不得不遍历到。
避免固定对象
对象固定是为了能够安全地将通过内存的引用传递给本机代码。最常见的用处就是传递数组和字符串。如果不与本机代码进行交互,就完全不应该有对象固定的需求。虽然开销不大,但是会导致容易出现内存碎片。垃圾回收器会记录固定对象,以便能够利用空闲内存,如果固定对象太多,还是会产生内存碎片和导致内存扩大。
对象固定可是显示的也可以是隐式的。使用GCHandleType.Pinned 类型的GCHandle 或者fixed 关键字来实现显示对象固定,代码块必需标记为unsafe。关键字fixed和GCHandle之间的关系类似于using和Dispose的差别。fixed/using用起来方便,但是无法在异步环境下使用,因为异步环境不能够传递handle,也不能在回调方法中销毁handle。
隐式的对象固定更为普遍,但是更难以发现,消除则更困难。最明显来源就是通过P/Invoke 传递给非托管代码的所有对象。这种P/Invoke 并不仅仅是由你编写的代码发起的,你调用的托管API 可以而且经常会调用本机代码,也需要对象固定。
CLR的内部数据结构中也会由谢被固定的对象,但这些对象通常不必理会。
所以尽量消除对象固定。如果做不到则尽可能缩小固定对象的生命周期。如果对象只是暂时被固定,拿影响下一次垃圾回收的机会就比较少。位于第2代和LOH中的固定对象影响比较小,因为移动的可能性很小。
那么可以LOH中分配大块缓冲区然后按需切分,或者在小对象堆中分配小块缓冲区并保证对象在被固定前提升到第2代,进行优化。
避免使用终结方法
若非必要,永远不要实现终结方法(Finalizer)。终结方法是一段由垃圾回收期引发调用的代码,用于清理非托管资源。终结方法由一个独立的线程调用,排成队列依次完成,而且只有在一次垃圾回收之后,对象被垃圾回收器声明为已销毁,才会进行调用。所以,如果实现了终结方法,对象就一定会滞留在内存中,即便是在垃圾回收时应该被销毁的情况下。终结方法不仅会降低垃圾回收的整理效率,而且清理对象的过程肯定会占用CPU资源。
如果实现了终结方法,那一定要实现IDisposable 接口以启用显示清理,而且还要再Dispose方法中调用GC.SuppressFinalize(this) 来把对象从移除终结队列中移除。只要能在下一次垃圾回收之前调用Dispose,那就能适时把对象清理干净,也就不需要运行终结方法了。
避免分配大对象
大对象的界限为8500个字节,如何大于这个对象的值都被认为时大对象,并在独立的内存中进行分配。避免使用大对象的原因是因为大对象的回收开销更大,而且内存碎片会导致内存用量不断变大。
LOH内存堆不会自己进行碎片整理,只能自己发起,但是期间会有很长一段时间的系统暂停。所以应该保证LOH中的对象在整个程序的生存期都持续可用,并以池化方式随时待命。
避免缓冲区复制
任何时候都应该避免复制数据,比如把文件数据读入MemoryStream,一但分配完成,就应该视为只读六,所有需要访问的数据都应该从同一个文件读取流。如果只需要读取缓冲区的一段,请使用ArraySegment
var f1= BitConverter.GetBytes(22);
var f2= BitConverter.GetBytes(56);
MemoryStream ms = new MemoryStream( );
ms.Write(f2, 0, f2.Length);
ms.Write(f1, 0, f1.Length);
var segment = new ArraySegment<byte>(ms.GetBuffer(), 4, 8);
int f3= BitConverter.ToInt32(segment.Array);
内存复制造成的最大影响肯定不是cpu,而是垃圾回收。如果你发现自己有复制缓冲区的需求,那就尽量把数据复制到另一个池化的或已经存在的缓存区中,以免发生新的内存分配。
对长期存活对象和大型对象进行池化
基本规则就是对象要么转瞬即逝要么一直存活,要么在第0代垃圾回收是消失,要么就是第2代一直存活下去。有些对象是静态的伴随程序一直存活下去,但是却可能存活下去一段时间,一直升级。这个时候进行池化收效甚佳。另一种强烈推荐池化的对象,就是在LOH中分配的对象,典型就是集合对象。
池化方法没有一定之规,也没有标准的API可用,确实只能自己开发,可以针对整个应用,也可以为特定的池化对象服务。
public interface IPoolableObject : IDisposable
{
int Size { get; }
void Reset();
void SetPoolManager(PoolManager poolManager);
}
public class PoolManager
{
class Pool
{
public int PooledSize { get; set; }
public int Count { get { return this.Stack.Count; } }
public Stack<IPoolableObject> Stack { get; private set; }
public Pool()
{
this.Stack = new Stack<IPoolableObject>();
}
}
public const int MaxSizePerType = 10 * (1 << 10);//10M
Dictionary<Type, Pool> _pools = new Dictionary<Type, Pool>();
public int TotalCount
{
get
{
int sum = 0;
foreach(var pool in this._pools.Values)
{
sum += pool.Count;
}
return sum;
}
}
public T GetObject<T>() where T : class, IPoolableObject, new()
{
Pool pool;
T valueToReturn = null;
if(_pools.TryGetValue(typeof(T),out pool))
{
if (pool.Stack.Count > 0)
{
valueToReturn = pool.Stack.Pop() as T;
}
}
if (valueToReturn == null)
{
valueToReturn = new T();
}
valueToReturn.SetPoolManager(this);
return valueToReturn;
}
public void ReturnObject<T>(T value) where T:class, IPoolableObject, new()
{
Pool pool;
if(!_pools.TryGetValue(typeof(T),out pool))
{
pool = new Pool();
_pools[typeof(T)] = pool;
}
if (value.Size + pool.PooledSize < MaxSizePerType)
{
pool.PooledSize += value.Size;
value.Reset();
pool.Stack.Push(value);
}
}
}
public class MyObject : IPoolableObject
{
private PoolManager _poolManager;
public byte[] Data { get; set; }
public int UsableLength { get; set; }
public int Size
{
get { return Data != null ? Data.Length : 0; }
}
public void Dispose()
{
this._poolManager.ReturnObject(this);
}
public void Reset()
{
UsableLength = 0;
}
public void SetPoolManager(PoolManager poolManager)
{
this._poolManager = poolManager;
}
}
被池化的对象必须要实现自定义接口,除了麻烦之外,为了实现池化和对象重用,每次用完都必须把对象重置为已知的,安全的状态。
回收起来也比较麻烦,因为并不是真要销毁对象,如果用户没有及时回收,则可以为可池化对象实现终结方法,以作为保险机制,如果运行了总结方法说明Dispose没有被调用过,存在错误,则可以写入日志。
必需尽量确保池中对象永远不会销毁,同时也必须确保池的大小太过庞大,只要超过了规定的大小,则应该交给GC进行清理,但是也要注意销毁对象会花费很长时间,尽量确保池的大小够用。
尽量不要把池化作为一个默认的解决方案。
减少LOH的碎片整理
如果做不到完全避免LOH分配,那就应该尽力避免碎片整理。
LOH 稍微不留意就是变大,通过空闲内存列表可以减轻这种现象。为了能让空闲内存发挥到最大作用,应该让每次内存分配时都能满足要求的可能性。
有一种方法可以提高这种可能性,就是保证LOH的每次分配尺寸都是统一的,或者几种标准,或者由几块固定大小的内存组成。如果某一块内存确实需要回收了,那么下次分配很大概率会在这块内存上,而不会放到堆的末尾。
PoolMemoryStream 实现将流视为一个整体,允许缓冲区无限增长,这种方式解决了很多LOH问题,但是产生严重的碎片问题。第二种方案主张多个独立的byte[]缓存区进行池化,每个缓冲区大小128Kb。多个小区组成一个大的虚拟缓冲区,再从大的缓冲区抽出流对象,把大缓冲区分为各种大小1M到10M,能够很好的减少碎片,但是当有一个跨越多个缓冲区的数据时,则需要把多个128kb的缓冲区复制到一个1MB的缓冲区中,但是这个代价时值得的。
某些场合可以强制完全回收
正常情况下不应该强制使用GC,但是以下情况可以主动调用。
- 采用了低延迟模式,这种模式内存大小会一直变大,可以自己主动来释放一些内存。
- 偶尔你会创建大量对象,并会存活很长事件,如果这些对象覆盖了即将成为垃圾的其他对象,应该主动调用一次
- 正处于对LOH进行碎片整理的情况
调用GC.Collect 方法,参数为需要回收的代数,既可以执行垃圾回收。还可以附带一个参数为GCCollerctionMode 枚举。
- Default 立即回收
- Forced 由垃圾回收器立即完全回收
- Optimized 允许垃圾回收器决定是否立即执行完全回收
GC.Collect(2)
//等效于
GC.Collect(2,GCCollectionMode.Forced);
必要时对LOH进行碎片整理
即使做了池化处理,还是可能存在无法控制的内存分配。LOH也会逐渐碎片化。
可以指挥GC在下一次回收时进行一次碎片整理。碎片整理非常耗时可能几十上百秒。
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
一旦开始处理状态就会自动重置为Default。
所以尽量减少LOH对象,并对必要的对象进行池化,以减少碎片。请把碎片整理作为最后手段,成为了系统问题时才考虑使用。
在垃圾回收前获得通知
如果你的应用程序绝对不能受到第2代垃圾回收的破坏,那么可以让GC在即将执行完
全垃圾回收时通知你。这样你就有机会暂停程序的运行,也许是停止向这台主机发送请求
或者是让你的应用程序进入更合适的状态这种通知机制貌似能一揽子解决所有的垃圾回收问题,但我还是提醒你要特别小心。只有在尽可能完成了其他优化之后,最后再考虑采用这一招。仅当以下条件都成立时,你才能从垃圾回收通知中受益:
- 完全垃圾回收的开销过大,以至于程序在正常运行期间无法承受。
- 你可以完全停止程序的运行(也许这时的工作可以由其他计算机或处理器承担)。
- 你可以迅速停止程序运行(停止运行的过程不会比真正执行垃圾回收的时间更久,你
就不会浪费更多的时间)。 - 第2代垃圾回收很少发生,因此执行一次还是划算的。
只有在大对象和高于第0代的对象都已最大程度地减少时,第2代垃圾回收才会很少发
生。所以要想真正受益于垃圾回收通知,前期还有相当多的工作要准备。
不幸的是,因为垃圾回收的触发时机并不确定,你只能用1~99的数字粗略指定获得通
知的提前量。数字越小,表示离垃圾回收的时间就越近,就越有可能在你准备就绪之前就启动垃圾回收了。数字越大,离垃圾回收的时间可能就越久,你收到通知的频率就会越高
序的运行效率也就不高。
提前量的取值完全取决于内存的分配量和整体占用情况。请注意
要指定两个数值,一个是第2代内存堆的阈值,另一个是LOH的阈值。和其他特性一样,
垃圾回收器对通知机制只是“尽力而为”。垃圾回收器从不保证你一定能及时躲开垃圾回
收请按以下步骤使用垃圾回收通知
- 调用GC.RegisterForFullGCNotification 方法,参数是两个阈值
- 调用GC.WaitForFullGCApproach 方法轮询(Poll)垃圾回收状态,可以一直等待下去或者指定一个超时值。
- 如果WaitForFullGCApproach 方法返回Success ,就将你的程序转入可以接受完全垃圾回收的状态(比如切断发往本机的请求)
- 调用GC.Collect 方法手动强制执行一次垃圾回收
- 调用GC.WaitForFullGCComplete 等待完全垃圾回收的完成。
- 重新开启请求
- 如果不想再收到完全垃圾回收通知,可以调用GC.CancelFullGCNotfication 方法。
因为要用到轮询机制,你需要在一个线程中周期性的完成检查任务。
class Program
{
static void Main(string[] args)
{
const int ArrSize = 1024;
List<byte[]> arrays = new List<byte[]>();
GC.RegisterForFullGCNotification(25, 25);
// 启动一个线程单独接收垃圾回收通知
Task.Run(()=>WaitForGCThread(null));
Console.WriteLine("Press any key to exit");
while (!Console.KeyAvailable)
{
try
{
arrays.Add(new byte[ArrSize]);
}
catch (OutOfMemoryException)
{
Console.WriteLine("OutOfMemoryException!");
arrays.Clear();
}
}
GC.CancelFullGCNotification();
}
private static void WaitForGCThread(object arg)
{
const int MaxWaitMs = 10000;
while (true)
{
// 无限等待还是会让WaitForGCApproach 过载
GCNotificationStatus status = GC.WaitForFullGCApproach(MaxWaitMs);
bool didCollect = false;
switch (status)
{
case GCNotificationStatus.Succeeded:
Console.WriteLine("GC approaching!");
Console.WriteLine("-- redirect processing to another machine -- ");
didCollect = true;
GC.Collect();
break;
case GCNotificationStatus.Canceled:
Console.WriteLine("GC Notification was canceled");
break;
case GCNotificationStatus.Timeout:
Console.WriteLine("GC notification timed out");
break;
}
if (didCollect)
{
do
{
status = GC.WaitForFullGCComplete(MaxWaitMs);
switch (status)
{
case GCNotificationStatus.Succeeded:
Console.WriteLine("GC completed");
Console.WriteLine("-- accept processing on this machine again --");
break;
case GCNotificationStatus.Canceled:
Console.WriteLine("GC Notification was canceled");
break;
case GCNotificationStatus.Timeout:
Console.WriteLine("GC completion notification timed out");
break;
}
// 这里的循环不一定有必要
// 但如果你在进入下一次等待之前还需要检查其他状态
} while (status == GCNotificationStatus.Timeout);
}
}
}
}
用弱引用作为缓存
弱引用指向的对象允许被垃圾回收器清理。与之相反,强引用会阻止所指对象被回收,有些对象我们原本是希望它们能长期存活,但在内存吃紧时也愿意释放出来。弱引用最大的用户就是缓存这种对象。
WeakReference weakRef = new WeakReference(MyObject);
//创建强引用
//现在就不会被GC考虑了
if(myObject!=null){
myObject.DoSomething();
}
WeakReference 的IsAlive只能判断是否已经消灭,不能判断是否存活,因为随时可能被释放。则只能先把弱引用复制给强引用。
WeakReference 的一种上佳用途就是构建对象缓存区,有些对象一开始由强制引用创建,经过相当长的一段事件后就会失去作用,然后由弱引用来保持,最终就可能会被销毁。