.NET面试题解析(06)-GC与内存管理

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 转自:http://www.cnblogs.com/anding/p/5260319.html  常见面试题目:1. 简述一下一个引用对象的生命周期?2. 创建下面对象实例,需要申请多少内存空间?public class User{ public int Age { get; ...

转自:http://www.cnblogs.com/anding/p/5260319.html

  常见面试题目:

1. 简述一下一个引用对象的生命周期?

2. 创建下面对象实例,需要申请多少内存空间?

public class User
{
    public int Age { get; set; }
    public string Name { get; set; }

    public string _Name = "123" + "abc";
    public List<string> _Names;
}

3. 什么是垃圾?

4. GC是什么,简述一下GC的工作方式?

5. GC进行垃圾回收时的主要流程是?

6. GC在哪些情况下回进行回收工作?

7. using() 语法是如何确保对象资源被释放的?如果内部出现异常依然会释放资源吗?

8. 解释一下C#里的析构函数?为什么有些编程建议里不推荐使用析构函数呢?

9. Finalize() 和 Dispose() 之间的区别?

10. Dispose和Finalize方法在何时被调用?

11. .NET中的托管堆中是否可能出现内存泄露的现象?

12. 在托管堆上创建新对象有哪几种常见方式?

  深入GC与内存管理

托管堆中存放引用类型对象,因此GC的内存管理的目标主要都是引用类型对象,本文中涉及的对象如无明确说明都指的是引用类型对象。

微笑 对象创建及生命周期

一个对象的生命周期简单概括就是:创建>使用>释放,在.NET中一个对象的生命周期:

  • new创建对象并分配内存
  • 对象初始化
  • 对象操作、使用
  • 资源清理(非托管资源)
  • GC垃圾回收

那其中重要的一个环节,就是对象的创建,大部分的对象创建都是开始于关键字new。为什么说是大部分呢,因为有个别引用类型是由专门IL指令的,比如string有ldstr指令(参考前面的文章:.NET面试题解析(03)-string与字符串操作),0基数组好像也有一个专门指令。

引用对象都是分配在托管堆上的, 先来看看托管堆的基本结构,如下图,托管堆中的对象是顺序存放的,托管堆维护着一个指针NextObjPtr,它指向下一个对象在堆中的分配位置。

创建一个新对象的主要流程:

image

以题目2中的代码为例,模拟一个对象的创建过程:

public class User
{
    public int Age { get; set; }
    public string Name { get; set; }

    public string _Name = "123" + "abc";
    public List<string> _Names;
}
  • 对象大小估算,共计40个字节:
    • 属性Age值类型Int,4字节;
    • 属性Name,引用类型,初始为NULL,4个字节,指向空地址;
    • 字段_Name初始赋值了,由前面的文章(.NET面试题解析(03)-string与字符串操作)可知,代码会被编译器优化为_Name=”123abc”。一个字符两个字节,字符串占用2×6+8(附加成员:4字节TypeHandle地址,4字节同步索引块)=20字节,总共内存大小=字符串对象20字节+_Name指向字符串的内存地址4字节=24字节;
    • 引用类型字段List<string> _Names初始默认为NULL,4个字节;
    • User对象的初始附加成员(4字节TypeHandle地址,4字节同步索引块)8个字节;
  • 内存申请: 申请44个字节的内存块,从指针NextObjPtr开始验证,空间是否足够,若不够则触发垃圾回收。
  • 内存分配: 从指针NextObjPtr处开始划分44个字节内存块。
  • 对象初始化: 首先初始化对象附加成员,再调用User对象的构造函数,对成员初始化,值类型默认初始为0,引用类型默认初始化为NULL;
  • 托管堆指针后移: 指针NextObjPtr后移44个字节。
  • 返回内存地址: 返回对象的内存地址给引用变量。

吐舌笑脸 GC垃圾回收

