【JavaScript 从入门到精通】垃圾回收| this

举报
xcc-2022 发表于 2022/09/16 20:25:44 2022/09/16
【摘要】 @[toc] 垃圾回收对于开发者来说,JavaScript 的内存管理是自动的、无形的。我们创建的原始值、对象、函数……这一切都会占用内存。当我们不再需要某个东西时会发生什么?JavaScript 引擎如何发现它并清理它? 可达性(Reachability)JavaScript 中主要的内存管理概念是 可达性。简而言之,“可达”值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。这...

@[toc]

垃圾回收

对于开发者来说,JavaScript 的内存管理是自动的、无形的。我们创建的原始值、对象、函数……这一切都会占用内存。

当我们不再需要某个东西时会发生什么?JavaScript 引擎如何发现它并清理它?

可达性(Reachability)

JavaScript 中主要的内存管理概念是 可达性

简而言之,“可达”值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。

  1. 这里列出固有的可达值的基本集合,这些值明显不能被释放。

    比方说:

    • 当前执行的函数,它的局部变量和参数。
    • 当前嵌套调用链上的其他函数、它们的局部变量和参数。
    • 全局变量。
    • (还有一些内部的)

    这些值被称作 根(roots)

  2. 如果一个值可以通过引用或引用链从根访问任何其他值,则认为该值是可达的。

    比方说,如果全局变量中有一个对象,并且该对象有一个属性引用了另一个对象,则 对象被认为是可达的。而且它引用的内容也是可达的。下面是详细的例子。

在 JavaScript 引擎中有一个被称作 垃圾回收器 的东西在后台执行。它监控着所有对象的状态,并删除掉那些已经不可达的。

一个简单的例子

这里是一个最简单的例子:

// user 具有对这个对象的引用
let user = {
  name: "John"
};

image-20220613185737408

这里的箭头描述了一个对象引用。全局变量 "user" 引用了对象 {name:"John"}(为简洁起见,我们称它为 John)。John 的 "name" 属性存储一个原始值,所以它被写在对象内部。

如果 user 的值被重写了,这个引用就没了:

user = null;

image-20220613185754179

现在 John 变成不可达的了。因为没有引用了,就不能访问到它了。垃圾回收器会认为它是垃圾数据并进行回收,然后释放内存。

两个引用

现在让我们想象下,我们把 user 的引用复制给 admin

// user 具有对这个对象的引用
let user = {
  name: "John"
};

let admin = user;

image-20220613185826757

现在如果执行刚刚的那个操作:

user = null;

……然后对象仍然可以被通过 admin 这个全局变量访问到,所以对象还在内存中。如果我们又重写了 admin,对象就会被删除。

相互关联的对象

现在来看一个更复杂的例子。这是个家庭:

function marry(man, woman) {
  woman.husband = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
});

marry 函数通过让两个对象相互引用使它们“结婚”了,并返回了一个包含这两个对象的新对象。

由此产生的内存结构:

image-20220613185901009

到目前为止,所有对象都是可达的。

现在让我们移除两个引用:

delete family.father;
delete family.mother.husband;

image-20220613185914015

仅删除这两个引用中的一个是不够的,因为所有的对象仍然都是可达的。

但是,如果我们把这两个都删除,那么我们可以看到再也没有对 John 的引用了:

image-20220613185927825

对外引用不重要,只有传入引用才可以使对象可达。所以,John 现在是不可达的,并且将被从内存中删除,同时 John 的所有数据也将变得不可达。

经过垃圾回收:

无法到达的岛屿

几个对象相互引用,但外部没有对其任意对象的引用,这些对象也可能是不可达的,并被从内存中删除。

源对象与上面相同。然后:

family = null;

内存内部状态将变成:

image-20220613190002359

这个例子展示了可达性概念的重要性。

显而易见,John 和 Ann 仍然连着,都有传入的引用。但是,这样还不够。

前面说的 "family" 对象已经不再与根相连,没有了外部对其的引用,所以它变成了一座“孤岛”,并且将被从内存中删除。

内部算法

垃圾回收的基本算法被称为 “mark-and-sweep”。

定期执行以下“垃圾回收”步骤:

  • 垃圾收集器找到所有的根,并“标记”(记住)它们。
  • 然后它遍历并“标记”来自它们的所有引用。
  • 然后它遍历标记的对象并标记 它们的 引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
  • ……如此操作,直到所有可达的(从根部)引用都被访问到。
  • 没有被标记的对象都会被删除。

