G

[Golang] 1.25 encoding/json/v2更新

RoLingG Golang 2025-11-30

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.Setsrc/encoding/json/v2/decode_reflect.go setLazy()
④ 元数据每趟 unmarshal重新解析 struct tag第一次把 tag→field 索引 缓存到全局 sync.Mapsrc/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/内存

  1. 扫描阶段

    • 旧:C 循环逐字节 switch ch { case ' ', '\t'... }
    • 新:汇编 TEXT ·scanBlock(SB) 一次 VMOVAPS 加载 32 B,用位掩码一次判空/转义。
      → 扫描耗时 ≈ 减半。
  2. 解析阶段

    • 旧:遇到 {reflect.New(Type) 产生零值对象;整棵树全构造完再返回。
    • 新:只记录 token 区间指针(起始偏移+长度),零堆分配;等你真正访问字段时才一次性 Set
      → 大对象解码内存分配 ↓ 50 % 以上。
  3. 反射阶段

    • 旧:每趟 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.Valueinterface{} 装箱,这些短期对象在高峰期反而给 GC 带来更大压力。


结论

  • 缓存额外占用 = 「字段数 × 几十字节」级别,与 JSON 大小/解码次数脱钩
  • 由于惰性解析 + 无中间对象树,峰值堆内存通常比 v1 低;GC 压力也更小。
  • 所以「大 JSON 解析」场景下,v2 既快又省,不会出现“常态缓存比 v1 多”的情况。

同一结构下,v2 用 O(字段数×1) 的常量成本就扛住了无限次解码;v1 则是 O(字段数×解码次数),反射 + 临时对象一个都逃不掉。

PREV
[Golang] 小试testing/synctest

评论(0)

发布评论