当前位置: 首页 > news >正文

Js 内存管理和闭包

上篇学习了 js 的运行原理, 过程, v8 引擎介绍, 作用域等知识, 目的是能更深入了解 js 这门语言的一些总体特性, 本篇则继续讲述 js 的内存管理和闭包.

特此说明, 涉及的知识点, 代码笔记等都来之 B站博主 coderwhy 大佬的公开视频资料整理

认识内存管理

不论是是使用何种编程语言, 在代码执行过程中都需要给它分配内存, 用来存储变量, 对象等, 自动化程度可2类:

  • 自动管理: Js, Python, Java, scala, Go, Dart 等, 全自动管理, 但会产生性能损失
  • 手动管理: C, C++, Rust 等, 需要手动 malloc 和 free, 精准控制, 性能极佳, 但容易产生内存泄露风险

这和开车是一样的, 新能源自动挡, 和手动挡的油车, 各有各的好处, 具体也是要看情况而定. 从编程语言应用看, 若对性能有极致追求, 对程序要求精准控制等场景, 如系统软件, 大型游戏, 即时通讯等, 则 C++/Rust 这种是合适的.

对于企业系统管理, 大型线上商城等需要快速开发, 高并发, 游戏后台等系统, 则用 Java/Go 这种自动挡是合适的, 语言简单上手, 生态丰富轮子多. 作为应用软件则非常合适. 至于其他的语言, 如 js, python 等就属于辅助性语言为主了, 整体难度都不大, 在细分场景如网页开发, 可视化渲染, 数据分析, 实现AI算法编写等还是有很多轮子的, 可以根据兴趣玩一玩.

不论是自动挡还是手动挡, 内存管理的声明周期都是一样的:

  • 使用前, 申请分配需要的内存
  • 使用时, 分配下来的内存 (存变量, 数据, 对象, 数据结构等)
  • 使用后, 及时手动/自动释放内存

在编程领域基本有一个共识: 程序 = 数据结构 + 算法. 以前一直对它不太理解, 但当我们明白, 计算机的内存是有限的, 要通过有限的内存, 高效地去完成任务, 这就是算你厉害. 同理, 假设内存是无限的, 不就随心所欲了嘛, 还学个屁的编程, 直接拉满.

Js 内存管理

js 在质疑变量时, 会自动为我们分配内存, 根据数据类型大体上分为 2类:

  • 基本数据类型: undefined, null, boolean, number, string, symbol, bigint 会在 栈空间分配 - 直接存值
  • 复杂数据类型: Object, Array, Function, Date, RegExp 等会在 堆空间分配 - 存的引用地址

这种划分方式, Java, Go 也是一样的, 我其实堆内存也不太理解, 大致是说像这种普通数据结构占用内存较小, 就直接存值即可; 对于内存占用可能会大的, 如函数, 对象, 一些数据结构等, 则存其在堆空间的地址即可.

在函数调用时, Js 和 Go 的基本对象都是按值传递的, 即搞个副本传过去, 即传值模式; 对复杂类型则通过传地址/指针方式实现.

补充: Go 的 slice / map / chnnel 看上去是引用类型, 但去吃是通过指针实现的, 传的是值哈!

Js 垃圾回收

因为内存是有限的, 所以当不再需要的时候要及时释放, 不然会造成内存泄露. 这个过程称为垃圾回收, 简称 GC.

在手动挡的语言中, 如 C / C++ 虽然能实现精准控制, 但其编写逻辑的代码非常低效, 同时 对程序员水平要求极高, 因为一不小心就会搞出野指针等问题, 产生 内存泄露. 所以更适合大牛来开发系统级程序, 俺就开自动挡就行.

但手动挡这里有个牛逼的编程语言 Rust 则采用了一种称为 所有权系统 的机制来管理内存, 让程序在编译期就能确定内存的分配和释放, 没有运行时的垃圾回收开销 且 内存安全, 这不比 C++ 还牛逼吗?

