[CSharp]C# 垃圾回收 GC(探究)

关于托管堆

托管堆

什么是资源

  文件,内存缓冲区,屏幕空间,网络连接,数据库资源等等都属于被程序利用的资源;要使用资源,就必须为资源分配内存;

C#中访问资源的步骤

  • 调用IL指令newobj,为代表资源的类型分配内存(一般是new操作符)
  • 初始化内存,设置资源的初始状态并使其可用;
  • 访问类型的成员使用资源(可以重复)
  • 摧毁资源状态以进行清理
  • 释放内存(Garbage collector 负责)

内存破坏和内存泄漏

  到需要程序员手动管理内存的时候,出现一些不可预测的情况之时,就是内存出现了BUG(使用完了的内存没有释放,导致了内存泄漏;内存中明明有东西,且处于有用的状态,强行修改其内容或者取内存中内容时越界,导致需要的内容不可预测,从而导致内存破坏)
  内存破坏的坏处比内存泄漏更加麻烦 – 因为无迹可寻,更加无法预测;

  现在使用高级语言,只要是写的可验证的类型安全的代码(非unsafe关键字下),就不可能出现内存破坏的情况;但是内存泄漏还是有可能存在的;一般是因为在集合中存储了对象,不需要时一直不去删除

GC的作用

  为了简化编程步骤,开发者常使用大部分类型都不需要摧毁资源状态以进行清理,托管堆能避免前面提到的BUG,还能为开发者提供一个简易的编程模型;
  大多数类型都不需要资源清理,垃圾回收器自动的帮我们做;
  对于特殊类型的话,编程模型还是那么简单,不过清理垃圾的话还是要我们按需尽快的清理,在这些特殊类中调用Dispose方法来进行清理;一般来说只有包装了本机资源的(文件,套接字,数据库连接)的类型才需要 ;


托管堆资源分配

CLR做了什么

  CLR说:”所有的对象都从托管堆分配”;
  进程初始化时, CLR要划出一个地址空间区域作为托管堆 | 还要维护一个指针(该指针指向下一对象在堆中的分配位置)

资源分配满了应该怎么办

  当一个区域被垃圾对象填满之后,CLR会分配更多的空间;该过程一直重复,直到被整个进程的空间被填满;
  所以应用程序的内存受到进程的虚拟地址空间的限制;

32位 64位
1.5GB 8TB

刚刚提到的C# new操作符导致CLR做了什么工作

  • 计算类型字段(以及从基类型继承的字段)的所需要的字节长度
  • 加上开销所需要的字节数;每个对象都有两个开销字段(类型对象指针 && 同步块索引) – (32位的应用程序的两个字段各需要32位,即是8个字节;64位的应用程序的两个字段各需要64位,即是16个字节 – 都是增加量)
  • CLR检查区域中是否有分配对象所需要的字节数;如果托管堆有足够的空间,就让CLR维护的指针NextObjPtr指向的地方放入对象,为对象分配的字节会清零; 然后调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象的引用;就在返回这个对象之前,NextObjPtr指针的之会加上对象占用的字节数来得到一个新值 – 下一个对象放入托管堆时的地址

为什么托管堆分配对象速度这么快

  对于托管堆,分配对象只需要在指针上加上一个值即可–速度就相当的快了;
  当我们连续创建对象的时候,托管堆在内存中也是连续的分配的空间,所以性能会得到一定的提升;
  具体的来讲,进程的工作集会变得非常的小,应用程序只会使用很少的内存,从而提高了的速度;
  这就意味者,代码使用的对象可以全部驻留在CPU的缓存中;这样,在CPU执行大部分的操作的时候,不会因为(cache miss)而被迫去访问速度更慢的RAM;

引入GC

  如果内存是无限的话,CLR就可以无限的分配(因为其高效的性能,所以让程序的性能也提升不少);但是,我们的内存是不可能无限制的;所以我们将使用垃圾回收的技术”删除”在堆中不需要的对象;


垃圾回收算法

什么时候执行垃圾回收

  应用程序调用new操作符创建对象的时候,可能没有足够的对象地址空间来分配该对象;当空间不足的时候,CLR就会执行垃圾回收;

COM模型中的GC实现方式

  一般的垃圾回收时通过引用计数来实现的(window的COM - Component Object Model),

  中文版的内容翻译的有点歧义,没有明确那个对象是那个对象

