龙空技术网

面试 | .NET基础知识快速通关(2)

EdisonTalk 165

前言:

现在我们对“三线城市net开发面试”大体比较重视,各位老铁们都想要分析一些“三线城市net开发面试”的相关内容。那么小编同时在网上网罗了一些对于“三线城市net开发面试””的相关知识,希望各位老铁们能喜欢,同学们快快来学习一下吧!

【.NET基础】| 总结/Edison Zhou

此系列文章为我在2015年发布于博客园的.NET基础拾遗系列,它十分适合初中级.NET开发工程师在面试前进行一个系统的复习,因此我将其搬到头条号分享与你。

本文为第二篇,我们会对.NET的内存管理进行基础复习,全文会以Q/A的形式展现,即以面试题的形式来描述。

上一篇:.NET基础知识快速通关(1)

1 .NET中的栈和堆的差异?

每一个.NET应用程序最终都会运行在一个OS(操作系统)进程中,假设这个OS的传统的32位系统,那么每个.NET应用程序理论上都可以拥有一个4GB的虚拟内存。.NET会在这个4GB的虚拟内存块中开辟三块内存作为 堆栈、托管堆 以及 非托管堆。

(1).NET中的堆栈

堆栈用来存储值类型的对象和引用类型对象的引用(地址),其分配的是一块连续的地址,如下图所示,在.NET应用程序中,堆栈上的地址从高位向低位分配内存,.NET只需要保存一个指针指向下一个未分配内存的内存地址即可。

对于所有需要分配的对象,会依次分配到堆栈中,其释放也会严格按照栈的逻辑(FILO,先进后出)依次进行退栈。(这里的“依次”是指按照变量的作用域进行的),假设有以下一段代码:

TempClass a = new TempClass();a.numA = 1;a.numB = 2;

其在堆栈中的内存图如下图所示:

这里TempClass是一个引用类型,拥有两个整型的int成员,在栈中依次需要分配的是a的引用,a.numA和a.numB。当a的作用域结束之后,这三个会按照a.numB→a.numA→a的顺序依次退栈。

(2).NET中的托管堆

众所周知,.NET中的引用类型对象时分配在托管堆上的,和堆栈一样,托管堆也是进程内存空间中的一块区域。But,托管堆的内存分配却和堆栈有很大区别。受益于.NET内存管理机制,托管堆的分配也是连续的(从低位到高位),但是堆中却存在着暂时不能被分配却已经无用的对象内存块。

当一个引用类型对象被初始时,会通过指向堆上可用空间的指针分配一块连续的内存,然后使堆栈上的引用指向堆上刚刚分配的这块内存块。下图展示了托管堆的内存分配方式:

如上图所示,.NET程序通过分配在堆栈中的引用来找到分配在托管堆的对象实例。当堆栈中的引用退出作用域时,这时仅仅就断开和实际对象实例的引用联系。而当托管堆中的内存不够时,.NET会开始执行GC(垃圾回收)机制。GC是一个非常复杂的过程,它不仅涉及托管堆中对象的释放,而且需要移动合并托管堆中的内存块。当GC之后,堆中不再被使用的对象实例才会被部分释放(注意并不是完全释放),而在这之前,它们在堆中是暂时不可用的。在C/C++中,由于没有GC,因此可以直接free/delete来释放内存。

(3).NET中的非托管堆

.NET程序还包含了非托管堆,所有需要分配堆内存的非托管资源将会被分配到非托管堆上。非托管的堆需要程序员用指针手动地分配和释放内存,.NET中的GC和内存管理不适用于非托管堆,其内存块也不会被合并移动,所以非托管堆的内存分配是按块的、不连续的。因此,这也解释了我们为何在使用非托管资源(如:文件流、数据库连接等)需要手动地调用Dispose()方法进行内存释放的原因。

2 一个字符串到底分配了多少内存?

string abc = "aaa"+"bbb"+"ccc";

