详解CLR的内存分配和回收机制

一、CLR

CLR:即公共语言运行时(Common Language Runtime),是中间语言(IL)的运行时环境,负责将编译生成的MSIL编译成计算机可以识别的机器码,负责资源管理(内存分配和垃圾回收等)。

可能有人会提问:为什么不直接编译成机器码,而要先编译成IL,然后在编译成机器码呢?

原因是:计算机的操作系统不同(分为32位和64位),接受的计算机指令也是不同的,在不同的操作系统中就要进行不同的编译,写出的代码在不同的操作系统中要进行不同的修改。中间增加了IL层,不管是什么操作系统,编译生成的IL都是相同的,IL被不同操作系统的CLR编译成机器码,最终被计算机执行。

JIT:即时编译器,负责编译成机器码。

二、内存分配

内存分配:指程序运行时,进程占用的内存,由CLR负责分配。

值类型:值类型是struct的,例如:int、datetime等。

引用类型:即class,例如:类、接口,string等。

1、栈

栈:即线程栈,先进后出的一种数据结构,随着线程而分配,其顺序如下:

看下面的例子:

定义一个结构类型

public struct ValuePoint
{
    public int x;
    public ValuePoint(int x)
    {
         this.x = x;
    }
}

在方法里面调用:

//先声明变量,没有初始化  但是我可以正常赋值  跟类不同
ValuePoint valuePoint;
valuePoint.x = 123;

ValuePoint point = new ValuePoint();
Console.WriteLine(valuePoint.x);

内存分配情况如下图所示:

注意:

(1)、值类型分配在线程栈上面,变量和值都是在线程栈上面。

(2)、值类型可以先声明变量而不用初始化。

2、堆

堆:对象堆,是进程中独立划出来的一块内存,有时一些对象需要长期使用不释放、对象的重用,这些对象就需要放到堆上。

来看下面的例子:

定义一个类

public class ReferencePoint
{
     public int x;
     public ReferencePoint(int x)
     {
           this.x = x;
     }
}

在代码中调用:

ReferencePoint referencePoint = new ReferencePoint(123);
Console.WriteLine(referencePoint.x);

其内存分配如下:

注意:

(1)、引用类型分配在堆上面,变量在栈上面,值在堆上面。

(2)、引用类型分配内存的步骤:

  • a、new的时候去对象堆里面开辟一块内存,分配一个内存地址。
  • b、调用构造函数(因为在构造函数里面可以使用this),这时才执行构造函数。
  • c、把地址引用传给栈上面的变量。

3、复杂类型

a、引用类型里面嵌套值类型

先看下面引用类型的定义:

public class ReferenceTypeClass
{
        private int _valueTypeField;
        public ReferenceTypeClass()
        {
            _valueTypeField = 0;
        }
        public void Method()
        {
            int valueTypeLocalVariable = 0;
        }
}

在一个引用类型里面定义了一个值类型的属性:_valueTypeField和一个值类型的局部变量:valueTypeLocalVariable,那么这两个值类型是如何进行内存分配的呢?其内存分配如下:

内存分配为什么是这种情况呢?值类型不应该是都分配在栈上面吗?为什么一个是分配在堆上面,一个是分配在栈上面呢?

_valueTypeField分配在堆上面比较好理解,因为引用类型是在堆上面分配了一整块内存,引用类型里面的属性也是在堆上面分配内存。

valueTypeLocalVariable分配在栈上面是因为valueTypeLocalVariable是一个全新的局部变量,调用方法的时候,会启用一个线程去调用,线程栈来调用方法,然后把局部变量分配到栈上面。

b、值类型里面嵌套引用类型

先来看看值类型的定义:

public struct ValueTypeStruct
{
        private object _referenceTypeField;
        public ValueTypeStruct(int x)
        {
            _referenceTypeField = new object();
        }
        public void Method()
        {
            object referenceTypeLocalVariable = new object();
        }
}

在值类型里面定义了引用类型,其内存是如何分配的呢?其内存分配如下:

从上面的截图中可以看出:值类型里面的引用类型的变量分配在栈上,值分配在堆上。

总结:

1、方法的局部变量

根据变量自身的类型决定,与所在的环境没关系。变量如果是值类型,就分配在栈上。变量如果是引用类型,内存地址的引用存放在栈上,值存放在堆上。

2、对象是引用类型

其属性/字段,都是在堆上分配内存。

3、对象是值类型

其属性/字段由自身的类型决定。属性/字段是值类型就分配在栈上;属性/字段是引用类型就分配在堆上。

上面的三种情况可以概括成下面一句话:

引用类型在任何时候都是分配在堆上;值类型任何时候都是分配在栈上,除非值类型是在引用类型里面。

4、String字符串的内存分配

首先要明确一点:string是引用类型。

先看看下面的例子:

string student = "大山";//在堆上面开辟一块儿内存  存放“大山”  返还一个引用(student变量)存放在栈上

其内存分配如下图所示:

这时,在声明一个变量student2,然后用student给student2赋值:

string student2 = student;

这时内存是如何分配的呢?其内存分配如下:

从上面的截图中可以看出:student2被student赋值的时候,是在栈上面复制一份student的引用给student2,然后student和student2都是指向堆上面的同一块内存。

输出student和student2的值:

Console.WriteLine("student的值是:" + student);
Console.WriteLine("student2的值是:"+student2);

结果:

从结果可以看出:student和student2的值是一样的,这也能说明student和student2指向的是同一块内存。

这时修改student2的值:

student2 = "App";

这时在输出student和student2的值,其结果如下图所示:

从结果中可以看出:student的值保持不变,student2的值变为App,为什么是这样呢?这是因为string字符串的不可变性造成的。一个string变量一旦声明并初始化以后,其在堆上面分配的值就不会改变了。这时修改student2的值,并不会去修改堆上面分配的值,而是重新在堆上面开辟一块内存来存放student2修改后的值。修改后的内存分配如下:

在看下面一个例子:

string student = "大山";
string student2 = "App";
student2 = "大山";
Console.WriteLine(object.ReferenceEquals(student,student2));

结果:

可能有人会想:按照上面讲解的,student和student2应该指向的是不同的内存地址,结果应该是false啊,为什么会是true呢?这是因为CLR在分配内存的时候,会查找是否有相同的值,如果有相同的值,就重用;如果没有,这时在重新开辟一块内存。所以修改student2以后,student和student2都是指向同一块内存,结果输出是true。

注意:

这里需要区分string和其他引用类型的内存分配。其他引用类型的情况和string正好相反。看下面的例子

先定义一个Refence类,里面有一个int类型的属性,类定义如下:

public class Refence
{
     public int Value { get; set; }
}

在Main()方法里面调用:

Refence r1 = new Refence();
r1.Value = 30;
Refence r2 = r1;
Console.WriteLine($"r2.Value的值:{r2.Value}");
r2.Value = 50;
Console.WriteLine($"r1.Value的值:{r1.Value}");
Console.ReadKey();

结果:

从运行结果可以看出,如果是普通的引用类型,如果修改其他一个实例的值,那么另一个实例的值也会改变。正好与string类型相反。

三、内存回收

值类型存放在线程栈上,线程栈是每次调用都会产生,用完自己就会释放。

引用类型存放在堆上面,全局共享一个堆,空间有限,所以才需要垃圾回收。

CLR在堆上面是连续分配内存的。

1、C#中的资源分为两类:

a、托管资源

由CLR管理的存在于托管堆上的称为托管资源,注意这里有2个关键点,第一是由CLR管理,第二存在于托管堆上。托管资源的回收工作是不需要人工干预的,CLR会在合适的时候调用GC(垃圾回收器)进行回收。

b、非托管资源

非托管资源是不由CLR管理,例如:Image Socket, StreamWriter, Timer, Tooltip, 文件句柄, GDI资源, 数据库连接等等资源(这里仅仅列举出几个常用的)。这些资源GC是不会自动回收的,需要手动释放。

2、托管资源

a、垃圾回收期(GC)

定期或在内存不够时,通过销毁不再需要或不再被引用的对象,来释放内存,是CLR的一个重要组件。

