所有的程序都必须和计算机内存打交道,从计算机申请内存,在不需要的时候释放内存,这是各个编程语言的设计难点。
目前有三种流派的内存管理:
为什么要学内存管理?
帮助开发同学更好的理解各种变量创建的过程,应用的性能情况有哪些优化空间,帮助我们开发高性能的应用,尤其是 Node 应用。
内存管理的知识是通用的,你在开发 JS、JAVA、C++、GO 等等各种语言都绕不开它。
一般我们看到栈和堆就会以为是两个数据结构:
栈是一个后进先出的数据结构,比如 JS 中的 函数调用栈、浏览器的网页历史记录
堆 是一种特别的完全二叉树的数据结构,还分为最大堆、最小堆,一般适用于优先级队列,比如 React 的 Scheduler
我们主要是讲 C/C++ 的内存分配。( V8 引擎是 C++ 实现的,Java 中的 JVM 是 汇编、C、C++ 实现的,比较有代表性)
我们这里所说的堆栈是两种内存管理的形式,所以也会叫栈内存(栈区)和堆内存(堆区)。
在 C/C++ 中内存分为 栈区、堆区、全局/静态存储区、常量存储区、代码区。
**静态内存分配:**编译时分配。包括:全局、静态全局、静态局部三种变量。
**动态内存分配:**运行时分配。包括:栈、堆。
是由编译器管理的
是线程私有的。
栈中的数据占有大小是已知的。
每个方法在执行时都会创建一个栈帧用于存储局部变量、操作数栈、方法出口等等信息。
哪些是存到栈内存的?:
字符(char)、数值(int)、布尔值、引用指针等等,可以看出来,这些值一旦声明了,它的大小是确定的,编译期就能知道的。
由此可以看出,我们一般说的 内存溢出、GC 等都是指的是堆内存。
哪些是存到堆内存的?:
容器:动态数组(C++ 种的 Vector,JS 中的 Array)、哈希表、JS 中的对象。
从这些可以看出来,这些变量声明后,大小是不确定的,编译器是不可知的。运行时会动态的变大变小。
垃圾回收机制的实现有两种:
引用计数收集器:最早的也是最简单的垃圾回收实现方法。会将存到堆空间的对象
当引用一个对象,计数器就加一,不引用计数器就减一,为 0 时释放空间
跟踪收集器
内存管理概念是 可达性。“可达”值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。
这里有个很简单的例子:
// user 具有对这个对象的引用
let user = {
name: 'John',
};
user
引用了 对象 { name: "John" }
。随后 user
的值被重写了
user = null;
此时 { name: "John" }
“断开了连接”,没有了引用,不可达了,那么就会被当作垃圾数据回收掉。
还有更加复杂的例子,下面这种情况,即使这个对象还引用了别的对象,也会被回收掉。
还有一个是无法到达的岛屿,尽管他们内部有各自的引用,但是始终无法到达,也会被回收。
V8 将标记-清除和标记-紧缩(标记-整理)两种算法结合使用。
这两个回收算法分为两个阶段,第一阶段的算法都是一样的:
标记(marking) 在托管堆(managed heap)中扫描后的活动对象(live objects)的位置
清除(sweeping) 在托管堆中被回收死亡对象(dead objects)的位置。
但是清除会带来一个问题就是空间不连续,所以需要优化,*紧缩(compacting)*就是用于优化不连续的空间整合到一片连续的空间。
可能有点抽象,我们直接看图说话:
比如现在有这样一个结构的引用关系:
当开始准备垃圾回收时,会暂停整个程序的全部运行的线程(Stop The World, STW)
找到所有的引用的根,并标记,并继续遍历,有点像有向图的广度优先遍历
全部标记完成后,开始清除没有被标记的(不可达的)
然后所有线程恢复运行
这种算法有一个必然的结果,就是每次运行 GC,都会导致整个应用卡顿,那体验也太差了。浏览器引擎对此做了很多优化,使得 GC 速度非常快,感受不到延迟:
V8 将堆分了很多个片区:
我们主要理解新老分代。
这里需要再引出一个算法:Scavenge 算法。
大概是将新生代区分为出区、入区,内存分配发生在出区,出区将要耗尽时(小周期),交换出区入区,将入区的活跃对象复制到出区或者老生代区。
这个算法的速度是比标记-清除和标记-紧缩快的,但是占用的空间比较大(直接需要一半的空间)。所以只适用于区域小的新生代区。
def scavenge():
swap(fromSpace, toSpace)
allocationPtr = toSpace.bottom
scanPtr = toSpace.bottom
for i = 0..len(roots):
root = roots[i]
if inFromSpace(root):
rootCopy = copyObject(&allocationPtr, root)
setForwardingAddress(root, rootCopy)
roots[i] = rootCopy
while scanPtr < allocationPtr:
obj = object at scanPtr
scanPtr += size(obj)
n = sizeInWords(obj)
for i = 0..n:
if isPointer(obj[i]) and not inOldSpace(obj[i]):
fromNeighbor = obj[i]
if hasForwardingAddress(fromNeighbor):
toNeighbor = getForwardingAddress(fromNeighbor)
else:
toNeighbor = copyObject(&allocationPtr, fromNeighbor)
setForwardingAddress(fromNeighbor, toNeighbor)
obj[i] = toNeighbor
def copyObject(*allocationPtr, object):
copy = *allocationPtr
*allocationPtr += size(object)
memcpy(copy, object, size(object))
return copy
老生区在标记-清除或标记-紧缩的过程中(大周期)进行回收。大周期进行的并不频繁。一次大周期通常是在移动足够多的对象至老生区后才会发生。
操作系统中的内存管理包含了物理内存管理&虚拟内存管理,他们各自有各自的管理内容,有着复杂的存储体系。
栈内存、堆内存是如何存储的?在真实的机器中,是存储在什么位置的?
PS:栈内存、堆内存是 OS 抽象出来的。
假设 V8 的堆内存为1.5G
,那么V8做一次小的垃圾回收需要 50ms 以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这 1s 的时间内,浏览器一直处于等待的状态。
C++
JS、V8
A tour of V8: Garbage Collection
操作系统
Rust