한국어 | English | 日本語
Webアプリケーションエンジニア (経験8.8年)
技術・開発
engineering
ウェブフロントエンドと バックエンド開発を扱います

JavaScriptエンジンの概要と実行プロセスから見るHoistingとClosure

JavaScriptは、単なるインタプリタ言語を超え、V8エンジンなどの高性能エンジンを介して現代的な方法で実行されます。本稿では、変数宣言が最上部に引き上げられるかのような「Hoisting(巻き上げ)」と、関数が終了しても状態を記憶する「Closure(クロージャ)」の魔法が、エンジン内部でどのように実装されているのか、その実態を深く掘り下げます。
JavaScriptエンジンの二段階実行プロセスである「コンパイル」と「実行」を中心に、Hoistingの発生原理を理解します。また、実行コンテキストとレキシカルスコープの関係を通じてClosureがメモリに保持されるメカニズムを学習し、ガベージコレクション(GC)の観点からClosure利用時の注意点についても考察します。

JavaScript

JavaScriptは、ウェブページの3つの要素のうちの1つです。

JavaScriptは、一般的なプログラミング言語と同様に、関数の宣言と呼び出しを通じて直接同期的に実行することも、コールバックを介して特定のイベント時に非同期的に実行することも可能です。実行には、開発者が書いたJavaScript言語を実行可能な言語に変換し、実行順序とメモリを管理するエンジンが必要です。

1つのブラウザは、HTML/CSSエンジン + JavaScriptエンジンで構成されています。

よく知られているChrome、Internet Explorer、Safariなど、様々なウェブブラウザはそれぞれ独自のHTML/CSS/JSエンジンを持っています。JavaScriptエンジンの代表的なものとしては、ChromeブラウザやNode.jsで使われているV8があります。今後説明するJavaScriptエンジンおよびランタイムは、このV8を基準に解説します。ここで、今後繰り返し言及される「JavaScriptエンジン」と「JavaScriptランタイム」という用語を明確にしておきましょう。より詳細な説明は、「JavaScriptエンジンおよびランタイム」の小見出しで行います。

JavaScriptランタイムは、JavaScriptの動作に必要となるJavaScriptエンジンを含むAPIと機能の集合体です。 JavaScriptエンジンは、狭義ではJavaScriptのインタプリティング(解釈実行)の役割を専門とするもので、JavaのJVMとして理解できます

例えば、私たちが利用するChromeは、V8 JavaScriptエンジンベースのJavaScriptランタイム上で動作しています。

JavaScript = インタプリタ言語

JavaScriptはスクリプト言語であり、エンジンを介して処理されるインタプリタ言語です。 ただし、コンパイル過程を持っています。これについて説明します。

JavaScriptエンジンは、一般的なシェルスクリプトが一行ずつ直接実行されるインタプリタ言語とは少し異なる実行構造を持っています。まず、実行する関数全体を、実行直前に変数や関数宣言のみを簡単にスキャンするⒶ JITコンパイル過程を経て、その後Ⓑ実行過程のサイクルで実行されます。 ここで、Ⓐ JIT (Just-in-Time)コンパイル過程は、私たちが一般的に知っているC++やJavaのようなコンパイル言語で中間コードを作成するAOT (Ahead-of-Time)コンパイル過程とは異なります。 JavaScriptをインタプリタ言語だと知っていた方は、少し驚くかもしれません。このようにJavaScriptエンジンに単純にコンパイル過程があるという事実だけでJavaScriptをコンパイル言語として言及することもありますが、厳密には既存のコンパイル言語の定義とは異なり、 JavaScriptエンジンは関数実行の時点でコンパイルを行うため、インタプリタ言語です。

JavaScriptエンジンは、Ⓐ JITコンパイル過程と**Ⓑ 実行過程**の2つに分かれます。 結論として、JavaScriptはコンパイル過程を持つインタプリタ言語と要約できるのではないでしょうか。

JavaScriptエンジンおよびランタイム

JavaScriptランタイムは、大きく2つの構成要素に分けられ、個別の要素としては5つに分けられます。

JavaScriptエンジンは、具体的には① ヒープ② スタックのみを指し、全てのコードをシングルスレッドで実行します。JavaScriptの非同期処理を学ぶ際に登場する③ Web API④ コールバックキュー⑤ イベントループは、厳密にはJavaScriptエンジンの構成要素ではありません。もしJavaScriptエンジンがシングルスレッドで全てのコードを実行するとすれば、同期的な実行しかできないはずですが、どのように非同期をサポートしているのでしょうか?非同期サポートのために、JavaScriptランタイムが③、④、⑤の3要素を追加しているのです。

