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

目录

[使用 List 和 Dictionary 时提高效率](#使用 List 和 Dictionary 时提高效率)

巧用struct

[struct 对性能优化的好处](#struct 对性能优化的好处)

[使用原值类型连续空间的方式来提高 CPU 的缓存命中率](#使用原值类型连续空间的方式来提高 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<int,List> 操作

解决:通用对象池

复制代码
//源自 Unity 的 UI 库中
internal class ObjectPool<T> where T : new()
{
    private readonly Stack<T> m_Stack = new Stack<T>();
    private readonly UnityAction<T> m_ActionOnGet;
    private readonly UnityAction<T> 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<T> actionOnGet, UnityAction<T> 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<T>
{
    // Object pool to avoid allocations.
    private static readonly ObjectPool<List<T>> s_ListPool = new ObjectPool<List<T>>(null, l => l.Clear());

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

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

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

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

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

字符串导致的性能问题

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

cs 复制代码
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 类。
cs 复制代码
//方法一
Dictionary<int,string> 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;
cs 复制代码
//方法二
Dictionary<int,string> 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 最近也最快的存储单元 ,它一般都用来临时存放数据的,当然我们也可以自己写汇编让,某些寄存器长期存放一些数据,以加快读取某数据的速度。

相关推荐
蒸蒸yyyyzwd8 小时前
AI应用相关可能的面试问题
笔记
叼烟扛炮9 小时前
[Git 实战] 学习笔记:从生成 SSH 密钥到解决 TortoiseGit 推送报错全指南
linux·运维·笔记·git·学习
蒸蒸yyyyzwd10 小时前
学习笔记 八股手撕
笔记·学习
揣一尾游鱼yu11 小时前
Spring上(IOC)笔记
java·笔记·spring·ioc
小李不想当小白13 小时前
BKP备份寄存器与RTC实时时钟(STM32标准库学习笔记)
笔记·stm32·单片机·嵌入式硬件·学习·嵌入式
瑞码空间15 小时前
JSP与Servlet核心开发与CRUD实战笔记
java·笔记·servlet·jsp
La Pulga15 小时前
【FreeRTOS源码学习笔记】链表的实现
数据结构·笔记·stm32·mcu·链表·嵌入式·freertos
wuyoutrans18 小时前
以色列专利OA答复翻译,重点要避开哪些坑?
笔记
宝绿特塑业再生科技18 小时前
HDPE再生颗粒户外家具表面粉化机理与改性技术全解析
笔记·户外家具·再生塑料·塑木桌椅
凉、介19 小时前
深入理解 ARMv7-A | Generic Timer
笔记·学习·嵌入式·arm