本文系原创,转载请注明出处~
Js的对象在V8引擎的堆中创建,V8会自动回收不被引用的对象。采用这种方式,降低了内存管理的负担,但也造成了一些不便,例如V8堆内存大小的限制。在32位系统上限制为0.7GB,64位为1.4GB。之所以存在这种限制,根源在于垃圾回收算法的限制。V8在执行垃圾回收的时候会阻塞Js代码的运行,堆内存过大导致回收算法执行变慢,表现在浏览器端,就是网页假死。鱼和熊掌不可兼得,有垃圾回收的地方,都会存在堆大小的限制,Java也存在堆溢出的错误。
宏观来看,V8的堆分为三部分,分别是 年轻分代,年老分代,大对象空间。这三者保存不同种类的对象。在V8启动时,年轻分代和年老分代的最大大小就被确定下来,并且不能再改变,如果运行中内存分配超过限制,会引起进程异常退出。当然,在Node启动时,可以通过参数改变默认大小,但这会影响到V8的垃圾回收,有许多方法可以解决堆内存的限制,此手段不应该被使用。
年轻分代
年轻分代的堆空间一分为二,只有一个处于使用中,另外的一半儿用于垃圾清理。年轻分代主要保存那些生命期短暂的对象,例如函数中的局部变量。它们类似于C++中在栈上分配的对象,当函数返回,调用栈中的局部变量都会被析构掉。
class Person{
public:
Person(){
age_ = 100;
name_ = "hello";
}
void Print(){
std::cout<<name_<<" "<<age_<<"\n";
}
std::string name_;
int age_;
};
void Print(){
Person p;
p.Print();
}
以上C++代码,当函数Print调用返回的时候,随着线程栈顶的回退,内部定义的Person对象占用的空间自然被回收。类似的过程,写成Js代码运行,当Print函数执行完之后,new 出来的Person还存在于年轻分代堆内存中,虽然已经不被任何对象和变量引用,但不会立即回收。这样做是为了提升效率,V8了解内存的使用情况,当发现内存空间不够需要清理时,才进行回收。具体步骤是,将还被引用的对象拷贝到另一半的区域,然后释放当前一半的空间,把当前被释放的空间留作备用,两者角色互换。年轻分代类似于线程的栈空间,本身不会太大,占用它空间的对象类似于C++中的局部对象,生命周期非常短,因此大部分都是需要被清理掉的,需要拷贝的对象极少,虽然牺牲了部分内存,但速度极快。
function Person(name, age){
this.name = name
this.age = age
this.Print(){
console.log(this.name + this.age)
}
}
function Print(){
var p = new Person('hello', 12)
p.Print()
}
在C++程序中,当调用一个函数时,函数内部定义的局部对象会占用栈空间,但函数嵌套总是有限的,随着函数调用的结束,栈空间也被释放调。因此其执行过程中,栈犹如一个伸长缩短的望远镜头。而Js代码的执行,因为对象是在年轻分代堆中分配空间,当要在堆中分配内存时,如果内存不够,由于新对象的挤压,要将垃圾对象清除出去,这个过程犹如在玩儿一种消除类游戏。
年老分代
年老分代中的对象类似于C++中使用new操作符在堆中分配的对象。因为这类对象一般不会因为函数的退出而销毁,因此生命期较长。年老分代的大小远大于年轻分代。主要包含如下数据: 从年轻分代中移动过来的对象 JIT之后产生的代码 全局对象 年老分代的内存要大许多64位为1.4GB,32位为700MB,如果采用年轻分代一样的清理算法,浪费一般空间不说,复制大块对象在时间上也难以忍受,因此必须采用新的方式。V8采用了标记清除和标记整理的算法。其思路是将垃圾回收分为两个过程,标记清除阶段遍历堆中的所有对象,标记活着的对象,然后清除垃圾对象。因为年老分代中需要回收的对象比例极小,所以效率较高。 当执行完一次标记清除后,堆内存变得不连续,内存碎片的存在使得不能有效使用内存。在后续的执行中,当遇到没有一块碎片内存能够满足申请对象需要的内存空间时,将会触发V8执行标记整理算法。 标记整理移动对象,紧缩V8的堆空间,将碎片的内存整理为大块内存。实际上,V8执行这些算法的时候,并不是一次性做完,而是走走停停,因为垃圾回收会阻塞Js代码的运行,所以采取交替运行的方式,有效的减少了垃圾回收给程序造成的最大停顿时间。
大对象空间
大对象空间主要存放需要较大内存的对象,也包括数据和JIT生成的代码。垃圾回收不会移动大对象,这部分内存使用的特点是,整块分配,一次性整块回收。
有效使用内存
Js的对象在V8引擎的堆中创建,V8会自动回收不被引用的对象,因此一般情况下,内存总能够正常回收。但也存在例外,如果以key-value的方式做缓存,并且缓存对象在文件全局中定义,或者一直被引用,则对象迟早会被移动到年老分区。随着缓存数据的增多,堆中可用内存会越来越少。在浏览器中,V8引擎实例随着用户关闭页面而结束,其占用的内存也会被释放。但如果使用Node编写服务端,此种方式会影响服务的稳定性。
function Cache() {
this.ca = {}
this.put(key, val){
this.ca[key] = val
}
}
var cache = new Cache()
为防止内存无限增长,应该设定缓存大小,当存储数据超过缓存大小时,采取例如LRU算法清除一部分老数据;
cache.del(key) // 执行 delete this.ca[key]
如果缓存数据不需要进程共享,可以写一个C++模块,使用堆外缓存; 对于需要跨进程共享的数据,使用redis做缓存。
Stream
当处理大文件,或者处理网络文件请求的时候,可能会遇到大块使用内存的情景。
var source = fs.readFileSync('./in.txt', {encoding: 'utf8'});
fs.writeFileSync('./out.txt', source);
source = null
以上操作,如果文件小,写完之后source就可以被释放,那么对性能影响不大。但如果文件较大,读写又在高并发的情况下,很容易消耗尽内存。因此,妥善的方案应该是,读写交替进行,这样不管文件有多大,总可以安全的执行完。
var fs = require('fs');
var readStream = fs.createReadStream('./in.txt');
var writeStream = fs.createWriteStream('./out.txt');
readStream.on('data', function(chunk) { // 当有数据流出时,写入数据,chunk的类型为Buffer
writeStream.write(chunk);
});
readStream.on('end', function() { // 当没有数据时,关闭数据流
writeStream.end();
});
除了读写文件可以采取流的方式,还有多种其他模块也有类似读写模式,例如http模块的request函数,其获取数据也是以流的方式。在data的事件函数中,参数chunk的类型是Buffer。Buffer犹如基本类型那样,在node中可以直接使用,其维护的数据所占用的内存空间在V8堆之外申请。
var buf = new Buffer('world');
var str = 'hello ' + buf
console.log(str)
应该避免类似的代码,因为将Buffer中的数据转换成字符串,意味着在V8的堆中申请空间,如果Buffer中的数据量大,则有可能碰触V8堆内存的红线。Buffer提供了丰富的函数,包含拼接,切分等,操作大块数据非常的高效。require(‘buffer’) 后也可以使用SlowBuffer直接在堆上分配内存(Buffer内部以8kb为单位,用js维护了一个堆管理器),此方法如若配合tcmalloc,操作堆外内存将更加高效。V8本身提供了一个接口,此接口外部可以调用,用来主动发起一次垃圾回收。V8垃圾回收不仅考虑了堆内空间,也考虑了堆外空间。如果堆外空间大于某个值,也将主动触发一次垃圾回收。
总结
本文细说了V8堆内存和垃圾回收的原理,目标是理解Js的运行,高效的使用内存,避免内存使用上的误区。在此基础上,介绍了Stream 和 Buffer 这两个概念和实际使用场景。V8的堆内存只应该保存JIT的代码,用户创建的对象,以及少量的数据。对于数据操作,应该使用Stream的方式,对大块数据的加载和处理,应该使用Buffer。
干货收藏
大神,麻烦帮我看一下这个地方的 3L:https://cnodejs.org/topic/57592d608316c7cb1ad35bd5
我印象中,v8 不会很积极的进行 gc,导致很多本来可以回收的对象一直留在内存中,这个行为现在 node v6 有做出改变吗?
node 在v0.12 的版本惰性 GC,可以导致 50%以上的内存一直保留,alinode 对此做了优化。 目前 node LTS 也版本中做了修正。v6也是如此。@alsotang