01 · 运行时解剖:JVM · V8 · Go 三方对照

“你只知道一种做法时,你不知道它是好是坏。“


开场:一个让人困惑的现象

小林:我们线上有个 Go 服务,堆内存稳定在 8GB,但 GC 暂停从来没超过 1ms。我同学的 Java 服务,堆只有 2GB,Minor GC 倒是很快,但 Full GC 动不动几百毫秒。Go 的 GC 真的比 Java 强那么多吗?

老陈:不是”更强”,是”优化目标不同”。 我问你:Java 的 GC 有几个可选项?

小林:Serial、Parallel、CMS、G1、ZGC、Shenandoah……

老陈:为什么有这么多?

小林:……适应不同场景?

老陈:对。Java 的 GC 是”可配置”的,因为 Java 要服务从手机到大型机的所有场景,从批处理到实时交易的所有负载。 你选 G1 还是 ZGC,取决于你要吞吐率还是要低延迟。

Go 呢?

小林:……就一个,没得选。

老陈:对。Go 只有一个 GC,而且只有两个可调参数(GOGC 和 MemoryLimit)。 为什么?

小林:因为 Go 的场景比较单一?

老陈:准确说,是因为 Go 明确放弃了”通用性”,只优化一个目标:低延迟的在线服务。

这就是关键。Java 说”我给你选择”,Go 说”我帮你选好了”。

没有对错,只有取舍。理解了这个取舍,你就能看懂 Go runtime 的每一个设计决策。


维度一:堆内存布局

JVM 的堆(分代)

┌─────────────────────────────────────────────────────┐
│                    JVM Heap                          │
├──────────────────┬──────────────────────────────────┤
│   Young Gen      │         Old Gen                  │
│   (新生代, 1/3)   │         (老年代, 2/3)             │
│                  │                                  │
│  ┌────┬───┬───┐  │                                  │
│  │Eden│ S0│ S1│  │   ← 8:1:1                        │
│  │ 8  │ 1 │ 1 │  │      存活对象在这里长期驻留        │
│  └────┴───┴───┘  │                                  │
│                  │                                  │
│  新对象在这里分配  │   Minor GC 存活多次后晋升到这里    │
│  Minor GC 频繁   │   Major/Full GC 少但慢             │
└──────────────────┴──────────────────────────────────┘

Java 8 之前还有 PermGen(永久代),存类元数据
Java 8+ 改成 Metaspace(元空间),用本地内存

Minor GC 的流程(复制算法):

① 新对象分配在 Eden
② Eden 满了 → Minor GC
③ 存活对象复制到 Survivor(S0)
④ 清空 Eden
⑤ 下次 Minor GC:Eden + S0 的存活对象复制到 S1
⑥ 清空 Eden + S0
⑦ 对象在 Survivor 之间来回复制,每熬过一次 GC,年龄 +1
⑧ 年龄达到阈值(默认 15)→ 晋升到 Old Gen
⑨ Survivor 空间不够 → 担保机制,直接晋升

为什么这样设计有效?

分代假说(Weak Generational Hypothesis):

绝大多数对象在分配后很快就变成垃圾。

实测数据:Java 程序中 70-98% 的对象在 Minor GC 时就死了。

所以 Minor GC 只需要复制 2-30% 的存活对象,代价与存活对象成正比,而不是与堆大小成正比。

V8 的堆(也是分代,但布局不同)

┌──────────────────────────────────────────────────────┐
│                     V8 Heap                           │
├───────────────────────────────────────────────────────┤
│  Young Generation (新生代, 1-8MB)                      │
│  ┌──────────────┬──────────────┐                     │
│  │  Nursery     │ Intermediate │   ← 两代 Survivor   │
│  └──────────────┴──────────────┘                     │
├───────────────────────────────────────────────────────┤
│  Old Generation (老年代)                               │
│  ┌──────────────┬──────────────┬─────────────────┐   │
│  │ Old Space    │ Map Space    │ Large Object    │   │
│  │ (普通对象)    │ (隐藏类)      │ Space (大对象)   │   │
│  └──────────────┴──────────────┴─────────────────┘   │
├───────────────────────────────────────────────────────┤
│  Code Space (JIT 编译后的机器码)                        │
├───────────────────────────────────────────────────────┤
│  Read-only Space / New Space / ...                    │
└──────────────────────────────────────────────────────┘

