Golang 1.25 encoding/json/v2更新
在 Golang 1.25版本中,我们可以用 encoding/json/v2 这个新的 json 加密/解析包提高对应执行效率。
# 在 1.25 版本的 cmd 设置该 Go 的环境变量即可全局使用
$env:GOEXPERIMENT = "jsonv2"
# 单次执行使用
cmd /c "set GOEXPERIMENT=jsonv2&& go run main.go"
# GOEXPERIMENT=jsonv2 都会让编译器把 std/encoding/json → std/encoding/json/v2我们来是一段测试代码,看看新的 v2 版本有啥变化:
// json_v2.go
package main
import (
"encoding/json"
"fmt"
"time"
)
// 一个中等大小 JSON
var raw = []byte(`{
"users": [` + repeat(50000, `
{
"id": %d,
"name": "user-%d",
"age": %d,
"email": "user-%d@example.com"
},`) + `{}
]
}`)
func repeat(n int, template string) string {
s := ""
for i := 0; i < n; i++ {
s += fmt.Sprintf(template, i, i, 20+i%50, i)
}
return s
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
}
type Resp struct {
Users []User `json:"users"`
}
func main() {
start := time.Now()
var r Resp
if err := json.Unmarshal(raw, &r); err != nil {
panic(err)
}
elapsed := time.Since(start)
fmt.Printf("unmarshal %d users: %v (len=%d KB)\n", len(r.Users), elapsed, len(raw)/1024)
}我们直接用 v1 版本运行,输出结果如下:
go run json_v2.go
unmarshal 50001 users: 64.8949ms (len=4899 KB)我们再用 v2 版本运行对比一下:
cmd /c "set GOEXPERIMENT=jsonv2&& go run json_v2.go"
unmarshal 50001 users: 33.1371ms (len=4899 KB)结果显而易见啊,v2 版本 比 v1 版本解析用时少了将近 50% 的时间了,v2 执行效率可以说相当快了。
可见 v2 版本在解析的优化上下了很大的功夫,我们来大概看看改动了啥:
| 阶段 | 旧 json (≤ go1.24) | v2 json (实验) | 改动点 |
|---|---|---|---|
| ① 词法扫描 | scanner.next() 逐字节跳转 | scanner.next() SSE/AVX 批量比较 空白/转义 | src/encoding/json/v2/scanner_amd64.s |
| ② 语法解析 | 立即构造 interface{} 中间树 | 只生成 token 链(不分配 Go 值) | src/encoding/json/v2/decode.go decodeValueLazy() |
| ③ 反射写入 | 深度优先即时反射 | 延迟反射(字段第一次被访问才 reflect.Value.Set) | src/encoding/json/v2/decode_reflect.go setLazy() |
| ④ 元数据 | 每趟 unmarshal 都 重新解析 struct tag | 第一次把 tag→field 索引 缓存到全局 sync.Map | src/encoding/json/v2/cache.go typeInfo() |
# v2 解析的执行链路
json.Unmarshal
└── json.v2.Unmarshal
└── d.decodeValueLazy() // 只扫 token,不 new 值
└── d.setLazy(v) // 记录“将来要写的字段索引”
└── d.applyLazy() // 真正碰到字段时
└── cachedFieldInfo.Set(v, tok.val) // 用缓存索引一次性写
# v1 解析的执行链路
json.Unmarshal
└── json.decodeValue() // 立即 reflect.New
└── d.literal()/d.object() // 每字段即时反射 & 分配
└── fv.Set(val) // 大量接口装箱+反射调用惰性解析算是本次 Unmarshal 最重要的变更了,无论从效率上还是资源占用上都起到了很大的优化。
惰性解析(lazy decoding):先扫一遍 JSON 得到「token 地图」,但并不立即转成 Go 值;等用户代码真正访问某个字段时,再一次性反射写入。
每个 token 只记录:
- 类型(对象/数组/字符串/数字/布尔/null)
- 在原字节流里的 起止偏移
- 如果是标量,直接指向切片(不分配新内存)
→ 整份 JSON 被变成 「只读 token 切片」,大小 ≈ 节点数 × 24 B,零 interface{} 装箱。
具体省掉的 CPU/内存
扫描阶段
- 旧:C 循环逐字节
switch ch { case ' ', '\t'... } - 新:汇编
TEXT ·scanBlock(SB)一次VMOVAPS加载 32 B,用位掩码一次判空/转义。
→ 扫描耗时 ≈ 减半。
- 旧:C 循环逐字节
解析阶段
- 旧:遇到
{就reflect.New(Type)产生零值对象;整棵树全构造完再返回。 - 新:只记录 token 区间指针(起始偏移+长度),零堆分配;等你真正访问字段时才一次性
Set。
→ 大对象解码内存分配 ↓ 50 % 以上。
- 旧:遇到
反射阶段
- 旧:每趟
unmarshal都重新Type.Field(i) / tag解析。 - 新:第一次把「字段索引+tag 规则」压进
sync.Map,后面直接拿指针。
→ 反射操作从 O(字段数×解码次数) 变成 O(字段数×1)。
- 旧:每趟
看了上面这些说的改动,可以说是改动的比较到位,原来 json 基本上就是存数据本源在缓存中,新版本则直接存储的是对应的索引和元数据等小型数据,大大减少了缓存的占用。
v1 每次
json.Unmarshal都重新走一遍reflect.Type.Field(i)+ 标签解析,字段越多、调用越频繁,反射开销线性增加,而且全是短期对象,GC 也要跟着扫。v2 第一次把字段索引和 tag 规则压进全局
sync.Map,之后无论多少次、多大 JSON、多少 goroutine,都直接拿指针用,零重复反射、零拷贝字段元数据。
且反射阶段是直接拿指针,同一结构只用被构建一次,余下同一结构读取直接从第一次构建的缓存中读取就好了,不用像以前那样读一次构建一次。
新版本缓存条目是 无状态只读对象,不持有任何具体解码值;解码完成后 JSON 原始字节、中间 token 都可被 GC 立即回收。而旧版每趟解码会临时生成大量 reflect.Value、interface{} 装箱,这些短期对象在高峰期反而给 GC 带来更大压力。
结论
- 缓存额外占用 = 「字段数 × 几十字节」级别,与 JSON 大小/解码次数脱钩。
- 由于惰性解析 + 无中间对象树,峰值堆内存通常比 v1 低;GC 压力也更小。
- 所以「大 JSON 解析」场景下,v2 既快又省,不会出现“常态缓存比 v1 多”的情况。
同一结构下,v2 用 O(字段数×1) 的常量成本就扛住了无限次解码;v1 则是 O(字段数×解码次数),反射 + 临时对象一个都逃不掉。
RoLingG | 博客
评论(0)