With a reference counting system, each object on the heap maintains an internal field indicating how many “parts” of the program are currently using that object

  使用引用计数系统,堆上的每个对象维护一个内部字段,该字段指示程序中有多少“部分”当前正在使用该对象;随着每一个”部分”不需要该对象的时候,该对象中的该字段中的引用计数就开始递减,当字段变成0时,就启用垃圾回收;

  • 但是会出现一种情况,当两个对象相互引用的时候,就会出现无法被释放掉的问题;

  所以CLR中使用的时改进版的引用跟踪算法;

引用跟踪算法

标记阶段

  在开始之前我们需要了解一些基本的东西:

  • 算法只关心引用类型的变量,只有引用类型的变量才可以访问到堆上面的东西;所以我们将所有的引用类型称之为
  • 在开始为对象进行标记的时候我们需要将所有的线程暂停;(防止线程在进行GC的时候访问对象并更改其状态 – 这样就比较麻烦了,所以我们先将所有的线程都暂停掉);

  前面我们也讲了同步块索引:具体和同步块索引相关的还是请参考:同步块索引01 同步块索引02 同步块索引03

开始进入标记阶段

  • 首先CLR遍历堆中的所有的对象,将同步块索引中的一位设置为0 — 即是说所有的对象都可以删除;
  • 其次检查所有的活动根,查看他们引用了哪些对象;如果一个根中包含了null,那么将继续检查下一个根;

  任何根引用了堆中的某一个对象的话,该对象将被标记,即是将其同步块索引中的一位设置成为1;

When an object is marked, the CLR examines the roots inside that object and marks the objects they refer to

  当一个对象被引用之后,CLR会检查该对象是否作为根引用了其他对象,如果存在引用则将该对象引用的对象(可能是多个)也标记,继续重复该操作,直到不在存在引用了
  举一个例子:

  在这个例子中,ACDF都是被直接的引用到了,当扫描到D时,CLR发现D中还引用了H,所以H也将被标记(同步块索引中的位设置成为1),然后当回收的时候,就直接回收同步块索引中位为0的对象;能够不被GC回收的,能被至少一个根引用,我们称之为可达的(reachable);要被GC回收的,没有一个根引用的,称之为不可达的(unreachable) 就将进入下一个步骤中;   

整理阶段

  其实整理阶段就相当于是一个碎片整理的过程针对大对象堆的话,不会存在整理),将空的位置利用后面存留下来的对象进行填充;
  此时CLR维护的那个指针就直接到托管堆中存留对象的最后面; – 这种整理解决了原生堆产生垃圾碎片的问题;
  还有一个问题没有解决,我们刚才暂停了线程,假如某一个线程在暂停之前正在访问H,这个时候的H整理之后移动了,整理完成之后当前的线程找不到原来的那个位置了;
  解决方式:CLR从每一个根减去所引用的对象的在内存中偏移的字节数;

其他情况

  如果CLR在一次GC之后不能够回收内存,并且进程中没有了多的空间用来分配新的GC区域 – 说明该进程的内存已经耗尽了;如果试图分配新的内存的话就会触发异常(OutofMemoryException);虽然应用程序可以捕捉异常并处理异常,但是大部分应用程序不会这么做;所以一般来说,发生这种情况,异常会成为未处理异常,操作系统会终止该进程并回收该应用程序的所有内存;


GC的回收和调试

  下面一段代码,我们通过两种方式生成可执行文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;
using System.Threading;

namespace SeniorCSharp
{
public static class Program
{
public static void Main(string[] args)
{
//创建一个每过2000ms就调用一次TimerCallBack函数的Timer对象
Timer timer = new Timer(TimerCallBack,null,0,2000);

//等待用户按回车
Console.ReadLine();
}

private static void TimerCallBack(Object o)
{
Console.WriteLine("IN timercallback" + DateTime.Now);

//为了演示,强制垃圾回收
GC.Collect();
}
}
}

使用cmd命令行csc FileName.cs调用csc.exe编译生成可执行文件

不同过任何特殊编译器编译代码–使用cmd_csc.exe_编译单独运行生成的exe的情况

  我们发现在这种方式中,TimerCallBack函数只执行了一次

直接通过vs2017编译运行

在VS2017中运行的情况

  我们发现在这种方式中,TimerCallBack函数却被执行了多次

原因分析