Unity3D高级编程主程手记 学习笔记二:C#技术要点

1.Untiy3D中C#的底层原理

Unity底层在运行C#程序时有两种机制:一种是Mono,另一种是IL2CPP。Mono存在的目的是为了跨平台,因为最初C#只支持Windows。而IL可以看成是一种汇编语言且完全基于堆栈,必须运行在虚拟机上。也就是说C#会被编译器编译成IL,当需要他们时就会被实时的加载到运行库中,由虚拟机动态的编译成汇编代码(JIT)再进行执行。

注:Unity中其他的两门脚本语言Boo,Untiy Script(这两暂时还没接触到)也是被编译成IL后再由Mono虚拟机解释并执行的。

IL有三种转译模式:

  1、Just-in-time(JIT)模式:在程序运行过程中将CIL(IL)转译为机器码。

  2、Ahead-of-Time(AOT)模式:将IL转译成机器码并存储在文件中,此文件并不能完全独立运行。通常此种模式可产生出绝大部分JIT模式所产生的机器码,只有部分例外。例如trampolines或是控制监督相关的代码任然需要JIT来运行。

  3、完全静态编译:这种模式只支持少数平台,它基于AOT编译模式更进一步所产生的机器码。完全静态编译可以使得程序在运行期间不需要JIT,这种做法适用于IOS,PS3,Xbox360等不允许使用JIT的操作系统

 

Mono使用垃圾回收机制来管理内存,应用程序向垃圾回收器申请内存,最终由垃圾回收器决定是否回收。当我们向垃圾回收器申请内存时,如果发现内存不足,就会自动出发垃圾回收(也可以主动触发),垃圾回收器会遍历内存中所有对象的引用关系,如果没有被任务对象引用则会释放内存。

3.1.1之后Mono正式将(Simple Generational GC)SGen-GC设置为默认的垃圾回收器,这种方式的主要思想是将对象分给两个内存池,一个新的一个老的。那些存活时间长的对象都会放到较老的对象池中。这种设计基于一个事实,程序经常会申请一些小的临时对象,用完了马上释放,而如果某个对象一直不释放则,往往以后的很长时间内也不会被释放

此外,我们称IL编码为托管代码,这部分代码由C#自动生成,由虚拟机JIT编译执行,且其中的对象由GC管理。而使用C++或C#以不安全类型写的代码称为非托管代码,虚拟机无法追踪到这类代码对象,故程序员需要对这部分代码的对象手动进行管理避免内存泄漏的问题。(一般情况下,我们使用托管代码来编写游戏逻辑,非托管代码用于更底层的架构、第三方库或者是操作系统相关接口)

 

(中间一部分为 List与Dictionary的源码剖析,可自行查阅源码进行学习)

这里只对部分函数做结论和总结:

List:

List使用Array数组作为底层数据结构,优点是随机存储,索引快,缺点是扩容时会很糟糕,每次针对数组进行new操作都会造成内存垃圾,会给GC带来负担。

Remove()函数,原理是使用Array.Copy()对数组进行覆盖,复杂度为O(N).

Clear()函数,并不会对内存进行操作真正的删除元素,而是将对象内的count置0,因为对内存进行操作会产生极大的性能消耗。

Enumerator()接口,这是枚举迭代部分细节的接口,其中Enumerator这个结构,每次在获取迭代器时都会被创建出来。如果大量使用迭代器,例如 foreach语句,就会产生大量的垃圾。这也就是为什么我们常常说要避免在Update()中使用foreach的底层原因。 

Sort()函数,内部使用Array.Sort()接口,使用快速排序进行排序。

总结:

1.List的效率并不是很高,只是通用性强,底层使用的大部分算法都是线性算法。

2.同时其内存分配方式也是不合理的,当增加元素需要对数组进行扩充时,原数组会被抛弃,这会给GC带来很大的压力。

3.内部代码是线程不安全的,底层没有对多线程做任何加锁和限制,如实现需要多线程操作需要手动加锁。

 

Dictionary:

private struct Entry{
    public int hashCode; // 低31位哈希值,如果未使用则为-1
    public int next; // 下一个实例索引,如果是最后一个则为-1
    public Tkey key; // 实例的Key
    public TValue value; // 实例的Value
}