JavaScriptエンジンの(2)スタックは、一般的なプログラミング言語のスタックとは異なります。他のプログラミング言語では、関数実行に伴い、各ローカル関数の変数などのコンテキスト情報がコールスタックにまとめて積まれます。ローカル関数に限定された情報を持つことから、このコンテキストをスコープとも呼びます。一方、JavaScriptエンジンもコールスタックに関数呼び出し順序を積載しますが、変数および関数宣言と代入の情報はヒープに別途保存し、コールスタックは本ヒープへのポインタのみを持っています。具体的に整理すると以下の通りです。

JavaScriptエンジンの実行過程

JavaScriptエンジンは、Ⓐ JITコンパイル過程と**Ⓑ 実行過程**の2つに分かれます。

Ⓐ Compilation Phase(コンパイル段階)

各関数実行時(JavaScriptの最初の実行関数はmain()です)に、AST(抽象構文木)が生成され、バイトコードに変換されます。JITコンパイル技術(バイトコードのキャッシュを通じて不要なコンパイル時間を削減する)のために、プロファイラが関数呼び出し回数を保存・追跡します。ここで覚えておくべきは、この過程で変数の「宣言」(宣言と代入のうち)と関数の「宣言」がヒープ(Heap)に積載されるということです。

JavaScriptにおける**変数の「宣言」**はvar aです。(a = 5は「代入(Assignment)」です。)


JavaScriptにおける**関数の「宣言」**はfunction a() {}です。


Ⓐ Compilation Phaseでは、変数および関数の「宣言(Declaration)」のみを抽出し、ヒープに積載します。 変数と関数の宣言は、JavaScriptの実行前にコンパイルによって保存され、実際の実行時に変数と関数が宣言されているかどうかが検索されます。

例えば、以下のJavaScriptファイルが初めて実行されると、ファイル全体にコンパイル過程が実行されます。

var a = 2;
b = 1;

function f(z) {
  b = 3;
  c = 4;
  var d = 6;
  e = 1;

  function g() {
    var e = 0;
    d = 3*d;
    return d;
  }

  return g();
  var e;
}

f(1);
  1. JavaScriptの最初の実行のため、main()関数のグローバルスコープ(Global Scope、window)領域がヒープに生成されます
# Global Scope (window)
-
-
  1. 変数宣言var aを見つけ、グローバルスコープ(window)領域に**aを積載します**。
  2. 変数代入b = 1は代入であるため、この領域には**bは積載されません**。
# Global Scope (window)
- a =
-
  1. 関数宣言function f(z) {}を見つけ、グローバルスコープ(window)領域に**fを積載します**。
  2. 関数積載時には、f関数のバイトコード(blob)へのポインタ値も一緒に積載します。
# Global Scope (window)
- a =
- f = a pointer for f functions bytecode

JavaScriptコードの最初の行から20行目までのコンパイル過程が完了すると、ヒープの構成は最終的に上記のようになります。

Ⓑ Execution Phase(実行段階)

変数の「代入(Assignment)」実際の関数の呼び出しおよび実行を行います。

JavaScriptにおける**変数の「代入」**はa = 1です。 a = 1の代入時、以前のコンパイル過程で変数aが宣言されているかを確認します。 もし存在しない場合、a変数を「宣言」と同時に「代入」して積載します。


JavaScriptにおける**関数の「呼び出しおよび実行」**はa()です。 a()実行時、最初に、以前のコンパイル過程で関数a()が宣言されているかを確認します。 a()実行時、次に、ヒープには新しい関数のためのローカル実行スコープ(Local Execution Scope)領域を生成し、 コールスタックには生成されたヒープへのポインタを持つ関数a()情報を積載します。 a()実行時、最後に、コンパイルを実行して本関数内の変数および関数を上記のローカル実行スコープ領域に積載します。


Execution Phaseでは、変数の「代入(Assignment)」値がヒープに積載され、関数は呼び出され実行されます。

関数呼び出しのたびにスタックに関数内の変数や関数を一緒に積載するスタックベース言語とは異なり、JavaScriptはスタックには関数呼び出し順序と、実際の変数や関数情報はヒープへのポインタを持ちます。ヒープ上の関数a()のためのローカル実行スコープは、a()関数が呼び出される前にヒープに存在していたグローバルスコープ(window)へのポインタを持っているため、エンジン内で以下のような処理が可能です。

