• [学习笔记].NET中的内存分析


    参考:

    一:《你必须知道的.NET》电子工业出版社

    二:对.Net 垃圾回收Finalize 和Dispose的理解

     

    .NET中的内存分配

     

    几个基本概念:

    TypeHandle:类型句柄,指向对应的方法表。每个对象创建时都包含该附加成员。每个类型都对应于一个方法表,方法表创建于编译时,主要包含了类型的特征信息、实现的接口数等等。

     

    SyncBlockIndex:用于线程同步,每个对象创建时也包含该附加成员,它指向一块被称为SyncBlockIndex的内存块,用于管理同对象同步。

     

    NextObjPtr, 由托管堆维护的一个指针,用于标识下一个新建对象分配时在托管堆中所处的位置。CLR初始化时,NextObiPtr位于托管堆的基地址。

     

    托管堆

    对32位处理器来说,应用程序完成进程初始化后,CLR 将在进程的可用地址空间上分配一块保留的地址空间,它是进程(每个进程可使用 4GB)中可用地址空间上的一块内存区域,但并不对应于任何物理内存,这块地址空间即是托管堆。

    托管堆又根据存储信息的不同划分为多个区域,其中最重要的是垃圾回收堆(GC Heap)和加载堆(Loader Heap)。

    GC Heap 用于存储对象实例,受 GC管理;

    Loader Heap 用于存储类型系统,又分为High-Frequency Heap、Low-Frequency Heap和 Stub Heap,不同的堆上存储不同的信息。Loader Heap最重要的信息就是元数据相关的信息,也就是 Type 对象,每个Type 在Loader Heap上体现为一个Method Table(方法表),而Method Table 中则记录了存储的元数据信息,例如基类型、静态字段、实现的接口、所有的方法等等。Loader Heap 不受GC控制,其生命周期为从创建到AppDomain卸载。

     

    值类型一般创建在线程的堆栈上。

    引用类型的实例分配于托管堆上。

    引用类型内存分配过程

    示例如下:

    using System;
    
    public class UserInfo
    {
        private Int32 age = -1;
        private char level = 'A';
    }
    
    public class User
    {
        private Int32 id;
        private UserInfo user;
    }
    
    public class VIPUser : User
    {
        public bool isVip;
        public bool IsVipUser()
        {
            return isVip;
        }
    
        public static void Main()
        {
            VIPUser aUser;
            aUser = new VIPUser();
            aUser.isVip = true;
            Console.WriteLine(aUser.IsVipUser());
        }
    
    }
    

     

    分配过程剖析:

    首先,将声明一个引用类型变量 aUser:

    VIPUser aUser;

    它仅是一个引用(指针),保存在线程的堆栈上,占用 4Byte 的内存空间,将用于保存 VIPUser对象的有效地址,此时 aUser未指向任何有效的实例,因此被自行初始化为 null,试图对 aUser 的任何操作将抛出 NullReferenceException异常。

     

     

    接着,通过new操作执行对象创建:

    aUser = new VIPUser();

    其过程又可细分为以下几步:

    (a)CLR按照其继承层次进行搜索,计算类型及其所有父类的字段,该搜索将一直递归到 System.Object类型,并返回字节总数。以本例而言类型 VIPUser 需要的字节总数为15Byte,具体计算为:VIPUser类型本身字段 isVip(bool型)为1Byte;父类User 类型的字段 id(Int32 型)为 4Byte,字段user 保存了指向 UserInfo 型的引用,因此占 4Byte,而同时还要为 UserInfo 分配6Byte

    字节的内存。

    (b)实例对象所占的字节总数还要加上对象附加成员所需的字节总数,其中附加成员包括TypeHandle和SyncBlockIndex,共计 8字节(在 32位 CPU平台下)。因此,需要在托管堆上分配的字节总数为23 字节,而堆上的内存块总是按照4Byte的倍数进行分配,因此本例中将分配24字节的地址空间。

    (c)CLR 在当前 AppDomain对应的托管堆上搜索,找到一个未使用的24字节的连续空间,并为其分配该内存地址。事实上,GC使用了非常高效的算法来满足该请求,NextObjPtr指针只需要向前推进24 个字节,并清零原 NextObjPtr指针和当前 NextObjPtr 指针之间的字节,然后返回原NextObjPtr指针地址即可,该地址正是新创建对象的托管堆地址,也就是 aUser引用指向的实例地址。而此时的NextObjPtr 仍指向下一个新建对象的位置。

    注意,栈的分配是向低地址扩展,而堆的分配是向高地址扩展。

    在上述操作时,如果试图分配所需空间而发现内存不足时,GC将启动垃圾收集操作来回收垃圾对象所占的内存。

     

     

    最后,调用对象构造器,进行对象初始化操作,完成创建过程。

    该构造过程,又可细分为以下几个环节:

    (a)构造VIPUser 类型的 Type对象,主要包括静态字段、方法描述、实现的接口等,并将其分配在上文提到托管堆的 Loader Heap上。

    (b)初始化 aUser的两个附加成员:TypeHandle和SyncBlockIndex。将TypeHandle 指针指向Loader Heap上的MethodTable,CLR 将根据 TypeHandle 来定位具体的 Type;将 SyncBlockIndex 指针指向Synchronization Block 的内存块,用于在多线程环境下对实例对象的同步操作。

    (c)调用 VIPUser 的构造器,进行实例字段的初始化。实例初始化时,会首先向上递归执行父类初始化,直到完成System.Object类型的初始化,然后再返回执行子类的初始化,直到执行 VIPUser类为止。以本例而言,初始化过程首先执行 System.Object类,再执行 User类,最后才是VIPUser类。最终,newobj分配的托管堆的内存地址,被传递给 VIPUser 的this 参数,并将其引用传给栈上声明的aUser。

     

    上述过程,基本完成了一个引用类型创建、内存分配和初始化的整个流程,然而该过程只能看作是一个简化的描述,实际的执行过程更加复杂,涉及一系列细化的过程和操作。对象创建并初始化之后,内存的布局,可以表示为下图。

    clip_image001[4]

     

    其余种种

    对于值类型嵌套引用类型的情况,引用类型变量作为值类型的成员变量,在堆栈上保存该成员的引用,而实际的引用类型仍然保存在 GC堆上;

    对于引用类型嵌套值类型的情况,则该值类型字段将作为引用类型实例的一部分保存在 GC堆上。

    方法调用过程

    如上所言,MethodTable中包含了类型的元数据信息,类在加载时会在 Loader Heap上创建这些信息,一个类型在内存中对应一份 MethodTable,其中包含了所有的方法、静态字段和实现的接口信息等。对象实例的 TypeHandle在实例创建时,将指向MethodTable 开始位置的偏移处(默认偏移12Byte)。通过对象实例调用某个方法时,CLR 根据TypeHandle 可以找到对应的 MethodTable,进而可以定位到具体的方法,再通过 JIT Compiler 将IL 指令编译为本地 CPU指令,该指令将保存在一个动态内存中,然后在该内存地址上执行该方法,同时该 CPU指令被保存起来用于下一次的执行。

    静态字段的内存分配和释放

    静态字段也保存在方法表中,位于方法表的槽数组后,其生命周期为从创建到 AppDomain卸载。因此一个类型无论创建多少个对象,其静态字段在内存中也只有一份。静态字段只能由静态构造函数进行初始化,静态构造函数确保在任何对象创建前,或者在任何静态字段或方法被引用前执行,其详细的执行顺序在 7.8 节“动静之间:静态和非静态”有所讨论。

     

    .NET的垃圾回收机制:

    CLR管理内存的区域主要有三块:

    一:

    线程的堆栈 ,用于分配值类型实例。堆栈主要有操作系统管理,不受垃圾收集器的控制,当值类型实例所在的方法结束时,其存储单位自动释放。栈的执行效率高,但存储容量有限。

    二:

    GC堆,用于分配小对象实例。如果引用类型对象的实例小于85000字节,实例将被分配在GC堆上。当有内存分配或者回收时,垃圾收集器会对GC对进行压缩。

    三:

    LOH(Large object heap),用于分配大对象实例。LOH堆不会被压缩,而且只有在完全GC回收时才会被回收,这种设计方案是对垃圾回收性能的优化考虑。

    GC如何判断某个对象为垃圾

    每个应用程序有一组根(指针),根指向托管堆中的存储位置,由 JIT编译器和 CLR运行时维护根指针列表,主要包括全局变量、静态变量、局部变量和寄存器指针等。

    当垃圾收集器启动时,它假设所有对象都是可回收的垃圾,并开始遍历所有的根,将根引用的对象标记为可达对象添加到可达对象图中,在遍历过程中,如果根引用的对象还引用着其他对象,则该对象也被添加到可达对象图中,依次类推,垃圾收集器通过根列表的递归遍历,将能找到所有可达对象,并形成一个可达对象图。同时那些不可达对象则被认为是可回收对象,垃圾收集器接着运行垃圾收集进程来释放垃圾对象的内存空间。

    垃圾收集器何时启动:

    (1)内存不足溢出时,更确切地应该说是第 0代对象充满时。

    (2)调用 GC.Collect 方法强制执行垃圾回收。

    (3)Windows报告内存不足时,CLR 将强制执行垃圾回收。

    (4)CLR 卸载AppDomain 时,GC将对所有代龄的对象执行垃圾回收。

    (5)其他情况,例如物理内存不足,超出短期存活代的内存段门限,运行主机拒绝分配内存

    等等

    何为紧缩

    GC在垃圾回收之后,堆上将出现多个被收集对象的“空洞”,为避免托管堆的内存碎片,会重新分配内存,压缩托管堆,此时 GC可以看出是一个紧缩收集器,其具体操作为:GC找到一块较大的连续区域,然后将未被回收的对象转移到这块连续区域,同时还要对这些对象重定位,修改应用程序的根以及发生引用的对象指针,来更新复制后的对象位置。

    关于Generation(代龄)的详细情况请参考《你必须知道的.NET》第五章,P187

     

    非托管资源的回收

    GC全权负责了对托管堆的内存管理,然而除了内存,还有一些非托管资源比如数据库链接、文件句柄、网络链接、互斥体、COM对象、套接字、位图和GDI+对象等, 这些GC是不管的。

    非托管资源的清理,主要有两种方式:Finalize 方法和 Dispose 方法。

    Finalize方式

    其实就是用析构函数来释放资源

    例如

    class GCApp: Object 
    { 
    	~GCApp() 
    	{ 
    		//执行资源清理 
    	} 
    }

    该析构函数会被编译成一个Finalize()方法。

    Finalize方法调用时,会强制调用父类的FInalize方法。

    若某个类有析构函数,则当GC回收该对象之前,会自动执行其析构函数来清理非托管资源,否则直接将该对象从内存清除。

    若一个对象调用了GC.SuppressFinalize()方法,则GC在清理它之前不会再调用它的析构函数。

    Finalize方式存在很多弊端

    比如

     

    调用Finalize()的时间无法控制,只在GC决定要回收该对象时才调用其析构函数,这导致一些资源比如文件句柄长时间被占据。

    极大地损伤性能,GC使用一个终止化队列的内部结构来跟踪具有 Finalize方法的对象。当重写了Finalize方法的类型在创建时,要将其指针添加到该终止化队列中,由此对性能产生影响。

    因此一般情况下在自定义类型中应避免重写 Finalize 方法,而通过 Dispose 模式来完成对非托管资源的清理

     

    Dispose模式

    其实就是某个类实现 System.IDisposable接口。

    该接口中定义了一个公有无参的 Dispose 方法,用户在该方法中清理非托管资源。

    Dispose方法需要用户显式的调用才能被执行。这保证了用户能够及时地释放非托管资源,而不必等到GC回收该对象之前才释放资源。

    例子

    class MyDispose : IDisposable 
    { 
        //实现IDisposable接口 
        public void Dispose() 
        { 
            //释放资源
        } 
    } 

    派生类中实现Dispose模式,应该重写基类的受保护Dispose方法,并且通过base调用基类的Dispose方法,以确保释放继承链上所有对象的引用资源,在整个继承层次中传播 Dispose模式。

        protected override void Dispose(bool disposing)
        {
            if (!disposed)
            {
                try
                {
                    //子类资源清理 
                    //...... 
                    disposed = true;
                }
    
                finally
                {
                    base.Dispose(disposing);
                }
            }
        } 

    最佳策略

    最佳的资源清理策略,应该是同时实现 Finalize 方式和 Dispose方式。

    一方面,Dispose方法可以克服Finalize 方法在性能上的诸多弊端;另一方面,Finalize 方法又能够确保没有显式调用 Dispose 方法时,也自行回收使用的所有资源。

    例子

    我们模拟一个简化版的文件处理类 FileDealer,其中涉及对文件句柄的访问

    using System;
    using System.Runtime.InteropServices;
    
    class FileDealer : IDisposable
    {
        //定义一个访问文件资源的 Win32句柄 
        private IntPtr fileHandle;
    
        //定义引用的托管资源 
        private ManagedRes managedRes;
    
        //定义构造器,初始化托管资源和非托管资源 
        public FileDealer(IntPtr handle, ManagedRes res)
        {
            fileHandle = handle;
            managedRes = res;
        }
    
        //实现终结器,定义Finalize 
        ~FileDealer()
        {
            if (fileHandle != IntPtr.Zero)
            {
                Dispose(false);//确保当没有显式调用Dispose时,在对象被GC销毁前也会调用Dispose方法。
            }
        }
    
        //实现IDisposable接口 
    
        public void Dispose()
        {
            Dispose(true);
            //阻止GC调用Finalize方法
            GC.SuppressFinalize(this);
        }
    
        //实现一个处理资源清理的具体方法 
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                //清理托管资源 
                managedRes.Dispose();
            }
    
            //执行资源清理,在此为关闭对象句柄 
            if (fileHandle != IntPtr.Zero)
            {
                CloseHandle(fileHandle);
                fileHandle = IntPtr.Zero;
            }
        }
    
        public void Close()
        {
            //在内部调用Dispose来实现 
            Dispose();
        }
    
        //实现对文件句柄的其他应用方法 
        public void Write() { }
        public void Read() { }
    
        //引入外部Win32API 
        [DllImport("Kernel32")]
    
        private extern static Boolean CloseHandle(IntPtr handle);
    
    }
    

    这个例子的妙处在于既可以显式调用Dispose方法,又能够保证当我们没有显式地调用Dispose()方法时,GC回收该对象之前也会调用Dispose(false)方法来释放非托管资源。

     

    本例子的说明:

    Dispose方法中,应该使用 GC. SuppressFinalize防止GC调用Finalize方法,因为显式调用Dispose之后就没有必要再让GC调用Finalize()方法了。

     

    公有Dispose方法不能实现为虚方法,以禁止在派生类中重写。

     

    在该模式中,公有Dispose方法通过调用重载虚方法 Dispose(bool disposing)方法来实现,具体的资源清理操作实现于虚方法中。Dispose(true)在Dispose()中调用,Dispose(false)在析构函数中调用,两者的区别在于Dispose(true)表示要将托管资源和非托管资源一并清理掉,而Dispose(false)由于是GC调用finalize()时候被调用,GC已经准备清理这个对象了,所以就不需要再手动地清理托管资源了。

    总结

    Finalize是CLR提供的一个机制, 它保证如果一个类实现了Finalize方法,那么当该类对象被垃圾回收时,垃圾回收器会调用Finalize方法

     

    Dispose(bool disposing)不是CRL提供的一个机制, 而仅仅是一个设计模式。

     

    Finalize由GC自行调用,而Dispose由开发者强制执行调用。

    尽量避免使用Finalize方式来清理资源,必须实现Finalize时,也应一并实现 Dispose方法,来提供显式调用的控制权限。

     

    执行Finalize()的准确时间是不确定的,这取决于GC。

     

    Finalize方法和Dispose方法,只能清理非托管资源,释放内存的工作仍由GC负责。

     

    对象使用完毕应该立即释放其资源,最好显式调用 Dispose方法来实现

     

    using语句

    凡是实现了Dispose模式的类型,均可以 using语句来定义其引用范围.

     

    例如:

    public static void Main() 
    { 
    	using(FileDealer fd = new FileDealer(new IntPtr(), new ManagedRes())) 
    	{ 
    		fd.Read(); 
    	} 
    } 
    

    当执行流超过了using的代码块之后,fd对象会自动调用Dispose()方法释放资源。

  • 相关阅读:
    【手把手】JavaWeb 入门级项目实战 -- 文章发布系统 (第三节)
    【手把手】JavaWeb 入门级项目实战 -- 文章发布系统 (第二节)
    【手把手】JavaWeb 入门级项目实战
    用大白话聊聊JavaSE -- 自定义注解入门
    用大白话聊聊JavaSE -- 如何理解Java Bean(一)
    从硬件工程师转到纯软件开发,回顾那些岁月
    TessorFlow学习 之 序言
    《图像处理实例》 之 二值图像分割
    《图像处理实例》 之 Voronoi 图
    《图像处理实例》 之 疏密程度统计
  • 原文地址:https://www.cnblogs.com/ybwang/p/1765117.html
Copyright © 2020-2023  润新知