V8 的 GC 演进:

版本算法特点
早期全停顿标记清除简单但停顿长
2011+增量标记 + 延迟清除把标记拆成小步骤
2016 (v5.x)并发标记标记与 JS 执行并发
2019 (v8.0)并发清除 + 并行压缩进一步减少停顿

V8 的 GC 名字:

  • Scavenge:新生代,复制算法,停顿 1-10ms
  • Major GC (Mark-Compact):老年代,标记-清除-整理,停顿 10-几百 ms

Go 的堆(不分代)

┌──────────────────────────────────────────────────────┐
│                    Go Heap                            │
│                                                       │
│  ┌─────────────────────────────────────────────┐     │
│  │  Heap Arena 0 (64MB)                        │     │
│  │  ┌─────┬─────┬─────┬─────┬─────┐           │     │
│  │  │span │span │span │span │span │  ...      │     │
│  │  │8B   │16B  │32B  │64B  │1KB  │           │     │
│  │  └─────┴─────┴─────┴─────┴─────┘           │     │
│  │   ↑ 按 size class 组织的 span               │     │
│  └─────────────────────────────────────────────┘     │
│                                                       │
│  ┌─────────────────────────────────────────────┐     │
│  │  Heap Arena 1 (64MB)                        │     │
│  └─────────────────────────────────────────────┘     │
│                                                       │
│  ┌─────────────────────────────────────────────┐     │
│  │  Heap Arena 2 (64MB)   ...                  │     │
│  └─────────────────────────────────────────────┘     │
│                                                       │
│  ★ 没有新生代/老年代的区分                              │
│  ★ 每个 span 有一个 8 位的"年龄",但只用于决定是否      │
│    优先清扫,不用于晋升                                │
└──────────────────────────────────────────────────────┘

对比:为什么 Go 不分代?

理由 1 · Go 大量使用栈分配

func process(data []byte) Result {
	var r Result          // 栈上
	temp := make([]int, 0, 100)  // 逃逸分析后可能还在栈上
	for _, b := range data {
		r.Sum += int(b)
	}
	return r               // 返回值,不需要逃逸
}

Go 的逃逸分析会把大量短生命周期对象放在栈上。栈上分配的对象不需要 GC——函数返回时自动释放(就一条 mov rsp, rbp)。

结果:Go 堆上的对象,平均生命周期比 Java 长得多。分代假说的收益变小。

理由 2 · 编译器和 runtime 的协作

Go 的编译器知道每个对象的类型,runtime 知道每个指针的位置(通过 GC bitmap)。这让 Go 可以精确地:

  • 只扫描指针,跳过非指针数据
  • 精确判断对象是否存活

Java 也有类型信息,但 Java 的对象头更重(每个对象 12-16 字节的 header)。Go 的对象头是 0(类型信息在类型描述符里,不在对象里)。

理由 3 · 明确的取舍:要低延迟,不要吞吐率

分代 GC 的优势是吞吐率高(因为 Minor GC 很便宜)。但代价是:

  • 需要写屏障记录跨代引用(Remembered Set / Card Table)
  • Full GC 时停顿很长(要标记整理整个老年代)

Go 选择:放弃吞吐率的优势,换取可预测的短停顿。

老陈:这不是”Go 做不到分代”,而是”Go 认为分代带来的收益不值得它的复杂度”。

有意思的是,这个判断是有争议的。 Go 团队内部也讨论过多次。目前的结论是:

  • Go 的主要场景(微服务)堆不大(几百 MB 到几 GB),并发标记的停顿已经够短
  • 加分会增加 runtime 复杂度和写屏障开销
  • 但如果 Go 用在大数据、AI 场景(堆几十 GB),分代可能就值得了

这印证了第 3 章开头那句话:设计是被使用场景塑造的。当场景迁移时,当年的决策就变成包袱。


维度二:代码执行方式

三种执行方式

① 解释执行(Interpretation)
   源码 → AST → 逐条解释执行
   · 启动最快,执行最慢
   · 例子:早期的 BASIC、Shell 脚本

② 字节码 + 虚拟机
   源码 → 字节码 → 虚拟机解释执行 → (热点代码 JIT 编译成机器码)
   · 启动较快,执行较快,可以跨平台
   · 例子:JVM、CPython、V8 的第一层

