前言
一个经典问题,来自 2ality.com/try-finally。
下面代码输出结果是什么?
var count = 0;
function foo() {
try {
return count;
} finally {
count++;
}
}
console.log(foo());
console.log(count);
输出:
0
1
因此作者断言:
- The
finallyclause is always executed, no matter what happens inside thetryclause (return, exception, break, normal exit).『finally 总会被执行,无论 try 语句内执行了何种操作,比如 return、抛错、break、正常退出』- However, it is executed after the return statement.『然而 finally 在 return 之后执行』
第一点是对的。第二点从结果看起来确实是先执行了 return 返回 0,然后再 count++,count 变成 1。但其实不然,return 不应该永远是函数最后执行的语句吗?因为函数一旦退出调用栈被摧毁,不可能再执行函数内任何代码了,这是函数的底层的工作机制,不是语法层能违背的。那么假如 finally 先执行,修改了 count 的值,为何对最终 return 的 count 没有任何影响?二者从 JS 代码层面看起来就是一个 count!
而且该文章第二条评论,也指出了该问题:finally 并不是在 return 之后执行,并且说 hold 即将返回的值,所以返回的值仍然是 0,就如同拍了一个快照。

Finally is not "after" the return nor do any of the previous comments explain it well, maybe this one will,
The try's return executes and the function essentially
holdsthat return value (taken from count when it was at 0), then the finally is executed.If the finally doesn't return or throw, then function returns the try's return value.
翻译:Finally 并非在 return 之后执行。return 先执行并且函数『保存』这个 return 的值(当时 count 的值为 0),然后 finally 才执行。如果 finally 里面没有 return 或 throw,那么函数返回 try 里面 return 的值。
以上每一句话都很重要,大家多读几遍。
这就是我们要论证的:return 是最后一个执行语句,以及到底是什么 hold 了这个返回值 0,两个 count 究竟是否是同一个。
What Holds the return Value
首先要有个常识 JS 代码最终执行的代码很可能不是你所看到的。What your see may be not what the V8 actually executed!因为 JS 是解释性语言,实际运行的代码会经历
JavaScript Code => V8 Bytecode => Machine Code
即这一张著名的图:

所以接下来我将把上面的代码以字节码的形式掰开揉碎,看看到底底层执行了什么?到底两个 count 是不是同一个。
将代码稍微变化下,增加特殊的数字 13,7,10 方便观察字节码:
let count = 13;
function foo() {
try {
count += 7;
return count;
}
finally {
count += 10;
}
}
console.log('return:', foo()); // 13 + 7 = 20
console.log('count:', count); // 13 + 7 + 10 = 30
返回
return: 20
count: 30
确实和之前的题目保持一致。转成字节码:
node --print-bytecode --print-bytecode-filter=foo finally-countpp.js
输出:如果行太长,请➡️右滑
[generated bytecode for function: foo]
Parameter count 1
Register count 4
Frame size 32
29 E> 0x1477b9ae18d6 @ 0 : a5 StackCheck
0x1477b9ae18d7 @ 1 : 27 ff f9 Mov <context>, r2
46 S> 0x1477b9ae18da @ 4 : 1a 04 LdaCurrentContextSlot [4]
0x1477b9ae18dc @ 6 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18de @ 8 : 40 07 00 AddSmi [7], [0]
0x1477b9ae18e1 @ 11 : 26 f8 Star r3
0x1477b9ae18e3 @ 13 : 1a 04 LdaCurrentContextSlot [4]
52 E> 0x1477b9ae18e5 @ 15 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18e7 @ 17 : 25 f8 Ldar r3
0x1477b9ae18e9 @ 19 : 1d 04 StaCurrentContextSlot [4]
63 S> 0x1477b9ae18eb @ 21 : 1a 04 LdaCurrentContextSlot [4]
0x1477b9ae18ed @ 23 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18ef @ 25 : 26 fa Star r1
0x1477b9ae18f1 @ 27 : 0c 01 LdaSmi [1]
0x1477b9ae18f3 @ 29 : 26 fb Star r0
0x1477b9ae18f5 @ 31 : 8b 07 Jump [7] (0x1477b9ae18fc @ 38)
0x1477b9ae18f7 @ 33 : 26 fa Star r1
0x1477b9ae18f9 @ 35 : 0b LdaZero
0x1477b9ae18fa @ 36 : 26 fb Star r0
0x1477b9ae18fc @ 38 : 0f LdaTheHole
0x1477b9ae18fd @ 39 : a6 SetPendingMessage
0x1477b9ae18fe @ 40 : 26 f9 Star r2
97 S> 0x1477b9ae1900 @ 42 : 1a 04 LdaCurrentContextSlot [4]
0x1477b9ae1902 @ 44 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae1904 @ 46 : 40 0a 01 AddSmi [10], [1]
0x1477b9ae1907 @ 49 : 26 f8 Star r3
0x1477b9ae1909 @ 51 : 1a 04 LdaCurrentContextSlot [4]
103 E> 0x1477b9ae190b @ 53 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae190d @ 55 : 25 f8 Ldar r3
0x1477b9ae190f @ 57 : 1d 04 StaCurrentContextSlot [4]
0x1477b9ae1911 @ 59 : 25 f9 Ldar r2
0x1477b9ae1913 @ 61 : a6 SetPendingMessage
0x1477b9ae1914 @ 62 : 25 fb Ldar r0
0x1477b9ae1916 @ 64 : 9f 01 02 00 SwitchOnSmiNoFeedback [1], [2], [0] { 0: @70, 1: @73 }
0x1477b9ae191a @ 68 : 8b 08 Jump [8] (0x1477b9ae1922 @ 76)
0x1477b9ae191c @ 70 : 25 fa Ldar r1
0x1477b9ae191e @ 72 : a8 ReThrow
0x1477b9ae191f @ 73 : 25 fa Ldar r1
114 S> 0x1477b9ae1921 @ 75 : a9 Return
0x1477b9ae1922 @ 76 : 0d LdaUndefined
114 S> 0x1477b9ae1923 @ 77 : a9 Return
Constant pool (size = 3)
Handler Table (size = 16)
from to hdlr (prediction, data)
( 4, 33) -> 33 (prediction=0, data=2)
return: 20
count: 30
怎么阅读字节码
背景知识
V8 的 Ignition 解释器采用 Register Machine 即寄存器机器架构。寄存器或栈这是几乎所有虚拟机的架构选型,为什么 V8 采用寄存器架构先不展开。V8 Ignition 使用普通寄存器 r0、r1、r2,…… 以及一个累加器寄存器 accumulator register,累加寄存器和普通寄存器几乎一样,但一般用作临时变量存储,我们在写指令的时候都会特意省略它,因为几乎所有的字节码指令都会操作累加器寄存器,通过省略累加器寄存器,可让字节码变得紧凑以及节省内存。比如 Add r1 将寄存器 r1 内的值和累加器中的相加并放到累加器中,通过省略累加器代码更短。
我们尝试读两个关键指令:LdaSmi [42] 和 Star r0。
大多是字节码以 Lda 或 Sta 开头,此 a 在 Lda 和 Sta 中代表 accumulator,即累加寄存器。例如 LdaSmi [42] 将 small integer (Smi) 42 加载到累加器寄存器中(Load the Small Integer (Smi) 42 into the accumulator register),42 => a。Star r0 将累加器中的值存到寄存器 r0 中(St ores the value currently in the a ccumulator in r egister r0),a => r0。
阅读思路有了,但只会阅读这两个当然还不够,我们的示例需要我们理解更多字节码,这就要翻阅 V8 源码了 interpreter/interpreter-generator.cc,里面有详细的注释,阅读难度中等偏下。
开始阅读
我们将通过增加注释方式和大家一起阅读,摘取关键字节码段落。
step1: count += 7
过长请➡️右滑
46 S> 0x1477b9ae18da @ 4 : 1a 04 LdaCurrentContextSlot [4] // 将当前上下文中的 count 13 加载到累加器(a=13)
...
0x1477b9ae18de @ 8 : 40 07 00 AddSmi [7], [0] // 🔥 1️⃣ 对应 count += 7; 并将结果 20 放到累加器中(a=20)
0x1477b9ae18e1 @ 11 : 26 f8 Star r3 // 将累加器中的值放到 r3 中。(r3=20)
...
0x1477b9ae18e7 @ 17 : 25 f8 Ldar r3 // 将 r3 中的值加载到累加器中,此时 a=20
0x1477b9ae18e9 @ 19 : 1d 04 StaCurrentContextSlot [4] // 将累加器中的值存储到上下文中,即保存上下文(context[4]=20),为切换上下文到 finally 做准备
63 S> 0x1477b9ae18eb @ 21 : 1a 04 LdaCurrentContextSlot [4] // a=20
0x1477b9ae18ed @ 23 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18ef @ 25 : 26 fa Star r1 // 🔥 r1=20,请记住这个 r1 寄存器
| acc | r0 | r1 | r2 | r3 | CurrentContextSlot |
|---|---|---|---|---|---|
| 13+7=20 | 20(记住 r1) | 20 | 20 |
step 2: count += 10
过长请➡️右滑
0x1477b9ae18f5 @ 31 : 8b 07 Jump [7] (0x1477b9ae18fc @ 38) // 跳到第 38 行,即跳到 finally
...
0x1477b9ae18fd @ 39 : a6 SetPendingMessage // keep context alive
...
97 S> 0x1477b9ae1900 @ 42 : 1a 04 LdaCurrentContextSlot [4] // 保活故能取到 a=20
...
0x1477b9ae1904 @ 46 : 40 0a 01 AddSmi [10], [1] // 🔥 2️⃣ 对应 `count += 10` a=30
0x1477b9ae1907 @ 49 : 26 f8 Star r3 // r3=a=30
...
0x1477b9ae190d @ 55 : 25 f8 Ldar r3 // a=r3=30
0x1477b9ae190f @ 57 : 1d 04 StaCurrentContextSlot [4] // 🔥 3️⃣ 保存上下文,context[4]=30
| acc | r0 | r1 | r2 | r3 | CurrentContextSlot |
|---|---|---|---|---|---|
| 13+7=20 | 20(记住 r1) | 20 | 20 | ||
| 20+10=30 | 20(记住 r1) | 30 | 30 |
step 3: return count
过长请➡️右滑
// throw 则跳转到 70,否则到 73
0x1477b9ae1916 @ 64 : 9f 01 02 00 SwitchOnSmiNoFeedback [1], [2], [0] { 0: @70, 1: @73 }
0x1477b9ae191a @ 68 : 8b 08 Jump [8] (0x1477b9ae1922 @ 76)
0x1477b9ae191c @ 70 : 25 fa Ldar r1
0x1477b9ae191e @ 72 : a8 ReThrow
0x1477b9ae191f @ 73 : 25 fa Ldar r1 // 🔥 4️⃣ a=r1=20 上面注意的 `r1` 此处出现了
114 S> 0x1477b9ae1921 @ 75 : a9 Return // 🔥 最终返回 20
0x1477b9ae1922 @ 76 : 0d LdaUndefined
114 S> 0x1477b9ae1923 @ 77 : a9 Return
| acc | r0 | r1 | r2 | r3 | CurrentContextSlot |
|---|---|---|---|---|---|
| 13+7=20 | 20(记住 r1) | 20 | 20 | ||
| 20+10=30 | 20(记住 r1) | 30 | 30 |
将 r1 返回,故最终返回 20,但上下文中的 count 变成了 30,故输出 30。
return: 20
count: 30
完整注释版:请➡️右滑
[generated bytecode for function: foo]
Parameter count 1 // 共 1 个参数,即隐式的 this,形参为 0 个
Register count 4 // 涉及普通寄存器 4 个 r0 r1 r2 r3
Frame size 32
29 E> 0x1477b9ae18d6 @ 0 : a5 StackCheck // 检查是否栈溢出
0x1477b9ae18d7 @ 1 : 27 ff f9 Mov <context>, r2 // r2=context=13
46 S> 0x1477b9ae18da @ 4 : 1a 04 LdaCurrentContextSlot [4] // 将当前上下文中的 count 13 加载到累加器(a=13)
0x1477b9ae18dc @ 6 : aa 00 ThrowReferenceErrorIfHole [0] // 因为 count 是 let 声明的,故需要判断累加器中是否是 hole,如果是则抛错,即所谓的『暂时性死区』TDZ 检查,若是 var 则无需检查
0x1477b9ae18de @ 8 : 40 07 00 AddSmi [7], [0] // 1️⃣ 对应 count += 7; 并将结果 20 放到累加器中(a=20)
0x1477b9ae18e1 @ 11 : 26 f8 Star r3 // 将累加器中的值放到 r3 中。(r3=20)
0x1477b9ae18e3 @ 13 : 1a 04 LdaCurrentContextSlot [4] // 将上下文中的 count 13 加载到累加器(a=13)
52 E> 0x1477b9ae18e5 @ 15 : aa 00 ThrowReferenceErrorIfHole [0] // TDZ 检查
0x1477b9ae18e7 @ 17 : 25 f8 Ldar r3 // 将 r3 中的值加载到累加器中,此时 a=20
0x1477b9ae18e9 @ 19 : 1d 04 StaCurrentContextSlot [4] // 将累加器中的值存储到上下文中,即保存上下文(context[4]=20),为切换上下文到 finally 做准备
63 S> 0x1477b9ae18eb @ 21 : 1a 04 LdaCurrentContextSlot [4] // a=20
0x1477b9ae18ed @ 23 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae18ef @ 25 : 26 fa Star r1 // r1=20
0x1477b9ae18f1 @ 27 : 0c 01 LdaSmi [1] // a=1
0x1477b9ae18f3 @ 29 : 26 fb Star r0 // r0=1
0x1477b9ae18f5 @ 31 : 8b 07 Jump [7] (0x1477b9ae18fc @ 38) // 跳到第 38 行,即跳到 finally
0x1477b9ae18f7 @ 33 : 26 fa Star r1
0x1477b9ae18f9 @ 35 : 0b LdaZero
0x1477b9ae18fa @ 36 : 26 fb Star r0
0x1477b9ae18fc @ 38 : 0f LdaTheHole // a=<the_hole> hole 是一种 undefined 但稍不同下一篇讲
0x1477b9ae18fd @ 39 : a6 SetPendingMessage // keep context alive
0x1477b9ae18fe @ 40 : 26 f9 Star r2 // r2=<the_hole>
97 S> 0x1477b9ae1900 @ 42 : 1a 04 LdaCurrentContextSlot [4] // 保活故能取到 a=20
0x1477b9ae1902 @ 44 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae1904 @ 46 : 40 0a 01 AddSmi [10], [1] // 2️⃣ 对应 `count += 10` a=30
0x1477b9ae1907 @ 49 : 26 f8 Star r3 // r3=a=30
0x1477b9ae1909 @ 51 : 1a 04 LdaCurrentContextSlot [4] // a=20
103 E> 0x1477b9ae190b @ 53 : aa 00 ThrowReferenceErrorIfHole [0]
0x1477b9ae190d @ 55 : 25 f8 Ldar r3 // a=r3=30
0x1477b9ae190f @ 57 : 1d 04 StaCurrentContextSlot [4] // 3️⃣ 保存上下文,context[4]=30
0x1477b9ae1911 @ 59 : 25 f9 Ldar r2 // a=<the_hole>
0x1477b9ae1913 @ 61 : a6 SetPendingMessage // a=<pending_message>
0x1477b9ae1914 @ 62 : 25 fb Ldar r0 // a=r0=1
// throw 则跳转到 70,否则到 73
0x1477b9ae1916 @ 64 : 9f 01 02 00 SwitchOnSmiNoFeedback [1], [2], [0] { 0: @70, 1: @73 }
0x1477b9ae191a @ 68 : 8b 08 Jump [8] (0x1477b9ae1922 @ 76)
0x1477b9ae191c @ 70 : 25 fa Ldar r1
0x1477b9ae191e @ 72 : a8 ReThrow // 抛异常, V8: https://v8docs.nodesource.com/node-0.8/d4/dc6/classv8_1_1_try_catch.html
0x1477b9ae191f @ 73 : 25 fa Ldar r1 // 4️⃣ a=r1=20
114 S> 0x1477b9ae1921 @ 75 : a9 Return // 最终返回 20
0x1477b9ae1922 @ 76 : 0d LdaUndefined
114 S> 0x1477b9ae1923 @ 77 : a9 Return
Constant pool (size = 3)
Handler Table (size = 16)
from to hdlr (prediction, data)
( 4, 33) -> 33 (prediction=0, data=2)
return: 20
count: 30
注解:
- SetPendingMessage: Sets the pending message to the value in the accumulator, and returns the previous pending message in the accumulator. pending message 导致 context 保持 alive 是 bug 而非特性,pending exception message 是 V8 实现 try-catch 的机制。
- 函数结果将存储在累加器中,一般
Return之前会执行Lda然后将累加器中的值return。
我们再宏观看看执行顺序和代码对照图:

从上图可以明显看到执行顺序是:先 finally 然后 return。
let count = 13;
function foo() {
try {
count += 7; // 1️⃣ 临时存储到 r1
return count; // 3️⃣ 返回 r1 中的值
}
finally {
count += 10; // 2️⃣ 修改 context 中的值
}
}
第二个疑惑也解开了:return 的 count 和 finally 中的 count 不是一个,前者是 r1(return 那一刻存储在寄存器),后者是上下文中的 count。
结论
- finally 在 return 之前执行。
- 二者实非同一个 count。
return中的count临时存储在寄存器r1中,finally中改变的只是上下文中的。因为有寄存器的存在所以可以做到finally修改不会导致最终返回值受到影响,因为return前一刻已经将返回值保存在寄存器中了,就如同将其拍了一个快照!
这不就正好验证了 finally 的作用吗?finally 必定在 try 里面的 return 前执行,用来保证某些资源必定会被释放 😄。
最后引申
有些知识你以为已经掌握了,某个契机再次遇到竟然发现和当初的认知有差异,我愿称之为『薛定谔的学习』😅。
至此完结了么?继续看评论:
If the finally doesn't return or throw, then the function returns the try's return value.
However, the finally can override that return value with it's own return value or the finally can stop any return value from being returned by throwing.
The proof is logically the same as what a previous commenter wrote:
挑重点翻译『the finally can override that return value』也就是若 finally 中有 return 则以其 return 为准甚至可以通过在 finally 中抛错阻止 return 返回任何值,但是因为这一不符合直觉的写法, eslint 专门有一条规则禁止 finally 中 return rules/no-unsafe-finally。
JavaScript suspends the control flow statements of
tryandcatchblocks until the execution offinallyblock finishes. So, whenreturn,throw,break, orcontinueis used infinally, control flow statements insidetryandcatchare overwritten, which is considered as unexpected behavior. Such as:var count = 0; function foo() { try { return count + 100;} finally { return ++count;} } console.log(foo()); console.log(count);输出:
1 1
还可以继续追问的:
- 为何 v8 选择 Register Machine 而非 Stack Machine?
- let const 到底是否存在变量提升?先出结论:存在。
- 字节码看 let var 性能。
参考
这一路翻阅了太多的资料,很多资料很宝贵,故记载之。