例如,使我们的对象有如下的结构:

image-20220613190028648

我们可以清楚地看到右侧有一个“无法到达的岛屿”。现在我们来看看“标记和清除”垃圾收集器如何处理它。

第一步标记所有的根:

image-20220613190052779

然后它们的引用被标记了:

image-20220613190108894

……如果还有引用的话,继续标记:

image-20220613190124098

现在,无法通过这个过程访问到的对象被认为是不可达的,并且会被删除。

image-20220613190135088

我们还可以将这个过程想象成从根溢出一个巨大的油漆桶,它流经所有引用并标记所有可到达的对象。然后移除未标记的。

这是垃圾收集工作的概念。JavaScript 引擎做了许多优化,使垃圾回收运行速度更快,并且不影响正常代码运行。

一些优化建议:

  • 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。许多对象出现,完成它们的工作并很快死去,它们可以很快被清理。那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少。
  • 增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。所以引擎试图将垃圾收集工作分成几部分来做。然后将这几部分会逐一进行处理。这需要它们之间有额外的标记来追踪变化,但是这样会有许多微小的延迟而不是一个大的延迟。
  • 闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

还有其他垃圾回收算法的优化和风格。尽管我想在这里描述它们,但我必须打住了,因为不同的引擎会有不同的调整和技巧。而且,更重要的是,随着引擎的发展,情况会发生变化,所以在没有真实需求的时候,“提前”学习这些内容是不值得的。当然,除非你纯粹是出于兴趣。我在下面给你提供了一些相关链接。

总结

主要需要掌握的内容:

  • 垃圾回收是自动完成的,我们不能强制执行或是阻止执行。
  • 当对象是可达状态时,它一定是存在于内存中的。
  • 被引用与可访问(从一个根)不同:一组相互连接的对象可能整体都不可达。

现代引擎实现了垃圾回收的高级算法。

《The Garbage Collection Handbook: The Art of Automatic Memory Management》(R. Jones 等人著)这本书涵盖了其中一些内容。

如果你熟悉底层(low-level)编程,关于 V8 引擎垃圾回收器的更详细信息请参阅文章 V8 之旅:垃圾回收

V8 博客 还不时发布关于内存管理变化的文章。当然,为了学习垃圾收集,你最好通过学习 V8 引擎内部知识来进行准备,并阅读一个名为 Vyacheslav Egorov 的 V8 引擎工程师的博客。我之所以说 “V8”,因为网上关于它的文章最丰富的。对于其他引擎,许多方法是相似的,但在垃圾收集上许多方面有所不同。

当你需要底层的优化时,对引擎有深入了解将很有帮助。在熟悉了这门编程语言之后,把熟悉引擎作为下一步计划是明智之选。

对象方法,“this”

通常创建对象来表示真实世界中的实体,如用户和订单等:

let user = {
  name: "John",
  age: 30
};

并且,在现实世界中,用户可以进行 操作:从购物车中挑选某物、登录和注销等。

在 JavaScript 中,行为(action)由属性中的函数来表示。

方法示例

刚开始,我们来教 user 说 hello:

let user = {
  name: "John",
  age: 30
};

user.sayHi = function() {
  alert("Hello!");
};

user.sayHi(); // Hello!

这里我们使用函数表达式创建了一个函数,并将其指定给对象的 user.sayHi 属性。

随后我们像这样 user.sayHi() 调用它。用户现在可以说话了!

作为对象属性的函数被称为 方法

所以,在这我们得到了 user 对象的 sayHi 方法。

当然,我们也可以使用预先声明的函数作为方法,就像这样:

let user = {
  // ...
};

// 首先,声明函数
function sayHi() {
  alert("Hello!");
}

// 然后将其作为一个方法添加
user.sayHi = sayHi;

user.sayHi(); // Hello!

ℹ️面向对象编程

当我们在代码中用对象表示实体时,就是所谓的 [面向对象编程](面向对象_百度百科 (baidu.com)),简称为 “OOP”。

OOP 是一门大学问,本身就是一门有趣的科学。怎样选择合适的实体?如何组织它们之间的交互?这就是架构,有很多关于这方面的书,例如 E. Gamma、R. Helm、R. Johnson 和 J. Vissides 所著的《设计模式:可复用面向对象软件的基础》,G. Booch 所著的《面向对象分析与设计》等。

方法简写

在对象字面量中,有一种更短的(声明)方法的语法:

// 这些对象作用一样