③ 直接编译(AOT)
   源码 → 机器码
   · 启动最快(直接就是机器码),执行快(但没有运行时信息优化)
   · 例子:C/C++、Rust、Go

JVM 的执行:混合模式

public class Demo {
    public static int add(int a, int b) {
        return a + b;
    }
    public static void main(String[] args) {
        int sum = 0;
        for (int i = 0; i < 10_000_000; i++) {
            sum += add(i, 1);   // 调用 1000 万次
        }
        System.out.println(sum);
    }
}

执行流程:

① 第一次执行:解释器逐条解释字节码
   · 每次调用 add() 都走解释器
   · 慢(比机器码慢 10-50 倍)

② HotSpot 检测到 add() 被调用了很多次(> 阈值,默认 10000 次)
   · 把它标记为"热点方法"
   · 交给 C1 编译器(Client Compiler)编译
   · C1 优化少,编译快

③ 继续调用,发现还是热(C1 编译的代码里有计数器)
   · 交给 C2 编译器(Server Compiler)编译
   · C2 做激进优化:内联、循环展开、逃逸分析、锁消除…

④ 最终:add() 被内联进 main(),整个循环可能被优化成一条算术指令

JIT 的杀手锏:基于运行时信息的优化

// 虚方法调用,静态编译无法内联
void callIt(Animal a) {
    a.speak();      // 不知道 a 是 Cat 还是 Dog
}
 
// JIT 的观察:过去 10000 次调用,9999 次是 Cat
// JIT 生成的代码:
void callIt(Animal a) {
    if (a instanceof Cat) {     // 快速路径
        ((Cat)a).speak();       // 内联!
    } else {
        a.speak();              // 慢速路径(去优化)
    }
}

如果后来真的传进来一个 Dog,会触发”去优化”(Deoptimization)——丢弃 JIT 编译的代码,回到解释器。

老陈:这是 JIT 最有意思的地方:它可以”赌”。

AOT 编译器不能赌,因为赌错了程序就错了。 JIT 可以赌,因为赌错了可以回退。

这是”运行时信息”的价值——你知道了实际的运行数据,就能做静态编译做不到的优化。

代价:JIT 本身要消耗 CPU 和时间(“预热”),而且去优化的开销很大。

V8 的执行:多层 JIT

V8 的演进:

时期架构问题
2008-2010Codegen(直接编译成机器码)+ Crankshaft(优化编译器)Codegen 生成的机器码没有类型信息,Crankshaft 常常要去优化
2010-2016Full-Codegen + Crankshaft + TurboFan三层,复杂
2016-2021Ignition(解释器)+ TurboFan(优化编译器)简化成两层
2021+Ignition + Sparkplug(非优化编译器)+ Maglev(中层)+ TurboFan(顶层)四层,平衡预热速度和峰值性能

当前架构(V8 11.x):

                  JS 源码
                     │
                     ▼  Parser
                   AST
                     │
                     ▼  Ignition(字节码生成器)
                  字节码
                     │
        ┌────────────┼────────────┬────────────┐
        ▼            ▼            ▼            ▼
   Ignition      Sparkplug      Maglev     TurboFan
   (解释执行)     (快速编译)     (中层优化)   (激进优化)
   
   启动最快      比解释快        SSA+优化    最激进
   无优化        几乎无优化      编译较快     编译最慢
                                             性能最好
   
   执行次数 ─────────────────────────────►

为什么要这么多层?

这是**“预热速度”和”峰值性能”的权衡**:

单层(只有解释器):启动快,但执行慢
单层(只有激进 JIT):启动慢(要编译),但执行快
多层:短命代码只走解释器,热代码逐步升级到激进优化

★ 真实情况:大部分 JS 代码只执行几次(比如页面初始化)
   用解释器执行几毫秒就够了,根本不值得花 10ms 去编译

实测数字(V8 团队的数据):

  • 典型网页:90% 的函数从未被优化编译
  • 只有 1-5% 的代码值得 TurboFan 编译

Go 的执行:纯 AOT

Go 源码 → 编译器 → 机器码 → 直接执行

· 没有解释器
· 没有 JIT
· 没有预热
· 启动即峰值(但峰值不如 JIT 优化后的)

