“栈”在数据结构层面的意义
- 栈是后进先出(LIFO:Last in,first out),新添加的/待删除的保存在栈的末尾,称作栈顶,另一端叫做栈底
- 对比:队列的先进先出(FIFO),从尾部添加新元素,从顶部移除元素。
什么是调用栈
- Call Stack,一种栈结构(数据结构层面的说法)
- 别称:执行栈(execution stack)、控制栈(control stack)、运行时栈(run-time stack)、机器栈(machine stack)、栈(stack)。
- 几乎所有的计算机程序都依赖于调用栈,高级语言一般将调用栈的细节隐藏在后台。
- 调用栈存储有关正在运行的子程序的消息,最常用于存放子程序的返回地址——调用子程序时,主程序必须暂存子程序运行完毕后应该返回到的地址,因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。
- 在递归程序中,每一层次递归都必须在调用栈上增加一条地址,因此如果程序出现无限递归(或仅仅是过多的递归层次),调用栈就会产生栈溢出。
- 调用栈还可以用于存放本地变量(不同子程序变量分离)、参数传递(寄存器不足以容纳子程序的参数)、环境传递(子程序利用主程序的本地变量)
JavaScript的调用栈
JavaScript语言特性
- JavaScript是单线程,这意味着它只有一个Call Stack,一次只能做一件事。
- 这也意味着不需要处理复杂场景,比如死锁,但是单线程运行起来性能也就很有限。
- 使用回调队列
为什么JavaScript是单线程的
- JS诞生就是为了提供Web页面上的交互,比如操作DOM,CSS样式等
- 如果JS是多线程,可能导致在多线程的并行下,他们需要操作的东西变成临界资源,需要浏览器(或者其他执行环境)来决定哪个线程生效。
- 要保证多线程下资源独占,就要引入互斥锁Mutual exclusion(先来的上锁,阻止后来的操作),会带来复杂性
进程process和线程thread
- 进程是能拥有资源和独立运行的最小单位,是程序执行的最小单位/载体,任意时刻,cpu(应该是最小的逻辑单位?)总是运行一个进程,其他进程处于非运行状态——代表cpu能处理的单个任务
- 进程是操作系统分配资源的最小单位,线程则是程序执行的最小单位,线程是一个进程中代码的不同执行路线,早期的操作系统没有线程。
- 进程之间是相互独立的,同一个进程下的各个线程之间共享程序的内存空间和资源
- 多进程是说同一个时间里,同一个计算机系统允许两个或以上的进程在运行状态;多线程是指程序包含多个执行流,完成各自的任务
- Chrome和许多多线程浏览器不同,一个网页就一个进程(这样一个tab崩溃也不会影响其他,另外,也不存在线程间共享地址空间和资源。),也就是一个网页就有一个渲染引擎实例。
JavaScript调用栈的工作原理
- JavaScript的解释器提供了调用栈,在脚本调用多个函数时,跟踪每个函数在完成执行时应该返回控制的点。参考MDN
- 调用一个函数时,解析器把该函数添加到栈中并且执行这个函数。形成一个栈帧(Stack Frame)
- 任何被这个函数调用的函数会被进一步添加到调用栈中,形成另一个栈帧,并且运行在它们被上个程序调用的位置。
- 当函数运行结束后,如果它没有调用其他函数,则解释器将它从堆栈中取出,并在主代码列表中继续执行代码。
- 异步函数的回调函数一般都会被添加到运行队列里面,如
setTimeout
会在到设定的时间后把回调函数放入队列中 - 值得一提的是,
setTimeout
和setInterval
其实有一个最小执行时间,4ms,就算不传第二个参数,也要等到4ms后才会执行。但第一个参数里如果有立即执行的东西,则会按照顺序立即执行,这是一个很有趣的地方。 - 如果栈占用的空间比分配给它的空间还大,那么则会导致“堆栈溢出”错误。
- 简单版的说法:被调用者形成一个栈帧,一个压在上一个之上;执行返回之后,被推出调用栈,上一个则继续执行;如此循环。
并发和事件循环
- 单线程的JS,调用栈是可能被阻塞的
- 如果浏览器开始在栈里处理过多的任务,可能会停止响应很长时间,部分浏览器会选择弹出一个错误,来询问用户是否终止网页。
- 执行大量代码而且不阻塞UI导致浏览器没响应的解决方法就是:异步回调。
this和调用栈
this是在运行时进行绑定的,不是编写的时候绑定的——所以,this的绑定和函数声明的位置没有任何关系,只取决于函数的调用的时刻,如下例子:
1 | function baz(){ |
执行上下文和执行栈
执行上下文
- Execution Context;(注意,执行栈是execution stack)
- 执行上下文又被叫做当前代码的执行环境/作用域
- 通常认为JS代码的执行环境有:
- Global code:默认环境、全局环境,代码首次执行的地方
- Function code:代码执行进入到函数体当中
- Eval code:eval函数内部执行的
执行上下文在执行栈里
- Execution Context Stack
- 底层其实是单线程的JavaScript的栈,所以一次只会发生一件事,其他行为都在栈里排队(这时候我们通常说是“执行栈”)
- 加载了JS脚本后在执行栈里发生的事
- 默认进入全局执行上下文,有且只有一个执行上下文
- 在全局环境里调用了一个函数,程序序列流进入被调用的函数里,创建一个新的执行上下文,并且将其压入执行栈
- 如果在当前函数里又调用了另一个函数,会发生和上面一样的事,新的执行上下文被压入现有栈的顶部,一旦执行函数在当前的执行上下文执行完毕,会被从栈的顶部弹出
- 每个函数调用都会创建一个新的上下文,包括递归的每一次。
- 函数调用结束后,控制权移交给当前栈的下一个上下文
执行上下文的细节——JS解释器的工作描述
创建阶段
- 执行代码之前,创建执行上下文
- 初始化作用域链
- 创建变量对象
- 创建参数对象,检查参数上下文,初始化其名称和值并且创建一个引用拷贝
- 扫描上下文中的函数声明,对每个被发现的函数,在变量对象里创建一个同名属性(函数在内存里的引用);如果函数名已经存在(变量对象),那么引用值会被覆盖(所以重复声明是覆盖)
- 扫描上下文里的变量声明,对每个发现的变量声明,在变量对象里创建一个同名属性,并且初始化为 undefined;如果变量名已经存在(变量对象),就什么都不做,继续扫描
- 决定“this”的值
执行阶段
- 在上下文中解释/执行函数代码,并在代码逐行执行时给变量赋值
- 注意,是这时候才给变量赋值,创建阶段仅仅是创建;但是参数却不是,因为创建阶段完成后,执行流就进入函数里了
- 这就是所谓“提升”(Hoisting)的机制。
另外,需要注意函数在变量之前被扫描,所以同名函数和变量声明有顺序(函数声明会提升的比变量声明更前)。比如下面的例子,foo是函数,而不是变量
1
2
3
4
5
6
7
8
9
10
11(function() {
console.log(typeof foo); // function pointer
console.log(typeof bar); // undefined
var foo = 'hello',
bar = function() {
return 'world';
};
function foo() {
return 'hello';
}
}());
浏览器里的JavaScript执行环境
- GUI渲染线程:
- 负责渲染界面的HTML元素
- 界面需要重绘Repaint或者由于某种操作引发回流reflow时执行
- JavaScript引擎线程
- 也就是JS内核,负责解析JS脚本
- 比如V8
- 定时触发器线程
- 比如我们常见的SetTimeOut
- 不是由JS引擎计数的,否则因为JS是单线程,会因为自己的阻塞导致计时不准确。
- 尽管如此,计时器触发的时候,也只是进入队列,并不一定被JS立即执行,毕竟JS是单线程的
- 事件触发线程
- 比如我们绑定的各种点击事件
- 事件触发,该线程就把事件添加到待处理的队列的队尾等待JS引擎的处理
- 异步http请求线程
- XMLHttpRequest在连接后,是通过浏览器新开了一个线程请求
- 检测到状态变化时,如果有设置回调,就把状态变更事件放到JS引擎的处理队列里等待处理
为什么JS和界面会相互阻塞
JS可以操作DOM,为了避免同时运行的时候出现不可预期的结果,浏览器就设置为这两个线程互斥,当JS引擎执行的时候就挂起GUI线程,将之保存在一个队列里,等JS执行完。这就是为什么JS脚本会阻塞GUI更新——就是故意这么设计的。而一般来说,如果JS执行时间比较长,就会给人一种页面“卡住了”的感觉——其实就是GUI渲染不连贯了。
V8引擎和调用栈
- 引擎主要由两个组件组成
- 堆Heap(内存):内存分配,是无结构的(相比栈),存储了声明的对象、变量
- 栈Stack(调用栈):代码执行时的堆栈帧,后进先出
- 运行时(Runtime)
- 通常,JS的运行时不仅有引擎,还有一些执行环境提供的API(比如DOM、AJAX、setTimeout)
- 还有事件循环Event Loop,回调队列(本质是回调队列,也叫执行队列Callback Queue),一个JS运行时包含了一个待处理的消息队列,每一个消息和一个函数相关联
- 引擎的工作描述:
- 栈有足够内存的时候,从队列取出一个消息进行处理(包含了调用和这个消息相关联的函数,以及因而创建了一个初始栈帧)
- 当栈再次为空的时候,意味着消息处理结束
- 处理队列的时候,涉及的机制就是事件循环,在浏览器里由事件触发线程进行。
- 所以流程就是:在事件循环驱动下,从队列里往包含了堆和栈的引擎里去传送处理,进一步和外部环境比如Web Api交互;外部环境的交互又进一步去影响队列。如此循环。
- Node也用的V8引擎
错误栈
- 调用栈也正是JS代码抛出异常的时候,构建stack trace的方法——基本就是异常发生的时候
- 比如
Uncaught Error: SessionStack will help you resolve crashes
;Uncaught RangeError: Maximum call stack size exceeded
——Blowing the stack,达到最大调用堆栈大小时,比如递归没边界。 - 另外,可以通过
console.trace()
来主动输出当前的堆栈
Error对象
- 程序运行出现错误的时候,通常抛出一个Error对象
- Error对象可以作为用户自定义错误对象继承的原型
- Error.prototype包含下列属性
- constructor——指向实例的构造函数
- message——错误信息
- name——错误的名字
- 不同的运行环境下,Error.prototype可能还含有特定属性,比如Node、Chrome、Firefox、Edge、IE 10+、Safari 6+下,还有stack属性,包含了错误的堆栈轨迹——内含自构造函数之后的所有堆栈结构
- 抛出错误,涉及
try...catch...finally
处理错误堆栈
- 对于支持Error.captureStackTrace的运行环境,比如Node,用起来跟console.trace()差不多,就是
Error.captureStackTrace(obj)
- 第一个参数是object,Error.captureStackTrace捕获堆栈信息后在第一个参数里创建stack属性来存储(通过.stack可以访问)
- 第二个可选参数是一个function,如果有第二个参数,将作为堆栈调用的终点。 捕获到的堆栈信息将只显示该函数调用之前的信息——用于隐藏一些不需要关注的,或者不需要展示的内部细节(比如你传入的是某个会出现在堆栈里的栈桢函数,那么输出的堆栈就只包含这个函数之前的信息)。
Session Stack
- TODO: 一款产品session stack
- 如果JS里出现难以复现和理解的问题,就可以看Session Stack,它会记录所有东西,包括:DOM更改、用户交互、JS异常、堆栈跟踪、网络请求失败、调试信息等;甚至可以以视频的方式来重现问题