跳到主要内容

杂项

单线程的 nodejs 是如何充分利用计算机 CPU 资源的呢?

虽然 Node.js 是单线程的,但是它能够充分利用计算机的 CPU 资源的原因在于其采用了事件驱动和异步 I/O 的方式来处理请求,而不是采用阻塞式 I/O 的方式。这使得 Node.js 能够在处理一个请求时不会因为等待 I/O 操作而阻塞,从而可以处理更多的请求。

具体来说,当 Node.js 启动一个程序时,会创建一个事件循环,不断地从事件队列中取出一个事件,然后调用相应的回调函数来处理该事件。当有新的请求到来时,Node.js 会将其添加到事件队列中,等待事件循环处理。同时,Node.js 还采用了非阻塞式 I/O 的方式,即在等待 I/O 操作时不会阻塞其他代码的执行,从而能够更好地利用 CPU 资源。

此外,Node.js 还采用了基于事件的回调机制来处理异步请求,这种机制可以避免线程切换和上下文切换带来的开销,提高 CPU 利用率。因此,虽然 Node.js 是单线程的,但是它能够充分利用计算机 CPU 资源,处理更多的请求。

如何使用 node 做一个全局命令行工具

一、创建项目结构

  1. 创建一个新的文件夹作为项目目录,例如my-cli-tool
  2. 在项目目录中,初始化一个新的 Node.js 项目,可以使用npm inityarn init命令,根据提示填写项目的基本信息。

二、安装必要的模块

  1. 安装commander模块,这是一个用于构建命令行界面的流行工具。
  • 使用 npm:npm install commander --save
  • 使用 yarn:yarn add commander

三、编写命令行工具代码

  1. 创建一个主入口文件,例如index.js
  2. 在入口文件中,引入commander模块并进行配置:
const program = require('commander')

program
.version('1.0.0')
.description('My awesome CLI tool')
.option('-n, --name <value>', 'Your name')
.action((options) => {
console.log(`Hello, ${options.name || 'World'}!`)
})

program.parse(process.argv)

在这个例子中,我们创建了一个命令行工具,它接受一个--name选项,并在执行时打印出问候语。

四、设置全局安装

  1. 在项目的package.json文件中,添加一个bin字段,指定命令行工具的入口文件:
{
"name": "my-cli-tool",
"version": "1.0.0",
"description": "My awesome CLI tool",
"main": "index.js",
"bin": {
"my-tool": "./index.js"
},
"dependencies": {
"commander": "^9.4.0"
}
}

这里将命令行工具的名称设置为my-tool,对应的入口文件是index.js

  1. 使用npm linkyarn link命令将项目链接到全局环境中。这将使你的命令行工具在系统中可用。
  • 使用 npm:npm link
  • 使用 yarn:yarn link

五、测试命令行工具

  1. 打开终端,输入my-tool --name John,如果一切正常,应该会看到输出Hello, John!

node 如何处理循环依赖

在前端工程中,可以通过以下方法检测并避免循环依赖:

一、检测循环依赖

  1. 手动审查代码:
  • 仔细检查项目中的模块导入关系。查看每个模块的导入语句,确定是否存在一个模块被另一个模块导入,而后者又反过来导入前者的情况。
  • 对于大型项目,可以使用工具辅助手动审查,如代码编辑器的搜索功能或一些专门的代码分析工具,来快速查找可能的循环依赖。
  1. 使用静态分析工具:
  • 有一些静态分析工具可以帮助检测循环依赖。例如,ESLint 的插件eslint-plugin-import可以配置规则来检测模块之间的循环依赖。
  • 使用这些工具可以在开发过程中自动检测循环依赖,并给出明确的错误提示,帮助开发者及时发现和修复问题。

