一些比较流行的框架设计思想都是基于反射,比如反转控制(IOC)和依赖注入(DI),但是你了解其中的性能表现吗?

一般来说文件 I/O 的延迟远远大于书写反射代码造成的时延。然而,更快的响应速度和更低的CPU使用率仍然是网络服务器的优化目标。所以反射不仅带来了灵活性,也带来了性能低下的束缚。

要善用反思 反射 这把双刃剑,就需要详细了解反射的性能表现。以下基准测试在结构体赋值、函数调用等方面比较了原生调用和反射调用之间的性能差异。

一、结构体赋值比较

由于反射在结构中应用较多,因此结构体的访问性能成为人们关注的关键点。下面的示例使用一个实例化结构,访问其成员变量,然后使用 Go 语言的基准测试快速测试结果。

1.1 原生结构体的访问和赋值过程:

// 声明一个结构体, 拥有一个字段
type data struct {
Age int8
}
func BenchmarkNativeValue(b *testing.B) {
// 实例化结构体
v := data{Age: 22}
// 停止基准测试的计时器
b.StopTimer()
// 重置基准测试计时器数据
b.ResetTimer()
// 重新启动基准测试计时器
b.StartTimer()
// 根据基准测试数据进行循环测试
for i := 0; i < b.N; i++ {
// 结构体成员赋值测试
v.Age = 30
}
}

由于测试必须集中在赋值性能测试上,因此需要减少其他代码的干扰,所以在赋值完成后,将基准测试的计时器复位,重新开始计时,使用循环中基准测试提供的测试数量。

1.2 反射访问结构体成员并赋值的过程:

func BenchmarkReflectValue(b *testing.B) {
v := data{Age: 22}
// 取出结构体指针的反射值对象并取其元素
vv := reflect.ValueOf(&v).Elem()
// 根据名字取结构体成员
f := vv.FieldByName("Age")
b.StopTimer()
b.ResetTimer()
b.StartTimer()
for i := 0; i < b.N; i++ {
// 反射测试设置成员值性能
f.SetInt(30)
}
}

上面的代码中用到的反射值对象 SetInt() 函数方法,其中 Go 源码如下:

func (v Value) SetInt(x int64) {
v.mustBeAssignable()
switch k := v.kind(); k {
default:
panic(&ValueError{"reflect.Value.SetInt", v.kind()})
case Int:
*(*int)(v.ptr) = int(x)
case Int8:
*(*int8)(v.ptr) = int8(x)
case Int16:
*(*int16)(v.ptr) = int16(x)
case Int32:
*(*int32)(v.ptr) = int32(x)
case Int64:
*(*int64)(v.ptr) = x
}
}

因此使用 SetInt() 函数赋值是利用指针转换并赋值,其中并不会遍历结构体和内存操作这些耗时的算法在里面。

二、结构体成员搜索并赋值对比

func BenchmarkReflectGetFieldAndValue(b *testing.B) {
v := data{Hp: 2}
vv := reflect.ValueOf(&v).Elem()
b.StopTimer()
b.ResetTimer()
b.StartTimer()
for i := 0; i < b.N; i++ {
// 测试结构体成员的查找和设置成员的性能
vv.FieldByName("Hp").SetInt(3)
}
}

上面的代码将反射值对象的 FieldByName() 函数与 SetInt() 函数放在循环里进行检测,主要对比测试FieldByName() 函数对性能的影响。FieldByName() 函数源码如下:

func (v Value) FieldByName(name string) Value {
v.mustBe(Struct)
if f, ok := v.typ.FieldByName(name); ok {
return v.FieldByIndex(f.Index)
}
return Value{}
}

底层代码介绍如下:

v.typ.FieldByName(name) 是通过名字查询类型对象,这里会进行一次遍历查找;找到类型对象后,return v.FieldByIndex(f.Index) 还需要在值中再次遍历一次查找对应的值。

通过查看源码可以看出,如果结构体字段数量和相对位置的不确定,那么 FieldByName() 函数就是效率比较低的查询方法。

