《Unity3D高级编程 主程手记》第二章 C#技术要点(八) 业务逻辑优化技巧

        

目录

使用 List 和 Dictionary 时提高效率

巧用struct

struct 对性能优化的好处

使用原值类型连续空间的方式来提高 CPU 的缓存命中率

尽可能地使用对象池

字符串导致的性能问题

解决方法

字符串的隐藏问题

程序运行原理

业务逻辑的优化方向

脱离 C# 语言,简单陈述程序运行原理

指令内存块

数据内存块


        不只是算法能大幅度提高业务逻辑的效率,普普通通的业务代码也同样可以有质的飞越。

        优秀程序员常关注代码对性能的影响,普通人只关注是否能完成功能,这样日积月累代码质量就会形成差距。

使用 List 和 Dictionary 时提高效率

        List、Dictionary 的实质都是数组。Dictionary有两个数组,一个数组用于保存索引,一个数组用于保存数据,我们遍历 List 和 Dictionary 都是在遍历数组

  1. 如果代码中使用 Insert 、Contains、Remove 函数频率比较高,就会带来不必要的性能消耗。
  2. 注意设置 Dictionary 的初始大小,尽量设置一个合理的大小。
  3. GetHasCode() 是用算法把内存地址转化为哈希值的过程,频繁使用会有算力损耗。可以用唯一 ID(标识)的方式来代替。

巧用struct

        struct 结构是值类型,传递时不是靠引用(指针)形式而是靠复制,是通过内存复制来实现传递(真实情况是通过字节对齐规则循环多次复制内存)。

struct 对性能优化的好处

  1. 不会产生内存碎片,不需要内存垃圾回收
  2. CPU 读取数据对连续内存非常友好、高效
    1. CPU 读取内存时会把一个大块内容放入缓存,下次读取会优先从缓存中查找,如果命中,则不需要向内存中读取数据(缓存比内存快100倍)。连续内存缓存命中率高,非连续内存命中率比较低,CPU 缓存命中率的高低很影响 CPU 的效率。
  3. struct 数组对提高内存访问速度有所帮助
    1. 它的内存和值类型都是连续的,而 class  数组只是引用变量空间连续。

        如果 struct 太大,超过了缓存复制的数据块,则缓存不再起作用。

使用原值类型连续空间的方式来提高 CPU 的缓存命中率

原值类型:int[]、bool[]、byte[]、float[] 等

方法:把所有数值都集合起来用数组的形式存放,而在具体对象上则只存放一个索引值

例子:

class A
{
	public int a;
	public float b;
	public bool c;
}

class B
{
	public int index;
}

class C
{
	private static C _instance;
	public static C instance
	{
		get
		{
			if(null == _instance)
			{
				_instance = new C();
				return _instance;
			}
			return _instance;
		}
	}

	public int[] a = new int{2, 3, 5, 6};
	public float[] b = new float{2.1f, 3.4f, 1.5f, 5.4f};
	public bool[] c = new bool{false, true, false, true};
}

public void main()
{
	A[] arrayA = new A[3]{new A(), new A(), new A()};

	print("A class this is a {0} b {1} c {3}",Aa.a, Aa.ba, Aa.c);
	
	B b = new B()
	b.index = 2;

	C c = C.instance;

	print("B class this is a {0} b {1} c {3}",c.a[b.index], c.b[b.index], c.c[b.index]);
}

        A 类数据的内存是分散的,而 B 类数据是内存连续的数据。

        值类型的数组分配内存一定是内存连续的,这样能更好的利用缓存,提高 CPU 读取数据的命中率。

        缓存机制是将最近使用的数据存入最近的空间中,离 CPU 最近的是一级缓存和二级缓存。

尽可能地使用对象池

        内存分配和内存消耗会对程序产生影响。不但要减少内存分配次数和内存碎片,还要避免内存卸载带来的性能损耗。

        因此当我们业务逻辑越庞大数据量越多时,垃圾回收需要检查的内容也越来越多,如果回收后依然内存不足,就得向系统请求分配更多内存。

        我们应该尽可能的用对象池来重复利用已经创建的对象,这有助于减少内存分配时的耗时,也减少了堆内存的内存块数量,最终减少了垃圾回收时带来的CPU损耗

