本文系原创,转载请注明出处~
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提供了丰富的函数,包含拼接,切分等,这些操作和内存的分配释放由C++实现,操作大块数据非常的高效。
总结 本文细说了V8堆内存和垃圾回收的原理,目标是理解Js的运行,高效的使用内存,避免内存使用上的误区。在此基础上,介绍了Stream 和 Buffer 这两个概念和实际使用场景。V8的堆内存只应该保存JIT的代码,用户创建的对象,以及少量的数据。对于数据操作,应该使用Stream的方式,对大块数据的加载和处理,应该使用Buffer。