上記で例として見たJavaScriptファイルのコンパイル過程を終えた後の実行過程は、以下の通り進行します。

  1. 前述のコンパイル後、以下のヒープを持ち、JavaScriptファイルコードの最初の行から再び実行が開始されます。
# Global Scope (window)
- a =
- f = a pointer for f functions bytecode
  1. 変数代入a = 2を見つけ、グローバルスコープ(window)領域に変数のa存在有無を確認します。
  2. 変数aが存在するため、該当a2を代入します。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
  1. 変数代入b = 1を見つけ、グローバルスコープ(window)領域に変数のb存在有無を確認します。
  2. 変数bが宣言されていないため、bの宣言と同時に1を代入します。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1
  1. 関数呼び出しf(1)を見つけ、グローバルスコープ(window)領域でf()の宣言有無を確認します。
  2. 関数f()のblobコンパイルおよび実行のため、ヒープに新しいローカル実行スコープ領域を生成します。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1

# Local Execution Scope for f()
- (hidden) A pointer for previous scope (= Global Scope (window))
-
-

f(1)関数実行時、新しく生成されたローカル実行スコープに再びCompilation Phase過程を通じて変数と関数が積載され、Execution Phase過程が実行されます。またf(1)関数内部にさらに別の関数がある場合、この過程を繰り返し再帰的に行います。

  1. 関数f()の**Ⓐ Compilation Phase**過程が完了すると、以下のようになります。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1

# Local Execution Scope for function f()
- (hidden) a pointer for previous scope (= Global Scope (window))
- z =
- d =
- e =
  1. 関数f()の**Ⓑ Execution Phase**過程が完了すると、関数f()内の変数代入および関数g()のスコープが生成されます。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 3

# Local Execution Scope for function f()
- (hidden) a pointer for previous scope (= Global Scope (window))
- z = 1
- d = 6
- e = 1
- c = 4

# Local Execution Scope for function g()
- (hidden) a pointer for previous scope (= Local Execution Scope for function f())
- e =

JavaScriptエンジンの特性

Function-level scope: var

JavaScriptの実行は、最終的に関数に基づいてⒶコンパイル、Ⓑ実行が再帰的に行われます。 最初はJavaScriptの実行開始時にmain()関数に対するⒶ、Ⓑ処理から始まり、内部で新しい関数呼び出しが発生すると、その新しい関数に対するⒶ、Ⓑ処理が始まり、さらに内部で関数呼び出しがあればその関数に対するⒶ、Ⓑ処理が…といった形で処理が繰り返されます。

特定の関数内の変数varの宣言は、本関数のⒶコンパイル時に定義されるため、変数varのスコープは関数レベル(function-level)になります。

iffor文のようなブロックレベル({})単位の変数のために、ES6では新たに**Block-level scope: constlet**が導入されました。

Scope Chain(スコープチェーン)

JavaScriptエンジンの実行過程で見たように、特定の関数に対するⒷ実行段階で変数代入時、まず本関数のヒープ領域に変数が宣言されているか検査されます。もし本関数内に変数が宣言されていなければ、その関数のヒープでは変数宣言を見つけることができません。この時、当該関数が呼び出される以前の関数へと(hidden) A pointer for previous scopeを通じて遡りながら、当該関数ヒープスコープに変数が宣言されているか確認します。 どの関数にも変数宣言がされていない場合は、最も最初に呼び出されたmain()関数まで遡って検索されます。関数呼び出しスタックの逆順で、最も最初のmain()関数まで各関数ヒープスコープに変数の宣言が存在するかを連鎖的にChainingしながら探すため、これをスコープチェーン(Scope Chain)と呼びます。

Variable Hoisting(変数巻き上げ)

Ⓐコンパイル段階で変数を先に宣言し、その後にⒷ実行段階で変数を代入するため、同じ関数レベルであれば以下のように変数宣言と代入を分けて行ったとしても、JavaScriptエンジンでは変数宣言が先にされたものとして処理されます。

a = 10
var a;
# Global Scope (window)
- a = 10

上記の例のようにvar aの宣言が同じ関数レベル内で最上段に「巻き上げられた」かのように実行されることもありますが、もし関数内に変数が宣言されていなかった場合、スコープチェーンを通じてmain()関数まで遡りながら変数宣言を探します。最終的にmain()関数ヒープスコープにも宣言されていなければ、main()関数領域に変数宣言が行われます。main()から呼び出されたどの関数もスコープチェーンを通じて今宣言された変数を見るため、これはグローバル変数となります。(main()のヒープスコープ領域の名称はグローバルスコープ(window)でもあります。)特定の関数内で変数を代入したが、この変数がどの関数にも存在しない変数であるため、main()関数まで「巻き上げられて」グローバル変数を宣言したことになります。変数宣言が「巻き上げられた」という意味で、この全てのケースをVariable Hoisting(変数巻き上げ)と表現します。