这是一个经典的基础知识题目,它涉及了字符串的类型、堆栈和堆的内存分配机制,因此被很多人拿来考核开发者的基础知识功底。

首先,我们都知道,判断值类型的标准是查看该类型是否会继承自System.ValueType,通过查看和分析,string直接继承于System.Object,因此string是引用类型,其内存分配会遵照引用类型的规范,也就是说如下的代码将会在堆栈上分配一块存储引用的内存,然后再在堆上分配一块存储字符串实例对象的内存。

string a = "edison";

现在再来看看string abc="aaa"+"bbb"+"ccc"。

按照常规的思路,字符串具有不可变性,大部分人会认为这里的表达式会涉及很多临时变量的生成,可能C#编译器会先执行"aaa"+"bbb",并且把结果值赋给一个临时变量,再执行临时变量和"ccc"相加,最后把相加的结果再赋值给abc。

But,其实C#编译器比想象中要聪明得多,以下的C#代码和IL代码可以充分说明C#编译器的智能:

// The first formatstring first = "aaa" + "bbb" + "ccc";// The second formatstring second = "aaabbbccc";// Display string Console.WriteLine(first);Console.WriteLine(second);

该C#代码的IL代码如下图所示:

正如我们所看到的,string abc="aaa"+"bbb"+"ccc"; 这样的表达式被C#编译器看成一个完整的字符串"aaabbbccc",而不是执行某些拼接方法,可以将其看作是C#编译器的优化,所以在本次内存分配中只是在栈中分配了一个存储字符串引用的内存块,以及在托管堆分配了一块存储"aaabbbccc"字符串对象的内存块。

那么,我们的常规思路在.NET程序中又是怎么体现的呢?我们来看一下一段代码:

int num = 1;string str = "aaa" + num.ToString();Console.WriteLine(str);

这里我们首先初始化了一个int类型的变量,其次初始化了一个string类型的字符串,并执行“+”操作,这时我们来看看其对应的IL代码:

如上图所示,在这段代码中执行“+”操作,会调用String的Concat方法,该方法需要传入两个string类型的参数,也就产生了另一个string类型的临时变量。换句话说,在此次内存分配中,堆栈中会分配一个存储字符串引用的内存块,在托管堆则分配了两块内存块,分别存储了存储"aaa"字符串对象和"1"字符串对象。

可能这段代码还是不熟悉,我们再来看看下面一段代码,我们就感觉十分亲切熟悉了:

string str = "aaa";str += "bbb";str += "ccc";Console.WriteLine(str);

其对应的IL代码如下图所示:

如图可以看出,在拼接过程中产生了两个临时字符串对象,并调用了两次String.Concat方法进行拼接,就不用多解释了。

3 能简要说说.NET的GC运行机制么?

(1)整体阐述

GC是垃圾回收(Garbage Collect)的缩写,它是.NET众多机制中最为重要的一部分,也是对我们的代码书写方式影响最大的机制之一。

.NET中的垃圾回收是指清理托管堆上不会再被使用的对象内存,并且移动仍在被使用的对象使它们紧靠托管堆的一边。

(2)细节介绍

下图展示了一次垃圾回收之后托管堆上的变化(这里仅仅为了说明,简化了GC的执行过程,省略了包含Finalize方法对象的处理以及大对象分配的特殊性):

如上图所示,我们可以知道GC的执行过程分为两个基本动作:

一是找到所有不再被使用的对象:对象A和对象C,并标记为垃圾;

二是移动仍在被使用的对象:对象B和对象D。

这样之后,对象A和对象C所占用的内存空间就被腾空出来,以备下次分配的时候使用。

那么,问题来了,追问:GC在哪些情况下会进行回收工作呢?

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

(3)补充说明

通常情况下,我们不需要手动干预垃圾回收的执行,不过CLR仍然提供了一个手动执行垃圾回收的方法:GC.Collect()。当我们需要在某一批对象不再使用并且及时释放内存的时候可以调用该方法来实现。

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

