Unity游戏开发客户端面经——C#(初级)

前言:记录了总6w字的面经知识点,文章中的知识点若想深入了解,可以点击链接学习。由于文本太多,按类型分开。这一篇是C# 常问问题总结,有帮助的可以收藏。


1.引用类型,值类型

       1.1 介绍

        值类型:int,bool,float,char,struct,enum。

        引用类型:string,object,delegate,interface,class,array。

     1.2 区别

  1. 值类型存储在栈中,引用类型存储在堆中。
  2. 值类型存储快,引用类型存储慢。
  3. 值类型表示实际数据,引用类型表示指向在内存堆中的指针和引用。
  4. 值类型在栈中可以自动释放,引用类型在堆中需要GC来释放
  5. 值类型继承与 System.ValueType,(System.ValueType继承于System.Object),引用类型继承于System.Object。
  6. 值类型在栈中存储的是直接的值,引用类型数据本身实在堆中,栈中存放的是一个引用的地址。

        1.3 底层  

        1.引用类型在实例化时,先在栈内开辟空间,用于存储堆中对象的地址,然后在堆内开辟空间,存储引用对象。

        2.而值类型直接在栈中开辟空间存储对象。值类型也有引用地址,但都在栈内的同一空间。

        3.在参数对象进入方法体内,实则是在栈中开辟了新的临时空间。(也就是参数对象的副本)栈内值类型的修改,由于栈中地址不同,所以值类型不会影响到主体。而引用类型的存储数据是一个堆内的地址,所以对于引用类型的修改是直接修改堆内的对象。

        4.值类型对象中的引用类型在堆中(struct中定义的string等)

        引用类型对象中的值类型也在堆中(class中的int等)

        详细请看:

C# “值类型“和“引用类型“在内存的分配_生产队的驴.的博客-CSDN博客_值类型和引用类型如何分配内存

2.String引用类型的特殊性

        2.1 介绍

        string的修改,实则是new 一个新的string,在堆内新开辟空间。而此时栈内的副本也会指向堆内新对象。因此string改变。

        是新建的对象,和本体没有联系。

        2.2 解决

        当频繁堆一个字符串进行修改时,利用StringBuilder代替String

        2.3 StringBuilder的底层实现?

        StringBuilder 是支持扩容的(char类型)数组,在每次空间不足时,会开辟一倍的空间(4 ->         8 -> 16...)。 在扩容的期间,会丢弃原数组内的内容,将内容拷贝到新数组。

        2.4 扩展:

        StringBuffer是线程安全,一般用于多线程

        StringBuilder是非线程安全,所以性能略好,一般用于单线程

        2.5 用StringBuilder拼接字符串就一定比string要好吗?

        答:不一定,stringbuilder有自身的GC消耗

        极少拼接(或者短字符串)的情况下 String甚至优于StringBuilder,因为String是公用API,通用性好,用途广泛,读取性能高,占用内存较小,Stringbuilder初始化花费时间更大。

        2.6 字符串池

        字符串池有什么用,原理是什么?

        字符串池是CLR一种针对于反复修改字符串对象的优化措施,作用能够一定程度减少内存消耗。原理是内部开辟容器通过键值对的形式注册字符串对象,键是字符串对象的内容,值是字符串在托管堆上的引用。这样当新创建的时候,会去检查,如果不存在就在这个容器中开辟空间存放字符串。