Dictionary使用链地址法来解决哈希冲突,内部有两个数组一个int类型的bucket,还有一个Entry类型(如上)的entries。

 其中bucket就是下图中的紫色部分,也就是哈希表。

Entry代表了下图中绿色部分,每一个Entry保存自己的Key,Value,以及哈希值,并且可以索引到下一个实例(如果有)。

总结:

1. Dictionary在效率上与List一样,最好在实例化对象时就确定大致的数量,这样会减少分配内存的频率。另外,使用值作为键值比使用对象要更高效,因为类对象的Hash值通常都是由地址内存再计算得到的。

2.从内存操作上看,Dictionary增长速度大约是两倍,删除数据时,同List一样不对内存进行操作

3.Dictionary是线程不安全的,需要自己进行lock操作

2.浮点数精度问题

在实际使用过程中,许多人都会想要使用double类型来代替float从而解决精度问题,但最后往往以失败告终。

因为无论是double类型还是float类型,在计算机底层存储都是以二进制的形式存储的,并且存储的位数有限(32位/64位)。所以十进制在转为二进制时很多数字会丢失精度。

2.1精度问题可能会发生的情况?

(1)数值比较不相等

  编写代码时通常会遇到数值触及阈值从而触发逻辑的情景。例如某一个变量,从0开始,每次加一个小于0.01的数,加到刚好0.23时触发事件。但是我们不能确定每次加多少,所以结果会出现要么比0.23大,要么比0.23小,很难会出现刚好等于0.23的情况。这时使用>,<,==等这些符号就不好处理,如果一定要比较,可以使用ABS()<0.001这样的方式解决。

(2)数值计算不确定

  例如x=1f, y=2f, z = (1f/5555f)* 11110f, 我们预期x/y应当是等于z的,但是由于精度问题,z结果可能为0.4999999,这样x/y与z就不会相等。

(3)不同设备计算结果不同

  不同计算机上操作系统,CPU寄存器都不同,计算时会有一定的精度偏差

2.2如何解决精度问题?

(1)由一台计算机进行运算

  在网络同步方面,可以使用一台计算机,例如特定的一台服务器用来进行计算。

(2)改用int,long类型来替代浮点数

  例如,4080,与4.08*1000在数学中是完全成立的,我们可以使用4080当做4.08进行存储,从而避免把数据放在小数点后面(精度截断)。

(3)用定点数保持一致性并缩小精度问题

  定点数就是,小数部分与整数部分分开存储。从而与方法二一样,其小数部分可以使用int或long来存储,避免小数截断的问题。

struct MyNum{
    int Interger; 
    int Decimal; 
}

(4)用字符串代替浮点数

  当精度要求特别高时,可以使用字符串来代替浮点数,这种方式不用担心越界问题,并且还可以自由的控制精度。缺点是很消耗CPU和内存。使用字符串代替浮点数,一次计算量相当于好几万次普通浮点数。所以当精度要求特别高,且计算次数不多时可以考虑这种方法。

3.委托、事件、装箱、拆箱

3.1委托与事件

  C#中指针被封装进了底层类中,绝大多数的情况都看不到指针,但回调函数依然存在,于是C#中多了委托这一概念。委托可以看成是一种更高级的函数指针,它不仅会把地址指向另一个函数,而且还能传递参数、获取返回值等多个信息。此外,系统还会为委托对象自动生成同步、异步的调用方法,开发人员可以使用BeginInvoke(),EndInvoke()来避开Thread类,从而直接使用多线程调用。

  委托(delegate)不是一个语言的基本类型,在创建委托的时候实际上就是在创建一个delegate类的实例,这个delegate类继承了Sysytem.MulticastDeleate类,类实例中有BeginInvoke(),EndInvoke(),Invoke()三个函数,分别表示异步调用开始,结束,以及直接调用。

  注:我们不能直接写一个类来继承Sysytem.MulticastDeleate类,和它的父类Delegate类。官方文档中的解释是这两个类是特殊的类,编辑器或其他工具可以从他这里继承,但是你不能直接继承它。

  Delegate中有一个变量用来存储函数地址可以认为是一个链表。重写了+=,-=操作符,用来向这个链表中添加或者删除元素。也就是把函数地址加入或者删除到链表中。

  所以delegate关键字只是一个修饰用词,背后C#编辑器会重写代码,我们可以认为编译过程把delegate关键字识别的对象转译成为Delegate类对象

  事件(event)则是在delegate上又做了一层封装,这次封装的意义是,限制用户直接操作delegate实例中的变量的权限。封装后,用户不再能通过直接赋值的方式(=操作符)来改变委托变量。只能通过注册或者注销委托的方式来增减委托函数的数量。通过这样的方式更好的维护了委托的秩序,增加了系统稳定性。