Go 用 AOT 的理由:

  1. 启动速度:容器、Serverless 场景下,冷启动是核心指标。JIT 的预热时间是纯浪费。
  2. 可预测性:AOT 的性能是确定的,没有”预热期性能差”的问题。在线服务最怕不可预测。
  3. 部署简单:没有 JIT 意味着不需要在运行时保留编译器,二进制更小,内存占用更少。
  4. 安全性:JIT 需要”可写可执行”的内存页(W^X 冲突),是攻击面。AOT 不需要。

代价:

  • 无法基于运行时信息优化(所以 Go 1.20+ 引入了 PGO 作为补救)
  • 无法做跨动态链接库的优化
  • 峰值性能通常比 JVM 的 C2 低 5-15%

老陈:有意思的是,Go 用 PGO 补上了 AOT 的一个短板。

PGO 的思路:编译时不知道运行时信息,那就先跑一遍收集信息,再用这些信息重新编译。

这可以看作”离线的 JIT”——把 JIT 在运行时的优化,挪到了构建时。

代价:需要维护 profile,代码变了 profile 就过期。 收益:没有运行时开销。

这是一个非常聪明的折中:既拿到了运行时信息的优化,又不付出运行时的代价。


维度三:动态分发

问题:怎么找到”要调用哪个方法”

// Java
Animal a = getAnimal();
a.speak();      // 运行时才知道是 Cat.speak 还是 Dog.speak
// JavaScript
let obj = getObject();
obj.x           // 运行时才知道 obj 的形状,x 在哪个偏移
// Go
var w io.Writer = getWriter();
w.Write(b)      // 运行时才知道具体类型

JVM 的方案:虚方法表(vtable)

每个类的内存布局:
┌──────────────────┐
│  对象头 (8-16B)   │
│  ├─ Mark Word    │  ← hashcode, GC 年龄, 锁状态
│  └─ Klass Pointer│  ← 指向类的元数据
├──────────────────┤
│  实例字段 1       │
│  实例字段 2       │
│  ...             │
└──────────────────┘

类的元数据(Klass)里有一张 vtable:
┌──────────────────┐
│ vtable[0] → Cat.speak 的地址  │
│ vtable[1] → Cat.eat   的地址  │
│ vtable[2] → Animal.toString   │
└──────────────────┘

调用 a.speak():
  1. 从对象头拿到 Klass Pointer
  2. 查 vtable,找到 speak 的槽位(编译期已知是 [0])
  3. 间接跳转到该地址

开销:

  • 1 次内存访问(加载 vtable)
  • 1 次间接跳转(分支预测器很难预测)

V8 的方案:隐藏类 + 内联缓存

JavaScript 的问题更严重:对象可以动态加属性。

let p = {};
p.x = 1;        // p 的形状变了
p.y = 2;        // 又变了

如果用字典存属性,每次访问 p.x 都要哈希查找 —— 慢得离谱。

V8 的解法:隐藏类(Hidden Class,也叫 Map 或 Shape)

// 代码                          // V8 内部的隐藏类变化
let p = {};                       // C0: {} (空)
p.x = 1;                          // C1: {x: offset 0}   ← 新建隐藏类 C1,从 C0 转换而来
p.y = 2;                          // C2: {x: offset 0, y: offset 1}
 
let q = {};
q.x = 3;                          // ★ q 经历同样的转换,得到同一个 C1
q.y = 4;                          // ★ q 的隐藏类也是 C2
 
// 现在 p 和 q 共享 C2,它们的属性在相同的偏移!
// 访问 p.x 和 q.x 可以用同一段机器码

内联缓存(Inline Cache,IC)

第一次执行 obj.x:
  · 查隐藏类,找到 x 的偏移(比如 8)
  · 把这个结果缓存到调用点:{隐藏类 C2 → 偏移 8}
  · 生成"快速路径"机器码:直接从 offset 8 读

第二次执行 obj.x(obj 还是 C2 形状):
  · 检查隐藏类是否匹配(一个指针比较)
  · 匹配 → 直接读 offset 8     ★ 极快(几条指令)

如果 obj 变成了别的形状:
  · 缓存未命中 → 退化到慢速路径(查表)
  · 如果是多态(2-4 种形状),变成"多态内联缓存"
  · 如果超过 4 种,变成"超态",直接用字典查找

V8 的内联缓存状态机:

未初始化 (Uninitialized)
    │ 第一次执行
    ▼
单态 (Monomorphic)        ← 只见过 1 种隐藏类,最快
    │ 见到第 2 种
    ▼