我曾经学了它半个月, 从入门到放弃, 真的是有点难度, 搞不懂的, 真的被编译器搞到心里崩溃.

  • 所有权: 每个值在 Rust 中都有一个所有者的变量; 每个值同时只能有一个所有者; 所有着离开作用域, 则释放
  • 借用: Rust 允许通过引用, 临时借用一个值而不转移其所有权 (ower); 分为不可变引用 (&T) 和 可变引用 (&mutT), 在同一作用域内的引用方式是互斥的, 以避免数据竞争
  • 声明周期: 用于告知编译器某个引用的有效作用域; 帮助编译器验证引用不会指向已经无效的数据

后来还是转向学 Go 了, 但并影响我对 Rust 优秀的赞美, 尤其是内存安全, 极致性能, 现代化类型系统等, 不明觉厉.

主要我的场景是搞个高性能的后台语言提供数据 api 服务, Go 的上手难度显然贼低, 但是性能也很高, 对比着 Python / Flask 不到半个月就转为 Go / Gin 上手开发了, 爽的很.

就是不想用 java spring boot 虽然成熟稳定可靠, 但是我嫌麻烦啰嗦, 毫无编程体验的快感.

继续, 在自动挡语言中, 如 Js, Java, Python, Go 中, 都有自己不同的垃圾回收机制, 如 Java 的 jvm 回收, Js 的 js引擎回收, Python 的解释器回收等. 那问题是, GC 是如何知道哪些变量, 对象等是不再使用了呢.

常见的 GC 算法

比较出名算法有: 引用计数, 标记清除, 标记整理, 分代回收, 并发回收, 增量回收, 复制算法等. 对于编程语言来说, 并不局限于一种算法, 通常是多种算法进行组合来用, 从而取众家之所长.

这里就简单介绍一下 引用计数 和 标记清除好了.

  • 引用计数: 一个对象有引用指向它时, 则对象引用值 +1, 当引用值为0, 则销毁; 但对于循环引用就失效了.
  • 标记清除: 设置一个根对象, 定期从根 root 开始, 递归查找所有存活的对象, 销毁掉哪些没存活的; 能有效解决循环引用的问题, 但是会导致内存碎片化, 影响大块内存的分配效率
// 引用计数
var obj  = { name: 'youge' }
var info = { name: 'cj', friend: obj }
var p    = { name: 'yaya', friend: obj }

这里会在 堆内存 中开辟 3 个内存空间 (对象): obj, info, p ; 对于 info 和 p 对象都有一个 friend 属性, 存的是同一个 obj 的地址, 比如是 0x101.

对于 obj 对象来说, 它当前的引用计数是 3:

  • 被 info 对象引用
  • 被 p 对象引用
  • 被 obj 对象引用, 就 obj 这个变量是存在 栈空间的, 值是内存地址而已

如果我现在做这样的一个操作:

// obj 引用计数最开始是 3info.friend = null // obj 引用 -1
p.friend = null    // obj 引用 -1
obj = null         // obj 引用 -1

当 obj 的引用计数为0, 则 js 引擎就自动进行内存释放了.

但它存在一个超级大的弊端, 无非处理循环引用的情况:

// 很大弊端: 循环引用var obj1 = {friend: obj2}
var obj2 = {friend: obj1}

这样的话, 两个对象互相引用, 则永远不会被释放, 就造成了内存泄露了.

对于标记清除算法, 它是从根对象开始遍历并标记 所有可达对象, 而不仅仅是看是否被引用

// 标记清除: 解决循环引用问题
let objA = {};
let objB = {};// 让俩对象互相引用
objA.ref = objB;
objB.ref = objA;// 让 objA 和 objB 失去外部引用
objA = null;
objB = null;

GC 从跟对象开始遍历时, 会发现 objA 和 objB 都不可达, 因为他们已经被设置 null 啦, 失去了所有外部引用,

即便他们互相还是引用着的, 那也不会被标记存活.

由此可见, 针对不同的应用场景, 需要组合各种 GC 算法来共同解决, 算啦, 我还是开自动挡吧, 这个玩不来.

Js 闭包

闭包算是大部分人都搞不清的东西, 不只是 js, 我以前学的 Java, Python, Go 中都有闭包的概念, 真正有用的话, 应该是前几年在 Python 中用 装饰器 的东西, 有涉及闭包.

