如何从C# JIT汇编代码判断是否创建了Action委托对象?
问题背景
假设我们有如下代码,我在待检查的代码位置添加了注释,运行环境为撰写本文时的最新.NET版本(.NET 5或6)。
我的目标是规避GC压力,不希望底层隐式创建Action对象。我可以接受少量垃圾内存,但无法接受高频调用函数时产生大量垃圾,进而引发不可接受的GC暂停,不过JIT或许足够智能,可以完全跳过对象创建流程。
为了验证这一点,我编写了如下程序,判断是否会生成new Action(...)的逻辑:
using System; public class ClassWithMethod { public void MethodToCall() { Console.WriteLine("hi"); } } public class Program { private ClassWithMethod instance = new ClassWithMethod(); private void Call(Action action) { action(); } public void DoStuff() { Call(instance.MethodToCall); // <=== 待检查的代码行 } public static void Main() { Program program = new Program(); program.DoStuff(); } }
该程序在amd64架构的Core CLR 5.0.721.25508环境下生成的汇编代码如下:
; Core CLR 5.0.721.25508 on amd64 ClassWithMethod..ctor() L0000: ret ClassWithMethod.MethodToCall() L0000: mov rcx, 0x2d94b437fc8 L000a: mov rcx, [rcx] L000d: jmp System.Console.WriteLine(System.String) Program..ctor() L0000: push rsi L0001: sub rsp, 0x20 L0005: mov rsi, rcx L0008: mov rcx, 0x7ffaf463cd50 L0012: call 0x00007ffb4be79b70 L0017: lea rcx, [rsi+8] L001b: mov rdx, rax L001e: call 0x00007ffb4be79780 L0023: nop L0024: add rsp, 0x20 L0028: pop rsi L0029: ret Program.Call(System.Action) L0000: mov rcx, [rdx+8] L0004: mov rax, [rdx+0x18] L0008: jmp rax Program.DoStuff() L0000: push rdi L0001: push rsi L0002: sub rsp, 0x28 L0006: mov rsi, rcx L0009: mov rcx, 0x7ffaec4c4288 L0013: call 0x00007ffb4be79b70 L0018: mov rdi, rax L001b: mov rdx, [rsi+8] L001f: test rdx, rdx L0022: je short L004c L0024: lea rcx, [rdi+8] L0028: call 0x00007ffb4be79780 L002d: mov rcx, ClassWithMethod.MethodToCall() L0037: mov [rdi+0x18], rcx L003b: mov rcx, [rdi+8] L003f: mov rax, [rdi+0x18] L0043: add rsp, 0x28 L0047: pop rsi L0048: pop rdi L0049: jmp rax L004c: call System.MulticastDelegate.ThrowNullThisInDelegateToInstance() L0051: int3 Program.Main() L0000: push rsi L0001: sub rsp, 0x20 L0005: mov rcx, 0x7ffaf463d0a0 L000f: call 0x00007ffb4be79b70 L0014: mov rsi, rax L0017: mov rcx, 0x7ffaf463cd50 L0021: call 0x00007ffb4be79b70 L0026: lea rcx, [rsi+8] L002a: mov rdx, rax L002d: call 0x00007ffb4be79780 L0032: mov rcx, rsi L0035: add rsp, 0x20 L0039: pop rsi L003a: jmp Program.DoStuff()
IL查看器输出的DoStuff方法IL代码如下:
.method public hidebysig instance void DoStuff () cil managed { // Method begins at RVA 0x206c // Code size 24 (0x18) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldarg.0 IL_0002: ldfld class ClassWithMethod Program::'instance' IL_0007: ldftn instance void ClassWithMethod::MethodToCall() IL_000d: newobj instance void [System.Private.CoreLib]System.Action::.ctor(object, native int) IL_0012: call instance void Program::Call(class [System.Private.CoreLib]System.Action) IL_0017: ret } // end of method Program::DoStuff
上面代码中的这一行:
IL_000d: newobj instance void [System.Private.CoreLib]System.Action::.ctor(object, native int)
似乎意味着会创建新的Action对象,这对我的场景来说非常不利。
但我无法判断JIT编译后的版本中是否真的发生了对象创建,汇编代码中存在如下行:
L0028: call 0x00007ffb4be79780
我不确定该指令是否调用的是构造函数,也不确定JIT是否省略了对象创建直接调用目标方法,请问如何从这段JIT汇编判断是否创建了对象?
解答
从你提供的JIT汇编可以明确每次调用DoStuff方法都会创建新的Action对象,判断依据如下:
- 先识别两个关键调用的功能:
L0013: call 0x00007ffb4be79b70:这是CoreCLR的托管堆内存分配函数,传入的rcx寄存器值0x7ffaec4c4288是Action类型的EEType指针,调用完成后rax寄存器返回新分配的Action对象地址,并存入rdi寄存器,这一步已经完成了Action对象的内存分配。L0028: call 0x00007ffb4be79780:这就是Action的构造函数,入参rcx是刚分配的Action对象的_target字段地址(对应lea rcx, [rdi+8]),入参rdx是绑定的ClassWithMethod实例,完全对应IL中newobj指令的两个入参,后续代码还把方法指针写入了Action对象的_methodPtr字段(偏移0x18的位置),完整执行了委托初始化的全部流程。
- 虽然JIT把
Call方法做了内联,最后直接跳转到MethodToCall执行,没有走常规的委托Invoke流程,但内存分配和构造的步骤完全没有被优化省略,高频调用DoStuff一定会在托管堆上生成大量Action对象,产生预期外的GC压力。
如果要完全避免委托分配,你可以选择以下优化方案:
- 缓存
Action对象:在类中预创建并缓存绑定好实例的Action,后续调用直接复用缓存的对象,仅会产生一次固定分配。 - 用泛型+接口替代委托:定义无分配的调用约束,比如定义
ICallable接口让ClassWithMethod实现,再把Call方法改成泛型方法Call<T>(T obj) where T : ICallable,JIT泛型特化后不会产生任何额外分配。 - .NET 6及以上版本可以用函数指针(
delegate*)替代Action传递,完全规避委托对象的创建。
内容的提问来源于stack exchange,提问作者Water
相关产品推荐
相关产品推荐