二、避免循环依赖

  1. 优化模块设计:
  • 重新审视项目的模块结构,确保模块之间的依赖关系清晰且单向。避免设计出相互依赖的模块结构。
  • 例如,如果模块 A 和模块 B 相互依赖,可以考虑将它们共同依赖的部分提取出来,创建一个新的模块 C,然后让 A 和 B 分别依赖于 C,而不是直接相互依赖。
  1. 采用依赖注入:
  • 依赖注入是一种设计模式,可以帮助减少模块之间的直接依赖。通过将依赖项作为参数传递给函数或构造函数,可以在运行时动态地注入依赖项,而不是在模块内部直接导入它们。
  • 这样可以降低模块之间的耦合度,减少循环依赖的可能性。
  1. 延迟加载:
  • 在某些情况下,可以使用延迟加载(lazy loading)来避免循环依赖。延迟加载是指在实际需要的时候才加载模块,而不是在应用程序启动时就加载所有模块。
  • 例如,可以使用动态导入(dynamic import)来实现延迟加载。这样可以避免在模块加载时立即出现循环依赖的问题,因为模块只有在需要的时候才会被加载。
  1. 建立清晰的模块边界:
  • 每个模块应该有明确的职责和功能范围。避免模块之间的职责重叠,这样可以减少模块之间的相互依赖。
  • 同时,尽量保持模块的独立性,使得模块可以独立开发、测试和维护。

实现一个命令行工具, 统计输入目录下面指定代码的行数

要实现一个命令行工具来统计输入目录下指定代码的行数,你可以使用Node.js的fs模块来读取文件内容并进行行数统计。以下是一个简单的实现示例:

const fs = require('fs')
const path = require('path')

function countLinesInDirectory (dirPath, fileExtension) {
let totalLines = 0

function countLinesInFile (filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split('\n')
totalLines += lines.length
}

function processDirectory (directoryPath) {
const files = fs.readdirSync(directoryPath)

files.forEach((file) => {
const filePath = path.join(directoryPath, file)
const stats = fs.statSync(filePath)

if (stats.isFile() && path.extname(file) === fileExtension) {
countLinesInFile(filePath)
} else if (stats.isDirectory()) {
processDirectory(filePath)
}
})
}

processDirectory(dirPath)

return totalLines
}

// 命令行参数,第一个参数是目录路径,第二个参数是文件扩展名
const [_, __, dirPath, fileExtension] = process.argv

const linesCount = countLinesInDirectory(dirPath, fileExtension)
console.log(`Total lines of ${fileExtension} files in ${dirPath}: ${linesCount}`)

你可以将上述代码保存为一个JavaScript文件,比如line-counter.js。然后,在终端中运行以下命令:

node line-counter.js /path/to/directory .js

其中/path/to/directory是你要统计的目录路径,.js是你要统计的文件扩展名。运行命令后,程序将会输出指定文件类型在指定目录中的总行数。

你可以根据需要自定义输出格式、文件过滤规则等。此示例只是一个基本的实现,你可以根据具体需求进行扩展和优化。

如何进行 node 内存优化?

8内存生命周期

假设代码中有一个对象 jerry ,这个对象从创建到被销毁,刚好走完了整个生命周期,通常会是这样一个过程: 1、这个对象被分配到了 new space; 2、随着程序的运行,new space 塞满了,gc 开始清理 new space 里的死对象,jerry 因为还处于活跃状态,所以没被清理出去; 3、gc 清理了两遍 new space,发现 jerry 依然还活跃着,就把 jerry 移动到了 old space; 4、随着程序的运行,old space 也塞满了,gc 开始清理 old space,这时候发现 jerry 已经没有被引用了,就把 jerry 给清理出去了。

说明: 第二步里,清理 new space 的过程叫做 Scavenge,这个过程采用了空间换时间的做法, 用到了上面图中的 inactive new space,过程如下: 当活跃区满了之后,交换活跃区和非活跃区,交换后活跃区变空了; 将非活跃区的两次清理都没清理出去的对象移动到 old space; 将还没清理够两次的但是活跃状态的对象移动到活跃区。