(4)背景知识

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

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

创建一个新对象的主要流程如下图所示:

4 了解Dispose和Finalize方法么?

(1)背景知识

由于有了垃圾回收机制的支持,对象的析构(或释放)和C++有了很大的不同,这就需要我们在设计类型的时候,充分理解.NET的机制,明确怎样利用Dispose方法和Finalize方法来保证一个对象正确而高效地被析构。

(2)Dispose方法

// 摘要://     定义一种释放分配的资源的方法。[ComVisible(true)]public interface IDisposable{    // 摘要:    //     执行与释放或重置非托管资源相关的应用程序定义的任务。    void Dispose();}

Microsoft考虑到很多情况下程序员仍然希望在对象不再被使用时进行一些清理工作,所以.NET提供了IDispose接口并且在其中定义了Dispose方法。

通常我们会在Dispose方法中实现一些托管对象和非托管对象的释放以及业务逻辑的结束工作等等。

But,即使我们实现了Dispose方法,也不能得到任何有关释放的保证,Dispose方法的调用依赖于类型的使用者,当类型被不恰当地使用,Dispose方法将不会被调用。因此,我们一般会借助using等语法来帮助Dispose方法被正确调用。

(3)Finalize方法

刚刚提到Dispose方法的调用依赖于类型的使用者,为了弥补这一缺陷,.NET还提供了Finalize方法。Finalize方法类似于C++中的析构函数(方法),但又和C++的析构函数不同。Finalize在GC执行垃圾回收时被调用,其具体机制如下:

① 当每个包含Finalize方法的类型的实例对象被分配时,.NET会在一张特定的表结构中添加一个引用并且指向这个实例对象,暂且称该表为“带析构方法的对象表”;

② 当GC执行并且检测到一个不被使用的对象时,需要进一步检查“带析构方法的对象表”来查询该对象类型是否含有Finalize方法,如果没有则将该对象视为垃圾,如果存在则将该对象的引用移动到另外一张表,暂且称其为“待析构的对象表”,并且该对象实例仍然被视为在被使用。

③ CLR将有一个单独的线程负责处理“待析构的对象表”,其执行方法内部就是依次通过调用其中每个对象的Finalize方法,然后删除引用,这时托管堆中的对象实例就被视为不再被使用。

④ 下一个GC执行时,将释放已经被调用Finalize方法的那些对象实例。

上述四个步骤的完整流程如下图所示:

(4)结合使用Dispose和Finalize方法:标准Dispose模式

Finalize方法由于有CLR保证调用,因此比Dispose方法更加安全(这里的安全是相对的,Dispose需要类型使用者的及时调用),但在性能方面Finalize方法却要差很多。

因此,我们在类型设计时一般都会使用标准Dispose模式:Finalize方法作为Dispose方法的后备,只有在使用者没有调用Dispose方法的情况下,Finalize方法才被视为需要执行。这一模式保证了对象能够被高效和安全地释放,已经被广泛使用。

下面的代码则是实现这种标准Dispose模式的一个模板:

