最近一直在死磕我的 C++ 虚拟机项目 cilly-vm-cpp 的垃圾回收(GC)模块。为了解决 Stop-The-World (STW) 带来的卡顿问题,我从最开始的单线程 Mark-Sweep,一步步优化到了现在的并行标记 (Parallel Marking) + 并发清除 (Concurrent Sweep)。
本来以为这个架构对于一个学习型项目来说已经挺能打了,直到我为了寻找进一步优化的灵感,去啃了字节跳动开源的 PrimJS(Lynx 底层的 JS 引擎)的源码。这一对比,直接让我看到了"学院派实现"和"工业级引擎"之间的鸿沟。
这篇文章既是我的学习笔记,也是一次深度的架构复盘。我将详细拆解我的实现方案,深入解析 PrimJS 的核心架构,通过详细的对比分析,聊聊我准备如何改进我的 VM。
一、 我的 GC 是怎么实现的?
我的核心目标很简单:别让主线程停太久。为了达成这个目标,我将 GC 拆解为两个阶段,并分别做了并发优化:
1. 并行标记 (Parallel Marking)
在"标记阶段",我们需要遍历整个对象图,找出所有活着的对象。为了利用多核 CPU,我引入了多线程标记:
- CAS 抢占 :给每个
GcObject加了一个std::atomic<bool>标记位。多个线程同时看到同一个对象时,利用 CAS (Compare-And-Swap) 保证只有一个线程能成功标记并将其入栈,有效防止了重复处理。 - 全局任务栈 :使用一个
vector<GcObject*> global_stack作为任务池。所有线程发现新对象(子节点)后,都往这个大栈里扔;没活干了,也都从这里拿。 - 锁保护 :因为大家都访问同一个栈,我必须加一把
mutex锁,保证数据安全。
2. 并发清除 (Concurrent Sweep)
在"清除阶段",如果有几万个死对象需要释放,主线程逐个 delete 会造成显著卡顿。
- 实现细节 :
- 主线程只做一件事:快速遍历链表,把死对象从链表上"摘"下来(Unlink),放到一个临时列表里。
- 然后启动一个后台线程 (
std::thread(...).detach()),把这个列表移交给它。 - 主线程立刻返回继续跑业务代码,后台线程在另一边慢慢
delete这些垃圾。
效果:通过这两步改造,主线程几乎瞬间恢复响应,STW 时间被压缩到了极致。
二、 工业级的 PrimJS 是怎么玩的?(源码深度解析)
PrimJS 是一个基于 QuickJS 深度魔改的高性能 JS 引擎,专为解决移动端的性能瓶颈而生。它的 GC 设计非常硬核,其核心思想是**"极致的性能与掌控"**------它甚至不信任系统的 malloc,而是自己接管了一切。
1. 核心架构设计
- 兼容性内存管理 (Compatible MM) :PrimJS 不仅管理 JS 对象,还接管了底层的内存分配。它实现了一个自定义的内存分配器(基于
dlmalloc的变种),让 GC 能直接操作内存块 (Chunk),而不是仅仅操作对象指针。 - 全并行/并发架构 :
- Parallel Marking:多线程并行标记。
- Parallel Sweeping:多线程并行清除(比我的更进一步,直接操作内存段)。
- Parallel Free List Rebuilding:多线程重建空闲链表,为下一次分配做准备。
2. 关键组件与实现细节
我在 docs/reference 中深入研究了它的源码,梳理出了以下几个关键组件:
A. 收集器 (Collector) ------ 总指挥
负责协调 GC 的全流程:
MarkLiveObjects(): 启动多线程标记活对象。SweepDeadObjects(): 启动多线程清除死对象。 同时包含了大量用于调试内存泄漏和性能分析的工具宏 (ENABLE_GC_DEBUG_TOOLS)。
B. 访问者 (Visitor) ------ 遍历引擎
负责遍历对象图,核心优化在于:
- 多线程队列 :
Queue *queue[THREAD_NUM]。每个线程有一个独立的任务队列 。这与我的全局栈形成了鲜明对比,配合工作窃取 (Work Stealing) 算法,大幅减少了锁竞争。 - 类型分发 :通过
ALLOC_TAG(分配标签)来区分对象类型(如JSObject,JSString),然后通过 switch-case 调用不同的 Visit 函数。
C. 清扫器 (Sweeper) ------ 最精彩的部分
这是 PrimJS 与普通 GC 最大的区别所在:
- 直接操作内存页 (Segments) :它不像普通 GC 那样遍历"对象链表",而是直接遍历底层的内存段 。通过地址计算直接找到 Chunk Header,判断
cinuse(是否在使用) 和is_marked(是否标记)。 - 并行清除 (Parallel Sweep) :
parallel_traverse_heap_segment函数将堆内存切分成多个段,分发给线程池 (ByteThreadPool) 并行处理。我的 Sweep 是主线程摘链表+后台单线程释放,而 PrimJS 是多线程推土机式地平推内存页。 - 重建 FreeList :GC 后,它会并行地将回收的内存块重新挂载到
freelist上,供下一次malloc使用。
三、 差距分析 (Gap Analysis)
通过详细对比,我整理了如下表格,清晰地展示了 cilly-vm-cpp 与 PrimJS 的技术差距:
| 特性 | 你的 cilly-vm-cpp | PrimJS | 评价 |
|---|---|---|---|
| 内存管理 | 依赖 C++ new/delete |
自研分配器 (dlmalloc 魔改) |
PrimJS 更底层,能控制内存布局,减少碎片,但实现难度极高,且跨平台维护成本巨大。 |
| 标记算法 | 全局栈 + 互斥锁 | 本地队列 + 工作窃取 | PrimJS 的扩展性更好。全局锁在核心数增多时会成为瓶颈,而本地队列能实现线性扩展。 |
| 清除算法 | 主线程摘除 + 后台单线程 delete | 多线程并行遍历内存段 | PrimJS 吞吐量更大,适合 GB 级超大堆;我的方案适合中小型堆,实现简单且有效。 |
| 对象识别 | C++ 虚函数 (Trace) |
AllocTag + switch-case |
虚函数有间接调用开销;Tag 分发极快,但代码耦合度极高,每加一个类型都要改核心代码。 |
| 线程模型 | 临时创建/销毁 (Fork-Join) | 常驻线程池 (Thread Pool) | 临时创建线程有昂贵的系统调用开销和冷启动问题;线程池响应极快。 |
| Finalizer | C++ 析构函数 | 显式 Finalizer 类 |
C++ 析构函数符合 RAII 惯例;PrimJS 需要手动管理资源生命周期,更复杂但更灵活。 |
四、 下一步改进计划 (Action Plan)
看完 PrimJS 的源码,我决定对我的 VM 进行"取其精华,去其糟粕"的升级。我不会盲目照搬,而是选择最适合我这个学习型项目的优化路线。
1. 必须做 (Must Do):引入线程池 (Thread Pool)
我目前的做法是每次 GC 都 new thread,结束后 join。这简直是把线程当"日结临时工"用。 改进:实现一个简单的线程池。VM 启动时创建好 Worker 线程,平时挂起(Wait),GC 时唤醒(Notify)。这将消除线程创建销毁的系统开销,显著提升响应速度。
2. 必须做 (Must Do):实现本地队列与工作窃取
全局锁是我目前最大的性能瓶颈。 改进 :拆掉 global_work_stack_,换成 vector<GcObject*> local_queues[THREAD_NUM]。
- Push/Pop 无锁化:线程优先操作自己的队列,完全不需要加锁。
- Work Stealing:只有当自己队列空了,才去尝试窃取其他线程的任务。 这是提升并行效率的关键一步。
3. 保留 (Keep):坚持用虚函数 Trace
PrimJS 用 switch-case 是为了榨干最后 1% 的 CPU 性能,牺牲了代码的可维护性。 决策 :我选择保留虚函数。作为一个学习型项目,代码的清晰、优雅和可扩展性更重要。我不想为了那一点点性能,把代码写成一坨难以维护的 switch-case。
4. 学习但暂不落地 (Learn):内存分配器
PrimJS 直接操作内存页的设计非常惊艳,但这属于"屠龙技"。 决策 :我现在学到了它的思想(内存连续性、缓存局部性),但暂时不去碰 malloc 魔改。除非哪天我真的想挑战 OS 级别的开发,否则 new/delete 配合后台释放对于当前量级的 VM 来说已经足够快了。
做系统开发就是这样,没有绝对最好的架构,只有最适合的 Trade-off。PrimJS 选择了极致的掌控,而 cilly-vm-cpp 选择了适度的性能与优秀的架构。通过这次深度复盘,我不仅看清了差距,更明确了进化的方向。下一步,开始搓线程池!