解釈実行もバイナリコードの直接実行もスタック構造を使います。では、なぜ関数呼び出しの管理にスタック構造を使うのでしょうか?
関数呼び出しの管理にスタックを使う理由
通常、関数には次の2つの主な特性があります。
- 関数は呼び出すことができます。関数の呼び出しが発生すると、実行中のコードの制御が親関数から子関数に移り、子関数の実行が完了するとコードの制御が親関数に戻ります。
- 関数にはスコープ機構があります。関数は、その内部で定義された変数を外部環境から隔離して実行することができます。 関数の内部で定義された変数は外部からアクセスすることができず、関数が実行されると内部変数は破棄されます。
関数の呼び出し側のライフサイクルは常に呼び出し側のライフサイクルよりも長く、呼び出し側のライフサイクルは常に呼び出し側のライフサイクルよりも先に終了します。関数のリソース割り当てとリカバリーの観点からは、呼び出し側関数のリソース割り当ての方が呼び出し側関数よりも常に遅く、リソースの解放も呼び出し側関数よりも常に先になります。これは、一種の後入れ先出しのLIFO戦略であり、スタック構造もこのパターンであるため、関数呼び出しの関係を管理するためにスタックを使用します。
スタックが関数コールを管理する方法
- x=5,変数xが初めてスタックに押されます。
- y=6,変数yが初めてスタックに押されます。
- x=100,スタックに押されたxの値を置き換えると、xの値は5から100に変更されます。
- x+yの値を計算し、zに代入してスタックに押します。
関数の実行中、その内部変数は実行順にスタックに押し込まれます。親関数が子関数の中に組み込まれている場合、子関数の呼び出しが終わると関数の実行が親関数に戻されます。
スタック最上位ポインタの目的は、どの位置に新しいエレメントを追加するかを示すことで、このポインタは通常 esp レジスタに格納されます。また、現在の関数の開始位置を保持するために、もう 1 つの ebp レジスタを追加します。この位置は、スタックフレームポインタと呼ばれます。
各スタックフレームは、まだ実行を終えていない関数に対応し、その関数のリターンアドレスとローカル変数がスタックフレームに格納されます。JSでも、関数の実行処理は同様です。新しい関数を呼び出すと、v8はその関数用のスタックフレームを作成し、関数が終了するとスタックフレームを破棄します。スタック構造の容量は決まっているので、破棄しないとスタックオーバーフローになりやすいです。
ヒープの役割
スタックの欠点は、メモリ上に連続した大きな領域を確保できないことで、スタック領域は限 られています。この時点でヒープ空間があり、これはいくつかの大きなデータを保持するために使われます。
ヒープ空間内のデータは連続的に格納される必要はなく、ヒープからのメモリ割り当てに決まったパターンはありません。大きなデータが発生すると、ヒープに領域が確保され、確保されたメモリのアドレスが返され、スタックに格納されます。例えば、以下の図 pp では、スタックのアドレスはヒープに割り当てられた空間のアドレスを指しています。
ヒープ内のデータが不要になったら、破棄する必要があります。破棄が間に合わないと、メモリリークを引き起こしやすくなります。
まとめ
- 関数の呼び出しをスタック構造で管理するプロセスをコールスタックと呼びます。
- スタックには最大容量の制限があり、スタック・オーバーフローを引き起こしやすくなります。そのため、大きなデータにアクセスするにはヒープを使用し、ヒープ参照アドレスをスタックに保存します。
- スタック・オーバーフローの解決は、同期関数を非同期関数に分割することでも対応できます。
最後に書く
Geek Timeの李冰さんによる「図解 goole V8」コースから、V8関連の学習内容をまとめました。