user = {
  sayHi: function() {
    alert("Hello");
  }
};

// 方法简写看起来更好,对吧?
let user = {
  sayHi() { // 与 "sayHi: function(){...}" 一样
    alert("Hello");
  }
};

如上所示,我们可以省略 "function",只写 sayHi()

说实话,这种表示法还是有些不同。在对象继承方面有一些细微的差别(稍后将会介绍),但目前它们并不重要。在几乎所有的情况下,较短的语法是首选的。

方法中的 “this”

通常,对象方法需要访问对象中存储的信息才能完成其工作。

例如,user.sayHi() 中的代码可能需要用到 user 的 name 属性。

为了访问该对象,方法中可以使用 this 关键字。

this 的值就是在点之前的这个对象,即调用该方法的对象。

举个例子:

let user = {
  name: "John",
  age: 30,

  sayHi() {
    // "this" 指的是“当前的对象”
    alert(this.name);
  }

};

user.sayHi(); // John

在这里 user.sayHi() 执行过程中,this 的值是 user

技术上讲,也可以在不使用 this 的情况下,通过外部变量名来引用它:

let user = {
  name: "John",
  age: 30,

  sayHi() {
    alert(user.name); // "user" 替代 "this"
  }

};

……但这样的代码是不可靠的。如果我们决定将 user 复制给另一个变量,例如 admin = user,并赋另外的值给 user,那么它将访问到错误的对象。

下面这个示例证实了这一点:

let user = {
  name: "John",
  age: 30,

  sayHi() {
    alert( user.name ); // 导致错误
  }

};


let admin = user;
user = null; // 重写让其更明显

admin.sayHi(); // TypeError: Cannot read property 'name' of null

如果我们在 alert 中以 this.name 替换 user.name,那么代码就会正常运行。

“this” 不受限制

在 JavaScript 中,this 关键字与其他大多数编程语言中的不同。JavaScript 中的 this 可以用于任何函数,即使它不是对象的方法。

下面这样的代码没有语法错误:

function sayHi() {
  alert( this.name );
}

this 的值是在代码运行时计算出来的,它取决于代码上下文。

例如,这里相同的函数被分配给两个不同的对象,在调用中有着不同的 “this” 值:

let user = { name: "John" };
let admin = { name: "Admin" };

function sayHi() {
  alert( this.name );
}

// 在两个对象中使用相同的函数
user.f = sayHi;
admin.f = sayHi;

// 这两个调用有不同的 this 值
// 函数内部的 "this" 是“点符号前面”的那个对象
user.f(); // John(this == user)
admin.f(); // Admin(this == admin)

admin['f'](); // Admin(使用点符号或方括号语法来访问这个方法,都没有关系。)

这个规则很简单:如果 obj.f() 被调用了,则 thisf 函数调用期间是 obj。所以在上面的例子中 this 先是 user,之后是 admin

ℹ️在没有对象的情况下调用:this == undefined

我们甚至可以在没有对象的情况下调用函数:

function sayHi() {
alert(this);
}

sayHi(); // undefined

在这种情况下,严格模式下的 this 值为 undefined。如果我们尝试访问 this.name,将会报错。

在非严格模式的情况下,this 将会是 全局对象(浏览器中的 window,我们稍后会在 全局对象 一章中学习它)。这是一个历史行为,"use strict" 已经将其修复了。

通常这种调用是程序出错了。如果在一个函数内部有 this,那么通常意味着它是在对象上下文环境中被调用的。

ℹ️解除 this 绑定的后果

如果你经常使用其他的编程语言,那么你可能已经习惯了“绑定 this”的概念,即在对象中定义的方法总是有指向该对象的 this

在 JavaScript 中,this 是“自由”的,它的值是在调用时计算出来的,它的值并不取决于方法声明的位置,而是取决于在“点符号前”的是什么对象。

在运行时对 this 求值的这个概念既有优点也有缺点。一方面,函数可以被重用于不同的对象。另一方面,更大的灵活性造成了更大的出错的可能。

这里我们的立场并不是要评判编程语言的这个设计是好是坏。而是要了解怎样使用它,如何趋利避害。

箭头函数没有自己的 “

箭头函数有些特别:它们没有自己的 this。如果我们在这样的函数中引用 thisthis 值取决于外部“正常的”函数。

举个例子,这里的 arrow() 使用的 this 来自于外部的 user.sayHi() 方法:

let user = {
  firstName: "Ilya",
  sayHi() {
    let arrow = () => alert(this.firstName);
    arrow();
  }
};