内存分配消耗:

  1. new 操作创建某个类
  2. new List 操作
  3. DIctionary 操作

解决:通用对象池

//源自 Unity 的 UI 库中
internal class ObjectPool where T : new()
{
    private readonly Stack m_Stack = new Stack();
    private readonly UnityAction m_ActionOnGet;
    private readonly UnityAction m_ActionOnRelease;

    public int countAll { get; private set; }
    public int countActive { get { return countAll - countInactive; } }
    public int countInactive { get { return m_Stack.Count; } }

    public ObjectPool(UnityAction actionOnGet, UnityAction actionOnRelease)
    {
        m_ActionOnGet = actionOnGet;
        m_ActionOnRelease = actionOnRelease;
    }

    public T Get()
    {
        T element;
        if (m_Stack.Count == 0)
        {
            element = new T();
            countAll++;
        }
        else
        {
            element = m_Stack.Pop();
        }
        if (m_ActionOnGet != null)
            m_ActionOnGet(element);
        return element;
    }

    public void Release(T element)
    {
        if (m_Stack.Count > 0 && ReferenceEquals(m_Stack.Peek(), element))
            Debug.LogError("Internal error. Trying to destroy object that is already released to pool.");
        if (m_ActionOnRelease != null)
            m_ActionOnRelease(element);
        m_Stack.Push(element);
    }
}

internal static class ListPool
{
    // Object pool to avoid allocations.
    private static readonly ObjectPool> s_ListPool = new ObjectPool>(null, l => l.Clear());

    public static List Get()
    {
        return s_ListPool.Get();
    }

    public static void Release(List toRelease)
    {
        s_ListPool.Release(toRelease);
    }
}

        对象池并不复杂,麻烦的是使用,程序中所有创建对象实例、销毁对象实例、移除对象实例的部分都需要用对象池去调用。

        在对象池上使用预加载:在程序运行前让对象池中的对象分配得多一些,这样就不需要临时分配内存了。

        资源也可以有对象池:提前将资源内容加载到内存中可以让内存分配次数减少,甚至完全避免临时的加载和分配。

字符串导致的性能问题

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

string 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]);
}

        注:字符串常量是不会丢弃的。比如这段程序中的 “test”、“Hello {0}, this is {1} and {2}.”

        这段程序中,每次循环都申请5次内存,并且抛弃一次 strA + i.ToString() 的字符串内容。每次循环结束都会将前面所有分配的内存内容抛弃,再重新分配一次,总共申请了500次。

        原因:C# 语言对于字符串没有任何缓存机制,每次使用都需要重新分配 string 内存。

解决方法

  1. 自建缓存机制,使用 Dictionary 容器将字符串缓存起来。
  2. 使用 C# 中的“不安全”的 native 方法,类似于 C++ 的指针方式来处理 string 类。
//方法一
Dictionary strCache;

string strName = null;
if(!strCache.TryGetValue(id, out strName))
{
	ResData resData = GetDataById(ID);
	string strName = "This is " + resData.Name;
	strCache.Add(id, strName);
}

return strName;
//方法二
Dictionary cacheStr;

public unsafe string Concat(string strA, string strB)
{
	int a_length = a.Length;

	int b_length = b.Length;

	int sum_length = a_Length + b_Length;

	string strResult = null;

	if(!cacheStr.TryGetValue(sum_length, out strResult))
	{
		//如果不存在sum_length长度的缓存字符串,那么久直接连接后存入缓存。
		strResult = strA + strB;

		cacheStr.Add(sum_length, strResult);

		return strResult;
	}

	//将缓存字符串再利用,用指针方式直接改变它的内容
	fixed(char* strA_ptr = strA)
	{
		fixed(char* strB_ptr = strB)
		{
			fixed(char* strResult_ptr = strResult)
			{
				//将strA中内容拷贝到strResult中
				memcopy((byte*)strResult_ptr, (byte*)strA_ptr, a_length*sizeof(char));

				//将strB中内容拷贝到strResult的a_Length长度后面内存中
				memcopy((byte*)strResult_ptr+a_Length, (byte*)strB_ptr, b_length*sizeof(char));
			}
		}
	}

	return strResult;
}

public unsafe void memcopy(byte* dest, byte* src, int len)
{
	while((--len)>=0)
	{
		dest[len] = src[len];
	}
}

字符串的隐藏问题

        字符串的隐藏问题涉及 ToCharArray、Clone、Compare 等内容。

        string.ToCharArray() 返回的 char[] 是一个新创建的字符串数组。

        string.Clone、string.ToString 接口,并不会重新构建一个 string,而是会直接返回当前的 string 对象。

字符串比较:

  1. 判断引用是否相等
  2. 判断长度是否相等
  3. 遍历字符串,判断每个字符是否相等

        如果两个字符串来自不同的内存段,那么在比较它们是否相等时就会遍历所有字符来判断是否相等。

string 源码地址:https://referencesource.microsoft.com/#mscorlib/system/string.cs

程序运行原理

业务逻辑的优化方向

  1. 如何利用好 CPU 缓存命中率
  2. 如何减少内存分配和卸载次数
  3. 何如利用好多线程,让每个线程协作顺畅、并且能分担任务

脱离 C# 语言,简单陈述程序运行原理

        一个程序在内存中运行时,通常由几个内存块组成。

指令内存块

        里面存储的都是已经编写设计好的执行的指令,需要执行的指令都会从指令内存块中去取,指令计数器也不断跳跃在这些指令中。

数据内存块

        里面存放的都是我们设置好的数据以及分配过的内存。

        静态数据块,通常里面存放的都是不变的数据,比如字符串常量,常量整数,常量浮点数,以及一些静态数据,这些数据在程序启动时最先被放入内存中。

        堆内存数据块,所有的动态内存申请都来自堆内存,我们可以认为它是一个很长的 byte 数组,当我们申请内存时,会从数组中找出一块我们指定大小的内存,这个内存不一定是空的,因为内存回收从来不会对内存单位有清理操作,那样太浪费算力了,从来都是将这段数据的指针回收或偏移。所以实际上,我们申请的内存块,在没初始化前都是未知的,有可能刚好前面用过的与我们相似的内容,如果不进行初始化,就有可能会出现逻辑问题

        这里还有个系统层,我们在系统层面上运行程序,所以遵循的是系统层面的逻辑。操作系统提供了虚拟地址,以此避免程序直接与硬件打交道。包括 iOS 和安卓,日常分配的都是虚拟内存。

        我们用惯类对象很容易以为内存中就是某个类的实例内存,其实在机器指令和内存中可没有这个说法,它只是块连续 byte 内存单元,具体其代表哪个类的实例只是我们想象的而已。

        类的方法或函数被编译成指令序列,放在了指令内存块中,所有的方法,函数都在那里集中存放着,随时能取到。

        一个可执行文件或程序库里,几乎都是指令机器码,以及指令附带的常量数据,如果常量比较多可执行文件也跟着会变大。可执行文件和库被装入内存成为指令段内存,里面装着所有类的方法或者函数,包括静态、公共的、私有的等,只是名字上不同,它以名字来区别共有公有的还是私有的,比如 Class_A_public_GetData 可以认为是类对象 A 的 public 方法的 GetData,这个函数只是代表指令的地址

        栈内存块,通常都是函数方法执行的重要部分,它与堆内存不同的是它是有秩序的,只允许遵守先进后出的规则。

        我们所说的值类型数据大都在栈中分配,除非它被用于构造其他类型,比如类、数组等。

        上述其实就是汇编里的数据段、代码段、栈段,它们分别使用了段地址和偏移量来表示数据和指令内容。

  • 当指令数据需要数据段内容时,就用数据段地址 + 偏移量去存取数据内存中的数据,
  • 当指令跳转时则使用代码段地址 + 偏移量来指向新的指令内存地址,
  • 当需要用到栈时则使用 pop 和 push 的汇编指令来偏移栈顶指针从而存取栈上的数据。

        除了内存,寄存器是离 CPU 最近也最快的存储单元,它一般都用来临时存放数据的,当然我们也可以自己写汇编让,某些寄存器长期存放一些数据,以加快读取某数据的速度。

你可能感兴趣的:(#,《Unity3D高级编程,主程手记》,笔记,性能优化,unity,c#)