問い — 同じ「VM」という名前の2つの概念は、同じものか
javaなどの高級言語から、アセンブリコードに変換されるまでには、一度vm言語・バイトコードといった中間言語に変換されると思います。この、vm、という概念は、いわゆる仮想マシン、つまり、ハイパーバイザー上に乗るvmと同じ概念なのでしょうか?あるいは、名前が似ているだけで別物でしょうか
良い質問です。結論から言うと、名前が似ているだけで、別物です。ただし、どちらも「抽象化レイヤー」という共通の思想から来ているため、完全に無関係でもありません。
コンパイラのVM(仮想マシン)
JavaやPythonで言う「VM」は、架空のCPUのことです。
たとえばJavaの場合:
Java コード → バイトコード (.class) → JVM が解釈・実行
JVM(Java Virtual Machine)は「架空のCPUアーキテクチャ」を定義していて、バイトコードはそのCPU向けの機械語です。実際のCPU(x86やARMなど)の代わりに、ソフトウェアがそのCPUの振る舞いをシミュレートします。
目的: どのOSやCPUでも同じバイトコードを動かせる(Write Once, Run Anywhere)
ハイパーバイザー型のVM
こちらは本物のコンピュータ全体を丸ごとエミュレートするものです。
物理マシン → ハイパーバイザー → Guest OS + アプリ(仮想マシン)
CPU・メモリ・ディスク・ネットワークカードまで仮想化し、その上でWindowsやLinuxが普通に動きます。
目的: 1台の物理サーバーで複数のOSを独立して動かす
比較まとめ
| コンパイラVM(JVMなど) | ハイパーバイザーVM | |
|---|---|---|
| 何を仮想化? | CPUの命令セット(ISA) | コンピュータ全体 |
| OSは必要? | 不要(ランタイムだけ) | Guest OSが必要 |
| 目的 | 移植性・言語抽象化 | リソース分離・集約 |
| 例 | JVM, CPython, .NET CLR | VMware, KVM, Hyper-V |
共通する「思想」
どちらも「実際のハードウェアを隠蔽して、抽象的なマシンを提供する」という発想から来ています。その意味では同じ「仮想化」という概念の親戚です。
ただし、コンパイラの文脈でVMと言う場合は「スタックマシンやレジスタマシンを模した架空のISA」を指していて、ハイパーバイザーのVMとは抽象化の対象もレイヤーも異なります。
なお、ご質問の「高級言語 → VM言語 → アセンブリ」という変換の流れですが、JVMのような場合は必ずしもアセンブリを経由しません。JITコンパイラがバイトコードを直接ネイティブコード(機械語)に変換することが多いです。
なるほど、ではjavaの話をもう少し深掘りさせてください。高級言語→vm言語→アセンブリ言語→機械語 のステップを常に踏むものと思っていたのですが、javaではそうではないのでしょうか?
また、.classってアセンブリだと思っていたのですがそうではないのでしょうか。とすると思って誰が機械語に翻訳するのかという話がありますが、それはjvmが担当するのでしょうか
はい、まさにそこがJavaの面白いところです。整理しましょう。
Javaの実際の変換フロー
Java (.java)
↓ javac(コンパイラ)
バイトコード (.class)
↓ JVM(実行時)
機械語(ネイティブコード)← アセンブリを経由しない
アセンブリは登場しません。 JVMがバイトコードを直接機械語に変換します。
.classファイルはアセンブリではない
| アセンブリ | バイトコード(.class) | |
|---|---|---|
| 対象 | 実在するCPU(x86, ARMなど) | 架空のCPU(JVM) |
| 人間が読める? | 一応読める | 基本バイナリ(読めない) |
| 変換先 | 機械語(1対1対応) | 機械語(JVMが変換) |
.classはJVMという架空のCPU向けの命令列なので、アセンブリとは別物です。javap -cというツールで逆アセンブルすると人間が読める形にはなりますが、あくまでJVM命令です。
JVMが機械語に変換する2つの方法
JVMは実行時に以下のどちらか(または両方)で機械語にします:
① インタープリタ方式 バイトコードを1命令ずつ読んで、その場で機械語に変換して実行。起動は速いが遅い。
② JITコンパイル(Just-In-Time) よく使われるコードをまとめてネイティブ機械語にコンパイルしてキャッシュ。2回目以降は爆速。
実際のJVMはこの両方を組み合わせて使っています。最初はインタープリタで動かし、頻繁に呼ばれる部分だけJITでコンパイルするという戦略です。
「常にアセンブリを経由する」は誰の話か
ご認識の流れ(高級言語→VM言語→アセンブリ→機械語)は、C/C++のような言語に近いです:
C言語
↓ コンパイラ(gccなど)
アセンブリ (.s)
↓ アセンブラ
機械語 (.o → 実行ファイル)
Javaはこのパイプラインとは設計思想が異なり、「どのマシンでも動く中間形式(バイトコード)を作り、実行時にJVMが機械語にする」という2段階構造になっています。これが「Write Once, Run Anywhere」を実現している仕組みです。
Java と C を対比する — 変換経路の違い
なるほど、javaでは「Write Once, Run Anywhere」を実現するために、
自由に記述可能な高級言語と、CPUの仕様によって異なるアセンブリとの間に、
「高級言語ほど高級ではないが、CPUの仕様まで踏み込む必要はなく、環境に寄らず普遍的に記述可能である」中間的な言語を挟むようになっていて、それがVM言語、ということでしょうか。
C言語と対比したいのですが、
java:java言語→VM言語→(アセンブリ言語は経由しない)→機械語
C:C言語→(VM言語は経由しない)→アセンブリ言語→機械語
という対比になるでしょうか?
はい、その理解はほぼ正確です。対比の構造は合っています。
ただし一点補足
C言語も「必ずアセンブリを経由する」とは限りません。
C言語
↓ コンパイラ(gcc/clangなど)
機械語(直接) ← アセンブリを省略することも多い
gccなどの現代的なコンパイラは、内部でアセンブリを生成するステップを持っているのですが、それはコンパイラ内部の話であって、ファイルとして出力されるわけではありません。gcc -Sというオプションを付けると明示的にアセンブリファイルを出力させることもできますが、通常はそのまま機械語まで一気に変換します。
整理するとこうなります
Java: Java → バイトコード(.class) → [JVMが実行時に] → 機械語
C: C言語 → [コンパイラ内部で] → 機械語
どちらも「アセンブリファイル」は必須ではなく、概念的なステップとしては存在するが省略されるというのが実態に近いです。
本質的な違いはここ
| Java | C | |
|---|---|---|
| 中間成果物 | バイトコード(.class)がファイルとして存在 | 中間物はコンパイラ内部で消える |
| 機械語への変換タイミング | 実行時(JVMが担当) | コンパイル時(開発者のマシンで完結) |
| 実行ファイルの中身 | バイトコード | ネイティブ機械語 |
Javaの「Write Once, Run Anywhere」の核心は、中間物(バイトコード)が実際にファイルとして流通し、機械語への変換を実行する側のJVMに委ねるという設計にあります。Cはコンパイルした時点で特定のCPU向けの機械語が確定してしまうので、Windows x86用にコンパイルしたものはMac ARMでは動きません。
うーん、ではgccでc言語をコンパイルしたときに得られるa.outは、機械語そのものを記述したファイルなのでしょうか?
はい、ほぼそうです。ただ「機械語そのもの」に加えて、いくつかのメタ情報も含まれています。
a.outの中身
a.outはELF形式(Executable and Linkable Format)というフォーマットのファイルで、こんな構造になっています:
a.out (ELF形式)
├── ELFヘッダー(メタ情報)
│ ├── 対象アーキテクチャ(x86_64など)
│ ├── エントリーポイント(どこから実行開始か)
│ └── 各セクションの場所など
├── .textセクション ← ここが機械語(CPU命令列)本体
├── .dataセクション ← 初期化済みのグローバル変数
├── .bssセクション ← 未初期化のグローバル変数
└── ...その他
CPUが実際に実行するのは.textセクションの中身で、これはまさに機械語のバイト列です。
確認してみると
$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, ...
$ objdump -d a.out # 逆アセンブル(機械語→アセンブリに戻す)
0000000000401000 <main>:
401000: 55 push rbp
401001: 48 89 e5 mov rbp, rsp
...
objdumpで逆アセンブルすると、.textセクションの機械語バイト列がアセンブリとして読める形で表示されます。55や48 89 e5というのが機械語の16進数表現です。
まとめると
a.outは「機械語本体 + OSが実行するために必要なメタ情報」を束ねたファイル
という理解で正確です。Javaの.classファイルが「バイトコード + メタ情報」の束であるのと、構造的には似たような発想ですね。
なぜ Java は逆コンパイルしやすいのか
つまり、javacが生成するアウトプットのほうが高級言語より、と理解しました。
一般に、cよりもjavaのほうが逆コンパイルしやすいという話がありますが、
これはこのあたりに原因がありますかね?
はい、まさにそこが主な原因です。非常に鋭い気づきです。
なぜJavaが逆コンパイルしやすいか
Javaのバイトコード(.class)
バイトコードには元のソースコードに近い情報が大量に残っています:
- クラス名・メソッド名・フィールド名
- 型情報(
intなのかStringなのか) - メソッドのシグネチャ
- 行番号情報(デバッグ用)
これらはJVMが実行時に必要とする情報なので、削除できないのです。
つまりjavapやjadxなどのツールで逆コンパイルすると、元のJavaコードにほぼ近い形が復元できてしまいます。
Cのa.out(機械語)
一方、機械語に変換された時点で:
- 変数名・関数名は消える(アドレスに置き換わる)
- 型情報は消える(ただのビット列になる)
- コンパイラの最適化で元のコードと構造が変わる
objdumpで逆アセンブルはできますが、得られるのはアセンブリであって、元のC言語のコードとはかけ離れたものになります。
抽象度の違いがそのまま逆コンパイルのしやすさに
Javaソース (.java)
↓ 変換後も情報が多く残る
バイトコード (.class) ← ここで配布・実行
↓ 逆コンパイルで高精度に復元できる
Javaソース(復元)
Cソース (.c)
↓ 情報がほぼ失われる
機械語 (a.out) ← ここで配布・実行
↓ 逆アセンブルはできるが...
アセンブリ(読みにくい)
配布物の抽象度がそのまま逆コンパイルの難易度に直結しているわけです。
だからこそJavaには難読化ツールがある
この問題への対策として、ProGuardやR8といった難読化ツールが使われます。これらは:
- クラス名・メソッド名を
a,b,cなどに置き換える - 不要なメタ情報を削除する
ただしあくまで「読みにくくする」だけで、バイトコードである以上、完全に逆コンパイルを防ぐことは原理的に難しいです。Cの機械語と比べると、どうしても不利な立場になります。
jvmは、実体としては、普遍的に生成されたバイトコードを、環境に応じた機械語に変換するためのプログラム、ということでしょうか。javaには、jreとか、他にもいくつか登場人物がいますが、他にもあったと思いますが、あれらとはどんな関係性なのでしょうか
はい、その理解で正確です。登場人物を整理しましょう。
包含関係
JDK(Java Development Kit)
├── JRE(Java Runtime Environment)
│ ├── JVM(Java Virtual Machine)
│ │ └── バイトコード→機械語変換・実行
│ └── 標準ライブラリ(java.lang, java.util など)
└── 開発ツール
├── javac(コンパイラ .java→.classを生成)
├── javap(逆アセンブラ)
├── jar(パッケージングツール)
└── ...など
JVMはJREの中に含まれ、JREはJDKの中に含まれるという入れ子構造です。
各役割
JVMはご理解の通り、バイトコードを機械語に変換して実行する本体です。
JREはJVMに加えて標準ライブラリをセットにしたもので、「Javaプログラムを動かす」のに必要な一式です。エンドユーザーが何かJavaアプリを実行するだけならJREがあれば十分です。
JDKはさらにjavacなどの開発ツールを加えたもので、「Javaプログラムを作る」のに必要な一式です。開発者はJDKを入れます。
一言で言うと
| 誰が使う | 何ができる | |
|---|---|---|
| JVM | JREに内包 | 実行のみ |
| JRE | エンドユーザー | 実行のみ |
| JDK | 開発者 | 開発+実行 |
現代(Java9以降)はJREが単独で配布されなくなり、JDKを入れれば全部入りという形に整理されましたが、概念としては今でもこの区別は有効です。
JIT した結果を保存しておけばいいのでは
ちょっと戻りますが、「JVMが機械語に変換する2つの方法」について。jitコンパイルしたものを保存しておけば次回実行時も早い気がしましたが、そうはしないのでしょうか。
良い着眼点です。結論から言うと、「そうしたい」という発想は正しく、実際にそれを実現する仕組みが存在します。
通常のJVMはキャッシュしない
一般的なJVMは、JITコンパイル結果をメモリ上にのみ保持し、プロセス終了とともに捨てます。理由は:
- ネイティブコードはCPUやOSに依存するため、次回実行時に環境が変わっていると使えない
- セキュリティ上、検証済みのバイトコードを毎回コンパイルする方が安全
- 実装がシンプルになる
でも「保存したい」という発想は実現されている
AOTコンパイル(Ahead-Of-Time) という仕組みがあります。
通常のJIT:
バイトコード → 実行時にJITコンパイル → 機械語(メモリのみ、終了時破棄)
AOT:
バイトコード → 事前にコンパイル → 機械語ファイルとして保存 → 次回からそのまま実行
具体的な実装例:
- GraalVM Native Image … .classからネイティブ実行ファイルを事前生成。起動が爆速になる
- Android(ART) … アプリインストール時にバイトコードを機械語に変換して保存する。これがまさにご質問の「JITした結果を保存する」に近い発想
トレードオフ
| JIT | AOT | |
|---|---|---|
| 初回起動 | 遅い | 速い |
| 実行時最適化 | できる(実行パターンに応じて) | できない(事前に決め打ち) |
| ポータビリティ | 保たれる | 環境依存になる |
JITの強みは「実際の実行パターンを見ながら最適化できる」点で、AOTより速くなるケースもあります。なのでどちらが常に優れているとは言えず、用途次第です。
この話、面白かったのでzennの記事にしようと思います。改めて、高級言語、VM言語、アセンブリ言語、機械語についてまとめてもらえないでしょうか。
ありがとうございます!では記事向けにまとめます。 Zennはmarkdownで書けるので、.mdファイルとして作成します。 この会話の流れに沿って、以下の構成でまとめました:
- 4つの言語レイヤーの定義
- JavaとCの変換フロー対比(表付き)
.classはアセンブリではないという整理- JVMの2つの実行方式(インタープリタ/JIT)
- 逆コンパイルしやすい理由
- JDK/JRE/JVMの関係
- AOTコンパイルの補足
ZennはそのままMarkdownを貼り付けられるので、このまま使えます。
CPU アーキテクチャの差を、誰が吸収しているのか
そういえば、実際のCPUアーキテクチャは、javaではJVMが吸収するということでしたが、cではgccが吸収するのでしょうか?つまり、jvm, gccは、CPUアーキテクチャごとに作られている
はい、まさにその通りです。 非常に本質的な理解です。
対比するとこうなります
| Java | C | |
|---|---|---|
| アーキテクチャの差異を吸収するもの | JVM | gcc(コンパイラ) |
| いつ吸収する? | 実行時 | コンパイル時 |
| アーキテクチャごとに存在する? | ✅ JVMがx86用、ARM用… | ✅ gccがx86用、ARM用… |
JVMもgccも、CPUアーキテクチャごとに別のバイナリが存在します。
ただし、gccの「吸収」はもう少し複雑
gccは正確にはターゲットアーキテクチャを指定してビルドされたものが存在します。
- x86_64 Linux上でARMの機械語を生成するgcc → クロスコンパイラと呼ぶ
- 組み込み開発(RaspberryPiやマイコン向け)などでよく使われる
つまりgccは「動作するマシン」と「生成する機械語のターゲット」が必ずしも一致しない、という柔軟性があります。JVMにはこの概念はありません(JVMは常に「自分が動いているCPU」向けに機械語を生成する)。
構造的に整理するとこう
【Java】
開発者のマシン(どのCPUでも同じ)
javac → バイトコード(CPU非依存)
エンドユーザーのマシン(CPUごとに異なるJVMが吸収)
x86用JVM / ARM用JVM / ...
【C】
開発者のマシン(CPUごとに異なるgccが吸収)
x86用gcc → x86機械語
ARM用gcc → ARM機械語
エンドユーザーのマシン(そのまま実行)
Javaは「吸収する責任をエンドユーザー側に置いた」、Cは「吸収する責任を開発者側に置いた」とも言えます。これがWrite Once, Run Anywhereの本質的な意味です。
「ただし、gccの「吸収」はもう少し複雑」ごめんなさい、この章がちょっとわかりませんでした。「つまりgccは「動作するマシン」と「生成する機械語のターゲット」が必ずしも一致しない、という柔軟性があります」これってどういうことなのでしょうか。gccは
ごめんなさい、説明が途中で切れてしまっています。続きを教えてもらえますか?
「ただし、gccの「吸収」はもう少し複雑」ごめんなさい、この章がちょっとわかりませんでした。「つまりgccは「動作するマシン」と「生成する機械語のターゲット」が必ずしも一致しない、という柔軟性があります」これってどういうことなのでしょうか。gccでコンパイルする際に、明示的にどの環境で動作させる用途の機械語を生成させるか指定することができる、特に指定しなければ現在の環境のCPUアーキテクチャで動くバイナリが生成される、ということなのでしょうか?
はい、まさにその理解で正確です。
具体例で説明すると
たとえばRaspberry Pi(ARM)向けのアプリを、手元のMac(x86)で開発したいとします。
# 通常のgcc(ターゲット指定なし)
$ gcc main.c -o app
→ x86の機械語が生成される(Macで動く、Raspberry Piでは動かない)
# クロスコンパイラ(ARMをターゲット指定)
$ arm-linux-gnueabihf-gcc main.c -o app
→ ARMの機械語が生成される(Raspberry Piで動く、Macでは動かない)
arm-linux-gnueabihf-gccというのが「ARM向けに機械語を生成するよう作られたgcc」で、これがクロスコンパイラです。
整理するとこう
| 動作するマシン | 生成する機械語 | |
|---|---|---|
| 通常のgcc | Mac(x86) | x86 |
| クロスコンパイラ | Mac(x86) | ARM ← ここがズレる |
「動作するマシン」と「生成する機械語のターゲット」が一致しないというのはこのことです。
JVMと対比すると
JVMにはこの概念がありません。JVMは常に「自分が今動いているCPU」向けに機械語を生成するので、ARM上のJVMはARM機械語を、x86上のJVMはx86機械語を生成します。ターゲットを別途指定するという発想がそもそもない。
これはJavaの設計思想(バイトコードを配布してエンドユーザー側のJVMに任せる)から自然に導かれる違いです。
Python はどうなるか — そして JIT とインタプリタは排他ではない
この感じで整理すると、pythonはどうなるでしょうか?そもそもインタプリタ言語なので、考え方の枠組みが全く違うあかもしれませんが、対比できるでしょうか。
良い問いです。実はPythonはJavaよりも複雑で、Javaに近い部分とインタープリタ独自の部分が混在しています。
Pythonの実際の変換フロー
Python (.py)
↓ 実行時に自動でコンパイル
バイトコード (.pyc) ← Javaの.classに相当
↓ CPython(インタープリタ)が1命令ずつ解釈
機械語(JITは基本なし)
意外と知られていませんが、Pythonもバイトコードに一度変換されています。 __pycache__フォルダの中に.pycファイルが生成されているのがこれです。
Java・C・Pythonの三者対比
| Java | C | Python | |
|---|---|---|---|
| 中間表現 | バイトコード(.class) | なし | バイトコード(.pyc) |
| 機械語への変換 | JIT(実行時) | コンパイル時 | 基本しない |
| 実行方式 | JIT+インタープリタ | ネイティブ実行 | インタープリタ |
| アーキテクチャ吸収 | JVM | gcc | CPython |
| 配布物 | .classまたは.jar | バイナリ | .pyソースまたは.pyc |
Pythonが「インタープリタ言語」と呼ばれる理由
JavaのJVMはバイトコードをJITで機械語に変換してから実行しますが、PythonのCPythonは基本的にバイトコードを機械語に変換せず、1命令ずつ読んでその場で処理します。
Java: バイトコード → 機械語に変換 → CPUが直接実行
Python:バイトコード → CPythonが解釈 → CPythonが代わりに処理
CPython自体はC言語で書かれたネイティブバイナリなので、「CPythonというプログラムがPythonバイトコードを読んで動く」という二重構造になっています。
ではPythonは遅いのか
基本的にはJavaより遅いです。JITがないので同じコードを何度実行しても最適化されません。これへの対応として:
- PyPy … JIT付きのPython実装。Javaに近い構造になる
- Cython … PythonコードをC言語に変換してコンパイル
- Numba … 数値計算コードに特化したJITコンパイラ
CPythonはあくまでPythonのデフォルト実装であって、Python言語仕様を満たす実装は他にもあります。
枠組みの違いをまとめると
C: 開発者がコンパイル → 機械語を配布 → そのまま実行
Java: 開発者がコンパイル → バイトコードを配布 → JVMが機械語に変換して実行
Python: コンパイルを意識させない → ソースを配布 → CPythonがその場で処理
Pythonの設計思想は「開発者にコンパイルを意識させない」という点でCやJavaと異なります。バイトコードへの変換は自動かつ透過的に行われ、ユーザーは.pyファイルを書いて実行するだけです。
java ってJIT+インタープリタ なのですか?必ずしも、実行時に機械語にすべてコンパイルしてから実行しているわけではなく、インタプリタ言語的な性質も持っているのでしょうか。
はい、その通りです。Javaは最初からJITで全部コンパイルするわけではありません。
JVMの実際の動作
バイトコード実行開始
↓
最初はインタープリタで1命令ずつ実行(起動が速い)
↓
JVMが「このメソッドは何度も呼ばれている」と判断
↓
そのメソッドだけJITコンパイル(機械語に変換してキャッシュ)
↓
以降そのメソッドは機械語で高速実行
つまり実行しながら徐々にJITコンパイルが進んでいくというイメージです。
なぜ最初からJITしないのか
JITコンパイル自体にコストがかかるからです。
全コードをJITしようとすると起動時間が長くなります。実際には8〜9割のコードは滅多に実行されないので、よく使われる部分だけをJITする方が合理的です。
「ホットスポット」という概念
JVMはこの戦略をHotSpotと呼んでいて、デフォルトのJVM実装の名前にもなっています。
コールドコード(呼ばれる頻度が低い)→ インタープリタのまま
ホットコード(頻繁に呼ばれる) → JITコンパイル
さらにJVMは段階的に最適化を強めていきます。
インタープリタ
↓ 一定回数呼ばれたら
C1コンパイラ(軽い最適化)
↓ さらに呼ばれたら
C2コンパイラ(重い最適化・ほぼネイティブC並みの速度)
結論
| タイミング | 方式 |
|---|---|
| 起動直後 | インタープリタ(遅いが即座に動く) |
| 実行中、頻度低いコード | インタープリタのまま |
| 実行中、頻度高いコード | JITコンパイル済み機械語 |
なのでJavaは「純粋なコンパイル言語」でも「純粋なインタープリタ言語」でもなく、実行状況に応じて両者を使い分けるハイブリッドという性格を持っています。Pythonとの違いは、Pythonは基本JITしない(CPythonの場合)のに対し、JavaはJITを積極的に活用する点です。