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-2010 | Codegen(直接编译成机器码)+ Crankshaft(优化编译器) | Codegen 生成的机器码没有类型信息,Crankshaft 常常要去优化 |
| 2010-2016 | Full-Codegen + Crankshaft + TurboFan | 三层,复杂 |
| 2016-2021 | Ignition(解释器)+ 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 的理由:
- 启动速度:容器、Serverless 场景下,冷启动是核心指标。JIT 的预热时间是纯浪费。
- 可预测性:AOT 的性能是确定的,没有”预热期性能差”的问题。在线服务最怕不可预测。
- 部署简单:没有 JIT 意味着不需要在运行时保留编译器,二进制更小,内存占用更少。
- 安全性: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 vtable | V8 隐藏类+IC | Go itab | |
|---|---|---|---|
| 解决的问题 | 虚方法分发 | 动态属性访问 | 接口方法分发 |
| 查找方式 | vtable[槽位] | 隐藏类 → 偏移(缓存) | itab.fun[槽位] |
| 多态支持 | 天然(每个类自己的 vtable) | IC 状态机(单态→多态→超态) | 天然(每个 (接口,类型) 一个 itab) |
| 最坏情况 | 接口调用(查 itable) | 超态,字典查找(10-30x 慢) | 无(总是 O(1)) |
| 内存开销 | 每对象 8-16B 头 + 每类一张 vtable | 每形状一个隐藏类 | 接口值 16B + 每个组合一个 itab |
老陈:注意一个关键差异:
JVM 和 Go 的多态是”设计出来的” —— 你写代码时就知道这是个虚方法/接口方法。
V8 的多态是”意外产生的” —— 你写
obj.x时没想过会有多种形状,但运行时可能真的有多种。这就是为什么 V8 需要内联缓存这个机制:它要在”你不知道会有多种形状”的情况下,尽可能让单态的情况快。
这是一个重要的洞察:动态语言的运行时,必须为”程序员可能写出任何东西”付出代价。 静态语言可以把这个代价转移到编译期。
维度四:并发模型
三种并发哲学
| JVM | V8/Node.js | Go | |
|---|---|---|---|
| 单位 | 线程(1:1 映射 OS 线程) | 单线程 + 事件循环 | goroutine(M:N) |
| 共享 | 共享内存 | 不共享(单线程) | 通过 channel 通信 |
| 同步 | 锁、volatile、CAS | 不需要(单线程) | channel、sync 包 |
| 阻塞处理 | 线程阻塞 | 回调/Promise/async | runtime 自动切换 |
| 心智负担 | 高(死锁、竞态) | 中(回调地狱 → 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 直接降到 10Go 的对应代码完全没问题——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 struct | V8 隐藏类 | |
|---|---|---|
| 布局确定时机 | 编译期 | 运行时(动态推导) |
| 存储位置 | 编译进二进制的类型描述符 | 运行时创建的 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 更容易优化。
这个思路值得记住:有时候,“限制用户”比”优化系统”更有效。
小结:这一节你应该带走的东西
-
三个运行时的取舍各不相同:JVM 要通用(多种 GC 可选),V8 要在动态语言中做到最快,Go 要低延迟和确定性。
-
Go 不分代不是做不到,是认为不值得:Go 大量用栈分配(逃逸分析),堆上对象生命周期长,分代假说收益小;而分代会增加写屏障开销和 Full GC 停顿。
-
JIT 的核心优势是可以”赌”:基于运行时信息做激进优化,赌错了可以去优化。AOT 不能赌,但 Go 用 PGO 把这个优化挪到了构建时。
-
V8 的隐藏类是”运行时推导的类型信息”,内联缓存是为了让单态情况尽可能快。多态退化到超态会慢 10-30 倍。
-
Go 的 itab 是”编译期确定的动态分发”,总是 O(1),没有最坏情况。这是静态类型的优势。
-
并发模型的复杂度要么在 runtime(Go),要么在用户代码(Java NIO),要么变成功能限制(Node.js 不能阻塞)。
下一节
理论讲完了。现在我们要亲手实现一个 GC——从最简单的标记清除,到三色标记,到并发 GC 和混合写屏障。
老陈的预告:写完之后你会明白,为什么 Go 的写屏障必须是”混合”的。这不是设计者的偏好,是被”Go 的堆不分代”和”GC 要并发”这两个约束逼出来的唯一解。