闭包的通用定义大致是这样说的:

  • 闭包 (Closure), 又称 词法闭包 或者 函数闭包 , 在支持 头等函数的语言中, 实现词法绑定的一种技术
  • 闭包在实现上是一个结构体 (js 中是对象), 它存储了一个函数和一个关联的环境, 类似一个符号查找表
  • **闭包同函数的区别在于, 当捕捉闭包时, 它的 自由变量 在捕捉时确定, 即便脱离了上下文, 依然能运行. **

其他语言中的闭包

在 Python 中装饰器就是再不影响原函数的情况加, 对其进行一些拓展延升, 简单实现就是定义一个 嵌套函数,

返回一个内置函数 (被修饰), 内函数能引用到外函数的一些变量等.

def my_decorator(func):def wrapper(*args, **kwargs):print(f"函数 {func.__name__} 即将执行")# 被修饰的函数正常调用, 可同时在它前后进行额外增强result = func(*args, **kwargs)print(f"函数 {func.__name__} 执行完毕")return resultreturn wrapper# @ 是个语法糖, 等价于
# say_hello = my_decorator(say_hello)
# say_hello()@my_decorator
def say_hello():print("Hello, world!")say_hello()
函数 say_hello 即将执行
Hello, world!
函数 say_hello 执行完毕

这里最让人迷惑的地方是嵌套的内部函数 wrapper 在 return 之后竟然还能获取到 外层函数的参数, 继续执行.

难道这就是 死而不亡者寿? , 其实这里就是实现了闭包 (Closure), 让一个函数能 记住 其外部作用域的变量, 即便外部函数已执行完毕.

js函数是一等公民

不只是 js, 在 Go, Python, Scala 语言中, 函数都是一等公民.

在 Python 和 Go 中, 函数是一种类型, 可以赋值, 传参, 返回; 在 Scala 中, 函数除了可作为变量, 参数, 返回值等之外, 还支持高阶函数, 匿名函数, 柯里化, 函数组合等.

函数, 尤其是函数式编程语言来说格外重要, 像我这种搞数据的, 就特别需要函数式编程语言来处理一步步迭代计算, 而这些就是面向对象语言不太能胜任的

js 中, 函数可以作为参数在函数中传递, 比如回调函数等, 或者是普通函数也行.

// 函数作为参数
function calc(num1, num2, calcFn) {result = calcFn(num1, num2)return result
}// 加减乘除
function add(num1, num2) {return num1 + num2
}function sub(num1, num1) {return num1 - num1
}function div(num1, num2) {return num1 / num2
}// 调用函数时候, 将函数作为参数传递
console.log(calc(10, 20, add))  // 30
console.log(calc(10, 20, sub))  // -10
console.log(calc(10, 20, div))  // 0.5

同时, 函数也可以作为返回值, 外部调一次则拿到内函数, 然后可以调用, 且外部参数不会销毁, 就很神奇.

// 函数作为返回值
function adder(count) {// 将外函数的 count 传到内函数function add(num) {return count + num}// 将内函数作为返回值, 外部可以继续调用return add 
}var adder10 = adder(10)
console.log(adder10(5)) // 15var adder20 = adder(20)
console.log(adder20(10)) // 30

高阶函数

当一个函数接收另外一个函数作为参数, 或者该函数会返回另外一个函数, 作为返回值的函数, 那么这个函数就称之为是一个高阶函数.

这里做一个查找所有偶数的栗子, 分别用普通逻辑方式和高阶函数进行对比.

// 普通逻辑写法, 一步步迭代
var nums = [10, 3, 11, 89, 22, 45]
var ret = []for (var i = 0; i < nums.length; i++) {var num = nums[i]if (num % 2 == 0) {ret.push(num)}
}console.log(ret)  // [10, 22]

而如果用高阶函数, js 有一些内置的, 可以直接用就好了, 则可以这样:

// 高阶函数: filter, 选出满足条件的
var nums = [10, 3, 11, 89, 22, 45]var ret = nums.filter(function(num) {// 遍历每个元素,返回符合条件的return num % 2 == 0
})console.log(ret) // [10, 22]

继续在补充一些数组比较常见的高阶函数, 当做复习一下.