三、调用函数方式性能测试

通过反射方式调用函数,其中可能导致的性能问题也要引起足够重视。

3.1 原生调用函数:

// 一个普通函数
func foo(v int) {
}
func BenchmarkNativeFunc(b *testing.B) {
for i := 0; i < b.N; i++ {
// 原生方式
foo(0)
}
}

3.2 反射调用函数:

func BenchmarkReflectFunc(b *testing.B) {
// 取函数的反射值对象
v := reflect.ValueOf(foo)
b.StopTimer()
b.ResetTimer()
b.StartTimer()
for i := 0; i < b.N; i++ {
// 反射调用函数
v.Call([]reflect.Value{reflect.ValueOf(2)})
}
}

下面对反射的相关方法分析一下:首先我们根据函数名取出反射值的对象,紧接着使用 reflect.ValueOf(2) 用 2 构造为反射值的对象,因为反射函数调用的参数必须都是反射值对象。再使用 []reflect.Value 构造多个参数列表传给反射值的对象并调用 Call() 函数。

通过反射调用函数的参数构造过程很非常复杂,构建很多对象会造成很大的内存回收负担。Call() 方法内部就更为复杂,需要将参数列表的每个值从 reflect.Value 类型转换为内存。调用完毕后,还要将函数返回值重新转换为 reflect.Value 类型返回。因此,反射调用函数的性能堪忧。

通过反射调用函数的参数构造过程很是复杂,而且构造很多对象会造成很大的内存回收问题。Call() 方法内部实现就更加复杂,它需要转换反射参数列表的每个值转换为内存值的类型。在调用完成后,应该将函数的返回值重新转换为 reflect.Value。所以反射调用函数的性能非常低效。

四、基准测试结果

通过执行 go test -v -bench=. 命令查看测试结果:

% go test -v -bench=.
goos: darwin
goarch: amd64
BenchmarkNativeValue
BenchmarkNativeValue-4 1000000000 0.326 ns/op
BenchmarkReflectValue
BenchmarkReflectValue-4 328987927 3.59 ns/op
BenchmarkReflectGetFieldAndValue
BenchmarkReflectGetFieldAndValue-4 13575862 80.4 ns/op
BenchmarkNativeFunc
BenchmarkNativeFunc-4 1000000000 0.325 ns/op
BenchmarkReflectFunc
BenchmarkReflectFunc-4 7053134 168 ns/op
PASS
ok test 5.226s

根据执行结果分析:

BenchmarkNativeValue 是原生的结构体成员变量的赋值,根据参考基准,每一次操作耗时 0.326 ns(纳秒)。

BenchmarkReflectValue 是通过反射的方式赋值,每一次操作耗时为 3.59 ns,性能比原生赋值低了 11 倍。

BenchmarkReflectGetFieldAndValue 是通过反射查找结构体成员且通过反射赋值,根据参考基准,每一次操作耗时 80.4 ns,减去通过反射结构体成员赋值的 80.4 - 3.59 = 76.81 ns,性能大概比原生低了 235 倍。这个测试结果与我们通过代码的分析结果类似。因为 SetInt 没有遍历操作性能可以接受,但是 FieldByName() 两次遍历导致性能非常低效。

BenchmarkNativeFunc 是原生函数的调用测试,性能与原生访问结构体成员接近,每一次操作耗时 0.325 ns。

BenchmarkReflectFunc 是通过反射函数调用的,性能就差了很多了,每一次操作耗时 168 ns,操作耗时比原生多消耗 516 倍。

五、总结

经过对代码的分析以及基准测试结果的数值对比,我们可以最终得出一些结论:

5.1 如果可以使用原生书写代码,应该尽可能减少反射代码的书写;

5.2 可以通过缓存反射值对象的方式,减少频繁获取反射值对象的性能影响;

5.3 尽可能避免通过反射的方式调用函数,如果必须使用反射调用函数也需提前缓存反射函数参数列表,并并尽可能少使用返回值。