内存管理
对之前的文章进行重新编辑,内容做了很多的调整,使其具有逻辑更加紧凑,内容更加全面。 1. 基础概念1.1 生命周期不管什么程序语言,内存生命周期基本是一致的:
在所有语言中第一和第二部分都很清晰。最后一步在低级语言(例如C语言)中很清晰,但是在像JavaScript等高级语言中,这一步依赖于垃圾回收机制,一般情况下不用程序员操心。 1.2 堆与栈我们知道,内存空间可以分为栈空间和堆空间,其中
1.3 基本类型与引用类型在JavaScript中
1.4 V8的变量存放
2. 垃圾回收2.1 分代策略脚本中,绝大多数对象的生存期很短,只有某些对象的生存期较长。为利用这一特点,V8将堆进行了分代。对象起初会被分配在新生区。在新生区的内存分配非常容易:我们只需保有一个指向内存区的指针,不断根据新对象的大小对其进行递增即可。当该指针达到了新生区的末尾,就会有一次清理(小周期),清理掉新生区中不活跃的死对象。对于活跃超过2个小周期的对象,则需将其移动至老生区。而在老生区则使用标记清除的算法来进行垃圾回收。V8通过分别对新生代对象和老生代对象使用不同的垃圾回收算法来提升来及回收的效率。这就是所谓的 默认情况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB 根据
大多数对象被分配在这里,新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。
这里包含大多数可能存储指向其他对象的指针的对象,大多数在新生区存活了一段时间(2个周期)的对象都会被挪到这里。
这里存放只包含原始数据的对象,这些对象没有执行其他对象的指针,例如字符串,数字数组等,它们在新生区存活了一段时间后会被移动到这里。
每一个区域都是由一组内存页构成的。除大对象区的内存页较大之外,每个区的内存页都是1MB大小,且按1MB内存对齐。对象超过一定大小时就会被放置到这个区,垃圾回收期从不移动这个区域的对象。
代码对象,也就是包含JIT之后指令的对象,会被分配到这里。这里是唯一拥有执行权限的内存区。(如果代码对象因过大而被放到大对象区,则该大对象所对应的内存也是可执行的。)
这些区域存放Cell、属性Cell和Map,每个区域因为都是存储相同大小的元素,因此内存结构很简单,这里也是为了方便进行回收。 在 node-v4.x 之后,区域进行了合并为:新生区,老生区,大对象区,Map区,Code区 此外,对于一个对象所占的内存空间,也涉及两个概念:
这两个概念在使用chrome的开发工具中会看到。 垃圾回收释放的内存即为Retained Size的大小。 2.2 新生区的半空间分配策略新生代使用半空间(Semi-space)分配策略,其中新对象最初分配在新生代的活跃半空间内。一旦半空间已满,一个Scavenge操作将活跃对象移出到其他半空间中,被认为是长期驻存的对象,并被晋升为老生代。一旦活跃对象已被移出,则在旧的半空间中剩下的任何死亡对象被丢弃。 具体的如下: YG被平分为两部分空间From和To,所有内存从To空间被分配出去,当To满时,开始触发GC。 例如说: 某时刻,To已经为A、B和C分配了内存,当前它只剩下一小块内存未分配。而From所有的内存都空闲着。 此时,一个程序需要为D分配内存,但D需要的内存大小超出了To未分配的内存,此时触发GC,页面停止执行 接着From和To进行对换,即原来的To空间被标志为From,From被标志为To。并且把活的变量值(B)标志出来,而垃圾(A、C)未被标志,它们将会被清掉。 活跃的变量(B)会被复制到To空间,而垃圾(A、C)则被回收。同时,D被分配到To空间,最后的情况如下。 至此,整个GC完成,此过程中页面会阻塞,所以要尽可能的快。 2.2.1 对象的晋升当一个新生代的对象在满足一定条件下,会从新生代被移到老生代,这就是对象的晋升。具体的移动的标准有两种
2.3 老生代V8在老生代中采用Mark-Sweep和Mark-Compact相结合的垃圾回收策略。 2.3.1 标记标记-清除算法分为标记和清除两个阶段。 标记阶段,所有堆上的活跃对象都会被标记,每个内存页有一个用来标记对象的位图,位图中的每一位对应的内存页中的一个字,这个位图需要占据一定的空间。另外还有两位用来标记对象的状态:
那么这里怎么理解标记的过程?这就必须知道:内存管理方式实际上基于 GC Root是内存的根节点,在浏览器中它是window,在Nodejs中则是global对象
有很多内部的GC Root对用户来说都不是很重要,从应用的角度来说有下面几种情况:
实际上,标记的过程正是以由GC Root建立的图为基础,来实现对象的标记,标记算法的核心是深度优先搜索,大致实现如下:
这个算法实现起来还是蛮繁琐的,从
标记结束后,所有的对象非黑(活跃节点)即白(垃圾节点)。 标记时间取决于必须标记的活跃对象的数目,对于一个大的web应用,整个堆栈的标记可能需要超过100ms。由于全停顿会造成了浏览器一段时间无响应,所以V8使用了一种增量标记的方式标记活跃对象,将完整的标记拆分成很多小的步骤,每做完一部分就停下来,让JavaScript的应用线程执行一会,这样垃圾回收与应用线程交替执行。V8可以让每个标记步骤的持续时间低于5ms。 举个例子:
例如图中灰色的节点,它原来代表ob变量值,当 2.3.2 清除(Sweep)由于标记完成后,所有对象都已经被标记,即不是活跃对象就是死亡对象,堆上有多少空间已经确定。清除时,垃圾回收器会扫描连续存放的死对象,将其变成空闲空间。这个任务是由专门的清扫线程同步执行。 2.3.3 整理(Compact)标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。 标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。 2.4 垃圾回收总结
3. 内存问题3.1 内存泄漏内存泄漏是指计算机可用内存的逐渐减少,原因通常是程序持续无法释放其使用的临时内存。 先来一个最简单的DOM泄漏的例子
程序非常简单,只是把id为_p的HTML元素从页面移除,在移除之前从GC Root遍历此P元素有两条路可走。在执行 3.2 内存占用过多这个问题很容易理解。例如使用事件代理来减少事件监听的函数,从而减少内存分配的开销。 3.3 gc卡顿如果你的页面垃圾回收很频繁,那说明你的页面可能内存使用分配太频繁了。频繁的GC可能也会导致页面卡顿。 在一些框架中,如果创建一个大对象之后,可能不会很快就将其释放,而是会缓存起来,直到没有用处为止。 4. chrome dev tools在使用Chrome进行内存分析的时候,要先在chrome菜单-》工具,或者直接按shift+esc,找到内存管理器,然后选上JavaScript使用的内存(JavaScipt Memory)。 4.1 timeline通过Timeline的内存模式,可以在宏观上观察到web应用的内存情况,一般我们需要关注的点:
这些关注点都可以在timeline的内存视图中看到,如图 timeline统计的内存变化主要有:
此外还可以通过 4.2 profile
profile使用必须知道的:
在profile中的几个概念:
4.2.1 Take Heap Snapshot使用快照,必须知道:
快照有三个视图,它们分别有各自的作用
4.2.2 Recode Heap Allocations这个功能可以动态监控,通过次工具可以看到
4.3 实践例子1:timeline来查看正常的内存 例子2:通过timeline来发现内存泄漏 可以看到随着时间的增长,页面占用的内存越来越多, 在这种情况下就可以怀疑有内存泄漏了,也有可能是浏览器还没有进行gc,这个时候我们可以强制进行垃圾回收(垃圾筒图标) 反复测试,如果发现无论怎么样,内存一直在增长,那么估计你就遇到内存泄漏的问题了。 如果页面中DOM节点的数量一直在攀升,那么肯定出现DOM泄漏了 例子3:验证快照之前会进行gc
例子4:通过snapshot来发现内存泄漏
可以看到,action之后,内存的数量是增加的(注意,已经gc过了),这说明web应用极有内存泄漏。 一个原则就是找到本不应该存在却还存在的那些值。 例子5:通过内存分配的情况来分析 点击蓝色的柱子,可以看到详细的情况,来进行分析 例子6:通过timeline来分析gc过于频繁导致卡顿的问题 此例子在移动手机的浏览器进行测试,页面还是相对简单,在比较复杂的移动web应用,这种情况还是比较危险的,可能会导致页面卡死。 参考 |
|