3.2装箱与拆箱   

  装箱:把值类型实例转换成为引用类型实例。拆箱:把引用类型实例转为成为值类型实例。(引用对象有:string类型,class实例,数组)(值对象有:所有整数,浮点数,bool,struct实例)

 

int a = 5;
object obj = a;

上面就是一个简单的装箱过程,因为a是一个值类型,是直接有数据的变量,obj为引用变量,指针与内存拆分开来,把a赋值给obj,实际上就是obj为自己创建了一个指针,并指向a的数据空间。

a = (int)obj

而这段代码就是一个简单的拆箱的过程,相当于把obj指向的内存空间复制一份给了a,因为a是值引用,所以它不允许指向某个内存空间,只能靠复制数据来传递数据。

3.3为何需要装箱?

  值类型是在声明之后就立即被初始化的了的,因为它一旦声明,就有了自己的空间,因此它不可能为null,也不能为null。而引用类型在分配内存以后,只是一个空壳子,可以认为是指针,初始化后不指向任何空间,因此默认为null

  这里要注意Struct部分,很多人会把它当成是类,而类是引用类型,结构体是值类型。所以a,b同是结构的实例,a赋值给了b,而b更改数据之后a的数值并没有改变。这是因为a,b是值类型,各自占用了一片内存空间,修改b对象不会对a有影响。在a赋值给b的过程实际上是将数据原封不动复制一份给了b。

  后面涉及到了堆栈内存,所以来说说堆栈内存是怎么回事?

  栈是存放对象的一种特殊的容器,它是最基本的数据结构之一,遵循先进后出的原则它是一段连续的内存,所以对栈数据定位比较快速;堆则是随机分布的空间,定位数据时需要堆内存的创建和删除节点时间复杂度是O(logN)。显然栈的速度更快,但是栈对象有严格的声明周期,并且栈空间有限,会有栈溢出的问题;而堆对象声明周期不确定,空间也几乎没有限制。所以并不是所有情况都应该使用栈。

  那值类型和引用类型就是堆和栈内存分配的区别吗?不是!

  引用类型指向的内存块都在堆内,一般这些内存块都在委托堆内,这样便于内存块的回收和控制,GC会对这些堆进行回收和整理。也有非委托内存不归委托堆管理的部分,这些部分需要自行管理。

  大部分时候,只有当程序逻辑和接口需要更加通用的时候才需要装箱。比如一个含类型为object的参数的方法,该object可支持任意类型,以便通用。当你需要一个值类型为(如nt32)传入时,就需要装箱。又比如一个非泛型的容器为了保证通用,而将元素类型定义为object,当值类型数据加入容器时,就需要装箱。

3.4装箱的优化

  由于装箱拆箱的过程,都是生成全新的对象。不断地分配和销毁内存不但会大量消耗CPU,同时也会增加内存碎片,降低性能。所以我们要尽可能的减少装箱拆箱。Struct类型比较特殊,它即是值类型,又可以继承接口,用途多,稍不留神就会增加性能消耗。以下为Struct的优化技巧:

  1)Struct通过重载函数来避免拆箱,装箱

    如果Strcut没有重载一些函数,实例调用它们的时候就会先装箱在调用,所以对于用到的那些需要调用的引用方法时,必须重载。

  2)通过泛型来避免拆箱、装箱

    Struct可以继承Interface接口,我们可以利用Interface做泛型接口,使用泛型来传递参数,这样就不会在装箱后再传递值了。比如B,C继承A,就有了这个泛型反复方法 Void Test(T t) where T : A, 以避免使用object引用类型来传递参数。

  3)通过继承统一接口提前拆箱、装箱,避免多次重复拆箱、装箱

    比如Struct A和Struct B都继承了接口1,我们调用的方法时void Test(I i)。当调用Test方法时,传进去的Struct A或 Struct B的实例相当于提前执行了装箱操作,Test方法拿到了参数后就不用担心后面会进行装箱拆箱操作了。