GC是垃圾回收(Garbage Collect)的缩写,是.NET核心机制的重要部分。她的基本工作原理就是遍历托管堆中的对象,标记哪些被使用对象(那些没人使用的就是所谓的垃圾),然后把可达对象转移到一个连续的地址空间(也叫压缩),其余的所有没用的对象内存被回收掉。

首先,需要再次强调一下托管堆内存的结构,如下图,很明确的表明了,只有GC堆才是GC的管辖区域,关于加载堆在前面文中有提到过(.NET面试题解析(04)-类型、方法与继承)。GC堆里面为了提高内存管理效率等因素,有分成多个部分,其中 两个主要部分:

  • 0/1/2代:代龄(Generation)在后面有专门说到;
  • 大对象堆(Large Object Heap),大于85000字节的大对象会分配到这个区域,这个区域的主要特点就是:不会轻易被回收;就是回收了也不会被压缩(因为对象太大,移动复制的成本太高了);

image

图3(Figure-3)

什么是垃圾?简单理解就是没有被引用的对象。

垃圾回收的基本流程包含以下三个关键步骤:

① 标记

先假设所有对象都是垃圾,根据应用程序根指针Root遍历堆上的每一个引用对象,生成可达对象图,对于还在使用的对象(可达对象)进行标记(其实就是在对象同步索引块中开启一个标示位)。

其中Root根指针保存了当前所有需要使用的对象引用,他其实只是一个统称,意思就是这些对象当前还在使用,主要包含:静态对象/静态字段的引用;线程栈引用(局部变量、方法参数、栈帧);任何引用对象的CPU寄存器;根引用对象中引用的对象;GC Handle table;Freachable队列等。

② 清除

针对所有不可达对象进行清除操作,针对普通对象直接回收内存,而对于实现了终结器的对象(实现了析构函数的对象)需要单独回收处理。清除之后,内存就会变得不连续了,就是步骤3的工作了。

③ 压缩

把剩下的对象转移到一个连续的内存,因为这些对象地址变了,还需要把那些Root跟指针的地址修改为移动后的新地址。

垃圾回收的过程示意图如下:

alt

 

垃圾回收的过程是不是还挺辛苦的,因此建议不要随意手动调用垃圾回收GC.Collect(),GC会选择合适的时机、合适的方式进行内存回收的。

吐舌笑脸 关于代龄(Generation)

当然,实际的垃圾回收过程可能比上面的要复杂,如果没次都扫描托管堆内的所有对象实例,这样做太耗费时间而且没有必要。分代(Generation)算法是CLR垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能。分代回收,速度显然快于回收整个堆。分代(Generation)算法的假设前提条件:

1、大量新创建的对象生命周期都比较短,而较老的对象生命周期会更长 
2、对部分内存进行回收比基于全部内存的回收操作要快 
3、新创建的对象之间关联程度通常较强。heap分配的对象是连续的,关联度较强有利于提高CPU cache的命中率

如图3,.NET将托管堆分成3个代龄区域: Gen 0、Gen 1、Gen 2:

  • 第0代,最新分配在堆上的对象,从来没有被垃圾收集过。任何一个新对象,当它第一次被分配在托管堆上时,就是第0代(大于85000的大对象除外)。 
  • 第1代,0代满了会触发0代的垃圾回收,0代垃圾回收后,剩下的对象会搬到1代。 
  • 第2代,当0代、1代满了,会触发0代、1代的垃圾回收,第0代升为第1代,第1代升为第2代。

alt

大部分情况,GC只需要回收0代即可,这样可以显著提高GC的效率,而且GC使用启发式内存优化算法,自动优化内存负载,自动调整各代的内存大小。

吐舌笑脸 非托管资源回收

.NET中提供释放非托管资源的方式主要是:Finalize() 和 Dispose()。

Dispose():

常用的大多是Dispose模式,主要实现方式就是实现IDisposable接口,下面是一个简单的IDisposable接口实现方式。

public class SomeType : IDisposable
{
    public MemoryStream _MemoryStream;
    public void Dispose()
    {
        if (_MemoryStream != null) _MemoryStream.Dispose();
    }
}