3. GC

        3.1 概念

  1. unity内部有两个内存管理池:堆内存栈内存。栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。unity中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在栈内存上,要么处于堆内存上。
  2. 只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。
  3. 一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,这样的操作就是内存回收。处于栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态不再使用的内存只会在GC的时候才会被回收。
  4. 垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。

        3.2 GC算法介绍

        C#:分代算法,有内存整理,避免碎片化。有压缩。

        0代,未被标记回收的新分配对象

        1代,上次垃圾回收中没有被回收的对象

        2代,在一次以上的垃圾回收之后任然没有被回收的对象

        3.3 简易流程

        1.GC会检查堆内存上的每个存储变量;

        2.对每个变量会检测其引用是否处于激活状态;

        3.如果变量的引用不再处于激活状态,则会被标记为可回收;

        4.被标记的变量会被移除,其所占有的内存会被回收到堆内存上。

        3.4 流程详细介绍

        1.当新建立引用类型对象时,检查0代储存空间是否有充足的空间使得新的引用类型对象存储。若没有,将0代对象进行遍历检查,是否有被调用(激活),没有被调用的对象被标记“可回收”。

        2.遍历完成后,将所有被“可回收”的对象进行垃圾回收,释放的空间返回给0代储存区,其他的对象的对象 迁移 到1代储存区,标记为“1代对象”,此时该对象是分散分布的,要进行 压缩 操作,使得1代对象顺序紧密排列。新对象存储于0代储存空间,标记为0代对象。

        3.当1代空间满了时,将1代对象按照上述操作遍历,迁移,压缩到2代储存区,标记为2代对象,同时0代迁移压缩到1代。

        3.5 GC带来的问题

        GC在unity内存管理中,会带来以下问题:

        1.游戏性能:GC操作是一个极其耗费事件的操作,堆内存上的变量或者引用越多则导致遍历检查时的操作变得十分缓慢,使得游戏运行缓慢,例如当CUP处于游戏性能的关键时刻,任何一个操作就会导致游戏帧率下降,造成极大的影响。

        2.游戏内存:(unityGC采用的是非分代非压缩的标记清除算法)GC操作会产生“内存碎片化”。当一个单元内存从堆中分配出来,其大小取决于存储变量的大小。当内存被回收到堆上时,有可能被堆内存分割成碎片化的单元。(就是说总容量大小时固定的,但是单元内存较小。例如房子很大,房间很小,找不到合适的房间)即下次分配时找不到合适的储存单元,就会触发GC操作,或者堆内存扩容操作,导致GC频发发生和游戏内存越来越大。

        3.6 何时触发

        1.在堆内存上进行内存分配操作,而内存不够的时候都会触发垃圾回收来利用闲置的内存;

        2.GC会自动的触发,不同平台运行频率不—样;

        3.GC可以被强制执行。

         3.7 如何避免GC?装箱拆箱介绍/对象池介绍/泛型介绍)

        1.减少临时变量的使用,多使用公共对象,多利用缓存机制。(将容器定义到函数外,用到容器的时候进行修改即可)

        2.减少new对象的次数。

        3.对于大量字符串拼接时,将StringBuilder代替String。(string不可修改性,修改即创建一个新的string对象,旧的直接抛弃等待GC,但少量字符串拼接用string,性能优于stringbuilder)

        4.使用扩容的容器时,例如:List,StringBuilder等,定义时尽量根据存储变量的内存大小定义储存空间,减少扩容的操作。(扩容后,旧的容器直接抛弃等待GC)

        5.代码逻辑优化:例如计时器当大于1s后才进行文本修改,而不是每帧都修改,或者禁止在关键时候GC,影响游戏性能,可以在加载页面或者进度条的时候GC。

        6.利用对象池:对象池是一种Unity经常用到的内存管理服务,针对经常消失生成的对象,例如子弹,怪物等,作用在于减少创建每个对象的系统开销。在我们想要对象消除时,不直接Destory,而是隐藏起来SetActive(false),放入池子中,当需要再次显示一个新的对象时,先去池子中看有没有隐藏对象,有就取出来(显示) SetActive(true),没有的话,再实例化。

        7.减少装箱拆箱的操作:

        7.1装箱拆箱介绍:

        装箱是将值类型转换为 object 类型或由此值类型实现的任何接口类型的过程。

        装箱的底层操作:

        去堆内存new一个Object类对象

        把值类型的数据存入到堆中的Object对象中

        将堆上创建的对象的地址返回给引用类型变量。

        拆箱是从 object 类型到值类型或从接口类型到实现该接口的值类型的显式转换。

        拆箱底层操作:

        获取已装箱的对象的地址检查对象实例,以确保它是给定值类型的装箱值。

        将该值从实例复制到值类型变量中。

        装箱是将一个 值类型 变量被用于 引用类型 变量的内部转换过程;拆箱 是将创想后的引用类型转回值类型的操作。(无装修即无拆箱)。

        7.2产生GC的原因:在Unity的装箱操作中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操作是非常普遍的一种产生内存垃圾的行为,即使代码中没有直接的对变量进行装箱操作,在插件或者其他的函数中也有可能会产生。最好的解决办法是尽可能的避免或者移除造成装箱操作的代码。

        7.3泛型介绍:处理多个代码对不同的数据类型执行相同指的操作。也可以理解为:多个类型共享一组代码。泛型类不是实际的类,而是类的模板。泛型不会进行装箱拆箱,所以性能很 高,且规定了变量类型的限制,编译器可以在一定程度上验证类 型的假设,提高了程序类型的安全性,因此在使用容器时多使用 带有泛型的容器例如(ArrayList与List)。

        8.协程: yeild return 0 会产生装箱拆箱,可以替换为 yeild return null。

        9.减少不必要的Log;