多态 (Polymorphic)        ← 见过 2-4 种,线性查找
    │ 见到第 5 种
    ▼
超态 (Megamorphic)        ← 见过 >4 种,用全局哈希表,最慢

性能差异(V8 官方数据):

单态:     1.0x    (基准)
多态(2):  1.5x
多态(4):  2.5x
超态:     10-30x  ← 灾难

这给 JS 程序员的启示:

// ❌ 不好的写法:同一个变量被赋不同形状的对象
function process(obj) {
    return obj.a + obj.b;
}
process({a: 1, b: 2});        // 形状 1
process({a: 1, b: 2, c: 3});  // 形状 2  ← 让 IC 变成多态
process({b: 2, a: 1});        // 形状 3  ← 属性顺序不同,隐藏类也不同!
 
// ✅ 好的写法:保持对象形状一致,且属性顺序一致
function Point(a, b) {
    this.a = a;    // 总是先 a 后 b
    this.b = b;
}

老陈:注意”属性顺序不同,隐藏类也不同”这一点。

{a: 1, b: 2} 和 {b: 2, a: 1} 在 JS 里语义相同,但 V8 会给它们不同的隐藏类。

这是一个”语义等价但性能不同”的经典案例。 很多 JS 性能问题都源于此。

Go 的方案:itab

type Writer interface {
	Write(p []byte) (n int, err error)
}
 
var w Writer = &File{}
w.Write(data)

Go 的接口值在内存中是两个字(16 字节):

┌──────────────┬──────────────┐
│   itab 指针   │   数据指针    │
│   (8 bytes)  │   (8 bytes)  │
└──────┬───────┴──────┬───────┘
       │              │
       ▼              ▼
  ┌─────────────┐   实际的对象
  │ itab        │
  │ ├─ inter    │ → 接口类型 (Writer)
  │ ├─ _type    │ → 具体类型 (*File)
  │ ├─ hash     │   类型哈希(用于类型断言的快速比较)
  │ ├─ fun[0]   │ → (*File).Write 的地址   ★ 直接是地址!
  │ └─ fun[1]   │ → ...
  └─────────────┘

调用 w.Write(data):

1. 从接口值取出 itab 指针
2. 从 itab 的 fun[0] 取出方法地址
3. 间接跳转

开销:1 次内存访问 + 1 次间接跳转。和 JVM 的 vtable 类似。

Go 的一个优化:itab 缓存

// runtime 里有一个全局的 itab 表
// 相同的 (接口类型, 具体类型) 组合只创建一次 itab
var itabTable map[[2]*type]*itab
 
// 所以 *File → Writer 的转换,只有第一次要查表
// 之后都是复用同一个 itab 指针

Go 的另一个优化:类型断言很快

if f, ok := w.(*File); ok {
	// ...
}

实现:直接比较 itab 里的 _type 指针是否等于 *File 的类型描述符。一次指针比较,O(1)。

三者对比

JVM vtableV8 隐藏类+ICGo itab
解决的问题虚方法分发动态属性访问接口方法分发
查找方式vtable[槽位]隐藏类 → 偏移(缓存)itab.fun[槽位]
多态支持天然(每个类自己的 vtable)IC 状态机(单态→多态→超态)天然(每个 (接口,类型) 一个 itab)
最坏情况接口调用(查 itable)超态,字典查找(10-30x 慢)无(总是 O(1))
内存开销每对象 8-16B 头 + 每类一张 vtable每形状一个隐藏类接口值 16B + 每个组合一个 itab

老陈:注意一个关键差异:

JVM 和 Go 的多态是”设计出来的” —— 你写代码时就知道这是个虚方法/接口方法。

V8 的多态是”意外产生的” —— 你写 obj.x 时没想过会有多种形状,但运行时可能真的有多种。

这就是为什么 V8 需要内联缓存这个机制:它要在”你不知道会有多种形状”的情况下,尽可能让单态的情况快。

这是一个重要的洞察:动态语言的运行时,必须为”程序员可能写出任何东西”付出代价。 静态语言可以把这个代价转移到编译期。


维度四:并发模型

三种并发哲学

JVMV8/Node.jsGo
单位线程(1:1 映射 OS 线程)单线程 + 事件循环goroutine(M:N)
共享共享内存不共享(单线程)通过 channel 通信
同步锁、volatile、CAS不需要(单线程)channel、sync 包
阻塞处理线程阻塞回调/Promise/asyncruntime 自动切换
心智负担高(死锁、竞态)中(回调地狱 → async/await)低(像写同步代码)

