前言
博客已经1年多没有更新了,这一年主要在实习并准备秋招和春招,目前已经上岸Unity客户端岗位,现将去年校招遇到的一些面试题的事后整理分享出来。答案是笔者自己整理的不一定保证准确,欢迎大家在评论区指出。
Unity客户端岗的面试题目的方向一般为C#、Lua/热更、Unity、简单的图形学(客户端岗位也会问,但比引擎岗简单的多)。由于篇幅有限,本文仅整理了部分C#面试题。
一、请简述ArrayList
和List
的主要区别?
非泛型集合ArrayList
存在不安全类型(ArrayList
会把所有插入其中的数据都当做Object来处理),装箱拆箱的操作(费时),List是泛型类,功能跟ArrayList
相似,但不存在ArrayList
的安全问题,其必须为同一类型的值更为规范,更适合日常的使用。
二、拆装箱
解释一下拆装箱
装箱(box):将值类型转换为引用类型的过程
拆箱(unbox):将引用类型转换为值类型的过程,注:只有装箱后才能拆箱
装箱可以隐式转换,而拆箱必须显示转换
装箱过程
对值类型在堆中分配一个对象实例,并将该值复制到新的对象中。按三步进行。 第一步:新分配托管堆内存(大小为值类型实例大小加上一个方法表指针(也称类型对象指针)和一个SyncBlockIndex
同步块索引)。 第二步:将值类型的实例字段拷贝到新分配的内存中。 第三步:返回托管堆中新分配对象的地址。这个地址就是一个指向对象的引用了。
过程耗时耗空间 所以应尽量减少拆装箱次数
三、同步块和同步块索引
**在程序运行时,CLR 管理一个同步块数组。**它是一个总共 32/64 位的多功能结构,其中,前 6 位的值提示访问者目前同步块索引的功能是什么,高 6 位就像 6 个开关,有的打开(1),有的关闭(0),不同位的打开和关闭有着不同的意义。
它的用处非常广泛,例如线程同步和 GC 都会用到它,它还会储存对象的哈希码。
同步块索引在线程同步中用来判断对象是被使用还是闲置。
默认的情况是,同步块索引被赋予一个特殊的值,一般是-1,此时对象没有被线程独占。当一个线程拿到对象,并打算对其操作时,它会检查对象的同步块索引。
如果索引的值为特殊值,说明没有任何线程正在操作它,此时这个线程获得它的操作权,并在 CLR 的同步块数组中分配一个新的同步块给它,并将该块的索引值写入实例的同步块索引值中。
这时,如果有其他线程来访问该实例,它就不能操作这个实例了,因为它的同步块索引的值不为特殊值。
当独占的线程操作完之后,同步块索引的值被重设回特殊值。
四、类型对象指针(方法表指针)
类型对象指针 其实就是指向类型对象 的引用。类型对象是反射的重要操作对象,其实就是System.Type
的实例对象,类型对象中存有该类型的方法表和静态字段,创建之后就不会再改变,通过这个事实可以验证静态字段的全局性。
小栗子: People people = new People(); 类型对象可以简单理解为System.Type pType= new People.Class()
实际上是通过GetType
获取的, pType
就是类型对象指针(引用),其指向后面new的类型对象。
方法表 就是记录类型所有的方法,包括静态方法和实例方法。方法会在初次执行时,经由 JIT 编译为机器码,并将机器码存在内存之中,获得一个入口地址。
此时,方法表中的该方法指向一个 jmp
指令,使得其可以跳跃到该入口地址。在下次调用该 方法时,直接跳到入口地址,无需再次编译。
注意所有对象内部均具有类型对象指针,普通对象指向其对应的类型对象,类型对象本身也是对象,其内部的类型对象指针指向自身 。每个对象均继承来自Object的GetType()
方法,其能获取此对象内部的类型对象指针,其实也就是获取了对象对应的类型对象的引用,进而能对类型对象加以操作。
五、C#内存布局和对齐
整个内存布局如下:
-
同步块索引。
-
方法表指针(指向方法表,它位于类型对象中,而类型对象一般位于同一个应用程序域的加载堆中)。
-
类型所有父对象的实例成员(静态成员存储在类型的类型对象中),其中,所有引用类型成员都分配 4 字节,因为只需要分配地址。分配顺序不定,CLR 会尽量消除对齐带来 的负面影响。
-
类型自己的实例成员(静态成员存储在类型的类型对象中),引用类型成员分配同上。
同步块索引和方法表指针(类型对象指针)只在引用类型中有 ,值类型中没有,它们在32位机器上均占4字节,在64位机器上均占8字节。
类型对象指针指向类型对象,方法表指针指向方法表,方法表是类型对象的最重要的部分,所以这两者之间一般也不加以区分。----待核实
引用类型在栈中内存的分配
首先,对内存分配从同步块索引开始,它占据 4 个字节。
栈上的引用将指向同步块索引后边的部分(称为偏移量),也就是说,同步块索引的地盘是从 -4 字节到 0。
然后,方法表指针(又名类型对象指针)上场,占据 4 个字节。
这 8 个字节是每个引用类型都一定会有的,没有办法直接操作它们(这会破坏类型安全性)。
下面,就轮到类型的实例字段(静态字段在类型对象中)。32 位机上,任何对象占据的字节数都必须是 4 的倍数。
所以,即使一个引用类型仅有一个 byte 类型的字段,它也占据 12 字节(实际占据9 字节,3 字节被浪费),而下一个引用类型不能从第 10 字节,而必须从第 13 字节开始分配内存,这称为内存的对齐(alignment)。
----这里解释一下,一个引用类型中至少有8字节的同步块索引和类型对象指针(8+Byte-1 = 9) 需要对齐。
而在 64 位机上,任何对象占据的字节数都必须是 8 的倍数,所以,仅有一个 byte 类型的字段的引用类型占据 24 字节(实际占据 17 字节,7 字节被浪费)。
默认情况下,CLR 会智能地将可以合并到 4/8 字节的对象尽量合并到一起,以免内存空间浪费,除非你显式地阻止它。例如,64 位机器上两个 int
,四个 short,8 个 byte 可以合并到一起。
六、请简述sealed关键字用在类声明时与函数声明时的作用
sealed修饰的类为密封类,类声明时可防止其他类继承此类,在方法中声明则可防止派生类重写此方法。
类似于Java 的 final关键字。
七、请简述private,public,protected,internal的区别
public:对任何类和成员都公开,无限制访问
private:仅对本类公开
protected:对该类和其派生类公开
internal:只能在包含该类的程序集中访问该类
八、请描述Interface与抽象类之间的不同
-
接口所有的成员必须是public abstract类型的,抽象类除抽象方法和属性不能是private abstract外没有限制(字段不能抽象)
-
接口定义的是一组对外的行为规范,只能包含抽象方法 和属性成员,抽象类则可以具有普通方法和字段。
-
实现接口必须实现接口定义的全部的一组方法,抽象类必须重写抽象方法,对于虚方法可根据需求进行重写。
-
接口支持派生类多实现,而抽象类只能被一个类继承,继承则是单继承。
====以上均是语法层面的不同===
-
抽象类仍然是类,具有类的性质,可以拥有自己的成员方法,对其进行封装,只不过相对于普通类其可以声明抽象方法,无法直接实例化,其派生类必须重写其抽象方法。
而接口是一组对外的行为规范,微软的自定义接口的名称结尾总是以able结尾,证明接口就是来表述一类"我能做xxx"的事情,就像为调用方和实现方签订一个契约,实现放实现接口表明我能做xxx事,调用方面向接口调用,表明我找能做
xxxx
事的人,利用好能极大降低耦合性。
顺便补充一下相同吧!
-
两者均是面向抽象编程中经常使用的方法,都是一种抽象形式。
-
二者都无法直接实例化,必须由派生类去实现或继承。
-
二者都可以包含抽象未实现的抽象方法声明。
-
接口和类一样都是引用类型的
九、.Net与Mono的关系?
这里介绍.NET .NETFramework .NETCore Mono
C# CLR等的关系
首先是**.NET**
.NET是一个开源的开发者平台。其是一个平台(类似于Java虚拟机一样),可以运行其所支持语言下编写的任何程序,其包括了运行时的环境和开发环境。
.NET Core .NET Framework Mono都是基于.NET提供的一系列组件的一些具体实现。
其中.NET Core和Mono 都支持跨平台开发,而.NET Framework是针对Windows应用的一种.NET实现。
然后是FCL CIL CLR
首先介绍一下 .NET Standard
.Net Standard是一组通用的API,所有的.Net实现平台都会实现它。它是一种标准规范。
一套.NET的实现,包含FCL,CLR和可以把其它代码转化为可以在CLR中运行的CIL 的编辑器。FCL(Framework Class Library ---框架类库)是对.Net Standard一套实现。CIL(Common Intermediate Language---通用中间语言---有时也称IL )就是C#,F#,VB等语言经过编译器生成的中间语言,然后CLR(Common Language Runtime---公共语言运行时)就是运行CIL的地方。
十、C#的堆和栈
首先交代清楚一些命名问题
栈:全称堆栈,容易混淆一般叫栈。
堆:C中叫堆 在C#中其实叫托管堆。
托管堆:托管堆不同于堆,它是在堆中开辟的一块连续的内存空间并由CLR(公共语言运行库(Common Language Runtime))管理,当托管堆中满了,会自动回收垃圾。
内存结构
栈:栈的结构是后进先出,先进后出,栈地址从高往底分配,内存分配连续
堆:堆地址从低到高分配,内存在物理地址上不连续 逻辑地址上连续(分页分段管理机制--- 页号段号连续 对应物理块不连续)
类型存放位置
值类型中方法体内部的局部变量存放在栈中,类的成员字段存放在堆中
引用类型的对象存放在堆中,引用类型的引用存放在栈上
回收问题
栈区:存放函数的参数,局部变量,返回数据等值,会自动释放(编译器在声明变量时会自动生成释放的代码--超出作用域释放)
堆区:存放着引用类型的对象,由CLR释放
优劣势
栈:空间小,速度快(少一次寻址)变量超出作用域编译器自动释放,存放局部值类型和引用类型的引用。
堆:空间大,速度慢,由CLR的GC程序释放,存放引用类型的对象。
十一、结构体和类有何区别
====语法层面的不同====
-
关键字不同 结构体是struct 类是class关键字
-
结构体不能在声明字段时直接对字段进行初始化,类可以。
----类中声明字段赋值初始化在.Net生成程序集时会将其放入构造函数中所以才可以这么用
-
结构体和类均提供了默认的无参构造函数,但是类一旦声明了构造函数(有参无参均可),编辑器就不在默认提供构造函数,但是结构体提供的默认构造函数一直都在。
-
源于上一条的缘故,编译器一直会默认提供无参构造函数,结构体不能显式地声明无参数的构造函数。
-
结构体的构造函数必须为所有字段进行赋值,若属性内get提供返回值则可不用对其在构造函数中赋值,若只是{get;set;}则还需要在构造函数中对其进行赋值。
-
结构体不能被继承,继而也不能使用abstract、sealed、virtual、protected等关键字,结构体本身也不允许使用static关键字修饰,但其内部字段方法等允许,值得一提的是struct支持override用于重写其基类
System.ValueType
内部的方法。 -
结构体是值类型,类是引用类型。所以结构体声明在局部变量时,全部都存放在栈中,类只有其引用会在栈上,其对象都存放在堆中。 结构体在传值和传参时传递的都是值,每次都会新开辟一段空间来存放,二者互不影响,值类型的特性。而类默认传递的是引用,不会新开辟一段空间存放类的对象,只是新增了一段空间存其对象的引用,两个引用指向堆中同一块对象。
-
结构体不能定义析构函数 ---析构函数的名称为~+类名组成,其不能有参数修饰符也无返回值,我们无法控制何时调用析构函数,在GC垃圾回收器认为该对象不在使用回收时才会回调析构函数,一般用于处理善后工作。
基于这些区别,来谈一谈什么时候使用结构,什么时候使用类
-
结构的局部变量存储在栈上,空间小,速度快,效率高,适合一些轻量级的对象,如点,颜色等只包含
xyz,rgba
等少数字段。而类的对象存储在堆上,适合逻辑复杂的重量级对象使用。当存储1000个点等轻量级对象时,如果使用类每个对象都会增加诸如同步块索引和类型对象指针的额外空间甚至占的比其本身都要多,此时更适合使用结构。 -
要用双面的态度去看问题,若栈的空间有限,且结构有非常多的逻辑对象还是该首选类。
-
如果对象需要多次使用传递,且都只需要使用同一份无需拷贝,使用结构体会造成较大的浪费(值类型的特性)可以考虑使用类或者ref进行传递参数,如果就是想传拷贝,也可直接使用结构体无需对类手动一遍遍复制。
-
如果只是以数据为主,可以使用结构体,如果想表现抽象和多级别的对象层次时,类是最好的选择,结构不支持继承,注定其只是轻量级的类型。
十二、ref参数和out参数是什么?有什么区别?
ref和out参数的效果近似,都是通过关键字找到定义在主函数里面的变量的内存地址,并通过方法体内的逻辑对其加以操作改变,由于是传递的地址,同时也会对调用方的参数同时发生改变。不同点就是out输出参数无法传递进函数内部,函数内部必须对其进行初始化。ref参数的值可以传递进函数中,可直接在函数内部进行操作。总的来说,就是ref有进有出,out只出不进。
十三、C#的委托是什么,有什么用?
C#的委托类似于C或C++中的函数的指针。委托就是一种指向某个方法的引用类型变量。
委托对象也就是将方法包装成了对象,通过调用委托对象间接调用方法,实现事件和回调机制。
委托是引用类型,使用方法创建委托对象,必须符合委托的原型。
作用
-
将方法做为参数进行传递,可以将一个方法的执行代码注入到另一个方法中。
-
实现回调机制,且比接口更加灵活。
-
实现任意方法的异步调用。
-
事件实现的基础。
十四、委托的多播和异步调用
-
多播委托 本质上是委托的一种特殊形式,以委托链的形式存在
-
多播委托绑定的方法有返回值,调用委托对象时默认只会收到最后一个方法的值
-
想要获取每个方法的返回值:调用委托
GetInvocationList()
方法获取委托链,然后循环调用DynamicInvoke
方法,即动态调用每个方法,可获取返回值。
多播委托原理
-
委托对象绑定(+=)多个方法时,本质调用
Delegate.Combine(原对象,新对象)
方法。 -
Combine方法内部创建新委托链(Delegate[]),再创建新委托对象,存入
_invocationList
字段中。 -
委托对象移除(-=)某个方法时,本质调用
Delegate.Remove(原对象,删除对象)
方法。 -
Remove方法内部删除匹配的委托(判断
_target
与_methodPtr
)。 -
如果删除后委托链中只剩下一个委托,则返回该委托;否则再新创建一个委托对象,初始化新委托链。
委托实现方法的异步调用
同步调用方法:排队调用,前一个方法执行时,后一个方法等待它的结束后才能启动。
异步调用方法:不排队调用,后一个方法不必等待它的结束就可启动,异步调用的方法是创建了一个新线程来工作的,与后一个方法所在不同的线程,各自独立,互不影响。
Framework中的任何方法都可以通过委托异步调用。(BeginInvoke
)
步骤:
-
为需要异步调用的方法定义一个相应的委托。
-
创建该委托的引用指向需要异步调用的方法。
-
使用委托类型的
BeginInvoke
方法开始异步调用。
a) BeginInvoke
中的参数IAsyncCallback
表示异步调用的方法结束时执行的回调方法,往往用回调方法来处理异步的结果。
b) BeginInvoke
中的参数object 表示在回调方法 中需要用到的数据,该数据被封装在IAsyncResult
的AsyncState
属性中。
- 如方法有返回值,则需要调用
EndInvoke
取得返回值。
十五、请简述GC(垃圾回收)产生的原因,并描述如何避免?
GC--Garbage Collector(垃圾回收器)
GC ,垃圾回收机制,为了避免内存的溢出,将定期回收没有有效引用的对象的内存。
其工作在.NET提供的CLR,JAVA提供的VM(Virtual Machine虚拟机)上,对内存进行管理。工作原理 就是以应用程序的root为基础,遍历应用程序在Heap(堆)上动态分配的所有对象(静态对象随程序的结束才释放),通过识别它们是否被引用来确定哪些对象是已经死亡的,哪些仍需要被使用。已经不在被应用程序的root或者其它对象所引用的对象就是已经死亡的对象,也就是所谓的垃圾,需要被回收,这就是GC的工作原理。.NET和Java都是利用Mark Sweep(标记-清扫)算法实现的GC。
GC带来的好处
-
提高了软件的抽象度 ---让软件开发更注重上层功能而非底层具体实现
-
程序员可以将精力集中在实际的问题上而不用分心来管理内存问题
-
可以使接口更加清晰,减少模块间的耦合 ---- 使用其它模块的功能时无需关心还要回收其它模块的对象。
-
大大减少了内存人为管理不当所带来的BUG。
-
使内存管理更加高效。
总的说来就是GC可以使程序员可以从复杂的内存问题中摆脱出来,从而提高了软件开发的速度、质量和安全性。
GC的执行时机
GC的基本假定
-
最近分配内存空间的对象最有可能被释放,一般在方法内部声明的对象在方法体结束时,其引用销毁后,其对应堆上的对象也会因无有效引用而被回收,所以搜索最近分配的对象集合有助于尽可能多释放内存空间。
-
生命周期长的对象释放可能性最小,即经过几轮垃圾回收后对象仍然存在,搜索其需要大量工作,且只能释放一小部分空间。例如一些全局的管理器对象,类型对象等等。
C#中的回收器是分代的垃圾回收器(Gererational GarbageCollector) 它将分配的对象分为3个类别或代。(可用GC.GetGeneration方法返回任意作为参数的对象当前所处的代)最近被分配内存的对象被放置于第0代,因为第0代很小,小到足以放进处理器的二级(L2)缓存,所以它能够提供对对象的快速存取。经过一轮垃圾回收后,仍然保留在第0代中的对象被移进第1代中,再经过一轮垃圾内存回收后,仍然保留在第1代中的对象则被移进第2代中,第2代中包含了生存期较长的对象。
当第0代中没有可以分配的有效内存时,就触发了第0代中的一轮垃圾回收,它将删除那些不再被引用的对象,并将当前正在使用的对象移至第1代。而当第0代垃圾回收后依然不能请求到充足的内存时,就启动第1代垃圾回收。如果对各代都进行了垃圾回收后仍没有可用的内存就会引发一个OutOfMemoryException异常。
执行时机
-
第一代空间不足,执行GC
-
调用了GC.Collect()
-
系统内存资源不足
C#垃圾回收时,如果某个对象已经被回收了,后续却又需要调用它,会发生什么问题?如何解决这个问题?你会如何设计垃圾回收机制来解决这个问题?
会抛出ObjectDisposedException的异常。
如果对象已经被回收,想要再次使用它无非就是重新实例化。
一般来讲 需要从两个维度考虑 空间和时间,如果存入一个较大的对象其占用内存很多,而且创建也比较耗时,如果一直长期不手动释放且保持它的引用就会十分占用内存,如果用完就手动释放后如果后续需要频繁使用,那就需要频繁创建销毁对运行速度也会造成很大的影响。
既然不能两全其美找到一个全局最优解,就只能局部最优,比如内存空间不足也就是触发GC的时候我去释放它,不触发GC我就不去释放,普通的强引用肯定实现不了这种功能,C#的一个弱引用机制可以实现此功能。
多介绍一点的话,C#的弱引用还具备一个追踪复活机制,有定义析构函数的对象第一次回收时会加入到一个队列执行析构函数并不会真回收,执行完析构函数后第二次才会真正回收。在执行析构函数时如果又被引用了就会实现复活。
-
非托管资源需要实现IDisposable接口和Dispose方法
-
非托管资源对象手动调用Close或者Dispose或使用using()会调用Dispose方法。
-
析构函数一般用于清理非托管资源
代(Generation)引入的原因主要是为了提高性能(Performance),以避免收集整个堆(Heap)。一个基于代的垃圾回收器做出了如下几点假设:
1、对象越新,生存期越短;
2、对象越老,生存期越长;
3、回收堆的一部分,速度快于回收整个堆。
.NET的垃圾收集器将对象分为三代(Generation0,Generation1,Generation2)。不同的代里面的内容如下:
1、G0 小对象(Size<85000Byte):新分配的小于85000字节的对象。
2、G1:在GC中幸存下来的G0对象
3、G2:大对象(Size>=85000Byte);在GC中幸存下来的G1对象
object o = new Byte[85000]; //large object
Console.WriteLine(GC.GetGeneration(o)); //output is 2,not 0
这里必须知道,CLR要求所有的资源都从托管堆(managed heap)分配,CLR会管理两种类型的堆,小对象堆(small object heap,SOH)和大对象堆(large object heap,LOH),其中所有大于85000byte的内存分配都会在LOH上进行。
代收集规则:当一个代N被收集以后,在这个代里的幸存下来的对象会被标记为N+1代的对象。GC对不同代的对象执行不同的检查策略以优化性能。每个GC周期都会检查第0代对象。大约1/10的GC周期检查第0代和第1代对象。大约1/100的GC周期检查所有的对象。
GC的工作流程
标记(Mark) → 计划(Plan) → 清理(Sweep) → 引用更新(Relocate) → 压缩(Compact)
-
Mark:所有对象都初始为Dead(无有效引用),找到所有的GC Root,遍历所有Root及其子节点将遍历到的节点标记为Live(有效引用),不包含弱引用。
-
Plan:根据Live标记判断是否需要压缩,根据特定的算法决策。
-
Sweep:回收没有有效引用的Dead对象,并将回收的空间加入到可用内存链表中。
-
Relocate & Compact:设置Forwarding指针,先更新引用,再移动对象进行压缩。
GC Root
-
全局变量,静态变量
-
栈中局部变量,参数变量
-
寄存器中的变量
-
均必须是引用类型变量
标记-清除
传统GC无需进行压缩而是维护一个空闲块链表,分配时有三种算法 均为O(n)
-
First-fit: 找到大于等于size的块立即返回
-
Best-fit:遍历整个空闲列表,返回大于等于size的最小分块
-
Worst-fit:遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分size大小,并将该部分返回。
-
可以设置多条链表,1字节,2字节,3字节---100字节这种,内存碎片和分配速度慢都可以得到解决。
标记-压缩
-
先进行一次标记-清除算法,搜索两次堆
-
设置Forwarding指针 搜索1次堆
void set_forwarding_ptr() { scan = new_address = $heap_start; while (scan < $heap_end) if (scan.mark == TRUE) scan.forwarding = new_address; new_address += scan.size; scan += scan.size; }
-
先更新引用,更新GC Root进而更新其子节点,又搜索一次
adjust_ptr(){ for(r : $roots) *r = (*r).forwarding; scan = $heap_start; while(scan < $heap_end) if(scan.mark == TRUE) for(child : children(scan)) *child = (*child).forwarding; scan += scan.size }
- 先更新后移动是为了防止移动对象覆盖了还未开始移动对象空间的情况。
-
移动对象,进行压缩,最后一次搜索堆
move_obj(){ scan = $free = $heap_start; while(scan < $heap_end) if(scan.mark == TRUE) new_address = scan.forwarding; copy_data(new_address, scan, scan.size); new_address.forwarding = NULL; new_address.mark = FALSE; $free += new_address.size; scan += scan.size; }
清除时间复杂度O(n) 光压缩就需要3次搜索堆
Two Finger优化算法
两个指针,左指针找空闲位置,右指针找非空闲位置并放入左指针处,要求所有块大小相等,限制较大。
三色标记增量清除算法的时间复杂度是多少?如何计算这个复杂度?
三色标记算法
最大的好处:减少了STW的时间,可以异步,效率高。
三色标记算法不使用STW(即在GC时暂停整个程序),而是使用了并发标记,在GC标记时程序仍在运行,会有以下两种情况
-
错标:浮动垃圾,原来不是垃圾的变为了垃圾
-
错杀:本来已经当做垃圾的,又得到了有效引用
白色:没有检查(或者为检查过没有引用指向它)
灰色:自身被检查了,成员还未检查(当前操作节点)
黑色:自身和成员已经被检查了
错标问题
- 错标问题影响不大,在下次GC时也会随之清除。
- 错杀会将应该存在的对象回收,影响程序正确性,需要解决。
var G = objE.fieldG; // 1.读
objE.fieldG = null; // 2.写
objD.fieldG = G; // 3.写
错杀的前提条件有两个
-
黑色对象指向了白色对象;
-
本来指向这个白色对象的灰色对象断开了对它的连接。
只需解决任意一个即可避免错杀。
Function1:写屏障 和 SATB **(Snapshot At The Beginning)**原始快照 G1
-
所谓写屏障有点像AOP,就是在写的操作后加入一个处理函数进行增强功能
-
SATB的思想
-
尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的GC Roots确定后,当时的对象图就已经确定了。
-
SATB在每次写入时都会保存记录旧值,以保证开始的对象图不受到影响。删除旧引用标记保留,增加新引用无视。
-
值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。
-
-
SATB破坏了条件2:灰色对象断开了对白色对象的引用
Function2: 写屏障 + 增量更新 CMS
-
思想:当有新引用插入进来时,记录下新的引用对象,即增量更新。
- 记录的新的引用对象会作为灰色节点再进行遍历即可,注意集合重新标记的遍历过程可能会一直增加,可能需要STW。
-
破坏了条件1:黑色对象增加对白色对象的引用
Function3: 读屏障 ZGC
- 思想:保守方法,在前提条件中黑色对象 引用了该 白色对象,引用的前提一定会先获取到白色,此时读屏障就可以发挥作用进行记录了。
GC时间复杂度的计算
-
初始化,所有节点置位白色 O(n) n为节点数
-
标记阶段,从每个根节点下开始遍历所有节点 O(n)
- 增量阶段 由于used判重的存在 仍为O(n)
-
清除阶段,清除白色的,可能是直接清除串成链表就是O(m),如果是压缩清除涉及到黑色的移动就还是O(n') 此时的n为整个堆
GC的工作区域
局部值类型和引用类型的引用存放在栈里,先进后出,堆栈是从高地址往低地址分配内存。基本上栈用于存储临时的数据(局部值类型和引用),当一个方法执行完毕后立刻自动清除---当变量离开其作用域时进行清楚,比堆的回收更简单和高效。
引用类型的对象分配在托管堆(Managed Heap)上,是从低地址往高地址分配内存。声明一个引用变量在栈上保存。当使用new等方式创建对象时,会把对象的地址存储在这个变量里,托管堆中的对象是由GC进行回收的,被回收的对象会回调其析构函数进行善后处理。
搞清楚GC的工作原理和范围,其对性能的消耗是不能无视的,当产生大量GC时势必会影响软件的性能
如何减少GC
-
减少new等创建对象的次数。
-
使用公用静态变量 static 或者常量
const
,但要慎用,因为静态成员和常量的生命周期是整个应用程序。 -
多使用
StringBuilder
代替直接使用String进行拼接字符串,string可能会造成内存泄漏---内存泄漏:因为疏忽程序未能释放掉不再使用的内存的情况。解释一下:直接利用string的+拼接字符串每个string都会生成一个新的内存空间,不及时回收会产生大量内存碎片,GC回收大量string对象也会对性能造成不小的浪费,而使用
StringBuilder
只创建一次而不会频繁创建。 -
使用对象池
ObjectPool
能复用就复用,减少频繁的创建和销毁回收
十六、字符串暂存池(缓冲池)
字符串是引用类型,其有一个特性就是字符串的不变性----直接对字符串进行赋新的值不会改变原来字符串在堆中的对象,而是新建一个对象再将引用指向新建的字符串对象。
程序中会存在大量的字符串对象,如果每次都创建一个字符串对象,会比较浪费内存、性能低,所以CLR在底层对其进行了优化,才有了字符串缓冲池的用法。其会对已经存在缓冲区的字符串对象进行重用。
十七、反射的实现原理
定义
反射就是动态获取 程序集,动态获取类型信息 , 动态创建对象, 动态访问成员信息的过程。
作用
在编译时无法了解类型,在运行时动态获取类型信息,动态创建对象和访问成员。
流程
-
获取数据类型
-
动态创建对象
-
查看类型信息(了解本身信息,成员信息)
反射的实现原理
反射的实现原理就是运行时审查元数据并获取元数据里面关于它的类型信息。元数据是程序集的一部分,就是一堆表,里面存有了该程序集下的所有类的信息极其成员属性和方法的信息。
十八、程序集的概念
程序集是代码进行编译时的一个逻辑单元 ,把相关的代码和类型进行组合,然后生成(可移植可执行文件)PE文件(例如可执行文件**.exe
** 和类库文件**.dll
** ) 任何基于.NET的代码在编译时都至少存在一个程序集 程序集和命名空间没有必然联系,一个程序集可能有多个命名空间,一个命名空间也可能分布在多个程序集。
二十:下列代码在运行中会发生什么问题?如何避免?
List ls = new List(new int[] { 1, 2, 3, 4, 5 }); foreach (int item in ls) { Console.WriteLine(item * item); ls.Remove(item); }
产生运行时错误,在 ls.Remove(item)这行,因为foreach是只读的。不能一边遍历一边修改。
二十一、简述StringBuilder
和string的区别?
string是字符串常量。---内部是常量字符数组不可修改
StringBuffer
是字符串变量 ,线程安全。---内部是普通字符数组可以更改,比Builder多上了锁,线程安全
StringBuilder
是字符串变量,线程不安全。---内部是普通字符数组可以更改
String类型是个不可变的对象,当每次对String进行改变时都需要生成一个新的String对象,然后将指针指向一个新的对象,如果在一个循环里面,不断的改变一个对象,就要不断的生成新的对象,所以效率很低,建议在不断更改String对象的地方不要使用String类型。
StringBuilder
对象在做字符串连接操作时是在原来的字符串变量上进行修改,只要不超过初始定义的长度就不会新增,若超过则增大为原来两倍用来存取。一般情况下更建议使用StringBuilder
,性能会更好。
二十三、C#中四种访问修饰符是哪些?各有什么区别?
public:对任何类和成员都公开,无限制访问
private:仅对本类公开
protected:对该类和其派生类公开
internal:只能在包含该类的程序集中访问该类
组合修饰符
protected protected internal内部保护,只能被本程序集内的所有类和这些类的派生类所存取
二十四、Heap与Stack有何区别?
-
heap是堆,stack是栈,其都是程序运行期间在内存中开辟的两块区域。
-
stack存取速度快,效率高但空间有限,heap的空间较大,但存取速度较慢。
-
stack的空间由操作系统自动分配和释放,heap的空间是手动申请和释放的(在C# Java等语言中由GC自动回收无需手动释放),heap常用new关键字来分配。
-
栈中一般存有函数的局部值类型变量和引用类型对象的引用,堆中一般存有引用类型的对象。
二十五、值类型和引用类型有何区别?
-
局部变量下的值类型的数据存储在内存的栈中;引用类型的数据存储在内存的堆中,而栈中只存放堆中对象的地址也就是引用。
-
值类型相较引用类型一般少了一步寻址的操作,且如果在栈中读取速度相较堆中比较块,所以一般值类型的操作速度比引用类型的要快。
-
值类型的变量直接存放实际的数据,而引用类型的变量存放的则是数据的地址,即对象的引用。
-
值类型继承自
System.ValueType
,引用类型继承自System.Object
-
局部值类型变量在栈中的内存分配是自动释放;而引用类型的对象在堆中由CLR提供的GC来释放
-
值类型没有同步块索引和类型对象指针等额外空间,所占空间即为数据的实际大小,而引用类型不仅有额外的空间消耗还有对齐等影响,空间相较值类型利用率较低。
二十六、什么是里氏替换元则?
里氏替换原则(Liskov Substitution Principle LSP
)面向对象设计的基本原则之一。简单来说,就是子类对象可以赋值给基类对象隐式转换,而基类对象不可以直接赋值给子类对象需要显示转换。
详细限制:子类要拥有父类的所有功能。子类在重写父类方法时,尽量选择扩展重写,防止改变了功能。
二十八、概述序列化
所谓的序列化就是把一个对象信息转化为一个可以持久存储的数据形式,经过转化后就可以方便的保存和传输了,因此序列化主要用于平台之间的通讯。由序列化我们可以反推出所谓的反序列化就是将持久存储的数据还原为对象。
举例:JSON XML 二进制文件的转换 都是序列化和反序列化的体现,在不同语言和平台下都能进行解析和转换。
根据Unity的官方定义,序列化就是将数据结构或对象状态转换成可供Unity保存和随后重建的自动化处理过程。
Unity中有很多自动序列化的引擎功能,AB包,场景,ScriptableObejct
都会。
一些Unity中对类和变量序列化的关键字
SerializeField : 表示变量可被序列化。众所周知,公有变量可以在检视面板中看到并编辑,而私有和保护变量不行。SerializeField与private,protected结合使用可以达到让脚本的变量在检视面板里可视化编辑,同时保持它的私有性的目的。
HideInInspector : 将原本显示在检视面板上的序列化值隐藏起来。
NonSerialized :通过此方法可以将一个公有变量不序列化并且不显示在检视面板中。
Serializable:用在类的前面,表示该类可被序列化。
二十九、概述c#中代理和事件?
委托
代理就是委托,类似于C/C++的函数指针,其实就是指向某个方法的引用。委托的使用实际就是将方法封装成一个委托对象,通过调用委托对象间接调用方法,实现回调机制。
委托的作用
-
将方法做为参数进行传递,可以将一个方法的执行代码注入到另一个方法中。
-
实现回调,且比接口更加灵活。
-
实现任意方法的异步调用。---本质创建新线程
-
事件实现的基础。
事件
事件就是当某一对象(事件源)达到某种条件或者发生某种改变时,将消息及时通知到注册了事件的相关对象。
注册了事件的对象都是观察者和订阅者,事件源就是被观察者和发布者。
作用
和委托基本一致
着重区别
委托就是一个普通的类,可以实例化,可以被外部调用,可以在类的外部触发。而事件就是一种特殊的委托,其在委托delegate关键字前加event,相当于为委托施加了保护,将委托私有化,外部不允许直接修改委托实例(不能用=),只允许在外部进行注册和注销,而且也只能在定义事件的类的内部进行触发不允许在外部触发。
总的来讲,event关键字有助于提高类的封装性,物理隔绝代码耦合,迫使类设计更追求高内聚。
三十一、介绍一下C#中的泛型(模板)
泛型是一种可以允许延迟编写类或方法中的变量的数据类型的语法。说白了的话,泛型是一种延迟声明:即定义的时候没有指定具体的参数类型,把参数类型的声明推迟到了调用的时候才指定参数类型。 延迟思想在程序架构设计的时候很受欢迎,反射等技术都体现了延迟的思想。
使用泛型是一种增强程序功能的技术,具体表现在以下几个方面:
-
它有助于您最大限度地重用代码、保护类型的安全以及提高性能。
-
您可以创建泛型集合类,常用的就是List集合。
-
您可以创建自己的泛型接口、泛型类、泛型方法、泛型事件和泛型委托。
-
您可以对泛型类进行约束以访问特定数据类型的方法。
-
关于泛型数据类型中使用的类型的信息可在运行时通过使用反射获取。
一般使用Object也能实现类似泛型的功能,但需要显示的转换,容易出错,且可能发生拆装箱的情况,效率不如使用泛型进行隐式的转换。
三十二、迭代器
迭代器是一种行为模式,在.NET中迭代器模式被IEnumerator和IEnumerable
及其对应的泛型接口所封装
迭代器模式使得你能够获取到序列中的所有元素而不用关心是其类型是array,list,linked list或者是其他什么序列结构。抽象了序列的类型。
在.NET中迭代器主要实现了MoveNext
方法和Current
属性,来进行对序列的遍历获取。诸如foreach
就是在迭代器的基础上实现了对序列的循环遍历不停的MoveNext
然后Current取值,一直到迭代器的末尾。
三十三、c#和unity中用的c#的差距
c#是由微软开发的,属于所谓的面向对象编程语言。它是用来使用. net framework开发应用程序的,这个平台提供了广泛的类库、编程接口和实用程序。
然而,Unity将该语言与.net框架的开源变种Mono框架结合使用,这意味着c#应用程序也可以在非microsoft
系统上运行(也称为Mono项目)。
原始C#是微软在.NET平台下开发的专为PC端开发软件的一门语言
Unity使用的是mono版的C#支持跨平台,语法层面几乎没有区别
三十四、Dictionary底层原理
先通过key找到hash_key % length去buckets中找到entries的索引,这个索引就是这个key值的头结点,在利用链表进行增删改查即可。
详细的源码解析可见博主的另一篇博客C# Dictionary源码解析_c# 源码_窗外听轩雨的博客-CSDN博客
三十五、什么是虚函数
在基类中由Virtual修饰的函数,在子类中Override进行重写,这就是虚函数。
-
重写后的函数仍是虚函数,这意味着它可以继续被子类重写
-
Virtual虚函数编辑器下的调用流程
-
当调用一个函数时,系统会优先检查声明类的函数,若其不为虚函数则直接执行
-
若其为虚函数,则转去检查 实例对象类的函数,如果实例类对应函数用Override修饰则直接调用实例对象的相关方法
-
若实例类没有用Override修饰则继续向上查找其父类直到找到Override或者回到了声明类本身,执行相关方法。
-
-
Virtual是实现多态性的重要手段,一般利用接口或抽象类定义抽象方法可以将方法的实现延迟到子类中进行。Virtual的定义也可以让子类重写父类的方法进而实现多态
-
Virtual可以在函数中实现一部分的共性,让子类重写时保留并加入自己的特性部分。
-
Virtual不强制要求重写,当想要某些函数子类可以选择性的实现,也可以利用虚函数实现,比如状态机并不是所有状态都需要进入,正在,离开的逻辑,就可以用Virtual修饰它们。
-
三十六、谈一谈重载和重写的区别
-
重载发生在一类内部,同一个类中,同名但参数列表不同的函数在调用时根据传参情况决定调用函数的过程。
-
重写发生在具体父子关系的不同类中,父类可以通过声明abstract或virtual方法,将方法的具体逻辑延迟到子类中实现,这种延迟思想对面向对象的设计十分重要。
三十七、面向组件式和继承式比较而言,分析一下优缺点。
传统的OO思想主要是运用了抽象继承多态,来实现同一类对象所衍生出的不同功能的子对象。对象之间继承存在层级关系。
而面向组件的ECS式变成,则粒度更小,所有Entity实体都是平级不存在继承关系,Component组件也是一种Entity,多个功能组件排列组合,就能实现多种多样的实体,需要注意的是Entity只负责属性的定义,System负责定义行为。
面向对象具有里程碑意义,是现在最主流的思想,已经站在了巨人的肩膀上,拥有很多指导原则,设计模式等指导程序员写出优美易于维护的项目。且采用继承多态,层次清晰,但如果使用不当很容易造成项目难以重构,牵一发而动全身的状况。
面向组件开发,定义的一套规范,让每个独立的功能作为一个组件,使用时需要将其组合起来,这种组件式的思想做到了很好的高内聚,灵活性很强,但带来高灵活性的同时,由于组件可以随意组合,其复杂度将会大大提高,难以理解。
三十八、队列在实际开发中的应用
-
网络通信,消息队列,比如Actor消息队列
-
寻路问题,迷宫问题一类,例如普通无差别4个方向寻路也就是BFS,需要用到队列。如果是A*这种启发式寻路,就需要用到优先队列,当然这跟队列就没关系了,这是个二叉堆。
-
符合先来先做的任务队列,这个就很广泛了,任务可以五花八门比比如就诊医院排队一类的,但主要是先进先出这一条。
三十九、栈在实际开发中的应用
-
语法检查,利用栈逆序输出的特点,判断成对出现符号的合法性是很方便的。
-
撤销和恢复,典型的命令模式中就需要用到两个栈来进行命令的执行,撤销和恢复操作。
-
UI栈的使用,多重界面不断深入后,返回时的顺序是后进先显示,正符合栈的性质,UI框架一般都会为有这些性质的UI界面创建栈。
-
替代递归,递归的使用能使得一个很复杂的功能只需要短短几行就能实现,但递归需要不断为函数开辟空间,效率低下,递归的本质就是将函数压栈出栈的过程,可以用栈代替递归,提高代码执行效率。
四十、Volatile关键字的作用
在C#语言编写的程序中,有时为了提高程序的运行效率,编译器会自动对其进行优化,把经常被访问的变量缓存起来,程序在读取这个变量时有可能会直接从缓存(例如寄存器)中来读取这个值,而不是去内存中读取。这样做的一个好处是提高了程序的运行效率,但当遇到多线程编程时,变量的值可能因为别的线程而改变了,而缓存中的值不会改变,从而造成应用程序读取的值和实际的值不一致。
volatile是一个类型修饰符,它用来修饰被不同线程访问和修改的变量。被volatile类型定义的变量,系统每次用到它时都是直接从对应的内存当中提取,而不会利用缓存。 在使用了volatile修饰成员变量后,所有线程在任何时候所看到的变量都是相同的。
四十一、负数为什么要补码
计算机中加法器不会进行减法,而是进行模运算,在4位计算机,0-15中
5-3 其实就是计算 5 + 13 即减一个数是加上其补数
而-3的的补码正好就是13 所以补码就相当于补数