# 前言
最近做项目优化,在整理主界面的按钮问题的时候,用到了只读结构体,这个结构体会保存于一个列表中,并采用 in
对列表中的值进行引用传递。
然后突后面然想到,保存于列表中的值类型,真的能直接被引用吗?
毕竟 List
数据结构本身由于存在扩容问题,因此是不允许直接返回其中数据引用的。
那么我们就有理由怀疑:实际类型为 List
容器中,采用下标取出的对象作为引用传递时,它可能依然会在栈上新建一个临时变量,然后再将其作为引用传递过去。那么这里就还是存在一个额外拷贝开销了。
于是考虑了一下,并且也比较好奇,于是就想着用 ILSpy
进行反编译查看生成的 IL
代码,以确定是否真的如猜测所示。
# 列表类型
首先,源代码如下所示:
private readonly struct MainRightButtonItem | |
{ | |
// 省略...... | |
} | |
private void RefreshButton(int index, in MainRightButtonItem buttonData) | |
{ | |
// 省略...... | |
} | |
/// <summary> | |
/// 刷新按钮状态数据 | |
/// </summary> | |
public void RefreshButtons() | |
{ | |
for (int i = 0; i < _buttonDataList.Count; i++) | |
{ | |
RefreshButton(i, _buttonDataList[i]); | |
} | |
} |
这是一个最简单的 for循环
,循环中通过下标取 List
并直接传递至 RefreshButton
方法中, RefreshButton
本身接收的则是一个 MainRightButtonItem
的引用地址。
编译为 IL
代码后如下所示:
IL_0000: ldc.i4.0 | |
IL_0001: stloc.0 | |
IL_0002: br.s IL_001e | |
// loop start (head: IL_001e) | |
IL_0004: ldarg.0 | |
IL_0005: ldloc.0 | |
IL_0006: ldarg.0 | |
IL_0007: ldfld class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem> BottonListHolder::_buttonDataList | |
IL_000c: ldloc.0 | |
IL_000d: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem>::get_Item(int32) | |
IL_0012: stloc.1 | |
IL_0013: ldloca.s 1 | |
IL_0015: call instance void BottonListHolder::RefreshButton(int32, valuetype BottonListHolder/MainRightButtonItem&) | |
IL_001a: ldloc.0 | |
IL_001b: ldc.i4.1 | |
IL_001c: add | |
IL_001d: stloc.0 | |
IL_001e: ldloc.0 | |
IL_001f: ldarg.0 | |
IL_0020: ldfld class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem> BottonListHolder::_buttonDataList | |
IL_0025: callvirt instance int32 class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem>::get_Count() | |
IL_002a: blt.s IL_0004 | |
// end loop |
# 分析
loop start
之后就是循环体,在 IL_0015
处的指令可以看出来,确实是传递的一个 MainRightButtonItem
的引用,这个跟想象的一样,没什么问题。
接着就需要确认它之前有没有将值拷贝到栈上的操作:
从 IL_0007
开始,首先将列表加载至栈上
接着 IL_000d
调用列表的 get_Item
方法 (索引器) 并传入下标
关键就在于 Stloc_1
和 Ldloca_S
指令:
Stloc_1:从计算堆栈顶部弹出当前值,并将其存储在索引 1 处的局部变量列表中。
Ldloca_S:将位于特定索引处的局部变量的地址加载到计算堆栈上(短格式)。
看这指令解释... 貌似还是执行了复制操作?
# 对比非引用传参
与上述唯一不同的是,调用的 RefreshButtonCopy
方法没有标记为引用传参,关键 IL
代码如下:
IL_0007: ldfld class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem> BottonListHolder::_buttonDataList | |
IL_000c: ldloc.0 | |
IL_000d: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem>::get_Item(int32) | |
IL_0012: call instance void BottonListHolder::RefreshButtonCopy(int32, valuetype BottonListHolder/MainRightButtonItem) |
看起来竟然还比引用传递少了两条指令!
# 数组类型
首先,数组结构的模式。
原代码:
public void Test() | |
{ | |
MainRightButtonItem[] items = new MainRightButtonItem[10]; | |
for (int i = 0; i < items.Length; i++) | |
{ | |
RefreshButton(i, items[i]); | |
} | |
} |
编译为 IL
代码如下:
IL_0000: ldc.i4.s 10 | |
IL_0002: newarr BottonListHolder/MainRightButtonItem | |
IL_0007: stloc.0 | |
IL_0008: ldc.i4.0 | |
IL_0009: stloc.1 | |
IL_000a: br.s IL_001e | |
// loop start (head: IL_001e) | |
IL_000c: ldarg.0 | |
IL_000d: ldloc.1 | |
IL_000e: ldloc.0 | |
IL_000f: ldloc.1 | |
IL_0010: ldelema BottonListHolder/MainRightButtonItem | |
IL_0015: call instance void BottonListHolder::RefreshButton(int32, valuetype BottonListHolder/MainRightButtonItem&) | |
IL_001a: ldloc.1 | |
IL_001b: ldc.i4.1 | |
IL_001c: add | |
IL_001d: stloc.1 | |
IL_001e: ldloc.1 | |
IL_001f: ldloc.0 | |
IL_0020: ldlen | |
IL_0021: conv.i4 | |
IL_0022: blt.s IL_000c | |
// end loop |
对于 ldelema
指令文档解释为:
ldelema:将位于指定数组索引的数组元素的地址作为 & 类型(托管指针)加载到计算堆栈的顶部。
- 对象引用 array 被推送到堆栈上。
- 索引值 index 被推送到堆栈上。
- index 并从 array 堆栈中弹出;将查找存储在位置 index array 的地址。
- 地址被推送到堆栈上。
可知这是一个对数组特异化的引用取值指令,表明此处确定是引用传递,与预想一致。
# 性能测试
对于 List
的结构体容器引用传参及非引用传参的性能,目前就有点拿不准了,于是还是走测试流程,实际比较一下吧。
这里直接使用 Unity3D
的 TestRunner
,代码如下:
public class RefStruckTest | |
{ | |
private List<Data> testList = new List<Data>(10000000); | |
private Data[] testArray = new Data[10000000]; | |
[Test] | |
public void TestListRef() | |
{ | |
for (int i = 0; i < testList.Count; i++) | |
{ | |
InvokeRef(testList[i]); | |
} | |
} | |
[Test] | |
public void TestListCopy() | |
{ | |
for (int i = 0; i < testList.Count; i++) | |
{ | |
InvokeCopy(testList[i]); | |
} | |
} | |
[Test] | |
public void TestArrayRef() | |
{ | |
for (int i = 0; i < testArray.Length; i++) | |
{ | |
InvokeRef(testArray[i]); | |
} | |
} | |
[Test] | |
public void TestArrayCopy() | |
{ | |
for (int i = 0; i < testArray.Length; i++) | |
{ | |
InvokeCopy(testArray[i]); | |
} | |
} | |
private void InvokeRef(in Data data) | |
{ | |
} | |
private void InvokeCopy(Data data) | |
{ | |
} | |
private readonly struct Data | |
{ | |
readonly long x, y, z, w, k, n; | |
} | |
} |
结果:
TestArrayCopy (0.073s) | |
TestArrayRef (0.061s) | |
TestListCopy (0.000s) | |
TestListRef (0.000s) |
# 总结
在 IL
代码分析中,对于 List
容器来说,可以看出直接传递的方式反而比引用传递少两条指令,但实际性能测试结果显示,引用传递的的方式性能还是更好。
另外,数组的性能更好 —— 至少在仅取值和传递方面不是一个级别的,这估计跟 CLR
对数组本身的优化支持、以及 List
索引实际还走了一道属性取值也有关系。
后续估计还得再研究一下 Stloc_1
和 Ldloca_S
两条指令深一点的功用。
例如,我们是否有理由怀疑:
- 引用传递只执行了一次复制,调用者的方法栈上,然后传递引用
- 复制传递方式则至少执行两次复制,即调用者的方法栈上,然后再复制一份传递至调用方法
因此虽然上述两条指令执行了赋值,依然造成了上述实际的性能差异,也就是说,引用传递怎么都还是比直接复制传参省一些。