Node.js 的事件循环

console.log('1');
 
setTimeout(() => console.log('2'), 0);
 
Promise.resolve().then(() => console.log('3'));
 
console.log('4');
 
// 输出顺序: 1, 4, 3, 2
// 为什么?

事件循环的结构:

┌──────────────────────────────────────────────────────┐
│                    Event Loop                          │
│                                                       │
│   ┌─────────────────────────────────────────────┐    │
│   │  timers          (setTimeout, setInterval)   │    │
│   ├─────────────────────────────────────────────┤    │
│   │  pending callbacks  (系统级回调)              │    │
│   ├─────────────────────────────────────────────┤    │
│   │  idle, prepare     (内部用)                   │    │
│   ├─────────────────────────────────────────────┤    │
│   │  poll              (IO 事件,大部分时间在这)   │    │
│   ├─────────────────────────────────────────────┤    │
│   │  check             (setImmediate)            │    │
│   ├─────────────────────────────────────────────┤    │
│   │  close callbacks   (socket.on('close'))      │    │
│   └─────────────────────────────────────────────┘    │
│                                                       │
│   每个阶段之间,会清空微任务队列(microtask)           │
│   ┌─────────────────────────────────────────────┐    │
│   │  microtasks: Promise.then, process.nextTick  │    │
│   └─────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────┘

上面代码的执行:

① 同步执行 console.log('1')        → 输出 1
② setTimeout 注册到 timers 阶段
③ Promise.then 注册到 microtask 队列
④ 同步执行 console.log('4')        → 输出 4
⑤ 同步代码执行完,清空 microtask   → 输出 3
⑥ 进入事件循环,timers 阶段         → 输出 2

Go 的 GMP

(第 1 章第 07 节已经详细讲过,这里只对比)

G (goroutine) × 10000
      ↓ Go runtime 调度(用户态,~0.2μs)
M (OS 线程) × N
      ↓ Linux CFS 调度(内核态,~1.5μs)
CPU 核 × 8

关键对比:IO 密集场景

场景:1 万个并发 HTTP 请求,每个请求要等 100ms 的后端响应。

Node.js:

内存: 1 万个回调闭包,每个几 KB → 几十 MB  ✓
CPU:  单线程,几乎不消耗                    ✓
问题: 任何 CPU 密集的任务会阻塞整个事件循环 ✗

Java(传统 BIO):

内存: 1 万个线程 × 1MB 栈 = 10 GB           ✗
CPU:  上下文切换开销大                       ✗

Java(NIO + 异步):

内存: 少量线程 + 回调                        ✓
CPU:  高效                                   ✓
问题: 代码复杂(回调地狱,虽然有 CompletableFuture)  ✗

Go:

内存: 1 万个 goroutine × 2KB 栈 = 20 MB     ✓
CPU:  用户态切换,开销小                     ✓
代码: 同步阻塞的写法,最简单                 ✓

老陈:这就是 Go 的核心优势:用同步的写法,获得异步的性能。

代价是 runtime 复杂。 Go 的调度器、netpoller、栈增长、异步抢占,加起来是几万行代码。

而这些复杂度,Java 把它留给了用户(要写 NIO/Netty),Node.js 把它变成了限制(不能写 CPU 密集代码)。

没有免费的午餐。复杂度要么在 runtime,要么在用户代码,要么变成功能限制。

什么时候 Node.js 的模型更好?

场景:纯 IO 密集,无 CPU 密集任务

Node.js: 单线程,无锁,无上下文切换 → 开销最小
Go:      有调度器,有栈拷贝,有 GC  → 开销略大

实测:纯 IO 转发(比如 API Gateway),Node.js 的吞吐率可以比 Go 高 10-30%。

但场景一变就完蛋:

// 一个不小心,整个服务卡死
app.get('/hash', (req, res) => {
    // CPU 密集的哈希计算,耗时 100ms
    const h = crypto.pbkdf2Sync(req.query.pw, salt, 100000, 64, 'sha512');
    res.send(h);
});
 
// 这 100ms 内,所有其他请求都被阻塞!
// QPS 直接降到 10

Go 的对应代码完全没问题——goroutine 阻塞了,调度器会跑别的。

老陈:这就是”约束”的代价。