4.业务逻辑优化技巧

4.1使用List和Dictionary时提高效率 

   每次使用List的Insert,Contain,Remove函数时,都是会顺序遍历List的,如果经常用到他们就会有比较大的性能消耗。

  Dictionary也有很多问题,它使用一个Hash冲突方案来解决关键字的字典组件,因此Hash值与容器中数组的映射和获取Hash值的函数GetHashCode()比较关键。Hash冲突与数组大小有很大关系,数组越大,哈希冲突率越小。在实际使用过程中应当初始化一个合理的大小,使得Hash冲突不那么频繁,且放任其自由扩容也会增加GC负担;GetHashCode()继承自基类Object类中的方法,用来获取类实例的Hash值,这个函数实质是用算法将内存地址转化成哈希值的过程,不会有任何缓存的过程。如果频繁的使用GetHashCode(),我们应当要关注此时这个函数的算力损耗,并确认是否可以作为唯一ID。

4.2巧用struct

   由于struct是一个值引用对象,所以传递struct时,其实是在不断地克隆数据。

struct A{
    public int gold;
}

public void main(){
    A a = new A();
    a.gold = 1;
    A b = a;
    b.gold =2;
}

  举这个例子,上述struct中有一个整数变量gold,实例a的gold值为1,将a赋值给b后,b的gold设置为2,此时a中的gold仍为1,因为a和b是两个不同的内存。

  struct这样的值变量对性能优化有什么好处呢,如果在函数中被定义成局部变量,则struct的值类型变量分配的内存是在栈上的,栈是连续内存,并且在函数调用结束后,栈的回收非常快速和简单,只有将尾指针置零就可以了,这样既不会产生内存碎片,又不需要内存垃圾回收,CPU读取数据对连续内存也非常高效

  除了以上,struct数组对提高内存访问速度也有所帮助,因为内部存储时连续存储的,CPU在读取数据时,连续内存可以帮助我们提高CPU的缓存命中率,因为CPU在读取内存时会把一个大块内存放入缓存,当下次读取时先从缓存中查询,如果命中则不需要再向内存读取数据(缓存比内存块100倍),非连续内存的缓存命中率比较低,而CPU缓存命中率的高低很影响CPU的效率

4.3尽可能得使用对象池

  对象的创建与销毁都会引起内存分配时的性能损耗以及垃圾回收时的艰难。尤其是当业务逻辑大,数据量多的时,垃圾回收需要检查的内存也越多,如果回收后依然内存不足,就得向系统请求分配更多内存。对象池的使用并不麻烦,我们是需要在创建销毁对象的时候调用对象池管理对象即可。关于对象池的代码有很多读者可自行查找相关代码。

4.4字符串导致的性能问题

   在C#中由于string是引用类型变量,每次动态创建一个string,C#都会在堆内存中分配一个内存用于存放字符串。

sting strA = "test";
for(int i=0;i<100;i++){
    string strB = strA + i.ToString();
    string[] strC = strB.Split('e');
    strB = strB + strC[0];
    string strD = string.Format("Hello{0}, this is {1} and {2}."strB,strC[0],strC[1]);  
}

4.5字符串隐藏问题

   字符串隐藏问题,当两个字符比较时,首先会比较两个字符串的地址是否一致(引用变量),如果不一致才会遍历字符串各个字符,如果都一致则返回true。

  该问题涉及到ToCharArray(),Clone(),Compare()函数。string.ToCharArray()返回的char []数组是一个新创建的字符串数组,与原有的string无关,我们修改返回的字符串时不会影响原来的string对象。而Clone和ToString接口则是直接返回该对象,并不会重新创建一个新对象。 

热门相关:地球第一剑   最强反套路系统   法医王妃不好当!   重生当学神,又又又考第一了!   横行霸道