Dispose需要手动调用,在.NET中有两中调用方式:

//方式1:显示接口调用
SomeType st1=new SomeType();
//do sth
st1.Dispose();

//方式2:using()语法调用,自动执行Dispose接口
using (var st2 = new SomeType())
{
    //do sth
}

第一种方式,显示调用,缺点显而易见,如果程序猿忘了调用接口,则会造成资源得不到释放。或者调用前出现异常,当然这一点可以使用try…finally避免。

一般都建议使用第二种实现方式,他可以保证无论如何Dispose接口都可以得到调用,原理其实很简单,using()的IL代码如下图,因为using只是一种语法形式,本质上还是try…finally的结构。

image

 

Finalize() :终结器(析构函数)

首先了解下Finalize方法的来源,她是来自System.Object中受保护的虚方法Finalize,无法被子类显示重写,也无法显示调用,是不是有点怪?。她的作用就是用来释放非托管资源,由GC来执行回收,因此可以保证非托管资源可以被释放。

  • 无法被子类显示重写:.NET提供类似C++析构函数的形式来实现重写,因此也有称之为析构函数,但其实她只是外表和C++里的析构函数像而已。
  • 无法显示调用:由GC来管理和执行释放,不需要手动执行了,再也不用担心猿们忘了调用Dispose了。

所有实现了终结器(析构函数)的对象,会被GC特殊照顾,GC的终止化队列跟踪所有实现了Finalize方法(析构函数)的对象。

  • 当CLR在托管堆上分配对象时,GC检查该对象是否实现了自定义的Finalize方法(析构函数)。如果是,对象会被标记为可终结的,同时这个对象的指针被保存在名为终结队列的内部队列中。终结队列是一个由垃圾回收器维护的表,它指向每一个在从堆上删除之前必须被终结的对象。
  • 当GC执行并且检测到一个不被使用的对象时,需要进一步检查“终结队列”来查询该对象类型是否含有Finalize方法,如果没有则将该对象视为垃圾,如果存在则将该对象的引用移动到另外一张Freachable列表,此时对象会被复活一次。
  • CLR将有一个单独的高优先级线程负责处理Freachable列表,就是依次调用其中每个对象的Finalize方法,然后删除引用,这时对象实例就被视为不再被使用,对象再次变成垃圾。
  • 下一个GC执行时,将释放已经被调用Finalize方法的那些对象实例。

上面的过程是不是很复杂!是就对了,如果想彻底搞清楚,没有捷径,不要偷懒,还是去看书吧!

简单总结一下:Finalize()可以确保非托管资源会被释放,但需要很多额外的工作(比如终结对象特殊管理),而且GC需要执行两次才会真正释放资源。听上去好像缺点很多,她唯一的优点就是不需要显示调用。

有些编程意见或程序猿不建议大家使用Finalize,尽量使用Dispose代替,我觉得可能主要原因在于:第一是Finalize本身性能并不好;其次很多人搞不清楚Finalize的原理,可能会滥用,导致内存泄露。因此就干脆别用了,其实微软是推荐大家使用的,不过是和Dispose一起使用,同时实现IDisposable接口和Finalize(析构函数),其实FCL中很多类库都是这样实现的,这样可以兼具两者的优点:

  • 如果调用了Dispose,则可以忽略对象的终结器,对象一次就回收了;
  • 如果程序猿忘了调用Dispose,则还有一层保障,GC会负责对象资源的释放;

 

吐舌笑脸 性能优化建议

尽量不要手动执行垃圾回收的方法:GC.Collect()

垃圾回收的运行成本较高(涉及到了对象块的移动、遍历找到不再被使用的对象、很多状态变量的设置以及Finalize方法的调用等等),对性能影响也较大,因此我们在编写程序时,应该避免不必要的内存分配,也尽量减少或避免使用GC.Collect()来执行垃圾回收,一般GC会在最适合的时间进行垃圾回收。

而且还需要注意的一点,在执行垃圾回收的时候,所有线程都是要被挂起的(如果回收的时候,代码还在执行,那对象状态就不稳定了,也没办法回收了)。