第四步里,清理 old space 的过程叫做 Mark-sweep ,这块占用内存很大,所以没有使用 Scavenge, 这个回收过程包含了若干次标记过程和清理过程: 标记从根(root)可达的对象为黑色; 遍历黑色对象的邻接对象,直到所有对象都标记为黑色; 循环标记若干次; 清理掉非黑色的对象。

简单来说,Mark-sweep 就是把从根节点无法获取到的对象清理掉了。

圾回收器

JavaScript的垃圾回收器

JavaScript使用垃圾回收机制来自动管理内存。 垃圾回收是一把双刃剑,其好处是可以大幅简化程序的内存管理代码,降低程序员的负担,减少因 长时间运转而带来的内存泄露问题。 但使用了垃圾回收即意味着程序员将无法掌控内存。 ECMAScript没有暴露任何垃圾回收器的接口。我们无法强迫其进 行垃圾回收,更无法干预内存管理

内存管理问题

在浏览器中,Chrome V8引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),而且运行在用户的机器上。 如果不幸发生内存泄露等问题,仅仅会 影响到一个终端用户。 且无论这个V8实例占用了多少内存,最终在关闭页面时内存都会被释放,几乎没有太多管理的必要(当然并不代表一些大型Web应用不需 要管理内存)。 但如果使用Node作为服务器,就需要关注内存问题了,一旦内存发生泄漏,久而久之整个服务将会瘫痪(服务器不会频繁的重启)。

hrome的内存限制

存在限制

Chrome限制了所能使用的内存极限(64位为1.4GB,32位为1.0GB),这也就意味着将无法直接操作一些大内存对象。

为何限制

Chrome之所以限制了内存的大小,表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景, 而深层次的原因 则是由于V8的垃圾回收机制的限制。 由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞 JavaScript应用逻辑, 直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。 若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。 这样浏览器将在1s内失去对用户的响 应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。

hromeV8的堆构成

V8的堆其实并不只是由老生代和新生代两部分构成,可以将堆分为几个不同的区域: *新生代内存区:大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁 * 老生代指针区:属于老生代,这里包含了大多数可能存在指向其他对象的指针的对象,大多数从新生代晋升的对象会被移动到这里 *老生代数据区:属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针 * 大对象区:这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象 *代码区:代码对象,也就是包含JIT之后指令的对象,会被分配在这里。唯一拥有执行权限的内存区 * Cell区、属性Cell区、Map区:存放Cell、属性Cell和Map,每个区域都是存放相同大小的元素,结构简单

每个区域都是由一组内存页构成,内存页是V8申请内存的最小单位,除了大对象区的内存页较大以外, 其他区的内存页都是1MB大小,而且按照1MB对 齐。 内存页除了存储的对象,还有一个包含元数据和标识信息的页头,以及一个用于标记哪些对象是活跃对象的位图区。 另外每个内存页还有一个单独分配在另外内 存区的槽缓冲区,里面放着一组对象,这些对象可能指向其他存储在该页的对象。 垃圾回收器只会针对新生代内存区、老生代指针区以及老生代数据区进行垃圾回收

hromeV8的垃圾回收机制

如何判断回收内容

如何确定哪些内存需要回收,哪些内存不需要回收,这是垃圾回收期需要解决的最基本问题。 我们可以这样假定,一个对象为活对象当且仅当它被一个根对象 或另一个活对象指向。根对象永远是活对象,它是被浏览器或V8所引用的对象。

如何识别指针和数据

垃圾回收器需要面临一个问题,它需要判断哪些是数据,哪些是指针。由于很多垃圾回收算法会将对象在内存中移动(紧凑,减少内存碎片),所以经常需要进行指针的改写。