// 数组高阶函数: map, 映射
var arr = [1, 2, 3]
var ret = arr.map(function(item) {return item * item 
})console.log(ret)  // [1, 4, 9]
// 数组高阶函数: forEach, 迭代
var arr = [1, 2, 3]var ret = arr.forEach(function(item) {console.log(item)
})console.log(ret) // 1
// 2
// 3
// 数组高阶函数: find / findIndex, 查询var friends = [{name: 'youge', age: 18},{name: 'cj',    age: 30},{name: 'yaya',  age: 20},
]// 找出名字为 cj 的信息
var cj = friends.find(function(item) {return item.name == 'cj'
})// 找出名字为 cj 的索引
var cjIndex = friends.findIndex(function(item) {return item.name == 'cj'
})// cj info: { name: 'cj', age: 30 }
console.log('cj index:', cjIndex)// cj index: 1
console.log('cj index:', cjIndex)  

其他就不列举了, 大致差不多, 感觉还是没有箭头函数写起来更简约呀. 补充了这些栗子也是为了说明函数是可以作变量, 为参数, 作为返回值使用的, 理解它也是非常关键.

js闭包

经上面的通用解释和对 js 函数是一等公民的理解, 现在来理解 js 中的闭包则就非常容易了.

  • js 闭包是一个组合区域, 由一个函数和对其周围状态 (词法环境) 的引用 捆绑 在一起的区域组合构成
  • js 闭包可以让一个内层函数 访问 到外层函数的作用域
  • js 中每当创建一个函数, 闭包就会在函数创建的同时被创建出来
// 闭包的内存表现function foo() {var name = 'foo'function bar() {console.log('bar func', name)}return bar
}var fn = foo() // 拿到 bar 及其自由变量
fn() // 继续调用, 输出 'bar func'

来分析一下闭包在内存中是怎么表现的,

编译阶段:

GO: { window..., foo: 0x101, fn: undefined }

执行上下文-解析:

AO-foo: { name: undefined, bar: 0x102 }

执行上下文-执行 (从上往下)

var fn = foo()

GO: { window..., foo: 0x101, **fn: 0x102 **}

AO-foo: {name: 'foo'}

正常来说, 这个 foo() 执行完, 返回一个 bar 函数的地址, 会给到 GO, 那 foo() 及 里面的 name 都应该销毁才对

但其实并不是这样, 因为对于 函数的 VO 来说, VO = 函数 + [[parent scope]]

  • 对于内函数 bar 来说, 它还 记忆 了其父级作用域, 及 foo (js 引擎内部实现的)
  • 此时 bar 还是在 GO 中的, 因此, bar 函数能继续调用, 且 bar 函数自身已经记忆了其父级的 foo
  • 因此, 在 bar 中, 还是能访问到, 父级 AO 的自由变量 name

此时的闭包, 就是 bar 函数及其父级 foo 组成的的 '区域', 词法解析阶段就已经确定啦, 就是前面将的 js作用域

// 继续执行
fn() // 即执行内部的 bar 函数

AO-bar: { } 是没有内容的, 因为执行了 console.log('xxx')

这样程序就执行结束了, 注意:

  • 此时的 GO 对象是不会销毁的
  • 此时的 AO-foo 会被销毁, 因为它没有被引用了, AO-bar 也会被销毁

继续来看 js 中闭包的争议情况:

var name = 'youge'function foo() {console.log(name)
}

上面的代码, 从词法来说就是形成闭包, 因为 foo 函数 的父级作用域是 全局作用域, 能访问到父级作用域的 name 自由变量.

function test() {}

这个 test 函数构成闭包吗? 有点小争议的, 通常我们认为, 如果没有访问外部作用域的东西, 就不是闭包.

但是呢, 从定义来看, 这个 test 函数的父级作用域是 全局作用域, 它只是没有访问, 但不代表不能访问, 从这个视角看, 它就是闭包. 当然严谨一点还是说, 有访问才形成.

小结一下:

  • 一个普通函数 function, 若它可以访问外层作用域的自由变量, 那这个函数就是一个闭包
  • 从广义角度看, js 中的所有函数都是闭包 (js 引擎在词法解析阶段就确定了, 函数及其父级作用域)
  • 从狭义角度看, js 中的一个函数, 如果访问了外层作用域的变量, 那它就是一个闭包.

