【Unity】Unity内存管理与优化(二)方法表、重写、隐藏、静/动态绑定、同步块索引

42 篇文章 11 订阅
34 篇文章 18 订阅


本文概述

上一篇文章我们了解了Unity运行时的三大内存域、堆栈的使用以及垃圾回收的机制。虽然对于堆栈的基本使用方法有了了解,但是具体的一个类从编译到实例化到垃圾回收的整个过程其实还是了解的比较片面的,这一章就来讨论一下这方面的细节。

当我打算写这样一篇文章时,我开始在网上搜索相关的资料,令我诧异的是,从2012年Unity上海公司成立到现在,Unity的使用在国内应该算是普及的不能再普及了,但是涉及到详细内存使用的文章却是少之又少,且大部分都只能讲出某一些小的点,无法做到很全面的讲解。无奈之下,只能尽可能的凭自己的理解写一篇讲解了。不得不说的是,幸亏@foyuan大神在他的文章https://blog.csdn.net/foyuan/article/details/1722481中贴出了一张当时MSDN中曾经出现的一张图(原文链接现已无法打开),认真分析这张图,也基本能够大致分析出整个对象对于内存使用的情况了,在此感谢@foyuan大神的分享,多谢!

下面,请随我一起了解 “一个对象忙碌的一生” 吧~

一个对象的一生

关于一个对象的一生,MSDN曾经有过这样一张图,基本将一个对象的内存使用情况讲清楚了。相信很多人看到这样一幅全是英文且看不懂的图大部分人想的都是:《C#从入门到放弃》。我认真看了这张图,觉得讲的很好,可惜清晰度不高,所以决定自己重新画一版,再配上中文讲解,希望能够对大家有帮助。

MSDN原版
在这里插入图片描述
高清重绘版
在这里插入图片描述

翻译讲解版
在这里插入图片描述
尽管图比较复杂,但是我们还是不难看出一个对象在实例化以后在内存中产生了哪些内容。
在图中我将访问方法的路径用序号标出来了,其中:

  • :是栈中的对象实例引用通过引用指向的地址找到了堆中真正的对象实例
  • :是通过对象实例中的类型句柄(类型句柄其实就是方法表的堆内实际地址)找到所属方法表对应内存地址的第0个位置,注意此处是第0个字节的位置,而在此位置之前还有一段内容是专门用来记录垃圾回收信息的;
  • :是在方法表中找到某个方法的引用后通过这个引用指向的地址找到相应的方法描述。这个方法的描述分为两部分:一部分是方法的存根(原图中stub应该是存根的意思,我们可以理解为方法的名字),另一部分是方法的实际执行代码。根据运行环境的不同,这段代码的语言种类也不一样,有可能是原生代码,也有可能是中间语言代码,系统会根据实际的运行情况来决定如何操作(当然,这是编译阶段就决定了的事情)。

图中方法表左侧的数字代表的是占位信息(偏移量),这个偏移值是固定的,对于偏移值来说flags是方法表的开头,然而实际的内存中还会有一段GC信息存放于方法表的前端。根据方法类别的不同,CLR会通过这个固定的偏移值jmp跳转到相应的位置,而不是采用遍历的方式。

由于图中有部分内容是不确定的,所以没有直接翻译,然而我自己是有一些猜测的,只是不确定的东西不想写出来,以免造成误导。如果有想进一步了解的,可以私信我,或者在评论区留言,我可以简单讲讲我的想法。

不管是哪个版本,相信大家都不难看出,绝对的C位就是中间蓝色的这一条 – MethodTable(方法表),既然方法表如此重要,那它到底做了什么呢?请看下文。

方法表

程序运行的过程中,每一次类在加载时,都会在内存中开辟一个空间,用于存放类的方法表。方法表就是对一个类型的详细描述,其中存放的内容也都只是方法的基本信息,真正的方法体其实是存在另外段内存中的。当我们创建一个类的对象后,内存中大致会有如下内容产生:
在这里插入图片描述
方法表是在类加载时产生的,但实际上方法表并不是一成不变的,由于多态的特性,方法表在运行过程中是会有所改变的,下文中会详细讲解。

引用类型通过类型对象指针可以找到类型方法表,从而调用方法。对于值类型,也有方法表(任何类型都有方法表),但值类型的实例没有类型对象指针指向它,需要访问类型元数据获得方法表。

对于虚方法, CLR 是把虚方法和一般方法分开来放的,两种方法的入口项不一样,方便子类对虚方法进行索引。虚方法的入口操作码不是 call ,而是 callvirt。

方法存放顺序