目前主要有三种方法来识别指针:

  1. 保守法: 将所有堆上对齐的字都认为是指针,那么有些数据就会被误认为是指针。 于是某些实际是数字的假指针,会背误认为指向活跃对象, 导致内存泄露(假指针指向的对象可能是死对象,但依旧有指针指向——这个假指针指向它)同时我们不能移动任何内存区域。
  2. 编译器提示法: 如果是静态语言,编译器能够告诉我们每个类当中指针的具体位置, 而一旦我们知道对象时哪个类实例化得到的,就能知道对象中所有指针。 这是JVM实现垃圾回收的方式,但这种方式并不适合JS这样的动态语言
  3. 标记指针法: 这种方法需要在每个字末位预留一位来标记这个字段是指针还是数据。 这种方法需要编译器支持,但实现简单,而且性能不错。 V8采用的是这种方式。V8将所有数据以32bit字宽来存储,其中最低一位保持为0,而指针的最低两位为01

V8的回收策略

自动垃圾回收算法的演变过程中出现了很多算法,但是由于不同对象的生存周期不同,没有一种算法适用于所有的情况。 所以V8采用了一种分代回收的策 略,将内存分为两个生代:新生代和老生代。 新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。 分别对新生代和老生代使用 不同的垃圾回收算法来提升垃圾回收的效率。 对象起初都会被分配到新生代,当新生代中的对象满足某些条件(后面会有介绍)时,会被移动到老生代(晋升)

V8的分代内存

默认情况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。 V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。 具体的计算公式是4*reserved_semispace_space_+ max_old_generation_size_, 新生代由两块reserved_semispace_space_组成,每块16MB(64位)或8MB(32位)

生代

新生代的特点

大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁。 在新生代分配内存非常容易,我们只需要保存一个指向内存区的指针,不断根据新对象的大小进行递增即可。 当该指针到达了新生代内存区的末尾,就会有一次清理(仅仅是清理新生代)

新生代的垃圾回收算法

新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。

具体的执行过程大致是这样: 首先将From空间中所有能从根对象到达的对象复制到To区, 然后维护两个To区的指针scanPtr和allocationPtr,分别指向即将 扫描的活跃对象和即将为新对象分配内存的地方,开始循环。 循环的每一轮会查找当前scanPtr所指向的对象,确定对象内部的每个指针指向哪里。 如果指向 老生代我们就不必考虑它了。 如果指向From区,我们就需要把这个所指向的对象从From区复制到To区,具体复制的位置就是allocationPtr 所指向的位置。 复制完成后将scanPtr所指对象内的指针修改为新复制对象存放的地址,并移动allocationPtr。 如果一个对象内部的所有指针 都被处理完,scanPtr就会向前移动,进入下一个循环。 若scanPtr和allocationPtr相遇,则说明所有的对象都已被复制完,From 区剩下的都可以被视为垃圾,可以进行清理了。

举个栗子(以及凑篇幅),如果有类似如下的引用情况:

 +----- A对象
|
根对象----+----- B对象 ------ E对象
|
+----- C对象 ----+---- F对象
|
+---- G对象 ----- H对象

D对象

在执行Scavenge之前,From区长这幅模样

+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H | |
+---+---+---+---+---+---+---+---+--------+

那么首先将根对象能到达的ABC对象复制到To区,于是乎To区就变成了这个样子:

 allocationPtr

+---+---+---+----------------------------+
| A | B | C | |
+---+---+---+----------------------------+

scanPtr

接下来进入循环,扫描scanPtr所指的A对象,发现其没有指针,于是乎scanPtr移动,变成如下这样

 allocationPtr

+---+---+---+----------------------------+
| A | B | C | |
+---+---+---+----------------------------+

scanPtr

接下来扫描B对象,发现其有指向E对象的指针,且E对象在From区,那么我们需要将E对象复制到allocationPtr所指的地方并移动allocationPtr指针:

 allocationPtr

+---+---+---+---+------------------------+
| A | B | C | E | |
+---+---+---+---+------------------------+

scanPtr

B对象里所有指针都已被复制完,所以移动scanPtr:

 allocationPtr