js 闭包内存泄露

闭包是有可能存在内存泄露的, 原因在于我们的 引用链 中的对象可能会存在无法释放的情况.

// 闭包: 内存泄露function foo() {var name = 'youge'function bar() {console.log(name)}return bar 
}var fn = foo()
fn()
  • 这里的 GO 中 fn 是指向 bar 函数的地址的
  • bar 和 foo 形成了闭包, 它也是会引用到其父级作用域 foo 的
  • Fn -> foo -> bar 始终可达, 只要这个全局的 fn 一直存在, 则 bar 就不会自动销毁

因此如果不手动去销毁调 fn 则就造成了内存泄露啦, 处理的办法也很简单直观:

// 让 fn 指向 null,  则 bar 就不可达了, 就被销毁了
fn = null
foo = null // 切断联系即可

继续来写一个闭包内存泄露的案例:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head>
<body><button class="create">创建超大数组对象</button><button class="destroy">销毁超大数组对象</button><script>function createArray() {// ['nb', 'nb', .....]var arr = new Array(1024*1024).fill('nb')function print() {// 让这个 print 函数去引用 arr 对象, 让其不会被销毁console.log(arr)}return print}// 交互var createBtnEl = document.querySelector('.create')var destroyBtnEl = document.querySelector('.destroy')var totalArr = []createBtnEl.onclick = function() {// 搞 100个出来, 让这个 print 都和它有联系for (var i = 0; i < 100; i++) {totalArr.push(createArray())}}// 上面的闭包, totalArr 大数组如果不进行手动销毁// 则会一直堆积, 最后内存泄露destroyBtnEl.onclick = function() {totalArr = [] // null 也可以}</script>
</body>
</html>

最后关于 js 闭包中的自由变量 , 即父级作用域中的变量, 如果在内函数中, 又被引用到则不会被销毁, 如果没有引用到, js 会自动进行销毁.

至此, 关于闭包 和 js 闭包的相关理解就清晰了, 它最迷惑的地方, 是要去理解闭包的概念和产生的原因. 很多场景都会用到闭包, 比如 Python 中的装饰器, web 框架 如 fastapi 中的路由装饰等;

同样, 在 js 中, 闭包的应用场景, 比如对数据的隐藏和封装, 实现函数工厂, 回调函数实现, 创建模块模式, 创建记忆函数 等都非常有空, 后面在举例子吧.

但同时也需要时刻警惕, 闭包用的不要就容易造成 内存泄露, 需要分析其引用是否需要 手动释放呢.

http://www.vanclimg.com/news/1908.html

相关文章:

  • js高级第二天
  • 双向循环链表完整实现与详解
  • CSS 线性渐变
  • VMware ESXi 8.0U3g 发布 - 领先的裸机 Hypervisor
  • 装机软件记录
  • day3_javascript1
  • day4_javascript2
  • 电化学
  • 亚马逊AutoML论文获最佳论文奖
  • 前端加密实现
  • SQL注入漏洞
  • MX galaxy Day16
  • 30天总结-第二十八天
  • 金华の第二场模拟赛
  • [Unity] 项目的一些系统架构思想
  • 多github账号的仓库配置
  • Project 2024 专业增强版安装激活步骤(附安装包)2025最新详细教程
  • MX galaxy Day15
  • Plant Com | 将基因编辑与组学、人工智能和先进农业技术相结合以提高作物产量
  • PhenoAssistant:一个用于自动植物表型分析的人工智能系统
  • 在Docker中,可以在一个容器中同时运行多个应用进程吗?
  • Computomics:利用先进的机器学习实现预测性植物育种
  • 在运维工作中,Docker 与 Kvm 有何区别?
  • 利用分子与数量遗传学最大化作物改良的遗传增益
  • 在运维工作中,详细说一下 Docker 有什么作用?
  • 7.29总结
  • busybox的编译简介
  • 基因组辅助作物改良
  • 洛谷题解:P1514 [NOIP 2010 提高组] 引水入城
  • 如何利用机器学习构建种质资源/品种分子鉴定系统?