方法表存放方法的顺序大致如下:
在这里插入图片描述

方法重写原理

方法重写的内存原理是,运行时父类方法地址替换为子类方法地址,注意,这里说的替换,就是在改变方法表

程序运行的过程中,每一次类在加载时,都会在内存中开辟一个空间,用于存放类的方法表。其内部生成的对象都在方法区内进行处理,假设我们有Transportation和Train两个类,Train类继承自Transportation类。当代码创建了一个Train类的实例后,会将父类方法表方法的地址改为子类方法的地址

  1. 在子类方法表中添加新记录。
  2. 修改父级方法表地址。
abstract class Transportation
{
    public abstract void Transport();
}
class Train : Transportation
{
    public override void Transport()
    {
        Console.WriteLine("坐火车回家");
    }
}
class Program
{
	static void Main()
	{
		Train t = new Train();
	}
}

在这里插入图片描述

是不是很迷惑?子类覆盖父类?那父类怎么办???怎么能这么随便改?别急,继续往下看。

假如Transportation还有一个子类叫做Airplane,就有了三个方法表,代码如下:

abstract class Transportation
{
    public abstract void Transport();
}
class Train : Transportation
{
    public override void Transport()
    {
        Console.WriteLine("坐火车回家");
    }
}
class Airplane : Transportation
{
    public override void Transport()
    {
        Console.WriteLine("坐飞机回家");
    }
}
class Program
{
	static void Main()
	{
		Train t = new Train();         // 父类方法被替换为Train类的方法
		Airplane a = new Airplane();   // 父类方法被替换为Airplane类的方法
	}
}

方法表图示:
在这里插入图片描述

没错,父类的方法表确实是被子类替换了,但并不是编译过程中就替换了,而是在运行的过程中替换的,由于运行是有顺序的,所以,当 Train t 对象被实例化后,Train的方法会替换父类的方法句柄,而当代码执行到 Airplane a 对象时,Airplane的方法又会再次替换父类的方法。系统有自己的一套机制,能够保证在对的时候做这个替换的事。

另外请注意:在同一个线程内执行代码,无论是否使用了协程,起码我们能保证程序运行的顺序,但如果是多个线程呢?这就涉及到了上下文切换的概念,关于上下文切换,我在文章《【Unity】进程、线程、协程》 以及文章《【Unity】Unity开发进阶(一)减少跨桥(上下文切换)》都有提及,有兴趣的可以看一下。

方法隐藏原理

如果不使用 override 关键字,直接在子类中重新定义父类已经有的虚方法或者成员方法,就会实现对父类方法的隐藏,此时父类方法仍然存在方法表中也不会将父类的方法地址替换,等于是两个方法并存,而不是简单的替换父类。

方法隐藏的语法现象是:在使用父类型对象调用方法时,会调用父类方法。

方法隐藏代码示例如下:

public class Transportation
{
    public virtual void Transport()
    {
        Console.WriteLine("走起来,瓷~");
    }
}
class Train : Transportation
{
	// 注意,此处并未使用 override 关键字,而使用了new关键字,
	// 这个 new 关键字的作用只是让代码更加清晰,不加也是可以的。
    public new void Transport()
    {
        Console.WriteLine("坐火车回家");
    }
}
class Program
{
	static void Main()
	{
		Train t1 = new Train();
		t1.Transport();                  // 通过子类型对象调用,执行子类方法。
		Transportation t2 = new Train();
		t2.Transport();                  // 通过父类型对象调用,执行父类方法。
	}
}

方法隐藏内存示意图
在这里插入图片描述

再对比一下方法重写
由于使用了 override 关键字,形成了方法的重写。由于父类方法地址会被替换,所以在使用父类型对象调用方法时,会执行子类方法

方法重写代码示例如下:

public class Transportation
{
    public virtual void Transport()
    {
        Console.WriteLine("走起来,瓷~");
    }
}
class Train : Transportation
{
    public override void Transport()       // 注意,此处使用了 override 关键字,
    {
        Console.WriteLine("坐火车回家");
    }
}
class Program
{
	static void Main()
	{
		Train t1 = new Train();
		t1.Transport();                  // 通过子类型对象调用,执行子类方法。
		Transportation t2 = new Train();
		t2.Transport();                  // 通过父类型对象调用,执行子类方法。
	}
}

方法重写内存示意图
在这里插入图片描述

动态绑定、静态绑定