Variable Shadowing(変数シャドウイング)

特定の関数のヒープスコープに変数が宣言されている場合、その変数への代入は現在の関数ヒープスコープに宣言されている変数に対して行われます。もしその関数を呼び出す以前の関数に同じ名称の変数が宣言されていたとしても、現在の関数ヒープスコープに既に存在するため、以前の関数のヒープスコープまでスコープチェーンする必要はありません。以前の関数に同じ名称の変数があったとしても、現在の関数はその存在を知ることも知る必要もないため、これをVariable Shadowing(変数シャドウイング)と呼びます。

Garbage Collection(ガベージコレクション)

関数の直接実行が終了すると、スタックから実行完了した関数の情報が削除され、ヒープメモリ内の実行完了した関数のヒープスコープも削除されます。メモリクリーンアップの意味でガベージコレクション(Garbage Collection)と呼びます。JavaScriptファイル全体の実行が終了すると、最後にmain()関数のグローバルスコープ(Window)も消滅します。参照カウント(Reference Count)によるガベージコレクションを行うSwift言語などもありますが、JavaScriptは単純に関数(ポインタ)の到達可能性(Reachability)に基づいてガベージコレクションを実行します。 関数の直接実行ではなく、関数実行を変数に代入した場合、関数実行が終了したとしても、代入された変数を通じて関数実行を繰り返し可能であるため、本関数に対するガベージコレクションが行われないケースが存在します。これがまさに以下で説明するクロージャ(Closure)の概念です。

Closure(クロージャ)

JavaScriptエンジンの実行説明で扱った例で、function fを直接実行せず、var myFunctionを宣言してそれに代入してみました。

var a = 2;
b = 1;

function f(z) {
  b = 3;
  c = 4;
  var d = 6;
  e = 1;

  function g() {
    var e = 0;
    d = 3*d;
    return d;
  }

  return g; // Changed from return g();
  var e;
}

var myFunction = f(1); // 新たに追加されたコード
myFunction();

関数呼び出しを変数に代入すると、関数の呼び出しは一度きりの実行で消滅するのではなく、myFunctionという変数を介して繰り返し呼び出しが可能であるため、f関数呼び出しのために生成されたf関数のヒープスコープは削除されません。少し簡単に考えると、f関数のヒープスコープにはf関数実行のために渡された引数値1も保持しているため、ヒープスコープをガベージコレクションできないのです。このように、関数呼び出しを変数に代入すると、f関数のヒープスコープとfを呼び出した関数のヒープスコープが引数1を基準に強く結びついているため、f関数の実行が終了してもf関数のヒープスコープがガベージコレクションされません。

クロージャ(Closure)は、関数のヒープスコープと、その関数を呼び出す関数のヒープスコープを連結するもので、関数呼び出しが終了してもスコープは依然としてその関数を呼び出した関数のスコープに「閉じ込められている」概念です。


JavaScriptエンジンの概要と実行プロセスから見るHoistingとClosure
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
同じカテゴリーの関連記事
最新記事
LLMフィルターが奪う会話の筋肉とコミュニケーション様式
会話における無礼さを濾過し、洗練された回答を生成するLLMツールが日常化した現代において、私たちは本当に思慮深い会話をしているのだろうか?リアルタイムのコミュニケーションにおける数多くの失敗を通じて磨かれるべき会話能力が、外部ツールに依存することで退化している現象と、それがもたらす社会的な不安や世代間の行動様式の変化について考察する。
シニア採用における年俸交渉の最適なタイミングと戦略
年俸交渉は単なる数字の交換ではなく、心理的な駆け引きとタイミングが重要です。本稿では、企業側にとって、候補者が計算的な態度を取りがちな最終合格後よりも、採用プロセスの初期段階から段階的に交渉を進めることが、なぜより効率的であり、率直な情報の共有に繋がるのかを考察します。
法治主義の限界と人間の多様性
全ての人間の行為を単一の法体系で規制できるという信念は、傲慢であるかもしれない。この記事は、中世の階層的な統制から脱却し、現代の無限の自由を手に入れた人類が直面する法治主義の逆説と、多様性という名のもとに深化する社会的強制力と他者への悪魔化現象を鋭く分析する。
토스트 예시 메세지