b、垃圾回收器销毁对象的两个条件

1)对象不再被引用----设置对象=null。

2)对象在销毁器列表中没有被标记。

c、垃圾回收发生时机

1)垃圾回收发生在new的时候,new一个对象时,会在堆中开辟一块内存,这时会查看内存空间是否充足,如果内存空间不够,则进行垃圾回收。

2)程序退出的时候也会进行垃圾回收。

d、垃圾回收期工作原理

GC定期检查对象是否未被引用,如果对象没有被引用,则在检查销毁器列表。若在销毁器列表中没有标记,则立即回收。若在销毁器列表中有标记,则开启销毁器线程,由该线程调用析构函数,析构函数执行完,删除销毁器列表中的标记。

注意:

不建议写析构函数,原因如下:

1)对象即使不用,也会在内存中驻留很长一段时间。

2)销毁器线程为单独的线程,非常耗费资源。

e、优化策略

1)分级策略

a、首次GC前 全部对象都是0级。

b、第一次GC后,还保留的对象叫1级。这时新创建的对象就是0级。

c、垃圾回收时,先查找0级对象,如果空间还不够,再去找1级对象,这之后,还存在的一级对象就变成2级,0级对象就变成一级对象。

d、垃圾回收时如果0~2级都不够,那么就内存溢出了。

注意:

越是最近分配的,越是会被回收。因为最近分配的都是0级对象,每次垃圾回收时都是先查询0级对象。

3、非托管资源

上面讲的都是针对托管资源的,托管资源会被GC回收,不需要考虑释放。但是,垃圾回收器不知道如何释放非托管的资源(例如,文件句柄、网络连接和数据库连接)。托管类在封装对非托管资源的直接或间接引用时,需要制定专门的规则,确保非托管的资源在回收类的一个实例时会被释放。

在定义一个类时,可以使用两种机制来自动释放非托管的资源。这些机制常常放在一起实现,因为每种机制都为问题提供了略为不同的解决方法。这两种机制是:

a、声明一个析构函数(或终结器),作为类的一个成员。

b、在类中实现System.IDisposable接口。

1)、析构函数或终结器

析构函数看起来类似于一个方法:与包含的类同名,但有一个前缀波形符号(~)。它没有返回值,不带参数,也没有访问修饰符。看下面的一个例子:

public class MyClass
{
        /// <summary>
        /// 析构函数
        /// </summary>
        ~MyClass()
        {
            // 要执行的代码
        }
}

析构函数存在的问题:

a、由于使用C#时垃圾回收器的工作方式,无法确定C#对象的析构函数何时执行。所以,不能在析构函数中放置需要在某一时刻运行的代码,也不应该寄希望于析构函数会以特定顺序对不同类的实例调用。如果对象占用了宝贵而重要的资源,应尽快释放这些资源,此时就不能等待垃圾回收器来释放了。

b、C#析构函数的实现会延迟对象最终从内存中删除的时间。没有析构函数的对象会在垃圾回收器的一次处理中从内存中删除,但有析构函数的对象需要两次处理才能销毁:第一次调用析构函数时,没有删除对象,第二次调用才真正删除对象。

c、运行库使用一个线程来执行所有对象的Finalize()方法。如果频繁使用析构函数,而且使用它们执行长时间的清理任务,对性能的影响就会非常显著。

注意:

在讨论C#中的析构函数时,在低层的.NET体系结构中,这些函数称为终结器(finalizer)。在C#中定义析构函数时,编译器发送给程序集的实际上是Finalize()方法,它不会影响源代码。C#编译器在编译析构函数时,它会隐式地把析构函数的代码编译为等价于重写Finalize()方法的代码,从而确保执行父类的Finalize()方法。例如,下面的C#代码等价于编译器为~MyClass()析构函数生成的IL:

protected override void Finalize()
{
       try
       {
            // 析构函数中要执行的代码
       }
       finally
       {
            // 调用父类的Finalize()方法
            base.Finalize();
       }
}

2)、IDisposable接口

在C#中,推荐使用System.IDisposable接口替代析构函数。IDisposable接口定义了一种模式,该模式为释放非托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾回收器相关的问题。IDisposable接口声明了一个Dispose()方法,它不带参数,返回void。例如:

