转载于 https://www.cnblogs.com/zhwl/p/4664604.html
一, 垃圾回收的必要性
由于字符串, 对象和数组没有固定大小, 所有当他们的大小已知时, 才能对他们进行动态的存储分配. JavaScript 程序每次创建字符串, 数组或对象时, 解释器都必须分配内存来存储那个实体. 只要像这样动态地分配了内存, 最终都要释放这些内存以便他们能够被再用, 否则, JavaScript 的解释器将会消耗完系统中所有可用的内存, 造成系统崩溃.
这段话解释了为什么需要系统需要垃圾回收, JS 不像 C/C++, 他有自己的一套垃圾回收机制(Garbage Collection).JavaScript 的解释器可以检测到何时程序不再使用一个对象了, 当他确定了一个对象是无用的时候, 他就知道不再需要这个对象, 可以把它所占用的内存释放掉了. 例如:
- var a = "before";
- var b = "override a";
- var a = b; // 重写 a
这段代码运行之后,"before" 这个字符串失去了引用(之前是被 a 引用), 系统检测到这个事实之后, 就会释放该字符串的存储空间以便这些空间可以被再利用.
二, 垃圾回收原理浅析
现在各大浏览器通常用采用的垃圾回收有两种方法: 标记清除, 引用计数.
1, 标记清除
这是 javascript 中最常用的垃圾回收方式. 当变量进入执行环境是, 就标记这个变量为 "进入环境". 从逻辑上讲, 永远不能释放进入环境的变量所占用的内存, 因为只要执行流进入相应的环境, 就可能会用到他们. 当变量离开环境时, 则将其标记为 "离开环境".
垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记. 然后, 它会去掉环境中的变量以及被环境中的变量引用的标记. 而在此之后再被加上标记的变量将被视为准备删除的变量, 原因是环境中的变量已经无法访问到这些变量了. 最后. 垃圾收集器完成内存清除工作, 销毁那些带标记的值, 并回收他们所占用的内存空间.
关于这一块, 建议读读 Tom 大叔的几篇文章, 关于作用域链的一些知识详解, 读完差不多就知道了, 哪些变量会被做标记.
2, 引用计数
另一种不太常见的垃圾回收策略是引用计数. 引用计数的含义是跟踪记录每个值被引用的次数. 当声明了一个变量并将一个引用类型赋值给该变量时, 则这个值的引用次数就是 1. 相反, 如果包含对这个值引用的变量又取得了另外一个值, 则这个值的引用次数就减 1. 当这个引用次数变成 0 时, 则说明没有办法再访问这个值了, 因而就可以将其所占的内存空间给收回来. 这样, 垃圾收集器下次再运行时, 它就会释放那些引用次数为 0 的值所占的内存.
但是用这种方法存在着一个问题, 下面来看看代码:
- function problem() {
- var objA = new Object();
- var objB = new Object();
- objA.someOtherObject = objB;
- objB.anotherObject = objA;
- }
在这个例子中, objA 和 objB 通过各自的属性相互引用; 也就是说这两个对象的引用次数都是 2. 在采用引用计数的策略中, 由于函数执行之后, 这两个对象都离开了作用域, 函数执行完成之后, objA 和 objB 还将会继续存在, 因为他们的引用次数永远不会是 0. 这样的相互引用如果说很大量的存在就会导致大量的内存泄露.
我们知道, IE 中有一部分对象并不是原生 JavaScript 对象. 例如, 其 BOM 和 DOM 中的对象就是使用 C++ 以 COM(Component Object
Model, 组件对象)对象的形式实现的, 而 COM 对象的垃圾回收器就是采用的引用计数的策略. 因此, 即使 IE 的 Javascript 引擎使用标记清除的策略来实现的, 但 JavaScript 访问的 COM 对象依然是基于引用计数的策略的. 说白了, 只要 IE 中涉及 COM 对象, 就会存在循环引用的问题. 看看下面的这个简单的例子:
- var element = document.getElementById("some_element");
- var myObj =new Object();
- myObj.element = element;
- element.someObject = myObj;
上面这个例子中, 在一个 DOM 元素 (element) 与一个原生 JavaScript 对象 (myObj) 之间建立了循环引用. 其中, 变量 myObj 有一个名为 element 的属性指向 element; 而变量 element 有一个名为 someObject 的属性回指到 myObj. 由于循环引用, 即使将例子中的 DOM 从页面中移除, 内存也永远不会回收.
不过上面的问题也不是不能解决, 我们可以手动切断他们的循环引用.
- myObj.element = null;
- element.someObject =null;
这样写代码的话就可以解决循环引用的问题了, 也就防止了内存泄露的问题.
三, 减少 JavaScript 中的垃圾回收
首先, 最明显的, new 关键字就意味着一次内存分配, 例如 new Foo(). 最好的处理方法是: 在初始化的时候新建对象, 然后在后续过程中尽量多的重用这些创建好的对象.
另外还有以下三种内存分配表达式(可能不像 new 关键字那么明显了):
- {} (创建一个新对象)
- [] (创建一个新数组)
- function() {...} (创建一个新的方法, 注意: 新建方法也会导致垃圾收集!!)
1, 对象 object 优化
为了最大限度的实现对象的重用, 应该像避使用 new 语句一样避免使用 {} 来新建对象.
{"foo":"bar"}这种方式新建的带属性的对象, 常常作为方法的返回值来使用, 可是这将会导致过多的内存创建, 因此最好的解决办法是: 每一次函数调用完成之后, 将需要返回的数据放入一个全局的对象中, 并返回此全局对象. 如果使用这种方式, 就意味着每一次方法调用都会导致全局对象内容的修改, 这有可能会导致错误的发生. 因此, 一定要对此全局对象的使用进行详细的注释和说明.
有一种方式能够保证对象 (确保对象 prototype 上没有属性) 的重复利用, 那就是遍历此对象的所有属性, 并逐个删除, 最终将对象清理为一个空对象.
cr.wipe(obj)方法就是为此功能而生, 代码如下:
- // 删除 obj 对象的所有属性, 高效的将 obj 转化为一个崭新的对象!
- cr.wipe = function (obj) {
- for (var p in obj) {
- if (obj.hasOwnProperty(p))
- delete obj[p];
- }
- };
有些时候, 你可以使用 cr.wipe(obj)方法清理对象, 再为 obj 添加新的属性, 就可以达到重复利用对象的目的. 虽然通过清空一个对象来获取 "新对象" 的做法, 比简单的通过 {} 来创建对象要耗时一些, 但是在实时性要求很高的代码中, 这一点短暂的时间消耗, 将会有效的减少垃圾堆积, 并且最终避免垃圾回收暂停, 这是非常值得的!
2, 数组 array 优化
将 [] 赋值给一个数组对象, 是清空数组的捷径 (例如: arr = [];), 但是需要注意的是, 这种方式又创建了一个新的空对象, 并且将原来的数组对象变成了一小片内存垃圾! 实际上, 将数组长度赋值为 0(arr.length = 0) 也能达到清空数组的目的, 并且同时能实现数组重用, 减少内存垃圾的产生.
3, 方法 function 优化
方法一般都是在初始化的时候创建, 并且此后很少在运行时进行动态内存分配, 这就使得导致内存垃圾产生的方法, 找起来就不是那么容易了. 但是从另一角度来说, 这更便于我们寻找了, 因为只要是动态创建方法的地方, 就有可能产生内存垃圾. 例如: 将方法作为返回值, 就是一个动态创建方法的实例.
在游戏的主循环中, setTimeout 或 requestAnimationFrame 来调用一个成员方法是很常见的, 例如:
- setTimeout(
- (function(self) {
- return function () {
- self.tick();
- };
- })(this), 16)
每过 16 毫秒调用一次 this.tick(), 嗯, 乍一看似乎没什么问题, 但是仔细一琢磨, 每一次调用都返回了一个新的方法对象, 这就导致了大量的方法对象垃圾!
为了解决这个问题, 可以将作为返回值的方法保存起来, 例如:
- // at startup
- this.tickFunc = (
- function(self) {
- return function() {
- self.tick();
- };
- }
- )(this);
- // in the tick() function
- setTimeout(this.tickFunc, 16);
相比于每次都新建一个方法对象, 这种方式在每一帧当中重用了相同的方法对象. 这种方式的优势是显而易见的, 而这种思想也可以应用在任何以方法为返回值或者在运行时创建方法的情况当中.
4, 高级技术
从根本上来说, javascript 本身就是围绕着垃圾收集来设计的. 随着我们工作的进行, 避免内存垃圾变得越来越困难. 因为很多方便实用的 Javascript 库方法也会产生一些新的对象. 对于这些库方法产生的垃圾, 我们束手无策, 只能重新翻看文档, 并且检查方法的返回值. 例如, 数组的 slice 方法返回一个新的数组 (在不修改原数组的基础上, 截取出一部分作为新数组), 字符串的 substr 方法返回一个新的字符串(在不修改原字符串的基础上, 截取出一部分字符串作为返回值) 等等.
调用这些库方法, 将会创建内存垃圾, 而你能做的, 只有避免调用这些方法, 或者用不创建系统垃圾的方式重写这些方法(有点极端啦~).
例如, 在 Construct2 引擎中, 从数组中利用下标来删除一个元素, 是经常进行的操作. 最初我们是用下面这种方式来实现的:
- var sliced = arr.slice(index + 1);
- arr.length = index;
- arr.push.apply(arr, sliced);
然而, slice 方法会返回一个新的数组对象(数组中的元素是原数组中删掉的部分), 并且会通过 arr.push.apply 方法将元素重新复制回原数组, 但是在此操作之后, 该数组就成为了一片内存垃圾. 由于这是我们引擎中的垃圾产生的热点代码(使用频率非常很高), 因此我们利用了迭代的方式重写了上述代码:
- for (var i = index, len = arr.length - 1; i < len; i++)
- arr[i] = arr[i + 1];
- arr.length = len;
显然, 重写大量的库函数是非常痛苦的, 因此你必须仔细权衡方法的易用性和内存垃圾产生情况. 如果产生大量内存垃圾的方法在动画的每一帧中被多次调用, 你可能就会兴高采烈的重写库函数啦.
在递归函数中, 通过 {} 构造空对象, 并在递归过程中传递数据, 虽然是很方便的. 但是更好的方式是: 利用一个单独的数组对象作为堆栈, 在递归过程中对数组进行 push 和 pop 操作. 更进一步, 不要调用 array 的 pop 方法(pop 将会使得 array 的最后一个元素将会变成内存垃圾), 而应该使用一个索引来记录数组的最后一个元素的位置, 在 pop 时简单的将索引减一即可; 类似的, 将索引加 1 来代替 array 的 push 操作, 只有当索引对应的元素不存在时, 才执行真正的 push 为数组加入一个新元素.
另外, 在任何时候, 都应该避免使用向量对象 (例如: 包含 x 和 y 属性的 vector2 对象). 有些方法将向量对象作为方法返回值, 既可以支持返回值的再次修改, 又能够将需要的属性一次性返回, 使用起来非常方便. 但是有时候在一帧动画中, 创建了成百上千个这样的向量对象, 从而导致严重的垃圾回收性能问题, 也是非常常见的. 因此最好将这些方法分离成具有独立职责的功能个体, 例如: 利用 getX() 和 getY()方法 (返回具体数据) 代替 getPosition()方法(返回一个 vector2 对象).
四, 总结
在 Javascript 中, 彻底避免垃圾回收是非常困难的. 垃圾回收机制与实时软件 (例如: 游戏) 的实时性要求, 从根本上就是对立的.
但是, 为了减少内存垃圾, 我们还是可以对 javascript 代码进行彻底检查, 有些代码中存在明显的产生过多内存垃圾的问题代码, 这些正是我们需要检查并且完善的.
我认为, 只要我们投入更多的精力和关注, 实现实时的, 低垃圾收集的 javascript 应用还是很有可能的. 毕竟, 对于可交互性要求较高的游戏或应用来说, 实时性和低垃圾收集, 两者都是至关重要.