網站首頁 編程語言 正文
我們選擇 go 語言的一個重要原因是,它有非常高的性能。但是它反射的性能卻一直為人所詬病,本篇文章就來看看 go 反射的性能問題。
go 的性能測試
在開始之前,有必要先了解一下 go 的性能測試。在 go 里面進行性能測試很簡單,只需要在測試函數前面加上 Benchmark
前綴, 然后在函數體里面使用 b.N
來進行循環,就可以得到每次循環的耗時。如下面這個例子:
func BenchmarkNew(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { New() } }
我們可以使用命令 go test -bench=. reflect_test.go
來運行這個測試函數,又或者如果使用 goland 的話,直接點擊運行按鈕就可以了。
說明:
- 在
*_test.go
文件中Benchmark*
前綴函數是性能測試函數,它的參數是*testing.B
類型。 -
b.ReportAllocs()
:報告內存分配次數,這是一個非常重要的指標,因為內存分配相比單純的 CPU 計算是比較耗時的操作。在性能測試中,我們需要關注內存分配次數,以及每次內存分配的大小。 -
b.N
:是一個循環次數,每次循環都會執行New()
函數,然后記錄下來每次循環的耗時。
go 里面很多優化都致力于減少內存分配,減少內存分配很多情況下都可以提高性能。
輸出:
BenchmarkNew-20 ? ?1000000000 ? ?0.1286 ns/op ? 0 B/op ? 0 allocs/op
輸出說明:
-
BenchmarkNew-20
:BenchmarkNew
是測試函數名,-20
是 CPU 核數。 -
1000000000
:循環次數。 -
0.1286 ns/op
:每次循環的耗時,單位是納秒。這里表示每次循環耗時 0.1286 納秒。 -
0 B/op
:每次循環內存分配的大小,單位是字節。這里表示每次循環沒有分配內存。 -
0 allocs/op
:每次循環內存分配的次數。這里表示每次循環沒有分配內存。
go 反射慢的原因
動態語言的靈活性是以犧牲性能為代價的,go 語言也不例外,go 的 interface{} 提供了一定的靈活性,但是處理 interface{} 的時候就要有一些性能上的損耗了。
我們都知道,go 是一門靜態語言,這意味著我們在編譯的時候就知道了所有的類型,而不是在運行時才知道類型。 但是 go 里面有一個 interface{}
類型,它可以表示任意類型,這就意味著我們可以在運行時才知道類型。 但本質上,interface{}
類型還是靜態類型,只不過它的類型和值是動態的。 在 interface{}
類型里面,存儲了兩個指針,一個指向類型信息,一個指向值信息。具體可參考《go interface 設計與實現》。
go interface{} 帶來的靈活性
有了 interface{}
類型,讓 go 也擁有了動態語言的特性,比如,定義一個函數,它的參數是 interface{}
類型, 那么我們就可以傳入任意類型的值給這個函數。比如下面這個函數(做任意整型的加法,返回 int64
類型):
func convert(i interface{}) int64 { typ := reflect.TypeOf(i) switch typ.Kind() { case reflect.Int: return int64(i.(int)) case reflect.Int8: return int64(i.(int8)) case reflect.Int16: return int64(i.(int16)) case reflect.Int32: return int64(i.(int32)) case reflect.Int64: return i.(int64) default: panic("not support") } } func add(a, b interface{}) int64 { return convert(a) + convert(b) }
說明:
-
convert()
函數:將interface{}
類型轉換為int64
類型。對于非整型的類型,會 panic。(當然不是很嚴謹,還沒涵蓋uint*
類型) -
add()
函數:做任意整型的加法,返回int64
類型。
相比之下,如果是確定的類型,我們根本不需要判斷類型,直接相加就可以了:
func add1(a, b int64) int64 { return a + b }
我們可以通過以下的 benchmark 來對比一下:
func BenchmarkAdd(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { add(1, 2) } } func BenchmarkAdd1(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { add1(1, 2) } }
結果:
BenchmarkAdd-12 ? ? ? ? 179697526 ? ? ? ? ? ? ? ?6.667 ns/op ? ? ? ? ? 0 B/op ? ? ? ? ?0 allocs/op
BenchmarkAdd1-12 ? ? ? ?1000000000 ? ? ? ? ? ? ? 0.2353 ns/op ? ? ? ? ?0 B/op ? ? ? ? ?0 allocs/op
我們可以看到非常明顯的性能差距,add()
要比 add1()
慢了非常多,而且這還只是做了一些簡單的類型判斷及類型轉換的情況下。
go 靈活性的代價(慢的原因)
通過這個例子我們知道,go 雖然通過 interface{}
為我們提供了一定的靈活性支持,但是使用這種動態的特性是有一定代價的,比如:
- 我們在運行時才知道類型,那么我們就需要在運行時去做類型判斷(也就是通過反射),這種判斷會有一定開銷(本來是確定的一種類型,但是現在可能要在 20 多個類型中匹配才能確定它的類型是什么)。同時,判斷到屬于某一類型之后,往往需要轉換為具體的類型,這也是一種開銷。
- 同時,我們可能需要去做一些屬性、方法的查找等操作(
Field
,FieldByName
,Method
,MethodByName
),這些操作都是在運行時做的,所以會有一定的性能損耗。 - 另外,在做屬性、方法之類的查找的時候,查找性能取決于屬性、方法的數量,如果屬性、方法的數量很多,那么查找性能就會相對慢。通過 index (
Field
,Method
)查找相比通過 name (FieldByName
,MethodByName
)查找快很多,后者有內存分配的操作 - 在我們通過反射來做這些操作的時候,多出了很多操作,比如,簡單的兩個
int
類型相加,本來可以直接相加。但是通過反射,我們不得不先根據interface{}
創建一個反射對象,然后再做類型判斷,再做類型轉換,最后再做加法。
總的來說,go 的 interface{} 類型雖然給我們提供了一定的靈活性,讓開發者也可以在 go 里面實現一些動態語言的特性, 但是這種靈活性是以犧牲一定的性能來作為代價的,它會讓一些簡單的操作變得復雜,一方面生成的編譯指令會多出幾十倍,另一方面也有可能在這過程有內存分配的發生(比如 FieldByName
)。
慢是相對的
從上面的例子中,我們發現 go 的反射好像慢到了讓人無法忍受的地步,然后就有人提出了一些解決方案, 比如:通過代碼生成的方式避免運行時的反射操作,從而提高性能。比如 easyjson
但是這類方案都會讓代碼變得繁雜起來。我們需要權衡之后再做決定。為什么呢?因為反射雖然慢,但我們要知道的是,如果我們的應用中有網絡調用,任何一次網絡調用的時間往往都不會少于 1ms,而這 1ms 足夠 go 做很多次反射操作了。這給我們什么啟示呢?如果我們不是做中間件或者是做一些高性能的服務,而是做一些 web 應用,那么我們可以考慮一下性能瓶頸是不是在反射這里,如果是,那么我們就可以考慮一下代碼生成的方式來提高性能,如果不是,那么我們真的需要犧牲代碼的可維護性、可讀性來提高反射的性能嗎?優化幾個慢查詢帶來的收益是不是更高呢?
go 反射性能優化
如果可以的話,最好的優化就是不要用反射。
通過代碼生成的方式避免序列化和反序列化時的反射操作
這里以 easyjson
為例,我們來看一下它是怎么做的。假設我們有如下結構體,我們需要對其進行 json 序列化/反序列化:
// person.go type Person struct { Name string `json:"name"` Age int `json:"age"` }
使用 easyjson
的話,我們需要為結構體生成代碼,這里我們使用 easyjson
的命令行工具來生成代碼:
easyjson -all person.go
這樣,我們就會在當前目錄下生成 person_easyjson.go
文件,里面包含了 MarshalJSON
和 UnmarshalJSON
方法,這兩個方法就是我們需要的序列化和反序列化方法。不同于標準庫里面的 json.Marshal
和 json.Unmarshal
,這兩個方法是不需要反射的,它們的性能會比標準庫的方法要好很多。
func easyjsonDb0593a3EncodeGithubComGinGonicGinCEasy(out *jwriter.Writer, in Person) { out.RawByte('{') first := true _ = first { const prefix string = ","name":" out.RawString(prefix[1:]) out.String(string(in.Name)) } { const prefix string = ","age":" out.RawString(prefix) out.Int(int(in.Age)) } out.RawByte('}') } // MarshalJSON supports json.Marshaler interface func (v Person) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} easyjsonDb0593a3EncodeGithubComGinGonicGinCEasy(&w, v) return w.Buffer.BuildBytes(), w.Error }
我們看到,我們對 Person
的序列化操作現在只需要幾行代碼就可以完成了,但是也有很明顯的缺點,生成的代碼會很多。
性能差距:
goos: darwin
goarch: amd64
pkg: github.com/gin-gonic/gin/c/easy
cpu: Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz
BenchmarkJson
BenchmarkJson-12 ? ? ? ? ? ?3680560 ? ? ? ? ?305.9 ns/op ? ? ?152 B/op ? ? ? ? 2 allocs/op
BenchmarkEasyJson
BenchmarkEasyJson-12 ? ? ? 16834758 ? ? ? ? ? 71.37 ns/op ? ? ? ? 128 B/op ? ? ? ? 1 allocs/op
我們可以看到,使用 easyjson
生成的代碼,序列化的性能比標準庫的方法要好很多,好了 4 倍以上。
反射結果緩存
這種方法適用于需要根據名稱查找結構體字段或者查找方法的場景。
假設我們有一個結構體 Person
,其中有 5 個方法,M1
、M2
、M3
、M4
、M5
,我們需要通過名稱來查找其中的方法,那么我們可以使用 reflect
包來實現:
p := &Person{} v := reflect.ValueOf(p) v.MethodByName("M4")
這是很容易想到的辦法,但是性能如何呢?通過性能測試,我們可以看到,這種方式的性能是非常差的:
func BenchmarkMethodByName(b *testing.B) { p := &Person{} v := reflect.ValueOf(p) b.ReportAllocs() for i := 0; i < b.N; i++ { v.MethodByName("M4") } }
結果:
BenchmarkMethodByName-12 ? ? ? ? 5051679 ? ? ? ? ? ? ? 237.1 ns/op ? ? ? ? ? 120 B/op ? ? ? ? ?3 allocs/op
相比之下,我們如果使用索引來獲取其中的方法的話,性能會好很多:
func BenchmarkMethod(b *testing.B) { p := &Person{} v := reflect.ValueOf(p) b.ReportAllocs() for i := 0; i < b.N; i++ { v.Method(3) } }
結果:
BenchmarkMethod-12 ? ? ? ? ? ? ?200091475 ? ? ? ? ? ? ? ?5.958 ns/op ? ? ? ? ? 0 B/op ? ? ? ? ?0 allocs/op
我們可以看到兩種性能相差幾十倍。那么我們是不是可以通過 Method
方法來替代 MethodByName
從而獲得更好的性能呢?答案是可以的,我們可以緩存 MethodByName
的結果(就是方法名對應的下標),下次通過反射獲取對應方法的時候直接通過這個下標來獲取:
這里需要通過 reflect.Type 的 MethodByName 來獲取反射的方法對象。
// 緩存方法名對應的方法下標 var indexCache = make(map[string]int) func methodIndex(p interface{}, method string) int { if _, ok := indexCache[method]; !ok { m, ok := reflect.TypeOf(p).MethodByName(method) if !ok { panic("method not found!") } indexCache[method] = m.Index } return indexCache[method] }
性能測試:
func BenchmarkMethodByNameCache(b *testing.B) { p := &Person{} v := reflect.ValueOf(p) b.ReportAllocs() var idx int for i := 0; i < b.N; i++ { idx = methodIndex(p, "M4") v.Method(idx) } }
結果:
// 相比原來的 MethodByName 快了將近 20 倍 BenchmarkMethodByNameCache-12 86208202 13.65 ns/op 0 B/op 0 allocs/op BenchmarkMethodByName-12 5082429 235.9 ns/op 120 B/op 3 allocs/op
跟這個例子類似的是 Field/FieldByName 方法,可以采用同樣的優化方式。這個可能是更加常見的操作,反序列化可能需要通過字段名查找字段,然后進行賦值。
使用類型斷言代替反射
在實際使用中,如果只是需要進行一些簡單的類型判斷的話,比如判斷是否實現某一個接口,那么可以使用類型斷言來實現:
type Talk interface { Say() } type person struct { } func (p person) Say() { } func BenchmarkReflectCall(b *testing.B) { p := person{} v := reflect.ValueOf(p) for i := 0; i < b.N; i++ { idx := methodIndex(&p, "Say") v.Method(idx).Call(nil) } } func BenchmarkAssert(b *testing.B) { p := person{} for i := 0; i < b.N; i++ { var inter interface{} = p if v, ok := inter.(Talk); ok { v.Say() } } }
結果:
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz
BenchmarkReflectCall-12 ? ? ? ? ?6906339 ? ? ? ? ? ? ? 173.1 ns/op
BenchmarkAssert-12 ? ? ? ? ? ? ?171741784 ? ? ? ? ? ? ? ?6.922 ns/op
在這個例子中,我們就算使用了緩存版本的反射,性能也跟類型斷言差了將近 25 倍。
因此,在我們使用反射之前,我們需要先考慮一下是否可以通過類型斷言來實現,如果可以的話,那么就不需要使用反射了。
總結
go 提供了性能測試的工具,我們可以通過 go test -bench=.
這種命令來進行性能測試,運行命令之后,文件夾下的測試文件中的 Benchmark*
函數會被執行。
性能測試的結果中,除了平均執行耗時之外,還有內存分配的次數和內存分配的字節數,這些都是我們需要關注的指標。其中內存分配的次數和內存分配的字節數是可以通過 b.ReportAllocs()
來進行統計的。內存分配的次數和內存分配的字節數越少,性能越好。
反射雖然慢,但是也帶來了一定的靈活性,它的慢主要由以下幾個方面的原因造成的:
- 運行時需要進行類型判斷,相比確定的類型,運行時可能需要在 20 多種類型中進行判斷。
- 類型判斷之后,往往需要將 interface{} 轉換為具體的類型,這個轉換也是需要消耗一定時間的。
- 方法、字段的查找也是需要消耗一定時間的。尤其是
FieldByName
,MethodByName
這種方法,它們需要遍歷所有的字段和方法,然后進行比較,這個比較的過程也是需要消耗一定時間的。而且這個過程還需要分配內存,這會進一步降低性能。
慢不慢是一個相對的概念,如果我們的應用大部分時間是在 IO 等待,那么反射的性能大概率不會成為瓶頸。優化其他地方可能會帶來更大的收益,同時也可以在不影響代碼可維護性的前提下,使用一些時空復雜度更低的反射方法,比如使用 Field
代替 FieldByName
等。
如果可以的話,盡量不使用反射就是最好的優化。
反射的一些性能優化方式有如下幾種(不完全,需要根據實際情況做優化):
- 使用生成代碼的方式,生成特定的序列化和反序列化方法,這樣就可以避免反射的開銷。
- 將第一次反射拿到的結果緩存起來,這樣如果后續需要反射的話,就可以直接使用緩存的結果,避免反射的開銷。(空間換時間)
- 如果只是需要進行簡單的類型判斷,可以先考慮一下類型斷言能不能實現我們想要的效果,它相比反射的開銷要小很多。
反射是一個很龐大的話題,這里只是簡單的介紹了一小部分反射的性能問題,討論了一些可行的優化方案,但是每個人使用反射的場景都不一樣,所以需要根據實際情況來做優化。
原文鏈接:https://juejin.cn/post/7186859098661453884
相關推薦
- 2022-10-30 Android中二維碼的掃描和生成(使用zxing庫)_Android
- 2022-10-26 Jira?任務管理系統項目總結講解_React
- 2023-05-15 Go語言實現AES加密并編寫一個命令行應用程序_Golang
- 2023-03-18 C#調用dll報錯:無法加載dll,找不到指定模塊的解決_C#教程
- 2022-09-04 Golang?實現?RTP音視頻傳輸示例詳解_Golang
- 2022-08-01 Python3?中return和yield的區別_python
- 2023-06-19 C++開放封閉原則示例解析_C 語言
- 2022-08-08 python中Pytest常用的插件_python
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支