绑定:类型与关联的方法调用关系,通俗讲就是一个类型能够调用哪些方法。
静态绑定:是指调用关系是在运行之前确定的,及编译期间。
动态绑定:是指调用关系是在运行期间确定的。
静态绑定因为编译期确定,不占用运行时间,所以调用速度比动态绑定要块。
动态绑定因为在运行期确定,占用运行时间,但是更灵活。
方法重写是动态绑定。
方法隐藏是静态绑定。

重写、重载、覆盖、隐藏对比

关于重写、重载、覆盖、隐藏、override、new、abstract、virtual 这些概念的定义,很多人都比较模糊,说法也比较多,在这里简单的总结一下(个人观点):

  • 重写(覆盖):重写是只在父子类之间才会出现的概念,当子类方法使用了override关键字重新定义了父类的方法时才能叫做重写,重写的方法与父类方法参数必须是完全相同的。
  • 重载:重载是无论父子类还是单独本类都可以出现的概念,当同名的方法使用了不同的参数时才叫做重载。
  • 隐藏:当子类出现了与父类相同参数且同名的方法时,就实现了方法隐藏,隐藏的意思是父类的方法还在,只不过默认使用的是子类的方法而已,如果想在子类的实现中使用父类的方法,需要使用base关键字。抽象方法是不可以隐藏的
  • override:这个关键字就是用来重写方法的,父类的abstract方法或者接口的方法都必须使用override关键字来进行方法的实现,而父类的virtual 方法也可以使用override来实现
  • new:new关键字用于方法的定义时,实际效果只是让代码更加清晰,让开发者更明确的了解这个方法隐藏了父类的方法,如果父类有重要的工作要做,可以使用base来显式调用。我看到有文章说new就是重写,这一点我是不敢苟同的。
  • abstract抽象方法是不可以隐藏的抽象方法必须在子类中得到实现或者子类继续将这个方法抽象下去,等待真正的实现类来实现这个方法。
  • virtual:虚方法由于有默认的实现,所以是可以隐藏的。

重写、隐藏总结

重写和隐藏的最大区别就是重写替换了父类方法的地址,而隐藏的父类方法依然是其本身。这样就形成了不同的运行结果,隐藏调子就是子,调父就是父。而重写则调子是子,调父还是子。

从程序运行的角度剖析,由于方法的隐藏是静态绑定,而方法重写是动态绑定,重写是需要占用运行时间的,所以实际上隐藏的执行效率是更高的。

从内存的角度剖析,这两种情况对于内存的影响其实并不大,在游戏开发的世界里,这种内存损耗几乎微乎其微。所以,非必要时不要使用重写。然而,重写这么重要且常用的功能,怎么可能不用呢~

另外,在Unity中,将子类附加到物体中,创建子类对象,相当于通过子类型引用调用脚本生命周期(实际上是通过反射调用的)。对于Unity的生命周期,子类的Start会隐藏掉父类的Start方法,如何解决这种生命周期冲突呢?只要简单的使用 base.Start(); 就可以搞定了。不要用override去定义Start,因为那是动态绑定的,会影响执行效率的。

同步块索引(Sycnblk Index)

内容:
Syncblk共有有5个位标志,其中一个位标志是为GC保留的,它标识对象是否是“可到达对象“(“可到达对象“是垃圾回收算法里的一个名词,简单的说就是指正在被应用程序使用的对象)。剩余的27个位作为一个索引,被称作syncindex,它指向一个表(CRL内部的同步索引块)。

作用:
Syncblk的主要功能是用于对象的锁定,即线程安全,本质上用到了系统的临界区域块,因为系统的临界区域块的某个引用只能被同一个线程访问,而CRL中的对象如果引用了这个块,就可以实现线程锁定访问。
在CRL中,一般该对象的这个区域是0,当用lock语句或者Monitor等操作的时候,CRL会将其关联到CRL的一个临界区域数组,操作完成,在取消对其引用
在这里插入图片描述

静态构造方法.cctor

在对象引用的那张总图上,我们看到了一个方法叫做 .cctor ,这个方法是C#中的静态构造方法。在创建第一个实例或引用任何静态成员之前,将自动调用静态构造方法(.cctor)。同一个程序域中.cctor 只会调用一次,而不是像.ctor每次实例化对象都会被调用。

在C#对象的生命周期中,有三个特殊的方法,分别为构造函数、静态构造函数、析构函数,详细的内容请参考另一篇文章:【Unity】Unity C#基础(八)类、构造器、静态构造器、对象初始化器、析构函数、静态成员,在此不做详述。


本文部分内容引自:https://blog.csdn.net/foyuan/article/details/1722481,再次感谢foyuan大神的分享。

更多内容请查看总目录【Unity】Unity学习笔记目录整理

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值