Spiga

CLR核心机制

2016-10-01 14:56:38

.NET CLR核心机制学习笔记

一、堆(Heap)与栈(Stack)的区别

1. 基本概念

  • 栈(Stack):像叠盘子,后进先出。线程创建时由系统自动分配,存储值类型(如int、float、struct)
  • 堆(Heap):像大仓库,需要时申请空间。存储引用类型(如class对象),由GC管理

2. 核心差异

特性
存储内容 值类型 引用类型
管理方式 自动维护(自动清理) GC垃圾回收
空间大小 较小(默认1MB) 较大(GB级)
访问速度 快(直接访问) 稍慢(通过指针)

二、内存分配实战

1. 值类型分配

int num = 42;  // 直接分配在栈上

2. 引用类型分配

class Person {}
var p = new Person();  // 实际对象在堆,p是栈上的引用

3. 混合情况

  • 值类型中的引用类型:值类型在栈,内部的引用类型数据在堆
  • 引用类型中的值类型:随对象整体存放在堆中

三、GC垃圾回收机制

1. 为什么要GC?

内存是有上限的,不可能无止境的分配空间,因此就产生了GC(Garbage Collector)的需求 .

1. 提高了软件开发的抽象度;
2. 程序员可以将精力集中在实际的问题上而不用分心来管理内存的问题;
3. 可以使模块的接口更加的清晰,减小模块间的偶合;
4. 大大减少了内存人为管理不当所带来的Bug;
5. 使内存管理更加高效。

总的说来就是GC可以使程序员可以从复杂的内存问题中摆脱出来,从而提高了软件开发的速度、质量和安全性。

什么样的对象需要垃圾回收

  1. 托管资源
  2. 存在堆里(含值类型+引用类型)

托管资源一般是指被CLR控制的内存资源,这些资源的管理可以由CLR来控制,例如程序中分配的对象,作用域内的变量等,大部分对象都是托管资源。

非托管资源是CLR不能控制或者管理的部分,这些资源有很多,比如文件流,数据库的连接,系统的窗口句柄,打印机资源,需要调用Dispose方法。

2. 回收算法(Mark-Compact)

  1. 标记阶段:从根对象出发,标记所有可达对象
  2. 清除阶段:回收未标记对象的内存
  3. 压缩阶段:整理内存碎片(像整理衣柜)

阶段1: Mark-Sweep 标记清除阶段,先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的;

阶段2: Compact 压缩阶段,对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。

3. 分代回收策略

代别 说明 回收频率
0代 新创建的对象
1代 存活过1次GC的对象
2代 存活较久的对象(如静态变量)

回收过程:先尝试回收0代→不够再1代→最后2代

第1次,遍历全部对象,找出没有引用的,删除,剩下1代

第2次,遍历0代对象,找出没有引用的,删除,剩下升级1代

如果还不够空间,才会遍历1代,没有引用的删除,还在被使用的,升级为2代。都不够才会检查2代

四、特殊场景处理

1. 大对象(>85KB)

  • 直接进入2代
  • 不进行内存压缩(避免性能损耗)

2. 装箱/拆箱

int i = 123;
object o = i;  // 装箱(值类型→引用类型)
int j = (int)o; // 拆箱

性能提示:频繁装箱拆箱会影响性能

3. 字符串特性

  • 不可变性:每次修改都创建新对象
  • 享元模式:相同字符串共享内存

五、最佳实践

  1. 避免频繁GC

    • 减少大对象创建
    • 重用对象(对象池)
  2. 慎用GC.Collect()

    // 除非明确知道需要回收(如卸载场景)
    GC.Collect(2);  // 强制回收2代
    
  3. Dispose模式

    • 及时释放非托管资源(文件/数据库连接)
    • 使用using语句块
    using(var stream = new FileStream(...))
    {
        // 自动调用Dispose()
    }
    

六、常见面试题

  1. 值类型和引用类型的内存分配区别?

值类型(int/float/struct等):

  • 直接存储在栈上(线程栈)

  • 赋值时是拷贝整个值

  • 生命周期与作用域绑定(自动释放)

    int a = 10;  // 栈上分配
    int b = a;   // 值拷贝(b是独立副本)
    

引用类型(class/string/数组等):

  • 实例存储在堆上,变量存储的是内存地址引用
  • 赋值时是拷贝引用地址(指向同一对象)
  • 由GC管理生命周期
class Person { public int Age; }
var p1 = new Person();  // 堆上分配
var p2 = p1;            // 引用拷贝(指向同一对象)

  1. GC的分代回收原理是什么?

分代假设​:

  • 新对象(0代)大多很快失效
  • 存活越久的对象(2代)可能继续存活

回收过程

  1. 初次分配:新对象进入0代

  2. 内存不足时:

    • 先回收0代(约占用1MB)
    • 若仍不足→回收1代(约10MB)
    • 最后回收2代(可能GB级)
  3. 存活对象升级:

    • 0代存活→升级到1代
    • 1代存活→升级到2代

优势

  • 减少扫描范围(80%回收只需扫描0代)

  • 平衡吞吐量与延迟

  1. 什么是装箱拆箱?有什么性能影响?

装箱:值类型→引用类型

int i = 123;
object o = i;  // 装箱(在堆上创建新对象)

过程:在堆分配内存→复制值→返回引用

IL指令:box int32

拆箱:引用类型→值类型

int j = (int)o;  // 拆箱(检查类型后复制值)
  • IL指令:unbox.any int32

性能问题

  1. 内存开销:每次装箱都产生新对象
  2. CPU开销:复制操作+类型检查
  3. GC压力:频繁装箱增加垃圾回收次数

优化建议

  • 避免在循环中装箱(如ArrayList存储值类型)
  • 使用泛型集合(List<int>
  1. 如何优化GC性能?

代码层面

// 避免创建大量短期对象
var sb = new StringBuilder(); // 代替频繁字符串拼接

// 对象池复用
static readonly ObjectPool<MyClass> pool = new DefaultObjectPool<MyClass>(...);

// 值类型替代引用类型(如用struct设计小对象)

GC策略

  1. 控制触发时机
    • 避免手动调用GC.Collect()
    • 游戏循环可在帧间隔调用
  2. 分代优化
    • 让短期对象快速死亡(保持在0代)
    • 长期对象尽量少修改(如配置数据)
  3. 大对象处理
    • 拆分>85KB的对象
    • 使用ArrayPool<T>租用数组

配置层面

<!-- ASP.NET Core配置 -->
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>

工具验证

  • 使用Visual Studio诊断工具观察GC事件
  • PerfView分析内存分配热点