对内存的理解

远子 •  2021年11月05日

内存分两种,一种是 ROM(Read Only Memory),ROM 是一种只用来读的内存。另一种是 RAM(Random Access Memory),RAM 是可读可写的内存。

我们常见的光盘(CD-ROM)就是 ROM 的一种,光盘只能写入一次数据,写入的过程称为烧录,光盘的结构简单使用方便,常用来存储固定程序和数据,比如音乐、系统盘。

RAM 是与 CPU 直接交换数据的部分,我们下载的应用程序到磁盘以后,需要先加载到内存中 CPU 才可以运行,内存越大,计算机运行越流畅。

RAM 又可以分为 DRAM(Dynamic RAM)和 SRAM(Static RAM)。

动态 RAM 使用电容存储,必须隔一段时间刷新一次,如果没有刷新数据就会丢失。静态 RAM 使用晶体管存储数据,只要保持通电,存储的数据就可以一直存在。

SRAM 比 DRAM 的性能高,造价比 DRAM 昂贵,功耗大于 DRAM。我们可以把 SRAM 想象成火箭,DRAM 想象成公交车,火箭容量小速度快价格昂贵,公交车容量大速度慢价格便宜。

SRAM 主要用于 CPU 和 DRAM 之间的高速缓存,程序从磁盘读取到内存后,转交给 SRAM,再转交给 CPU 运行。这个过程好比,马斯克(数据)从公司(硬盘)出发,做公交车(内存)到机场后换乘火箭(SRAM),升空后换乘宇宙飞船(CPU)。

我电脑的内存是 16G,型号是 LPDDR4,LPDDR4 可以简单理解成双倍速率的 DRAM。

image-20211104182929444

现在来看下内存的物理结构:

image-20211105111633599

内存实际上是一种名为内存 IC 的电子元件,如上图,内存 IC 的物理结构很简单,两边有一些针脚,像一条蜈蚣。

内存 IC 里通直流电,所有针脚只有两种状态,通 +5V直流电时表示 1,不通电则表示 0。这个特性和二进制非常类似,这也是计算机用二进制表示数据的原因。

VCC 针脚和 GND 针脚是电源接口,A0~A9 是存数据的地方,D0~D7 是读写数据的入口,RD 是 Read 的意思,RD 通电的时候表示接下来要读数据,WR 是 Write 的意思,WR 通电的时候表示接下来要写数据。

A0~A9 相当于楼层号,A0~A9 是 10 个二进制数,即 1024 层楼。D0~D7 则相当于“内存大楼”的大门。

image-20211105114733610

当 WR 通电,A号针脚表示 0000000001 这个二进制数,D 号针脚表示 00000011,如下表格:

A号针脚A0A1A2A3A4A5A6A7A8A9
是否通电
含义0000000001
D 号针脚D0D1D2D3D4D5D6
是否通电
含义0000011

这表示把 D号针脚表示的 00000011 存入到 A 号针脚表示的 0000000001 号楼层,说人话是:把 3 存放在内存大楼的 1 层。

当 RD 通电,A 号针脚表示 0000000001 这个二进制数时,则表示取出内存大楼的 1 层的数据。

我们知道编程语言有不同的数据类型,比如占 1 字节的 char,2 字节的 short,4 字节的 long,换算到“内存大楼”中,用 1 层楼存储 chart 类型的数据,用 2 层楼存储 short 类型的数据,用 4 层楼存储 long 类型的数据。

我们知道 1 层楼代表 1K,1K 又等于 8B,这表示 1 层楼最多存储 2 的 8 次方个数据,也就是 256 个,所以 char 的数据范围是 -128~127。同样的,2 层楼的数据范围 2 的 16 次方个数据,也就是 65536 个,所以 short 类型的数据范围是 -32768~32767;4 层楼的数据范围是 2 的 32 次方个数据。

内存的表现和数组也十分类似。

上边的例子中,我们把“内存大楼”理解成长度为 1024 的数组,数组中的每个元素可以存储 1K 的数据,数组的索引则相当于楼层号,也就是 A 针脚。

数组的特性是根据下标读数据的复杂度低,只有 O(1),但是增删的复杂度是 O(n)。

栈、队列、链表、二叉树则是数据的变形方法,这些数据结构各自有自己的用途。

我们知道 CPU 无法直接运行磁盘上的程序,必须加载到内存中,在内存低、程序大的情况下,计算器有虚拟内存的机制。

虚拟内存是把磁盘的一部分当做内存使用,逻辑也很简单,比如内存 1G,程序 10G 的情况下,先把程序按照 1G 分成 10 页,先读第 1 页到内存中(这个过程称为 PageIn),然后把第一页写入到磁盘中(这个过程称为 PageOut),接着读取第 2 页,第 3 页...第 10 页。这样计算机就能在内存不足的情况下运行程序。

有一种解约内存的方法是通过 dll 文件,所谓的 dll 文件就是公共库,这个前端很好理解,比如有 5 个系统都在使用 vue 框架,我们就可以把 vue 的库文件单独抽离出来,让这 5 个系统共用同一份 vue 代码。

现在的 JavaScript 主要运行在 V8 引擎中,JS 作为前端时在 Chrome 里运行,JS 作为后端时在 Node 里运行。

V8 引擎有自动的垃圾回收策略,其主要基于分代式垃圾回收机制,所谓分代是指将内存分成存活时间较短的新生代和存货时间较长的老生代两种。

在 64 位系统下,老生代的内存最多有 1400M,新生代的最多有 32M。

在分代的基础上,新生代中的内存通过 Scavenge 算法进行垃圾回收。这个算法很好理解,首先将 32M 的内存一份为二,一份称为 From 空间,另一份称为 To 空间,当开始进行垃圾回收时,把 From 空间的存活对象复制到 To 空间,同时释放掉 From 空间里的非存活对象,然后清空 From 空间,最后把 From 空间和 To 空间互换。

这种做法的缺点是只有一半的内存空间被使用,另一半处于闲置状态,所以无法大规模应用到所有的垃圾回收中,但是可以发现 Scavenge 非常适合应用在新生代,因为新生代中对象的生命周期短。

老生代中的对象存活时间长,通过 Mark-Sweep 标记清除的方式进行垃圾回收,它分为标记和清除两个阶段,在标记阶段时,遍历并标记内存中所有的存活对象,在清除阶段清除掉没有加标记的对象。

V8 进行垃圾回收的时候,需要暂停 JS 的执行,而回收老生代的垃圾耗时较长,所以 V8 对内存大小加了限制。Chrome 里每个标签页都是一个 V8 实例,而每个实例都分配 1.4G 左右的内存,这也是 Chrome 吃内存的原因。

在 Node 服务中,请求大的时候会导致老生代的存活对象骤增,垃圾回收变得缓慢,耗尽内存的时候卡顿,甚至溢出。

全局变量和闭包函数内的变量不会被垃圾回收。

(完)