public class BaseTemplate : IDisposable{    // 标记对象是否已经被释放    private bool isDisposed = false;    // Finalize方法    ~BaseTemplate()    {        Dispose(false);    }    // 实现IDisposable接口的Dispose方法    public void Dispose()    {        Dispose(true);        // 告诉GC此对象的Finalize方法不再需要被调用        GC.SuppressFinalize(this);    }    // 虚方法的Dispose方法做实际的析构工作    protected virtual void Dispose(bool isDisposing)    {        // 当对象已经被析构,则不必再继续执行        if(isDisposed)        {            return;        }        if(isDisposing)        {            // Step1:在这里释放托管资源        }        // Step2:在这里释放非托管资源        // Step3:最后标记对象已被释放        isDisposed = true;    }    public void MethodA()    {        if(isDisposed)        {            throw new ObjectDisposedException("对象已经释放");        }        // Put the logic code of MethodA    }    public void MethodB()    {        if (isDisposed)        {            throw new ObjectDisposedException("对象已经释放");        }        // Put the logic code of MethodB    }}public sealed class SubTemplate : BaseTemplate{    // 标记子类对象是否已经被释放    private bool disposed = false;    protected override void Dispose(bool isDisposing)    {        // 验证是否已被释放,确保只被释放一次        if(disposed)        {            return;        }        if(isDisposing)        {            // Step1:在这里释放托管的并且在这个子类型中申明的资源        }        // Step2:在这里释放非托管的并且这个子类型中申明的资源        // Step3:调用父类的Dispose方法来释放父类中的资源        base.Dispose(isDisposing);        // Step4:设置子类的释放标识        disposed = true;    }}

真正做释放工作的只是受保护的虚方法Dispose,它接收一个bool参数,主要用于区分调用者是类型的使用者还是.NET的GC机制。

两者的区别在于通过Finalize方法释放资源时不能再释放或使用对象中的托管资源,这是因为这时的对象已经处于不被使用的状态,很有可能其中的托管资源已经被释放掉了。

在Dispose方法中GC.SuppressFinalize(this)告诉GC此对象在被回收时不需要调用Finalize方法,这一句是改善性能的关键,记住实现Dispose方法的本质目的就在于避免所有释放工作在Finalize方法中进行。

5 了解GC中的分代机制么?

(1)背景知识

GC的基本算法,大体上都逃不出 标记清除、复制收集 及 引用计数 三种方式以及它们的衍生品。而.NET CLR中的GC机制所采用的的分代机制 也正是 标记清除 的升级衍生品。有关GC的基本算法的介绍,可以参考我的这一篇文章:《内管管理与GC那点事儿》。

(2).NET GC将垃圾分为三代

在.NET的GC执行垃圾回收时,并不是每次都扫描托管堆内的所有对象实例,这样做太耗费时间而且没有必要。相反,GC会把所有托管堆内的对象按照其已经不再被使用的可能性分为三类,并且从最有可能不被使用的类别开始扫描,.NET对这样的分类类别有一个称呼:代(Generation)。

GC会把所有的托管堆内的对象分为0代、1代和2代:

第0代,新近分配在堆上的对象,从来没有被垃圾收集过。任何一个新对象,当它第一次被分配在托管堆上时,就是第0代。

第1代,经历过一次垃圾回收后,依然保留在堆上的对象。

第2代,经历过两次或以上垃圾回收后,依然保留在堆上的对象。如果第2代对象在进行完垃圾回收后空间仍然不够用,则会抛出OutOfMemoryException异常。

对于这三代,我们需要知道的是并不是每次垃圾回收都会同时回收3个代的所有对象,越小的代拥有着越多被释放的机会。

(3).NET GC分代的基本算法

CLR对于代的基本算法是:

每执行N次0代的回收,才会执行一次1代的回收,而每执行N次1代的回收,才会执行一次2代的回收。

当某个对象实例在GC执行时被发现仍然在被使用,它将被移动到下一个代中上,下图简单展示了GC对三个代的回收操作。

根据.NET的垃圾回收机制,0代、1代和2代的初始分配空间分别为256KB、2M和10M。

说完分代的垃圾回收设计,也许我们会有疑问,为什么要这样弄?

其实分代并不是空穴来风的设计,而是参考了这样一个事实:

一个对象实例存活的时间越长,那么它就具有更大的机率去存活更长的时间。

换句话说,最有可能马上就不被使用的对象实例,往往是那些刚刚被分配的对象实例,而且新分配的对象实例通常都会被马上大量地使用。

这也解释了为什么0代对象拥有最多被释放的机会,并且.NET也只为0代分配了一块只有256KB的小块逻辑内存,以使得0代对象有机会被全部放入处理器的缓存中去,这样做的结果就是使用频率最高并且最有可能马上可以被释放的对象实例拥有了最高的使用效率和最快的释放速度。

因为一次GC回收之后仍然被使用的对象会被移动到更高的代上,因此我们需要避免保留已经不再被使用的对象引用,将对象的引用置为null是告诉.NET该对象不需要再使用的最直接的方法。

在前面我们提到Finalize方法会大幅影响性能,通过结合对代的理解,我们可以知道:

在带有Finalize方法的对象被回收时,该对象会被视为正在被使用从而被留在托管堆中,且至少要等一个GC循环才能被释放。

画外音:为什么是至少一个?因为这取决于执行Finalize方法的线程的执行速度。

很明显,需要执行Finalize方法的那些对象实例,被真正释放时最乐观的情况下也已经位于1代的位置上了,而如果它们是在1代上才开始释放或者执行Finalize方法的线程运行得慢了一点,那该对象就在第2代上才被释放,相对于0代,这样的对象实例在堆中存留的时间将长很多。

6 GC如何判断一个对象仍然在被使用?

(1).NET GC分代的基本算法

在.NET中引用类型对象实例通常通过引用来访问,而GC判断堆中的对象是否仍然在被使用的依据也是引用。简单地说:当没有任何引用指向堆中的某个对象实例时,这个对象就被视为不再使用。

在GC执行垃圾回收时,会把引用分为以下两类:

(1)根引用:往往指那些静态字段的引用,或者存活的局部变量的引用;

(2)非根引用:指那些不属于根引用的引用,往往是对象实例中的字段。

垃圾回收时,GC从所有仍在被使用的根引用出发遍历所有的对象实例,那些不能被遍历到的对象将被视为不再被使用而进行回收。我们可以通过下面的一段代码来直观地理解根引用和非根引用:

class Program{    public static Employee staticEmployee;    static void Main(string[] args)    {        staticEmployee = new Employee(); // 静态变量        Employee a = new Employee();     // 局部变量        Employee b = new Employee();     // 局部变量        staticEmployee.boss = new Employee();   // 实例成员        Console.ReadKey();        Console.WriteLine(a);    }}public class Employee{    public Employee boss;    public override string ToString()    {        if(boss == null)        {            return "No boss";        }        return "One boss";    }}

上述代码中一共有两个局部变量和一个静态变量,这些引用都是根引用。而其中一个局部变量 a 拥有一个成员实例对象,这个引用就是一个非跟引用。

下图展示了代码执行到Console.ReadKey()这行代码时运行垃圾回收时的情况。

从上图中可以看出,在执行到代码Console.ReadKey()时,存活的根引用有staticEmployee和a,前者因为它是一个公共静态变量,而后者则因为后续代码还会使用到a。

通过这两个存活的根引用,GC会找到一个非跟引用staticEmployee.boss,并且发现三个仍然存活的对象。而b的对象则将被视为不再使用从而被释放。

画外音:更简单地确保b对象不再被视为在被使用的方法时把b的引用置为null,即b=null;

此外,当一个从根引用触发的遍历抵达一个已经被视为在使用的对象时,将结束这一个分支的遍历,这样做可以避免陷入死循环。

7 .NET托管堆是否可能出现内存泄漏?

(1)整体认知

首先,必须明确一点:即使在拥有垃圾回收机制的.NET托管堆上,仍然是有可能发生内存泄露现象的。

其次,什么是内存泄露?内存泄露是指内存空间上产生了不再被实际使用却又不能被分配的内存空间,其意义很广泛,像内存碎片、不彻底的对象释放等都属于内存泄露现象。内存泄露将导致主机的内存随着程序的运行而逐渐减少,无论其表现形式怎样,它的危害是很大的,因此我们需要努力地避免。

(2)常见内存泄漏场景

按照内存泄露的定义,我们可以知道在大部分的时候.NET中的托管堆中存在着短暂的内存泄露情况,因为对象一旦不再被使用,需要等到下一个GC时才会被释放。

这里列举几个在.NET中常见的几种对系统危害较大的内存泄露情况,我们在实际开发中需要极力避免:

① 大对象的分配

.NET中所有的大对象(这里主要是指对象的大小超过指定数值[85000字节])将分配在托管堆内一个特殊的区域内,暂且将其称为“大对象堆”(这也算是CLR对于GC的一个优化策略)。

大对象堆中最重要的一个特点就是:没有代级的概念,所有对象都被视为第2代。在回收大对象堆内的对象时,其他的大对象不会被移动,这是考虑到大规模地移动对象需要耗费过多的资源。

这样,在程序过多地分配和释放大对象之后,就会产生很多内存碎片。下图解释了这一过程:

如图所示可以看出,随着对象的分配和释放不断进行,在不进行对象移动的大对象堆内,将不可避免地产生小的内存碎片。我们所需要做的就是尽量减少大对象的分配次数,尤其是那些作为局部变量的,将被大规模分配和释放的大对象,典型的例子就是String类型。

② 不恰当的根类型的引用

最简单的一个错误例子就是不恰当地把一个对象申明为公共静态变量,一个公共的静态变量将一直被GC视为一个在使用的根引用。更糟糕的是:当这个对象内部还包含更多的对象引用时,这些对象同样不会被释放。例如下面一段代码:

public class Program{    // 公共静态大对象    public static RefRoot bigObject = new RefRoot("test");    public static void Main(string[] args)    {                    Console.ReadKey();    }}public class RefRoot{    // 这是一个占用大量内存的成员    public string[] BigMember;    public RefRoot(string content)    {        // 初始化大对象        BigMember = new string[1000];        for (int i = 0; i < 1000; i++)        {            BigMember[i] = content;        }    }}

在上述代码中,定义了一个公共静态的大对象,这个对象将直到程序运行结束后才会被GC释放掉。如果在整个程序中各个类型不断地使用这个静态成员,那这样的设计有助于减少大对象堆内的内存碎片,但是如果整个程序极少地甚至只有一次使用了这个成员,那考虑到它占用的内存会影响整体系统性能,设计时则应该考虑设计成实例变量,以便GC能够及时释放它。

③ 不正确的Finalize方法

前面已经介绍了Finalize方法时由GC的一个专用的线程进行调用,抛开Microsoft怎样实现的这个具体的调度算法,有一点可以肯定的是:不正确的Finalize方法将导致Finalize方法不能被正确执行。如果系统中所有的Finalize方法不能被正确执行,包含它们的对象也只能驻留在托管堆内不能被释放,这样的情况将会导致严重的后果。

那么,什么是不正确的Finalize方法?

Finalize方法应该只致力于快速而简单地释放非托管资源,并且尽可能快地返回。相反,不正确的Finalize方法则可能包含以下这样的一些代码:

没有保护地写文件日志;访问数据库;访问网络;把当前对象赋给某个存活的引用;

例如,当Finalize方法试图访问文件系统、数据库或者网络时,将会有资源争用和等待的潜在危险。试想一个不断尝试访问离线数据库的Finalize方法,将会在长时间内不会返回,这不仅影响了对象的释放,也使得排在Finalize方法队列中的所有后续对象得不到释放,这个连锁反应将会导致很快地造成内存耗尽。

此外,如果在Finalize方法中把对象自身又赋给了另外一个存活的引用,这时对象内的一部分资源已经被释放掉了,而另外一部分还没有,当这样一个对象被激活后,将导致不可预知的后果。

总结

本文总结复习了.NET的内存管理和GC垃圾回收相关的重要知识点,下一篇会总结.NET面向对象相关的实现相关的重要知识点,欢迎继续关注!

参考资料(全是经典)

朱毅,《进入IT企业必读的200个.NET面试题》

张子阳,《.NET之美:.NET关键技术深入解析》

王涛,《你必须知道的.NET》

标签: #三线城市net开发面试