user.sayHi(); // Ilya

这是箭头函数的一个特性,当我们并不想要一个独立的 this,反而想从外部上下文中获取时,它很有用。在后面的 【深入理解箭头函数】一章中,我们将深入介绍箭头函数。

总结

  • 存储在对象属性中的函数被称为“方法”。
  • 方法允许对象进行像 object.doSomething() 这样的“操作”。
  • 方法可以将对象引用为 this

this 的值是在程序运行时得到的。

  • 一个函数在声明时,可能就使用了 this,但是这个 this 只有在函数被调用时才会有值。
  • 可以在对象之间复制函数。
  • 以“方法”的语法调用函数时:object.method(),调用过程中的 this 值是 object

请注意箭头函数有些特别:它们没有 this。在箭头函数内部访问到的 this 都是从外部获取的。

✅任务

在对象字面量中使用 “this”

重要程度:five:

这里 makeUser 函数返回了一个对象。

访问 ref 的结果是什么?为什么?

function makeUser() {
  return {
    name: "John",
    ref: this
  };
}

let user = makeUser();

alert( user.ref.name ); // 结果是什么?

解决方案

答案:一个错误。

试一下:

function makeUser() {
return {
 name: "John",
 ref: this
};
}

let user = makeUser();

alert( user.ref.name ); // Error: Cannot read property 'name' of undefined

这是因为设置 this 的规则不考虑对象定义。只有调用那一刻才重要。

这里 makeUser() 中的 this 的值是 undefined,因为它是被作为函数调用的,而不是通过点符号被作为方法调用。

this 的值是对于整个函数的,代码段和对象字面量对它都没有影响。

所以 ref: this 实际上取的是当前函数的 this

我们可以重写这个函数,并返回和上面相同的值为 undefinedthis

function makeUser(){
return this; // 这次这里没有对象字面量
}

alert( makeUser().name ); // Error: Cannot read property 'name' of undefined

我们可以看到 alert( makeUser().name ) 的结果和前面那个例子中 alert( user.ref.name ) 的结果相同。

这里有个反例:

function makeUser() {
return {
 name: "John",
 ref() {
   return this;
 }
};
}

let user = makeUser();

alert( user.ref().name ); // John

现在正常了,因为 user.ref() 是一个方法。this 的值为点符号 . 前的这个对象。

创建一个计算器

重要程度:five:

创建一个有三个方法的 calculator 对象:

  • read() 提示输入两个值,并将其保存为对象属性,属性名分别为 ab
  • sum() 返回保存的值的和。
  • mul() 将保存的值相乘并返回计算结果。
let calculator = {
  // ……你的代码……
};

calculator.read();
alert( calculator.sum() );
alert( calculator.mul() );

打开带有测试的沙箱。

解决方案

let calculator = {
  sum() {
    return this.a + this.b;
  },

  mul() {
    return this.a * this.b;
  },

  read() {
    this.a = +prompt('a?', 0);
    this.b = +prompt('b?', 0);
  }
};

calculator.read();
alert( calculator.sum() );
alert( calculator.mul() );

使用沙箱的测试功能打开解决方案。

链式(调用)

重要程度:two:

有一个可以上下移动的 ladder 对象:

let ladder = {
  step: 0,
  up() {
    this.step++;
  },
  down() {
    this.step--;
  },
  showStep: function() { // 显示当前的 step
    alert( this.step );
  }
};

现在,如果我们要按顺序执行几次调用,可以这样做:

ladder.up();
ladder.up();
ladder.down();
ladder.showStep(); // 1
ladder.down();
ladder.showStep(); // 0

修改 updownshowStep 的代码,让调用可以链接,就像这样:

ladder.up().up().down().showStep().down().showStep(); // 展示 1,然后 0

这种方法在 JavaScript 库中被广泛使用。

打开带有测试的沙箱。

解决方案

解决方案就是在每次调用后返回这个对象本身。

let ladder = {
step: 0,
up() {
 this.step++;
 return this;
},
down() {
 this.step--;
 return this;
},
showStep() {
 alert( this.step );
 return this;
}
};

ladder.up().up().down().showStep().down().showStep(); // 展示 1,然后 0

我们也可以每行一个调用。对于长链接它更具可读性:

ladder
.up()
.up()
.down()
.showStep() // 1
.down()
.showStep(); // 0

使用沙箱的测试功能打开解决方案。

👉练习打卡

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。