4. 面向对象的三大特征:继承、封装、多态

        4.1 继承:

        提高代码重用度,增强软件可维护性的重要手段,符合开闭原则(软件中的对象扩展是开放的,修改是关闭的)。继承就是把子类的公共属性集合起来(变量,方法等)共同管理,这些公共属性设置为父类,C#的继承是单继承,但继承有传递性:A继承B,B继承C,A可以调用C#中的方法。

        4.2 封装:

  封装是将数据和行为相结合,通过行为约束代码修改数据的程度,增强数据的安全性,属性是C#封装实现的最好体现。将一些复杂的逻辑包装起来,程序员不管内部是如何实现的,只负责使用里面的数据或者逻辑,目的是保护或者防止代码被无意修改。

        4.3 多态性:

  多态性是指同名的方法在不同环境下,自适应的反应出不同得表现,是方法动态展示的重要手段。例如叫声,在鸟这个类中是“鸣啼”在狗这个类中是“犬吠”。

5. 修饰符

  1. public:对任何类和成员都公开,无限制访问
  2. private:仅对该类公开
  3. protected:对该类和其派生类公开
  4. internal:只能在包含该类的程序集中访问该类

      protected internal: protected + internal

6. 密封类

        关键字sealed,类声明时可防止其他类继承此类,在方法中声明则可防止派生类重写此方法。与override一起使用。

Unity游戏开发客户端面经——C#(初级)_第1张图片

详细请看:

文章https://blog.csdn.net/qq_40323256/article/details/86771078?ops_request_misc=&request_id=&biz_id=102&utm_term=sealed%E7%9A%84%E4%BD%BF%E7%94%A8&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-0-86771078.142%5Ev42%5Enew_blog_pos_by_title,185%5Ev2%5Econtrol&spm=1018.2226.3001.4187

7. 结构体和类

     7.1 区别

  1. 结构体是值类型,类是引用类型
  2. 结构体存在栈中,类存在堆中
  3. 结构体变量和类对象进行类型传递时,结构体变量进行的就是值传递,而类对象进行的是引用传递,或者说传递的是指针,这样在函数中改变参数值,结构体对象的值是不变的,而类对象的值是变化了
  4. 在C#中结构体类型定义时,成员是不能初始化的,这样就导致了,定义结构体变量时,变量的所有成员都要自己赋值初始化。但对于类,在定义类时,就可以初始化其中的成员变量,所以在定义对象时,对象本身就已经有了初始值,你可以自己在重新给个别变量赋值。(注意在C++中,类的定义中是不能初始化的,初始化要放在构造函数中)
  5. 结构体不能申明无参的构造函数,而类可以
  6. 声明了结构类型后,可以使用new运算符创建构造对象,也可以不使用new关键字。如果不使用new,那么在初始化所有字段之前,字段将保持未赋值状态且对象不可用。
  7. 结构体申明有参构造函数后,无参构造不会被顶掉
  8. 结构体不能申明析构函数,而类可以
  9. 结构体不能被继承,而类可以。
  10. 结构体需要在构造函数中初始化所有成员变量,而类随意
  11. 结构体不能被静态static修饰(不存在静态结构体),而类可以

7.2 使用环境

      结构体:

  1. 结构是值类型在栈中,栈的存取速度比堆快,但是容量小,适合轻量级的对象,比如点、矩形、颜色。
  2. 如果对象时数据集合时,优先考虑接结构体(位置,坐标)
  3. 在变量传值的时候,希望传递对象的是拷贝,而不是对象的引用地址,这个时候就可以使用结构体。

        类:

       1.类是引用类型,存储在堆中,堆的容量大,适合重量级的对象,栈的空间不大,大量的对应当存在于堆中。

        2.如果对象需要继承和多态特征,用类(玩家、怪物)。

        什么时候用结构呢?

        结构使用简单,并且很有用,但是要牢记:结构在堆栈中创建,是值类型,而类是引用类型。每当需要一种经常使用的类型,而且大多数情况下该类型只是一些数据时,使用结构能比使用类获得更佳性能。

8. 接口和抽象类

    区别

  1. 接口不是类(无构造函数和析构函数),不能被实例化,抽象类可以间接实例化(可以被继承,有构造函数,可以实例化子类的同时间接实例化抽象类这个父类)。
  2. 接口只能做方法申明,抽象类中可以做方法申明,也可以做方法实现。
  3. 抽象类中可以有实现成员,接口只能包含抽象成员。因此接口是完全抽象,抽象类是部分抽象。
  4. 抽象类要被子类继承,接口要被类实现。
  5. 抽象类中所有的成员修饰符都能使用,接口中的成员都是对外的,所以不需要修饰符修饰。
  6. 接口可以实现多继承,抽象类只能实现单继承,一个类只能继承一个类但可以实现多个接口。
  7. 抽象方法要被实现,所以不能是静态的,也不能是私有的。