Node.js 的单线程模型给你了”无锁”的简洁,但也给了你”不能阻塞”的紧箍咒。一旦违反,代价是整个服务停摆。

Go 的模型给你了”随便写”的自由,代价是 runtime 要在背后帮你擦屁股。

判断标准很简单:你的服务有没有 CPU 密集的任务?

  • 有 → 用 Go
  • 没有,且确定未来也没有 → Node.js 也不错

一个综合案例:为什么 Go 的 HTTP 服务”开箱即用”性能好

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// 这里可以随便写,包括阻塞的 IO
		data, err := db.Query("SELECT ...")
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		w.Write(data)
	})
	http.ListenAndServe(":8080", nil)
}

这段代码能轻松支撑几万并发。为什么?

四个层面的协作:

① netpoller 层
   · 每个连接注册到 epoll
   · 有数据到达时,唤醒对应的 goroutine
   · 不需要"一个连接一个线程"

② goroutine 层
   · 每个请求一个 goroutine
   · 阻塞在 DB 查询上?没关系,runtime 调度别的 goroutine
   · 2KB 栈,1 万个并发只要 20MB

③ OS 线程层
   · 默认 GOMAXPROCS = CPU 核数
   · 线程数 == 核数,上下文切换开销最小

④ GC 层
   · 并发标记,STW < 1ms
   · 逃逸分析把临时对象放栈上

对比:如果用 Java 写同样的代码(BIO):

// 每个请求一个线程
server.setExecutor(Executors.newFixedThreadPool(200));
// 200 个线程,第 201 个请求要排队
// 每个线程 1MB 栈 = 200MB

要支撑几万并发,Java 必须改成 NIO(Netty)或者用虚拟线程(Java 21+)。

老陈:这就是为什么 Go 在云原生时代流行。

不是因为 Go 语法好看,也不是因为性能最强,而是因为——它让”正确的并发代码”变成了”最自然的写法”。

这是语言设计的胜利:把复杂留给自己,把简单留给用户。


思考题 ·【理解层】

V8 的隐藏类(Hidden Class)和 Go 的 struct 有什么本质区别?为什么 Go 不需要隐藏类?如果 Go 要支持”运行时给对象加字段”(像 JS 那样),需要什么机制?

参考答案

本质区别:类型信息在什么时候确定

Go structV8 隐藏类
布局确定时机编译期运行时(动态推导)
存储位置编译进二进制的类型描述符运行时创建的 Map 对象
能否改变不能(静态类型)能(加属性就转新的隐藏类)
字段访问编译期算出偏移量,直接 mov查隐藏类 → 拿偏移(有缓存)

Go 为什么不需要隐藏类

因为 Go 是静态类型语言,对象的形状在编译期就完全确定了。

type Point struct {
	X, Y int64
}
 
p := Point{1, 2}
p.X    // 编译器知道 X 在偏移 0,直接生成 movq 0(%rax), %rbx

编译后的机器码就一条指令,没有查找,没有缓存,没有多态。

而 JS 的 obj.x:

obj.x

编译器(Parser)看到这行代码时,完全不知道 obj 是什么。obj 可能是:

  • 一个空对象
  • 一个有 x 属性的对象
  • 一个有 x 和 y 的对象
  • 一个 Proxy
  • 甚至是一个数字(会自动装箱)

所以只能在运行时查。隐藏类就是”运行时推导出来的类型信息”。

这就是第 1 章讲的”把信息提前到编译期几乎总是划算”的又一个例证。

Go 把类型信息放在编译期 → 字段访问是 1 条指令 JS 把类型信息留到运行时 → 字段访问需要隐藏类 + 内联缓存,最坏情况慢 30 倍

如果 Go 要支持运行时加字段

需要什么?至少需要这四样东西:

① 对象头(Object Header)

现在的 Go 对象没有头部——类型信息在类型描述符里,通过指针的”类型位”或者 itab 关联。

如果要支持动态加字段,每个对象必须自带一个指针指向它的”形状描述”:

type DynamicObject struct {
	shape *Shape        // ★ 8 字节开销,每个对象都有
	// 后面是字段数据
}
 
type Shape struct {
	parent   *Shape                 // 转换前的形状(用于隐藏类树)
	props    map[string]int         // 属性名 → 偏移
	size     int
}

代价:每个对象多 8 字节。

② 属性访问的间接层