+---+---+---+---+------------------------+
| A | B | C | E | |
+---+---+---+---+------------------------+

scanPtr

接下来扫描C对象,C对象中有两个指针,分别指向F对象和G对象,且都在From区,先复制F对象到To区:

 allocationPtr

+---+---+---+---+---+--------------------+
| A | B | C | E | F | |
+---+---+---+---+---+--------------------+

scanPtr

然后复制G对象到To区

 allocationPtr

+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G | |
+---+---+---+---+---+---+----------------+

scanPtr

这样C对象内部的指针已经复制完成了,移动scanPtr:

 allocationPtr

+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G | |
+---+---+---+---+---+---+----------------+

scanPtr

逐个扫描E,F对象,发现其中都没有指针,移动scanPtr:

 allocationPtr

+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G | |
+---+---+---+---+---+---+----------------+

scanPtr

扫描G对象,发现其中有一个指向H对象的指针,且H对象在From区,复制H对象到To区,并移动allocationPtr:

 allocationPtr

+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H | |
+---+---+---+---+---+---+---+------------+

scanPtr

完成后由于G对象没有其他指针,且H对象没有指针移动scanPtr:

 allocationPtr

+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H | |
+---+---+---+---+---+---+---+------------+

scanPtr

此时scanPtr和allocationPtr重合,说明复制结束 可以对比一下From区和To区在复制完成后的结果:

//From区
+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H | |
+---+---+---+---+---+---+---+---+--------+
//To区
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H | |
+---+---+---+---+---+---+---+------------+

D对象没有被复制,它将被作为垃圾进行回收

写屏障

如果新生代中的一个对象只有一个指向它的指针,而这个指针在老生代中, 我们如何判断这个新生代的对象是否存活? 为了解决这个问题,需要建立一个列表用来记录所有老生代对象指向新生代对象的情况。 每当有老生代对象指向新生代对象的时候,我们就记录下来

对象的晋升

当一个对象经过多次新生代的清理依旧幸存,这说明它的生存周期较长,也就会被移动到老生代,这称为对象的晋升。具体移动的标准有两种:

  1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,如果是,则复制到老生代中,否则复制到To空间中
  2. 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代

生代

老生代的特点

老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象,而且老生代占用的内存较多

老生代的垃圾回收算法

老生代占用内存较多(64位为1.4GB,32位为700MB),如果使用Scavenge算法, 浪费一半空间不说,复制如此大块的内存消耗时间将 会相当长。所以Scavenge算法显然不适合。 V8在老生代中的垃圾回收策略采用 Mark-Sweep和Mark-Compact 相结合

Mark-Sweep(标记清除)

标记清除分为标记和清除两个阶段。 在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。 在清除阶段总,只清除没有被标记的对象。 由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高

标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。 如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

Mark-Compact(标记整理)

标记整理正是为了解决标记清除所带来的内存碎片的问题。 标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。 在整理的过程中,将活着的 对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。 紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。

算法思路

标记清除和标记整理都分为两个阶段:标记阶段、清除或紧缩阶段

在标记阶段,所有堆上的活跃对象都会被标记。 每个内存页有一个用来标记对象的位图,位图中的每一位对应内存页中的一个字。 这个位图需要占据一定的空 间(32位下为3.1%,64位为1.6%)。 另外有两位用来标记对象的状态,这个状态一共有三种(所以要两位)——白,灰,黑:

  • 如果一个对象为白对象,它还没未被垃圾回收器发现
  • 如果一个对象为灰对象,它已经被垃圾回收器发现,但其邻接对象尚未全部处理
  • 如果一个对象为黑对象,说明他步进被垃圾回收器发现,其邻接对象也全部被处理完毕了

结合使用标记清除和标记整理

Chrome V8的老生代使用标记清除和标记整理结合的方式,主要采用标记清除算法,如果空间不足以分配从新生代晋升过来的对象时,才使用标记整理

参考文章:

22%