使用环境:

        使用抽象类是为了代码的复用,而使用接口的动机是为了实现多态性。

        抽象类适合用来定义某个领域的固有属性,也就是本质,接口适合用来定义某个领域的扩展功能。

抽象类

        1.当2个或多个类中有重复部分的时候,我们可以抽象出来一个基类,如果希望这个基类不能被实例化,就可以把这个基类设计成抽象类。

        2.当需要为一些类提供公共的实现代码时,应优先考虑抽象类。因为抽象类中的非抽象方法可以被子类继承下来,使实现功能的代码更简单。

接口

        当注重代码的扩展性跟可维护性时,应当优先采用接口。

        ①接口与实现它的类之间可以不存在任何层次关系,接口可以实现毫不相关类的相同行为,比抽象类的使用更加方便灵活;

        ②接口只关心对象之间的交互的方法,而不关心对象所对应的具体类。接口是程序之间的一个协议,比抽象类的使用更安全、清晰。一般使用接口的情况更多。

9. 静态构造函数

  1. 静态构造函数既没有访问修饰符,也没有参数。
  2. 在创建第一个类实例或任何静态成员被引用时,.NET将自动调用静态构造函数来初始化类。
  3. 一个类只能有一个静态构造函数。
  4. 无参数的构造函数可以与静态构造函数共存。
  5. 最多只运行一次。
  6. 静态构造函数不可以被继承。
  7. 如果没有写静态构造函数,而类中包含带有初始值设定的静态成员,那么编译器会自动生成默认的静态构造函数。
  8. 如果静态构造函数引发异常,运行时将不会再次调用该构造函数,并且在程序运行所在的应用程序域的生存期内,类型将保持未初始化。

10. 虚函数实现原理

        每个虚函数都会有一个与之对应的虚函数表,该虚函数表的实质是一个指针数组,存放的是每一个对象的虚函数入口地址。对于一个派生类来说,他会继承基类的虚函数表同时增加自己的虚函数入口地址,如果派生类重写了基类的虚函数的话,那么继承过来的虚函数入口地址将被派生类的重写虚函数入口地址替代。那么在程序运行时会发生动态绑定,将父类指针绑定到实例化的对象实现多态。 

11. 指针和引用的区别

  1. 引用不能为空,即不存在对空对象的引用,指针可以为空,指向空对象。
  2. 引用必须初始化,指定对哪个对象的引用,指针不需要。
  3. 引用初始化后不能改变,指针可以改变所指对象的值。
  4. 引用访问对象是直接访问,指针访问对象是间接访问。
  5. 引用的大小是所引用对象的大小,指针的大小,是指针本身大小,通常是4字节。
  6. 引用没有const,指针有const
  7. 引用和指针的+自增运算符意义不同。
  8. 引用不需要分配内存空间,指针需要。

12. ref 与 out

        ref和out的作用

        解决值类型和引用类型在函数内部改值或者重新申明能够影响外部传入的变量让其也被修改。

        使用

        就是在申明参数的时候前面加上ref和out的关键字即可,传入参数时同上。

         区别

        ref传入的变量必须初始化但是在内部可改可不改。

        out传入的变量不用初始化但是在内部必须修改该值(必须赋值)。

13. 不安全代码与非托管代码

        托管代码: 在公共语言运行时(CLR)控制下运行的代码。

        非托管代码: 不在公共语言运行时(CLR)控制下运行的代码。

        不安全(Unsafe)代码: 不安全代码可以被认为是介于托管代码和非托管代码之间的。不安全代码仍然在公共语言运行时(CLR)控制下运行,但它将允许您直接通过指针访问内存。

14. 委托

介绍

        委托是约束集合中的一个类,而不是一个方法,相当于一组方法列表的引用,可以便捷的使用委托对这个方法集合进行操作。委托是对函数指针的封装。

委托和接口的区别

     接口介绍

        接口是约束类应该具备功能的集合,约束了类应该具备哪些功能,使类从复杂的逻辑中解脱出来,方便类的管理和拓展,同时解决类的单继承问题。

  使用情况

        接口:无法继承的场所

                   完全抽象的场所

                   多人协作的场所

        委托:多由于事件的处理

委托和事件的区别

        事件可以看做成委托中的一个变量。

        事件是基于委托的存在,事件是委托的安全包裹 让委托的使用更具有安全性。

  1. 委托可以用“=”来赋值,事件不可以。
  2. 委托可以在声明它的类外部进行调用,而事件只能在类的内部进行调用。
  3. 委托是一个类型,事件修饰的是一个对象。

        详细情况:从使用层面上了解委托和事件的区别 - 陈哈哈 - 博客园

