瀏覽器中的JavaScript執行機制:08 | 調用棧:為什么JavaScript代碼會出現棧溢出?


前言:該篇說明:請見 說明 —— 瀏覽器工作原理與實踐 目錄

 

  在上篇文章中,我們講到了,當一段代碼被執行時,JavaScript 引擎先會對其進行編譯,並創建執行上下文。但是並沒有明確說明到底什么樣的代碼才算符合規范。

 

  那么接下來我們就來明確下,哪些情況下代碼才算是“一段”代碼,才會在執行之前就進行編譯並創建執行上下文。一般說來,有這么三種情況:

  1、當 JavaScript 執行全局代碼的時候,會編譯全局代碼並創建全局執行上下文,而且在整個頁面的生存周期內,全局執行上下文只有一份。

  2、當調用一個函數的時候,函數體內的代碼會被編譯,並創建函數執行上下文,一般情況下,函數執行結束之后,創建的函數執行上下文會被銷毀。

  3、當使用 eval 函數的時候,eval 的代碼也會被編譯,並創建執行上下文。

 

  好了,又進一步理解了執行上下文,那本節我們就在這基礎之上繼續深入,一起聊聊調用棧。學習調用棧至少有以下三點好處:

  1、可以幫助你了解 JavaScript 引擎背后的工作原理;

  2、讓你有調試 JavaScript 代碼的能力;

  3、幫助你搞定面試,因為面試過程中,調用棧也是出鏡率非常高的題目。

 

  比如你在寫 JavaScript 代碼的時候,有時候可能會遇到棧溢出的錯誤,如下圖所示:

棧溢出的錯誤

 

  那為什么會出現這種錯誤呢?這就涉及到了調用棧的內容。你應該知道 JavaScript 中有很多函數,經常會出現在一個函數中調用另外一個函數的情況,調用棧就是用來管理函數調用關系的一種數據結構。因此要講清楚調用棧,你還要先弄明白函數調用棧結構

 

什么是函數調用

  函數調用就是運行一個函數,具體使用方式是使用函數名稱跟着一對小括號。下面我們看個簡單的示例代碼:

var a = 2
function add() {
    var b = 10
    return a+b
}
add()

  這段代碼很簡單,先是創建了一個 add 函數,接着在代碼的最下面又調用了該函數。

 

  那么下面我們就利用這段簡單的代碼來解釋下函數調用的過程。

 

  在執行到函數 add() 之前,JavaScript 引擎會為上面這段代碼創建全局執行上下文,包含了聲明的函數和變量,你可以參考下圖:

全局執行上下文

 

  從圖中可以看出,代碼中全局變量和函數都保存在全局上下文的變量環境中。

 

  執行上下文准備好之后,便開始執行全局代碼,當執行到 add 這時,JavaScript 判斷這是一個函數調用,那么將執行以下操作:

  • 首先,從全局執行上下文中,取出 add 函數代碼。
  • 其次,對 add 函數的這段代碼進行編譯,並創建該函數的執行上下文可執行代碼
  • 最后,執行代碼,輸出結果。

 

  完整流程你可以參考下圖:

函數調用過程

 

  就這樣,當執行到 add 函數的時候,我們就有了兩個執行上下文了 —— 全局執行上下文和 add 函數的執行上下文。

 

  也就是說在執行 JavaScript 時,可能會存在多個執行上下文,那么JavaScript 引擎是如何管理這些執行上下文的呢?

 

  答案是通過一種叫棧的數據結構來管理的。那什么是棧呢?它又是如何管理這些執行上下文呢?

 

什么是棧

  關於棧,你可以結合這么一個貼切的例子來理解,一條單車道的單行線,一端被堵住了,而另一端入口處沒有任何提示信息,堵住之后就只能后進去的車子先出來,這時這個堵住的單行線就可以被看作是一個棧容器,車子開進單行線的操作叫做入棧,車子倒出去的操作叫做出棧

 

  在車流量較大的場景中,就會發生反復的入棧、棧滿、出棧、空棧和再次入棧,一直循環。

 

  所以,棧就是類似於一端被堵住的單行線,車子類似於棧中的元素,棧中的元素滿足先進后出的特點。你可以參考下圖:

棧示意圖

 

什么是 JavaScript 的調用棧

  JavaScript 引擎正是利用棧的這種結構來管理執行上下文的。在執行上下文創建好后,JavaScript 引擎會將執行上下文壓入棧中,通常把這種用來管理執行上下文的棧稱為執行上下文棧,又稱調用棧

  

  為便於你更好地理解調用棧,下面我們再來看段稍微復雜點的示例代碼:

var a = 2

function add(b, c){
    return b+c
}
function addAll(b, c){
    var d = 10
    result = add(b, c)
    return a+result+d
}
addAll(3, 6)

  在上面這段代碼中,你可以看到它是在 addAll 函數中調用了 add 函數,那在整個代碼的執行過程中,調用棧是怎么變化的呢?

 

  下面我們就一步步地分析在代碼的執行過程中,調用棧的狀態變化情況。

 

  第一步:創建全局上下文,並將其壓入棧底。如下圖所示:

全局執行上下文壓棧

  從圖中你也可以看出,變量 a、函數 add 和 addAll 都保存到了全局上下文的變量環境對象中。

 

  全局執行上下文壓入到調用棧后,JavaScript 引擎便開始執行全局代碼了。首先會執行 a=2 的賦值操作,執行該語句會將全局上下文變量環境中 a 的值設置為 2。設置后的全局上下文的狀態如下圖所示:

賦值操作改變執行上下文中的值

 

  接下來,第二步是調用 addAll 函數。當調用該函數時,JavaScript 引擎會編譯該函數,並為其創建一個執行上下文,最后還將該函數的執行上下文壓入棧中,如下圖所示:

執行 addAll 函數時的調用棧

 

  addAll 函數的執行上下文創建好之后,便進入了函數代碼的執行階段了,這里先執行的是 d=10 的賦值操作,執行語句會將 addAll 函數執行上下文中的 d 由 undefined 變成了 10。

 

  然后接着往下執行,第三部,當執行到 add 函數調用語句時,同樣會為其創建執行上下文,並將其壓入調用棧,如下圖所示:

執行 add 函數時的調用棧

 

  當 add 函數返回時,該函數的執行上下文就會從棧頂彈出,並將 result 的值設置為 add 函數的返回值,也就是 9。如下圖所示:

add 函數執行結束時的調用棧

 

  緊接着 addAll 執行最后一個相加操作后並返回,addAll 的執行上下文也會從棧頂彈出,此時調用棧中就只剩下全局上下文了。最終如下圖所示:

addAll 函數執行結束時的調用棧

 

  至此,整個 JavaScript 流程執行結束了。

 

  好了,現在你應該知道了調用棧是 JavaScript 引擎追蹤函數執行的一個機制,當一次有多個函數被調用時,通過調用棧就能夠追蹤到哪個函數正在被執行以及各函數之間的調用關系。

 

在開發中,如何利用好調用棧

  鑒於調用棧的重要性和實用性,那么接下來我們就一起來看看在實際工作中,應該如何查看和利用好調用棧。

 

1. 如何利用瀏覽器查看調用棧的信息

  當你執行一段復雜的代碼時,你可能很難從代碼穩重分析其調用關系,這時候你可以在你想要查看的函數中加入斷點,然后當執行到該函數時,就可以查看該函數的調用棧了。

 

  這么說可能有點抽象,這里我們拿上面的那段代碼做個演示,你可以打開 “開發者工具” ,點擊 “Source” 標簽,選擇 JavaScript 代碼的頁面,然后在第 3 行加上斷點,並刷新頁面。你可以看到執行到 add 函數時,執行流程就暫停了,這里可以通過右邊 “call stack” 來查看當前的調用棧的情況,如下圖:

查看函數調用關系

 

  從圖中可以看出,右邊的 “call stack’ 下面顯示出來了函數的調用關系:棧的最底部是 anonymous,也就是全局的函數入口;中間是 addAll 函數;頂部是 add 函數。這就清晰地放映了函數的調用關系,所以在分析復雜結構代碼,或者檢查 Bug 時,調用棧都是非常有用的

 

  除了通過斷點來查看調用棧,你還可以使用 consol.trace() 來輸出當前的函數調用關系,比如在示例代碼中的 add 函數里面加上了 console.trace() ,你就可以看到控制台輸出的結果,如下圖:

使用 trace 函數輸出當前調用棧信息

 

2. 棧溢出(Stack Overflow)

  現在你知道了調用棧是一種用來管理執行上下文的數據結構,符合后進先出的規則。不過還有一點你要注意,調用棧是有大小的,當入棧的執行上下文超過一定數目,JavaScript 引擎就會報錯,我們把這種錯誤叫做棧溢出

 

  特別是在你寫遞歸代碼的時候,就很容易出現棧溢出的情況。比如下面這段代碼:

function division(a,b){ 
    return division(a,b)
}
console.log(division(1,2))

 

  當執行時,就會拋出棧溢出錯誤,如下圖:

棧溢出錯誤

 

  從上圖你可以看到,拋出的錯誤信息為:超過了最大棧調用大小(Maximum call stack size exceeded)。

 

  那為什么會出現這個問題呢?這是因為當 JavaScript 引擎開始執行這段代碼時,它首先調用函數 division,並創建執行上下文,壓入棧中;然而,這個函數是遞歸的,並且沒有任何終止條件,所以它會一直創建新的函數執行上下文,並反復將其壓入棧中,但棧是有容量限制的,超過最大數量后就會出現棧溢出的錯誤。

 

  理解了棧溢出原因后,你就可以使用一些方法來避免或者解決棧溢出的問題,比如把遞歸調用的形式改造成其他形式,或者使用加入定時器的方法來把當前任務拆分為其他很多小任務。

 

總結

  好了,今天的內容就到這里,下面來總結下今天的內容。

  • 每調用一個函數,JavaScript 引擎會為其創建執行上下文,並把該執行上下文壓入調用棧,然后 JavaScript 引入開始執行函數代碼。
  • 如果在一個函數 A 中調用了另外一個函數 B,那么 JavaScript 引擎會為 B 函數創建執行上下文,並將 B 函數的執行上下文壓入棧頂。
  • 當前函數執行完畢后,JavaScript 引擎會將該函數的執行上下文彈出棧。
  • 當分配的調用棧空間被占滿時,會引發 “堆棧溢出” 問題。

 

  棧是一種非常重要的數據結構,不光應用在 JavaScript 語言中,其他的編程語言,如 C/C++、Java、Python 等語言,在執行過程中也都使用了棧來管理函數之間的調用關系。所以棧是非常基礎且重要的知識點,你必須得掌握。

 

思考時間

最后,我給你留個思考題,你可以看下面這段代碼:(作者的意思:runStack要執行50000次的,但是要避免棧溢出,改成斐波那契數列的列子可能好點)

function runStack (n) {
  if (n === 0) return 100;
  return runStack( n- 2);
}
runStack(50000)

  這是一段遞歸代碼,可以通過傳入參數 n,讓代碼遞歸執行 n 次,也就意味着調用棧的深度能達到 n,當輸入一個較大的數時,比如 50000,就會出現棧溢出的問題,那么你能優化下這段代碼,以解決棧溢出的問題嗎?

 

問題記錄

1、關於調用棧的大小,不用的平台,比如瀏覽器,nodejs 怎么查看設置的,還是硬編碼的?

作者回復: 調用棧有兩個指標,最大棧容量和最大調用深度,滿足其中任意一個就會棧溢出,
不過具體多大和多深,這個沒有研究過,你可以拿我留的作業那段代碼去各平台測試下,應該很快
就能測試出來最大調用深度。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM