目录
使用 List 和 Dictionary 时提高效率
巧用struct
struct 对性能优化的好处
使用原值类型连续空间的方式来提高 CPU 的缓存命中率
尽可能地使用对象池
字符串导致的性能问题
解决方法
字符串的隐藏问题
程序运行原理
业务逻辑的优化方向
脱离 C# 语言,简单陈述程序运行原理
指令内存块
数据内存块
不只是算法能大幅度提高业务逻辑的效率,普普通通的业务代码也同样可以有质的飞越。
优秀程序员常关注代码对性能的影响,普通人只关注是否能完成功能,这样日积月累代码质量就会形成差距。
List、Dictionary 的实质都是数组。Dictionary有两个数组,一个数组用于保存索引,一个数组用于保存数据,我们遍历 List 和 Dictionary 都是在遍历数组
struct 结构是值类型,传递时不是靠引用(指针)形式而是靠复制,是通过内存复制来实现传递(真实情况是通过字节对齐规则循环多次复制内存)。
如果 struct 太大,超过了缓存复制的数据块,则缓存不再起作用。
原值类型: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损耗。
内存分配消耗:
解决:通用对象池
//源自 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 内存。
//方法一
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 对象。
字符串比较:
如果两个字符串来自不同的内存段,那么在比较它们是否相等时就会遍历所有字符来判断是否相等。
string 源码地址:https://referencesource.microsoft.com/#mscorlib/system/string.cs
一个程序在内存中运行时,通常由几个内存块组成。
里面存储的都是已经编写设计好的执行的指令,需要执行的指令都会从指令内存块中去取,指令计数器也不断跳跃在这些指令中。
里面存放的都是我们设置好的数据以及分配过的内存。
静态数据块,通常里面存放的都是不变的数据,比如字符串常量,常量整数,常量浮点数,以及一些静态数据,这些数据在程序启动时最先被放入内存中。
堆内存数据块,所有的动态内存申请都来自堆内存,我们可以认为它是一个很长的 byte 数组,当我们申请内存时,会从数组中找出一块我们指定大小的内存,这个内存不一定是空的,因为内存回收从来不会对内存单位有清理操作,那样太浪费算力了,从来都是将这段数据的指针回收或偏移。所以实际上,我们申请的内存块,在没初始化前都是未知的,有可能刚好前面用过的与我们相似的内容,如果不进行初始化,就有可能会出现逻辑问题
这里还有个系统层,我们在系统层面上运行程序,所以遵循的是系统层面的逻辑。操作系统提供了虚拟地址,以此避免程序直接与硬件打交道。包括 iOS 和安卓,日常分配的都是虚拟内存。
我们用惯类对象很容易以为内存中就是某个类的实例内存,其实在机器指令和内存中可没有这个说法,它只是块连续 byte 内存单元,具体其代表哪个类的实例只是我们想象的而已。
类的方法或函数被编译成指令序列,放在了指令内存块中,所有的方法,函数都在那里集中存放着,随时能取到。
一个可执行文件或程序库里,几乎都是指令机器码,以及指令附带的常量数据,如果常量比较多可执行文件也跟着会变大。可执行文件和库被装入内存成为指令段内存,里面装着所有类的方法或者函数,包括静态、公共的、私有的等,只是名字上不同,它以名字来区别共有公有的还是私有的,比如 Class_A_public_GetData 可以认为是类对象 A 的 public 方法的 GetData,这个函数只是代表指令的地址。
栈内存块,通常都是函数方法执行的重要部分,它与堆内存不同的是它是有秩序的,只允许遵守先进后出的规则。
我们所说的值类型数据大都在栈中分配,除非它被用于构造其他类型,比如类、数组等。
上述其实就是汇编里的数据段、代码段、栈段,它们分别使用了段地址和偏移量来表示数据和指令内容。
除了内存,寄存器是离 CPU 最近也最快的存储单元,它一般都用来临时存放数据的,当然我们也可以自己写汇编让,某些寄存器长期存放一些数据,以加快读取某数据的速度。