15. C#中基本类型占用的字节数

类型

字节数

bool -> System.Boolean

布尔型,其值为 true 或者 false

byte -> System.Byte

字节型,占 1 字节,表示 8 位正整数,范围 0 ~ 255

sbyte -> System.SByte

带符号字节型,占 1 字节,表示 8 位整数,范围 -128 ~ 127

char -> System.Char

字符型,占有两个字节,表示 1 个 Unicode 字符

short -> System.Int16

短整型,占 2 字节,表示 16 位整数,范围 -32,768 ~ 32,767

ushort -> System.UInt16

无符号短整型,占 2 字节,表示 16 位正整数,范围 0 ~ 65,535

uint -> System.UInt32

无符号整型,占 4 字节,表示 32 位正整数,范围 0 ~ 4,294,967,295

int -> System.Int32

整型,占 4 字节,表示 32 位整数,范围 -2,147,483,648 到 2,147,483,647

float -> System.Single

单精度浮点型,占 4 个字节

ulong -> System.UInt64

无符号长整型,占 8 字节,表示 64 位正整数,范围 0 ~ 大约 10 的 20 次方

long -> System.Int64

长整型,占 8 字节,表示 64 位整数,范围大约 -(10 的 19) 次方 到 10 的 19 次方

double -> System.Double

双精度浮点型,占8 个字节

        特殊:bool:true/false

        1:byte、char

        2:char、short

        4:int,float

        8:long、double

16. New的实现

        rPoint1 = new RefPoint(1);

  1. 在应用程序堆上创建一个引用类型对象的实例,并为它分配内存地址。
  2. 自动传递该实例的引用给构造函数(正因如此,在构造函数中才能使用this来访问这个实例)。
  3. 调用该类型的构造函数。
  4. 返回该实例的引用内存地址,复制给 rPoint1 变量,该rPoint1 引用对象保存的数据是指向在堆上创建该类型的实例地址。

17. 协变与逆变

        协变(out):

        和谐、自然的变化

        里式替换原则中,父类容器可以装载子类对象,子类可以转换成父类。比如string转object,感受是和谐的。

        逆变(in):

        逆常规、不正常的变化

        里式替换原则中,父类容器可以装载子类对象,但是子类对象不能装载父类。所以父类转换为子类,比如object转string,感受是不和谐的。

        协变和逆变是用来修饰泛型的,用于泛型中修饰字母,只有泛型接口和泛型委托能使用.

      作用:

//1.返回值与参数

//用out修饰的泛型,只能作为返回值

delegate T Testout();

//用in修饰的泛型,只能作为参数

delegate T TestIn(T t);

18. 反射

        可以在加载程序运行时,动态获取和加载程序集,并且可以获取到程序集的信息反射即在运行期动态获取类、对象、方法、对象数据等的—种重要手段。

     反射面向对象体现

        之前了解的面向对象是基于类实现,而反射中就是基于程序集实现,只不过把类再用程序集包裹了一下,封装是把一些属性方法封装到一个类中,限制其数据修改的程度,那多加一层皮(程序集 ) 就是一个道理了,继承多态就是和类一样,把类换成程序集去理解。

    优点:

        允许在运行时发现并使用编译时还不了解的类型以及成员。

    缺点:

        1.根据目标类型的字符串搜索扫描程序集的元数据的过程耗时。

        2.反射调用方法或属性比较耗时。(首先必须将实参打包成数组,在内部,反射必须将这些实参解包到线程栈上。可以使用多态避免反射操作)

    通过反射去获取对象的一个实例

        反射可以直接访问类的构造,直接通过getConstructor,去访问这个构造函数,然后通过不同的参数列表,就可以具体的定位到哪一个构造的重载,通过这个方法,去得到类的实例,把对象就拿到了。

19. 想要在for循环中删除List(或者vector,都行)中的元素时,有可能出现什么问题,如何避免?

        当删除遍历节点后面的节点时,会导致List.Count进行变化,删除元素后,当根据i++,遍历到删除的节点会发生异常。

        处理

        可以从后往前元素元素,即删除在访问的前面

20. 有两个敌人我敌人A实例化了一个实体,我直接用B=A,请问会导致什么问题

        原理:两个引用指向内存中同一份数据。

        实际游戏场景中,首先的问题就是A掉血 B也会掉血,并且最大的问题是A死了一般情况是要删除,那么B引用会报空,可能出现未知的错误。

你可能感兴趣的:(Unity客户端开发面经,c#语言,c#,开发语言)