给自己的 JavaScript 解释器写个简易垃圾回收——实现篇

192 阅读11分钟

前情提要:之前我趁着春节前的空闲,从零写了一个支持 ES5 语法的 JavaScript 解释器,名字叫 es:

zhuzilin/es

之前写了篇文章介绍了一下垃圾回收的基础知识,有兴趣的朋友可以看这里:

朱小霖:给自己的 JavaScript 解释器写个简易垃圾回收——设计篇

在那篇文章中我提到,为了尝试一下最经典的设计,我选择从复制垃圾回收开始,等 gc 成为性能瓶颈的时候再逐步升级为分代垃圾回收。所以本文主要介绍一下该如何实现复制垃圾回收,以及相关的一些 JavaScript engine 优化。提醒,前方大量代码出没!

Cheney 算法

复制垃圾回收的一个最经典的算法就是 Cheney 算法,V8 的新生代部分也是基于它的。Cheney 算法也是基于复制垃圾回收的大框架的,也就是:

  • 整个堆空间平分为 2 部分,from space 和 to space。分配的过程中只从 to space 里面分配;
  • to space 满了的时候触发垃圾回收,会从一些根节点(root)开始搜索,把所有还能被引用到的对象复制到 from space 去;
  • 清空 to space,并把 from space 和 to space 交换一下。

Cheney 算法的特殊之处在于利用这些分配都是连续的的特点,在从根节点进行搜索的时候不需要单独分配一个栈结构,而是只需要移动指针就好了。具体的伪代码就不在这里贴出来了。

从零开始的垃圾回收

介绍(重温)了一下复制垃圾回收之后,我们来尝试从一个最基础的版本开始,一步一步地展现一下一些需要注意的要素。

我们来拿一个简化版的 JavaScript 类型举例:

上图是 JavaScript 类型系统的一个子集,就是说基本类型有数字,字符串和对象,对象中把数组对象和普通区分开。那么根据上图中的继承关系,可以写出来如下的代码,我们来看看如果要加入垃圾回收,这段代码要做哪些修改。

class JSValue {
 public:
  JSValue(Type t) : type(t) {}
  Type type;
};
​
class Number : public JSValue {
 public:
  Number(double n) : JSValue(JS_NUMBER) num(n) {}
  double num;
};
​
class String : public JSValue {
 public:
  String(std::string s) : JSValue(JS_STRING), str(s) {}
  std::string str;
};
​
class JSObject : public JSValue {
 public:
  JSObject(ObjType t) : JSValue(JS_OBJECT), obj_type(t) {}
  virtual JSValue* Get(JSValue* P);          // Get property P from the object
  virtual void Put(JSValue* P, JSValue* V);  // Set value of property P to V
  ObjType obj_type;
  std::unordered_map<std::string, JSValue*> properties;
};
​
class Array : public JSObject {
 public:
  Array() : JSObject(OBJ_ARRAY) {
    Put(new String("length"), new Number(0));
  }
  JSValue* Get(JSValue* P) override;          // Get property P from the object
  void Put(JSValue* P, JSValue* V) override;  // Set value of property P to V
}

注:数组需要自己的 Get 和 Put,因为添加或修改数组中的元素还会间接调整数组的 length 属性。

在堆上初始化对象

在加入垃圾回收之前,我们需要把所有 JSValue 都放在堆上,类似于:

Number* a = static_cast<Number*>(Heap::Global()->alloc(sizeof(Number)));
a->num = 1;

这样的一种转变就带来了 2 个问题:

  1. 无法初始化 stl 容器,因为我们不知道该怎么给他们分配初始内存;
  2. 无法初始化对象的虚指针(vptr)。

那么对于第一个问题,解决的方法就是要自己实现容器,例如 JSObject 会被调整成:

class JSObject : public JSValue {
  // ...
  HashMap* properties;
};

对于 String 来说,由于 JavaScript 的字符串是不可变的(immutable),可以直接把字符串拷贝到函数类里面,也就是:

String* New(std::string s) {
  void* mem = Heap::Global()->alloc(sizeof(size_t) + s.size());
  // 分配内存的第一个位置保存字符串的长度
  static_cast<size_t*>(mem)[0] = s.size();
  // 后面的部分保存字符串的内容
  memcpy(static_cast<char*>(mem) + sizeof(size_t), s.data(), s.size(); 
  return static_cast<String*>(mem);
}

注意,由于我们要直接操作内存,所以就没法用普通的构造函数了,也就转成了上面的静态 New 函数。

第二个问题则更棘手一点,因为我们不知道 vptr 的值,也就没法通过手工赋值的方式来初始化它。这个时候,我们可以使用 C++ 的一个语言特性,placement new。

placement new 允许我们给 new 运算符传一些参数,让它利用这些参数来进行对象的初始化,例如在我们的场景下,我们只需要给 JSValue 定义一个这样的重载:

void* operator new(void* mem) {
  return mem;
}

placement new 就会在我们传入一段内存作为参数的时候,拿这段内存进行初始化,而不是单独从别的地方分配一段内存了,使用方法大概是:

void* mem = malloc(...);
new (mem) Array();

有了 placement new 的帮助,我们只需要在分配内存的时候,给 vptr 留出空间,就可以让 new 来帮我们进行初始化了。

经过这样两处调整,我们的代码就变成了这样:

#define PTR(ptr, offset) \
  reinterpret_cast<char*>(ptr) + offset
#define SET_VALUE(ptr, offset, val, T) \
  *reinterpret_cast<T*>(PTR(ptr, offset)) = val;class JSValue {
 public:
  static JSValue* New(Type t, size_t size) {
    void* mem = Heap::Global()->alloc(kJSValueSize + size);
    SET_VALUE(mem, sizeof(void*), t, Type);
    return static_cast<JSValue*>(mem);
  }
  void* new(void* mem) { return mem; }
  static constexpr size_t kJSValueSize = sizeof(void*) + sizeof(Type);
};
​
class Number : public JSValue {
 public:
  static Number* New(double n) {
    JSValue* jsval = JSValue::New(JS_NUMBER, sizeof(double));
    SET_VALUE(jsval, kJSValueSize, n, double);
    return static_cast<Number*>(jsval);
  }
};
​
class String : public JSValue {
 public:
  String* New(std::string s) {
    JSValue* jsval = JSValue::New(JS_STRING, sizeof(size_t) + s.size());
    SET_VALUE(jsval, kJSValueSize, s.size(), size_t);
    memcpy(PTR(jsval, kJSValueSize + sizeof(size_t)), s.data(), s.size()); 
    return static_cast<String*>(jsval);
  }
};
​
class JSObject : public JSValue {
 public:
  static JSObject* New(ObjType t) {
    JSValue* jsval = JSValue::New(JS_OBJECT, sizeof(ObjType) + sizeof(void*));
    HashMap* properties = HashMap::New();
    SET_VALUE(jsval, kJSValueSize, t, ObjType);
    SET_VALUE(jsval, kJSValueSize + sizeof(ObjType), properties, HashMap*);
    return new (jsval) JSObject();
  }
  virtual JSValue* Get(JSValue* P);          // Get property P from the object
  virtual void Put(JSValue* P, JSValue* V);  // Set value of property P to V
};
​
class Array : public JSObject {
 public:
  static Array* New() {
    JSObject* jsobj = JSObject::New(OBJ_ARRAY);
    Put(new String("length"), new Number(0));
    return new (jsobj) Array();
  }
  JSValue* Get(JSValue* P) override;          // Get property P from the object
  void Put(JSValue* P, JSValue* V) override;  // Set value of property P to V
}

拜拜,指针

把所有的值都移到堆上之后,很快,我们会发现上面的实现有个问题。就是到处都使用的是指针。由于我们选用的是 moving-gc,所以垃圾回收之后指针指向的值就不是对应的位置了。

为了解决这个问题,V8 提出了一个叫 Handle 的东西,它并不保存指针,而是保存指针的指针,实现类似于:

template<typename T>
class Handle {
 public:
  explicit Handle(T* value) {
    ptr_ = reinterpret_cast<T**>(HandleScope::Add(value));
  }

  template<typename S>
  Handle(Handle<S> base) {
    ptr_ = reinterpret_cast<T**>(base.ptr_);
  }

  T* val() {
    return *reinterpret_cast<T**>(ptr_);
  }

 private:
  T** ptr_;
};

这里调用的 HandleScope::Add 里面会把 value 保存起来,并返回 value 的指针。有了 Handle 的帮助,使得我们只需要在 gc 后更新一下 ptr_ 里面存储的值,就可以让 handle.val() 一直都指向正确的位置了。

因此我们需要把所有使用指针的地方都换成 Handle<T> 。

除了代码中显式使用的指针之外,还有一个使用指针的地方很容易被遗漏,那就是方法里的 this,例如:

Handle<Array> arr;
arr.val()->Put(P, V);

在 Put 的过程中,有可能需要分配新内存,也就有可能会触发 gc,那么在 gc 之后,Put 方法里面的 this 指针就不再指向正确的位置了。为了解决这个问题,我们需要把这些可能会触发 gc 的方法都改成全局函数,类似于:

void Put(Handle<JSObject> O, Handle<JSValue> P, Handle<JSValue> V) {
  if (O.val()->obj_type() == JSObject::OBJ_ARRAY) {
    ...
  } else {
    ...
  }
}

经过这样的修改,我们的代码就变成了:

#define PTR(ptr, offset) \
  reinterpret_cast<char*>(ptr) + offset
#define SET_VALUE(ptr, offset, val, T) \
  *reinterpret_cast<T*>(PTR(ptr, offset)) = val;class JSValue {
 public:
  static Handle<JSValue> New(Type t, size_t size) {
    void* mem = Heap::Global()->alloc(kJSValueSize + size);
    SET_VALUE(mem, sizeof(void*), t, Type);
    return Handle<JSValue>(mem);
  }
  void* new(void* mem) { return mem; }
  static constexpr size_t kJSValueSize = sizeof(void*) + sizeof(Type);
};
​
class Number : public JSValue {
 public:
  static Handle<Number> New(double n) {
    Handle<JSValue> jsval = JSValue::New(JS_NUMBER, sizeof(double));
    SET_VALUE(jsval.val(), kJSValueSize, n, double);
    return static_cast<Handle<Number>>(jsval);
  }
};
​
class String : public JSValue {
 public:
  String* New(std::string s) {
    Handle<JSValue> jsval = JSValue::New(JS_STRING, sizeof(size_t) + s.size());
    SET_VALUE(jsval.val(), kJSValueSize, s.size(), size_t);
    memcpy(PTR(jsval.val(), kJSValueSize + sizeof(size_t)), s.data(), s.size()); 
    return static_cast<Handle<String>>(jsval);
  }
};
​
class JSObject : public JSValue {
 public:
  static Handle<JSObject> New(ObjType t) {
    Handle<JSValue> jsval = JSValue::New(JS_OBJECT, sizeof(ObjType) + sizeof(void*));
    Handle<HashMap> properties = HashMap::New();
    SET_VALUE(jsval.val(), kJSValueSize, t, ObjType);
    SET_HANDLE_VALUE(jsval, kJSValueSize + sizeof(ObjType), properties, HashMap);
    new (jsval.val()) JSObject()
    return Handle<JSObject>(jsval.val());
  }
};
​
class Array : public JSObject {
 public:
  static Handle<Array> New() {
    JSObject* jsobj = JSObject::New(OBJ_ARRAY);
    Put(new String("length"), new Number(0));
    new (jsobj.val()) Array();
    return Handle<Array>(jsobj);
  }
}

Handle<JSValue> Get(Handle<JSObject> O, Handle<JSValue> P);
void Put(Handle<JSObject> O, Handle<JSValue> P, Handle<JSValue> V);

根节点有哪些?

进行了上述的重构之后,我们的代码已经可以抵御 gc 中内存拷贝带来的影响了。在实现 Cheney 算法的时候,还有一个小细节需要考虑好,那就是哪些值是需要作为垃圾回收的根节点的。从 JavaScript 语言的角度,有两种很明显的根节点:

  1. Global Object;
  2. 调用栈中存储的对象(spec 中称为 environment record 和 lexical environment)。

在具体实现的时候,还有需要注意保存运算中的中间结果,例如在运行下面的代码的时候:

a = []
a.push(1 + 2, 3 + 4)

假如在计算 3 + 4 的时候触发了 gc,那么需要注意保存好 1 + 2 的结果。

我是采用类似 V8 中的 HandleScope 类的实现来解决的:在每个语句(statement)运行过程中,都要临时保存一下语句中的中间结果,在 gc 的时候,也要注意把它们加入根节点。

一些反思

上文是我觉得在实现复制垃圾回收中几个比较重要的点。那么这里再来聊聊在实现过程中的一些别的小收获。

先说说 es 的现状。目前 es 已经通过了 test262(tc39 官方的测试)中大部分 es5 测试。至于性能,我使用了 Google Octane 测试中的 typescript 测试进行了 benchmark,这个测试的大致的内容是用一个基于 javascript 的 typescript 转移器,将一个用 typescript 实现的 typescript 转移器转成 javascript。目前 es 的数据如下:

JavaScript 引擎全部时间/s垃圾回收时间/s
es v0.0.5 (copy collection)86.311.1
es v0.0.5 (mark and compilation)91.89.7
V8 v9.4.146.24-node.201.74

速度上大约比 V8 慢 50 倍左右,也就是 CPython 的水平吧(笑)。可以看到我还简单地实现了一个 mark and compilation 垃圾回收。由于 m&c 可以使用全部的堆栈,所以垃圾回收的参数相对少一些,从而使得其垃圾回收需要的时间更少。复制垃圾回收的整体实现更少可能是因为复制垃圾回收的分配的变量是连续的,对 cache 更友好一些。不过朴素的 m&c 在一些经常要区分非常长的字符串的时候会出现一些问题,所以我还是会默认使用拷贝垃圾回收。

我还尝试了一些比较经典的解释器优化方法:

  1. 一个是 tagged pointer,也就是考虑到堆上指针地址是 8 对齐的,所以可以利用空出来的几位来把一些比较短小的变量存在指针里而不再分配到堆上,比较详细的介绍可以看 R 大的帖子
  2. 区分 array index properties 和 named properties。在 spec 中,所有的属性都是转为字符串存储的,一个优化方法是把所有的整数下标的属性单独保存,有利于一些数组操作,有兴趣的朋友可以看一些这篇 V8 的官方博客
  3. 隐藏类(hidden classes),也就是单独保存所有对象的形状,有利于做 inline caching,这篇文章讲的特别好:mathiasbynens.be/notes/shape…

不过很遗憾,tagged pointer 虽然可以明显降低内存需求量,从而让垃圾回收的时间降低 20% 左右,但是由于加入了很多特异化的判断,我的实现中整体执行速度反而慢了很多,让我理解了 JSC 博文中提到的,JavaScript 执行得很慢,所以不要太追求 gc 速度的意思;我也尝试简单地区分了数组下标,但是可能因为这个 benchmark 中数组操作并不多,所以没有明显的性能提升;隐藏类应该是需要配合字节码才能达到效果的,在现在 AST 解释器的阶段,最后也还是会转为哈希表的查询。因为没有啥比较明显的提升,考虑到奥卡姆剃刀,这些优化最后都被我删去了。

另外,我又一次体验到了系统设计中没有免费的午餐的含义,复制垃圾回收虽然看起来很简洁高效,但是为了实现内存的复制,不得不引入了一系列配合的机制(例如 Handle),并且也影响了代码的可维护性(各种直接往内存 offset 上写数据写得我害怕...)。所以我也慢慢理解了 QuickJS 中选择引用计数的原因,因为虽然引用计数可能不是最快的 gc,但是会好维护很多。

暂时想不到啥比较有效的提升运行速度的方法了,所以我计划开始研究如何引入字节码了。欢迎大家来讨论字节码的设计,也超级希望能推荐一些相关的资料或者书籍~ 也欢迎任何对 VM 设计有兴趣的朋友来 es 的 github 或者是知乎找我讨论,王婆卖瓜地说,我觉得我的代码质量还可以,应该比较好懂~

感谢你能读到这里~ 最后习惯性地要个 star!

github.com/zhuzilin/es…