public class People : IDisposable
{
        public void Dispose()
        {
            this.Dispose();
        }
}

Dispose()方法的实现代码显式地释放由对象直接使用的所有非托管资源,并在所有也实现了IDisposable接口的封装对象上调用Dispose()方法。这样,Dispose()方法为何时释放非托管资源提供了精确的控制。

3)、using语句

C#提供了一种语法,可以确保在实现了IDisposable接口的对象的引用超出作用域时,在该对象上自动调用Dispose()方法。该语法使用了using关键字来完成此工作。例如:

using (var people = new People())
{
      // 要处理的代码
}

4)、析构函数和Dispose()的区别

a、析构函数

析构函数 主要是用来释放非托管资源,等着GC的时候去把非托管资源释放掉 系统自动执行。GC回收的时候,CLR一定调用的,但是可能有延迟(释放对象不知道要多久呢)。

b、Dispose()

Dispose() 也是释放非托管资源的,主动释放,方法本身是没有意义的,我们需要在方法里面实现对资源的释放。GC的时候不会调用Dispose()方法,而是使用对象时,使用者主动调用这个方法,去释放非托管资源。

5)、终结器和IDisposable接口的规则

a、如果类定义了实现IDisposable的成员(类里面的属性实现了IDisposable接口),那么该类也应该实现IDisposable接口。

b、实现IDisposable并不意味着也应该实现一个终结器。终结器会带来额外的开销,因为它需要创建一个对象,释放该对象的内存,需要GC的额外处理。只在需要时才应该实现终结器,例如。发布本机资源。要释放本机资源,就需要终结器。

c、如果实现了终结器,也应该实现IDisposable接口。这样,本机资源可以早些释放,而不仅是在GC找出被占用的资源时,才释放资源。

d、在终结器的实现代码中,不能访问已经终结的对象。终结器的执行顺序是没有保证的。

e、如果所使用的一个对象实现了IDisposable接口,就在不再需要对象时调用Dispose方法。如果在方法中使用这个对象,using语句比较方便。如果对象是类的一个成员,那么类也应该实现IDisposable接口。