// 现在
p.X              // 编译成: movq 0(%rax), %rbx
 
// 支持动态后
obj.X            // 编译成:
                 //   1. 从 obj.shape 查 X 的偏移
                 //   2. movq offset(%rax), %rbx
                 // 而且第一步要有缓存(内联缓存)

代价:字段访问从 1 条指令变成 10+ 条指令(即使有缓存)。

③ 内联缓存机制

每个属性访问点都要维护一个缓存:

type InlineCache struct {
	expectedShape *Shape
	offset        int
	hitCount      int
	// 多态时是个小数组
}

代价:代码体积变大,且缓存本身要占用内存。

④ 多态的退化处理

当同一个访问点见过太多种形状,缓存失效,退化成字典查找。

代价:性能不可预测——同样的代码,数据不同,性能差 30 倍。


综合评估

如果要给 Go 加动态字段,代价是:

内存:      每个对象 +8 字节(对象头)
访问速度:   字段访问慢 10-30 倍(即使有 IC)
代码体积:   每个属性访问点多几十字节
可预测性:   严重下降(多态退化)
GC 复杂度:  扫描对象要先读 shape,才能知道哪些是指针
编译器:     无法做逃逸分析、无法内联

这个代价,Go 团队绝对不会接受。

因为 Go 的核心价值主张就是”简单 + 高性能 + 可预测”。动态字段会同时破坏这三点。


一个有趣的反例:Go 的 map 就是”动态对象”

m := map[string]any{}
m["x"] = 1        // 动态加字段
m["y"] = 2
fmt.Println(m["x"])

Go 支持动态,但用了一个独立的类型 map 来表达,而不是让 struct 变成动态的。

这是一个非常聪明的设计:

struct: 静态、快、零开销        ← 默认选择,覆盖 95% 场景
map:    动态、慢、有开销        ← 需要动态时显式使用

★ 你付出什么代价,由你自己选择
★ 而不是"所有对象都默认承担动态性的开销"

这体现了 Go 的一个核心设计原则:让代价可见。

对比 JS:所有对象默认都是动态的,你无法选择”我这个对象不需要动态性”。


延伸:TypeScript 的做法

TypeScript 的思路很有意思:

interface Point {
    x: number;
    y: number;
}
 
let p: Point = {x: 1, y: 2};
p.x = 3;        // OK
p.z = 4;        // 编译错误!

TypeScript 在编译期强制了形状,但生成的代码还是 JS(运行时还是可能有多态)。

效果:

  • 编译期:类型检查,catch 掉大部分错误
  • 运行时:因为大家都遵守类型,V8 的隐藏类很少退化成超态 → 性能好

这是一个”用编译期约束改善运行时性能”的巧妙做法。

TypeScript 没有改变 JS 的运行时,但它通过约束程序员的写法,让 JIT 更容易优化。

这个思路值得记住:有时候,“限制用户”比”优化系统”更有效。


小结:这一节你应该带走的东西

  1. 三个运行时的取舍各不相同:JVM 要通用(多种 GC 可选),V8 要在动态语言中做到最快,Go 要低延迟和确定性。

  2. Go 不分代不是做不到,是认为不值得:Go 大量用栈分配(逃逸分析),堆上对象生命周期长,分代假说收益小;而分代会增加写屏障开销和 Full GC 停顿。

  3. JIT 的核心优势是可以”赌”:基于运行时信息做激进优化,赌错了可以去优化。AOT 不能赌,但 Go 用 PGO 把这个优化挪到了构建时。

  4. V8 的隐藏类是”运行时推导的类型信息”,内联缓存是为了让单态情况尽可能快。多态退化到超态会慢 10-30 倍。

  5. Go 的 itab 是”编译期确定的动态分发”,总是 O(1),没有最坏情况。这是静态类型的优势。

  6. 并发模型的复杂度要么在 runtime(Go),要么在用户代码(Java NIO),要么变成功能限制(Node.js 不能阻塞)。


下一节

02 · 动手:用 Go 写一个 GC

理论讲完了。现在我们要亲手实现一个 GC——从最简单的标记清除,到三色标记,到并发 GC 和混合写屏障。

老陈的预告:写完之后你会明白,为什么 Go 的写屏障必须是”混合”的。这不是设计者的偏好,是被”Go 的堆不分代”和”GC 要并发”这两个约束逼出来的唯一解。