可能有人认为相比于 ForTest1,ForTest2 存储了数组的 Length,少了对于数组属性的频繁调用,会有更好的性能表现。[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;

namespace JITPropertyAccessInFor
{
internal static class Program
{
public static void Main(string[] args)
{
}

private static void Test1()
{
var a = new int[5];
}

private static void ForTest1()
{
var a = new int[5];
for (var i = 0; i < a.Length; i++)
{
Console.WriteLine(a[i]);
}
}

private static void ForTest2()
{
var a = new int[5];
int len = a.Length;
for (var i = 0; i < len; i++)
{
Console.WriteLine(a[i]);
}
}
}
}

以下 是 上段代码编译出的 IL code:(以下所述栈均为操作数栈 (Operand stack))[2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.method private hidebysig static void  ForTest1() cil managed
{
// Code size 30 (0x1e)
.maxstack 2 // 栈最大深度
.locals init ([0] int32[] a, // 变量声明 (局部变量表)
[1] int32 i)
IL_0000: ldc.i4.5 // int32 5 入栈 (声明的数组大小)
IL_0001: newarr [mscorlib]System.Int32 // 创建0基一维数组的对象引用入栈
IL_0006: stloc.0 // 出栈 置于局部变量表0位置 (初始化数组完毕)
IL_0007: ldc.i4.0 // int32 0 入栈 (i = 0)
IL_0008: stloc.1 // 出栈 置于局部变量表1位置 (i = 0)
IL_0009: br.s IL_0017 // 无条件地将控制转移到目标指令(短格式)(至 for 中判断开始位置)
IL_000b: ldloc.0 // 局部变量表0位置变量入栈 (数组元素入栈)
IL_000c: ldloc.1 // 局部变量表1位置变量入栈 (i 入栈)
IL_000d: ldelem.i4 // 按指令指定类型(i4),将指定数组索引中的元素入栈
IL_000e: call void [mscorlib]System.Console::WriteLine(int32) // 调用由传递的方法说明符指示的方法 (打印a[i])
IL_0013: ldloc.1 // 局部变量表1位置变量入计算栈 (i 入栈) (i++ 开始)
IL_0014: ldc.i4.1 // int32 1 入栈
IL_0015: add // 出栈两次,出栈值相加,结果入栈
IL_0016: stloc.1 // 出栈 置于局部变量表1位置 (i++ 结束)
IL_0017: ldloc.1 // 局部变量表1位置变量入栈 (i 入栈) (for 中判断开始位置)
IL_0018: ldloc.0 // 局部变量表0位置变量入栈 (a 入栈,准备获取数组长)
IL_0019: ldlen // 将0基一维数组的元素数目推送到计算栈上。(数组长入栈)
IL_001a: conv.i4 // 将栈顶元素转换为 int32 类型
IL_001b: blt.s IL_000b // 判断计算栈顶两值大小(计算栈出栈两次,后出栈的是第一个值)。若第一个值小于第二个值,将控制转移到目标指令 (短格式)。
IL_001d: ret // 从当前方法返回
} // end of method Program::ForTest1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.method private hidebysig static void  ForTest2() cil managed
{
// Code size 32 (0x20)
.maxstack 2 // 栈最大深度
.locals init ([0] int32[] a, // 变量声明 (局部变量表)
[1] int32 len,
[2] int32 i)
IL_0000: ldc.i4.5 // int32 5 入栈 (声明的数组大小)
IL_0001: newarr [mscorlib]System.Int32 // 创建0基一维数组的对象引用入栈
IL_0006: stloc.0 // 出栈 置于局部变量表0位置 (初始化数组完毕)
IL_0007: ldloc.0 // 局部变量表1位置变量入栈 (a 入栈,准备获取数组长)
IL_0008: ldlen // 将0基一维数组的元素数目推送到计算栈上。(数组长入栈)
IL_0009: conv.i4 // 将栈顶元素转换为 int32 类型
IL_000a: stloc.1 // 出栈 置于局部变量表1位置 (len = a.Length)
IL_000b: ldc.i4.0 // int32 0 入栈 (声明的数组大小)
IL_000c: stloc.2 // 出栈 置于局部变量表2位置 (i = 0)
IL_000d: br.s IL_001b // 无条件地将控制转移到目标指令(短格式)(至 for 中判断开始位置)
IL_000f: ldloc.0 // 局部变量表0位置变量入栈 (a 入栈)
IL_0010: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈)
IL_0011: ldelem.i4 // 按指令指定类型(i4),将指定数组索引中的元素入栈
IL_0012: call void [mscorlib]System.Console::WriteLine(int32) // 调用由传递的方法说明符指示的方法 (打印a[i])
IL_0017: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈) (i++ 开始)
IL_0018: ldc.i4.1 // int32 1 入栈
IL_0019: add // 出栈两次,出栈值相加,结果入栈
IL_001a: stloc.2 // 出栈 置于局部变量表2位置 (i++ 结束)
IL_001b: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈) (for 中判断开始位置)
IL_001c: ldloc.1 // 局部变量表1位置变量入栈 (len 入栈)
IL_001d: blt.s IL_000f // 判断计算栈顶两值大小(计算栈出栈两次,后出栈的是第一个值)。若第一个值小于第二个值,将控制转移到目标指令 (短格式)。
IL_001f: ret // 从当前方法返回
} // end of method Program::ForTest2

对比上述的 IL code,确实临时存储数组长,能够少在 for 的比较进行中少进行一定的操作,无需将数组从局部变量表(Local Variable Table)入操作数栈 (Operand stack),并执行 ldlen 获取数组长。[3] 但要注意, JIT 编译器知道 Length 是 Array 类的属性,生成的代码中只会调用该属性一次,结果会存储到临时变量中,此后的检查中调用的都是此临时变量。[4]不需要自己用局部变量做缓存,这样既没有性能提升,还可能造成可读性下降[5]

参阅

CLR via C# (第四版) 16.7 数组的内部工作原理

注释

Ildasm.exe(IL 反汇编程序)

一般,该工具位于 NETFX 4.7.2 Tools 中

C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\ildasm.exe


  1. Microsoft Learn, C# arraysArray.Length Property 支撑数组实例长度是数组自身的稳定事实;因此这里的性能直觉应落在 JIT 是否识别数组循环形状,而不是把 Length 当普通可变属性。 ↩︎

  2. ECMA-335 Common Language Infrastructure 定义 CIL 指令与运行时模型;Microsoft Learn Ildasm.exe 支撑用 ILDASM 查看程序集 IL。IL 能说明编译器输出,但不能直接等同最终机器码性能。 ↩︎

  3. ECMA-335 定义 ldlen 为读取数组长度的 CIL 指令;这支撑文中从 IL 层面对 ForTest1ForTest2 的比较。 ↩︎

  4. Jeffrey Richter, CLR via C#, Fourth Edition 用数组内部工作原理解释数组长度和运行时模型;Microsoft .NET 5 performance post 也明确讨论 for (int i = 0; i < arr.Length; i++) 这类循环中 JIT 可证明索引不会越界并消除 bounds check。 ↩︎

  5. Microsoft .NET 5.NET 8.NET 9.NET 10 performance posts 对 bounds check elimination、range analysis、PGO 和 loop 优化的持续讨论说明:这类微优化依赖目标运行时、Release 构建、JIT 机器码和测量数据。普通数组循环默认保持 i < array.Length 的清晰形状;只有上界昂贵、有副作用、需要快照语义或有测量证据时,才应手动缓存。 ↩︎