到此这篇关于详解CLR的内存分配和回收机制的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • C#之CLR内存原理初探

    本文初步讲述了C#的CLR内存原理.这里所关注的内存里面说没有寄存器的,所以我们关注的只有托管堆(heap),栈(stack), 字符串常量池(其中string是一个很特殊的对象) 首先我们看两个方法: void M1() { string name = "Tom"; M2(name); } void M2(string name2) { int length = 10; double rate = 10.0; name2 = "Joe"; return; } 这里

  • C#之CLR内存深入分析

    本文不再对值类型进行讨论,主要讨论一下引用类型.如要看内存值类型的朋友可以看一下前一篇C#之CLR内存原理初探. C#引用类型具体分析如下: 先来装备两个类: internal class Employee { public static Employee LookUp(string name) { return null; } public virtual string GetProgressReport() { return string.Empty; } } internal class

  • 带着问题读CLR via C#(笔记一)CLR的执行模型

    Q1: 什么是CLR? A1: CLR (Common Language Runtime) 是一个可以由多种编程语言使用的"运行时". Q2: CLR的核心功能有哪些? A2: 1)内存管理:2)程序集加载:3)安全性:4)异常处理:5)线程同步 Q3: CLR与使用的编程语言有关吗? A3: 无关.只要编译器是面向CLR的就行. Q4: 选用不同编程语言经过面向CLR的编译器编译后生成的结果相同吗? A4: 相同.无论选择什么语言,相应的编译器变异的结果都是一个托管模块,即一个标准的

  • C#内存管理CLR深入讲解(上篇)

    半年之前,PM让我在部门内部进行一次关于“内存泄露”的专题分享,我为此准备了一份PPT.今天无意中将其翻出来,觉得里面提到的关于CLR下关于内存管理部分的内存还有点意思.为此,今天按照PPT的内容写了一篇文章.本篇文章不会在讨论那些我们熟悉的话题,比如“值类型引用类型具有怎样的区别?”.“垃圾回收分为几个步骤?”.“Finalizer和Dispose有何不同”.等等,而是讨论一些不同的内容.整篇文章分上下两篇,上篇主要谈论的是“程序集(Assembly)和应用程序域(AppDomain)”.也许

  • C#内存管理CLR深入讲解(下篇)

    <上篇>中我们主要讨论的是程序集(Assembly)和应用程序域(AppDomain)的话题,着重介绍了两个不同的程序集加载方式——独占方式和共享方式(中立域方式):以及基于进程范围内的字符串驻留.这篇将关注点放在托管对象创建时内存的分配和对大对象(LO:Large Object)的回收上,不对之处,还望各位能够及时指出. 一.从类型(Type)与实例(Instance)谈起 在面向对象的世界中,类型和实例是两个核心的要素.不论是类型和实例,相关的信息比如加载到内存中,对应着某一块或者多块连续

  • C#之CLR内存字符串常量池(string)

    C#中的string是比特殊的类,说引用类型,但不存在堆里面,而且String str=new String("HelloWorld")这样的重装也说没有的. 我们先来看一个方法: class Program { static void Main(string[] args) { String s = "HelloWorld"; Console.WriteLine(s); } } 然后我们用ildasm.exe工具把它生成IL语言来看一看它里面是怎么玩的: .met

  • 带着问题读CLR via C#(笔记二)类型基础

    Q1: Object类型包含哪些方法? A1: Object类型共包含6个方法,Equals, GetHashCode, ToString, GetType, MemberwiseClone和Finalize. Q2: new一个对象的过程是什么? A2: 1)计算对象所需字节数,包括该类型及其基类型定义的所有实例字段所需的字节数和类型对象指针.同步块索引所需字节数,类型指针和同步块索引是CLR用来管理对象的:2)在托管堆上分配该对象所需内存空间:3)初始化类型对象指针和同步块索引:4)执行构造

  • 重温C# clr 笔记总结

    1: .net framework 由两个部分组成:CLR 和 FCL. 2:在CLR中,所有错误都是通过异常来报告的. 3:智能感知功能主要是靠解析元数据实现的. 4:允许在不同语言之间方便的切换,并对各种语言进行紧密集成是CLR的出色特性. 5:一个方法只有在首次运行时才会由于jit造成一定的性能损失,以后对该方法的调用都以本地代码的形式全速运行. 6:方法签名指定了参数的数量(及其顺序),参数的类型:方法是否有返回值,如果有返回值,还要指定返回值的类型. 7:无论使用哪一种语言,类型的行为

  • 详解CLR的内存分配和回收机制

    一.CLR CLR:即公共语言运行时(Common Language Runtime),是中间语言(IL)的运行时环境,负责将编译生成的MSIL编译成计算机可以识别的机器码,负责资源管理(内存分配和垃圾回收等). 可能有人会提问:为什么不直接编译成机器码,而要先编译成IL,然后在编译成机器码呢? 原因是:计算机的操作系统不同(分为32位和64位),接受的计算机指令也是不同的,在不同的操作系统中就要进行不同的编译,写出的代码在不同的操作系统中要进行不同的修改.中间增加了IL层,不管是什么操作系统,

  • 详解python的内存分配机制

    开始 作为一个实例,让我们创建四个变量并为其赋值: variable1 = 1 variable2 = "abc" variable3 = (1,2) variable4 = ['a',1] #打印他们的ids print('Variable1: ', id(variable1)) print('Variable2: ', id(variable2)) print('Variable3: ', id(variable3)) print('Variable4: ', id(variabl

  • 详解C++ 动态内存分配与命名空间

    1.C++中的动态内存分配 通过new关键字进行动态内存申请 C++中的动态内存申请时基于类型进行的 delete关键用于内存释放 C语言其实是不支持动态内存分配的,是通过malloc库函数来实现的,可能有一些硬件根本不支持malloc:而C++ new是一个关键字,不管在任意编译器上,任意硬件平台上都是能够进行动态内存分配的,这是本质区别. malloc是基于字节来进行动态内存分配的,new则是基于类型来进行动态内存分配 // 变量申请: Type * pointer = new Type;

  • 详解Swift的内存管理

    内存管理 和OC一样, 在Swift中也是采用基于引用计数的ARC内存管理方案(针对堆空间的内存管理) 在Swift的ARC中有三种引用 强引用(strong reference):默认情况下,代码中涉及到的引用都是强引用 弱引用(weak reference):通过weak定义弱引用 无主引用(unowned reference):通过unowned定义无主引用 weak 弱引用(weak reference):通过weak定义弱引用必须是可选类型的var,因为实例销毁后,ARC会自动将弱引用

  • 详解Java的内存模型

    JVM的内存模型 Java "一次运行,到处编译" 的真面目 说JVM内存模型之前,先聊一个老生常谈的问题,为什么Java可以 "一次编译,到处运行",这个话题最直接的答案就是,因为Java有JVM啊,解释这个答案之前,我想先回顾一下一个语言被编译的过程: 一般编程语言的编译过程大抵就是,编译--连接--执行,这里的编译就是,把我们写的源代码,根据语义语法进行翻译,形成目标代码,即汇编码.再由汇编程序翻译成机器语言(可以理解为直接运行于硬件上的01语言):然后进行连

  • 详解C/C++内存区域划分(简而易懂)

    C语言在内存中一共分为如下几个区域,分别是: 1. 内存栈区: 存放局部变量名: 2. 内存堆区: 存放new或者malloc出来的对象: 3. 常数区: 存放局部变量或者全局变量的值: 4. 静态区: 用于存放全局变量或者静态变量: 5. 代码区:二进制代码. 知道如上一些内存分配机制,有助于我们理解指针的概念. C/C++不提供垃圾回收机制,因此需要对堆中的数据进行及时销毁,防止内存泄漏,使用free和delete销毁new和malloc申请的堆内存,而栈内存是动态释放. C/C++内存区域

  • Java详解线上内存暴涨问题定位和解决方案

    前因: 因为REST规范,定义资源获取接口使用GET请求,参数拼接在url上. 如果按上述定义,当参数过长,超过tomcat默认配置 max-http-header-size :8kb 会报一下错误信息: Request header is too large 可以修改springboot配置,调整请求头大小 server: max-http-header-size: xxx 后果: 如果max-http-header-size设置过大,会导致接口吞吐下降,jvm oom,内存泄漏. 因为tom

  • 详解Java volatile 内存屏障底层原理语义

    目录 一.volatile关键字介绍及底层原理 1.volatile的特性(内存语义) 2.volatile底层原理 二.volatile--可见性 三.volatile--无法保证原子性 四.volatile--禁止指令重排 1.指令重排 2.as-if-serial语义 五.volatile与内存屏障(Memory Barrier) 1.内存屏障(Memory Barrier) 2.volatile的内存语义实现 六.JMM对volatile的特殊规则定义 一.volatile关键字介绍及底

  • 详解LeakCanary分析内存泄露如何实现

    目录 前言 LeakCanary的使用 LeakCanary原理 源码浅析 初始化 使用 总结 前言 平时我们都有用到LeakCanary来分析内存泄露的情况,这里可以来看看LeakCanary是如何实现的,它的内部又有哪些比较有意思的操作. LeakCanary的使用 官方文档:square.github.io/leakcanary/… 引用方式 dependencies { // debugImplementation because LeakCanary should only run i

  • 深入了解java内存分配和回收策略

    一.导论 java技术体系中所提到的内存自动化管理归根结底就是内存的分配与回收两个问题,之前已经和大家谈过java回收的相关知识,今天来和大家聊聊java对象的在内存中的分配.通俗的讲,对象的内存分配就是在堆上的分配,对象主要分配在新生代的Eden上(关于对象在内存上的分代在垃圾回收中会补上,想了解的也可以参考<深入理解java虚拟机>),如果启动了本地线程分配缓冲,讲按线程优先在TLAB上分配.少数情况下也是直接在老年代中分配. 二.经典的分配策略 1.对象优先在Eden上分配 一般情况下对

随机推荐