推荐Dispose代替Finalize

如果你了解GC内存管理以及Finalize的原理,可以同时使用Dispose和Finalize双保险,否则尽量使用Dispose。

选择合适的垃圾回收机制:工作站模式、服务器模式

  题目答案解析:

1. 简述一下一个引用对象的生命周期?

  • new创建对象并分配内存
  • 对象初始化
  • 对象操作、使用
  • 资源清理(非托管资源)
  • GC垃圾回收

2. 创建下面对象实例,需要申请多少内存空间?

public class User
{
    public int Age { get; set; }
    public string Name { get; set; }

    public string _Name = "123" + "abc";
    public List<string> _Names;
}

40字节内存空间,详细分析文章中给出了。

3. 什么是垃圾?

一个变量如果在其生存期内的某一时刻已经不再被引用,那么,这个对象就有可能成为垃圾

4. GC是什么,简述一下GC的工作方式?

GC是垃圾回收(Garbage Collect)的缩写,是.NET核心机制的重要部分。她的基本工作原理就是遍历托管堆中的对象,标记哪些被使用对象(哪些没人使用的就是所谓的垃圾),然后把可达对象转移到一个连续的地址空间(也叫压缩),其余的所有没用的对象内存被回收掉。

5. GC进行垃圾回收时的主要流程是?

① 标记:先假设所有对象都是垃圾,根据应用程序根Root遍历堆上的每一个引用对象,生成可达对象图,对于还在使用的对象(可达对象)进行标记(其实就是在对象同步索引块中开启一个标示位)。

② 清除:针对所有不可达对象进行清除操作,针对普通对象直接回收内存,而对于实现了终结器的对象(实现了析构函数的对象)需要单独回收处理。清除之后,内存就会变得不连续了,就是步骤3的工作了。

③ 压缩:把剩下的对象转移到一个连续的内存,因为这些对象地址变了,还需要把那些Root跟指针的地址修改为移动后的新地址。

6. GC在哪些情况下回进行回收工作?

  • 内存不足溢出时(0代对象充满时)
  • Windwos报告内存不足时,CLR会强制执行垃圾回收
  • CLR卸载AppDomian,GC回收所有
  • 调用GC.Collect
  • 其他情况,如主机拒绝分配内存,物理内存不足,超出短期存活代的存段门限

7. using() 语法是如何确保对象资源被释放的?如果内部出现异常依然会释放资源吗?

using() 只是一种语法形式,其本质还是try…finally的结构,可以保证Dispose始终会被执行。

8. 解释一下C#里的析构函数?为什么有些编程建议里不推荐使用析构函数呢?

C#里的析构函数其实就是终结器Finalize,因为长得像C++里的析构函数而已。

有些编程建议里不推荐使用析构函数要原因在于:第一是Finalize本身性能并不好;其次很多人搞不清楚Finalize的原理,可能会滥用,导致内存泄露,因此就干脆别用了

9. Finalize() 和 Dispose() 之间的区别?

Finalize() 和 Dispose()都是.NET中提供释放非托管资源的方式,他们的主要区别在于执行者和执行时间不同:

  • finalize由垃圾回收器调用;dispose由对象调用。
  • finalize无需担心因为没有调用finalize而使非托管资源得不到释放,而dispose必须手动调用。
  • finalize不能保证立即释放非托管资源,Finalizer被执行的时间是在对象不再被引用后的某个不确定的时间;而dispose一调用便释放非托管资源。
  • 只有class类型才能重写finalize,而结构不能;类和结构都能实现IDispose。

另外一个重点区别就是终结器会导致对象复活一次,也就说会被GC回收两次才最终完成回收工作,这也是有些人不建议开发人员使用终结器的主要原因。

10. Dispose和Finalize方法在何时被调用?

  • Dispose一调用便释放非托管资源;
  • Finalize不能保证立即释放非托管资源,Finalizer被执行的时间是在对象不再被引用后的某个不确定的时间;

11. .NET中的托管堆中是否可能出现内存泄露的现象?

是的,可能会。比如:

  • 不正确的使用静态字段,导致大量数据无法被GC释放;
  • 没有正确执行Dispose(),非托管资源没有得到释放;
  • 不正确的使用终结器Finalize(),导致无法正常释放资源;
  • 其他不正确的引用,导致大量托管对象无法被GC释放;

12. 在托管堆上创建新对象有哪几种常见方式?

  • new一个对象;
  • 字符串赋值,如string s1=”abc”;
  • 值类型装箱;
相关文章
|
16天前
|
存储 缓存 安全
Java内存模型深度解析:从理论到实践####
【10月更文挑战第21天】 本文深入探讨了Java内存模型(JMM)的核心概念与底层机制,通过剖析其设计原理、内存可见性问题及其解决方案,结合具体代码示例,帮助读者构建对JMM的全面理解。不同于传统的摘要概述,我们将直接以故事化手法引入,让读者在轻松的情境中领略JMM的精髓。 ####
28 6
|
1月前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
2月前
|
XML JSON API
ServiceStack:不仅仅是一个高性能Web API和微服务框架,更是一站式解决方案——深入解析其多协议支持及简便开发流程,带您体验前所未有的.NET开发效率革命
【10月更文挑战第9天】ServiceStack 是一个高性能的 Web API 和微服务框架,支持 JSON、XML、CSV 等多种数据格式。它简化了 .NET 应用的开发流程,提供了直观的 RESTful 服务构建方式。ServiceStack 支持高并发请求和复杂业务逻辑,安装简单,通过 NuGet 包管理器即可快速集成。示例代码展示了如何创建一个返回当前日期的简单服务,包括定义请求和响应 DTO、实现服务逻辑、配置路由和宿主。ServiceStack 还支持 WebSocket、SignalR 等实时通信协议,具备自动验证、自动过滤器等丰富功能,适合快速搭建高性能、可扩展的服务端应用。
143 3
|
2月前
|
C++
【C++】深入解析C/C++内存管理:new与delete的使用及原理(二)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
2月前
|
编译器 C++ 开发者
【C++】深入解析C/C++内存管理:new与delete的使用及原理(三)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
9天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
2月前
|
测试技术 API 开发者
精通.NET单元测试:MSTest、xUnit、NUnit全面解析
【10月更文挑战第15天】本文介绍了.NET生态系统中最流行的三种单元测试框架:MSTest、xUnit和NUnit。通过示例代码展示了每种框架的基本用法和特点,帮助开发者根据项目需求和个人偏好选择合适的测试工具。
41 3
|
2月前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理方式,特别是垃圾回收机制。我们将了解Java的自动内存管理是如何工作的,它如何帮助开发者避免常见的内存泄漏问题。通过分析不同垃圾回收算法(如标记-清除、复制和标记-整理)以及JVM如何选择合适的垃圾回收策略,本文旨在帮助Java开发者更好地理解和优化应用程序的性能。
|
2月前
|
存储 安全 Java
JVM锁的膨胀过程与锁内存变化解析
在Java虚拟机(JVM)中,锁机制是确保多线程环境下数据一致性和线程安全的重要手段。随着线程对共享资源的竞争程度不同,JVM中的锁会经历从低级到高级的膨胀过程,以适应不同的并发场景。本文将深入探讨JVM锁的膨胀过程,以及锁在内存中的变化。
44 1
|
2月前
|
SQL 开发框架 .NET
ASP.NET连接SQL数据库:实现过程与关键细节解析an3.021-6232.com
随着互联网技术的快速发展,ASP.NET作为一种广泛使用的服务器端开发技术,其与数据库的交互操作成为了应用开发中的重要环节。本文将详细介绍在ASP.NET中如何连接SQL数据库,包括连接的基本概念、实现步骤、关键代码示例以及常见问题的解决方案。由于篇幅限制,本文不能保证达到完整的2000字,但会确保

推荐镜像

更多