JekyllブログをDocusaurusに移行する
最近、ブログを新しいプラットフォームに移行する作業を行いました。様々な問題に直面し、その解決策をメモしておいたので、他の人にも役立つかもしれないと思い、ここに詳細な移行プロセスを記録します。
最近、ブログを新しいプラットフォームに移行する作業を行いました。様々な問題に直面し、その解決策をメモしておいたので、他の人にも役立つかもしれないと思い、ここに詳細な移行プロセスを記録します。
miseを使えば、どの言語やツールを使っても正確に必要なバージョンを使用でき、他のバージョンに切り替えたり、プロジェクトごとにバージョンを指定することも可能です。ファイルで明示するため、チームメンバー間でどのバージョンを使うか議論するなどのコミュニケーションコストも減らせます。
これまでこの分野で最も有名だったのはasdfでした[^fn-nth-1]。しかし、最近miseを使い始めてからは、miseの方がUXの面で少し優れていると感じています。今回は簡単な使用例を紹介しようと思います。
意図的かどうかは分かりませんが、ウェブページさえも似ています。
mise
(「ミーズ」と発音するようです)は開発環境設定ツールです。この名前はフランス料理の用語に由来し、大まかに「設定」または「所定の位置に置く」と訳されます。料理を始める前にすべての道具と材料が所定の位置に準備されている必要があるという意味だそうです。
簡単な特徴を列挙すると以下の通りです。
複数のクライアントリクエストを同時に処理できるサーバアプリケーションの実装は、今や非常に簡単です。Spring MVCを使うだけで、すぐに実現できます。しかし、エンジニアとして、その基礎原理に興味があります。本記事では、明らかに見えることを問い直しながら、マルチコネクションサーバを実装するための考慮事項について考察していきます。
例のコードはGitHubで確認できます。
最初の目的地は「ソケット」です。ネットワークプログラミングの観点から、ソケットはネットワーク上でデータを交換するための通信エンドポイントです。「ファイルのように使用される」という説明が重要です。これは、ファイルディスクリプタ(fd)を通じてアクセスされ、ファイルと同様のI/O操作をサポートするためです。
ソケットはIP、ポート、および相手のIPとポートを使用して識別できますが、fdを使用する方が好まれます。これは、接続が受け入れられるまでソケットには情報がなく、単純な整数(fd)以上のデータが必要だからです。
ソケットを使用してサーバアプリケーションを実装するには、次の手順を踏む必要があります:
PostgreSQLでは、FOR UPDATEロックはトランザクション内でSELECTクエリを実行する際にテーブルの行を明示的にロックするために使用されます。このロックモードは、選択された行がトランザクションが完了するまで変更されないようにし、他のトランザクションがこれらの行を変更したり、競合するロックをかけたりするのを防ぐために使用されます。
例えば、特定の顧客がチケット予約プロセスを進めている間に他の顧客がデータを変更するのを防ぐために使用されることがあります。
この記事で検討するケースは少し特殊です:
select for update
はどのように動作するのか?PostgreSQLでは、select for update
句はトランザクション分離レベルによって異なる動作をします。したがって、各分離レベルでの動作を確認する必要があります。
以下のデータが存在する場合にデータが変更されるシナリオを仮定します。
id | name |
---|---|
1 | null |
データをネットワーク経由で送信するにはどうすれば良いでしょうか?受信者と接続を確立し、一度にすべてのデータを送信するのが最も簡単な方法のように思えます。しかし、この方法は複数のリクエストを処理する際に非効率的です。なぜなら、1つの接続は1回のデータ転送しか維持できないからです。大きなデータ転送のために接続が長引くと、他のデータは待たなければなりません。
データ送信プロセスを効率的に処理するために、ネットワークはデータを複数の部分に分割し、受信側がそれらを再構成する必要があります。これらの分割されたデータ構造をパケットと呼びます。パケットには、受信側がデータを正しい順序で再構成できるようにするための追加情報が含まれています。
複数のパケットでデータを送信することで、パケットスイッチングを通じて多くのリクエストを効率的に処理できますが、データの損失や誤った順序での配信など、さまざまなエラーが発生する可能性もあります。こうした問題をどのようにデバッグすれば良いのでしょうか?🤔
bootRun
を使用してテストする必要がある場合もあります。.env
ファイルは通常Gitで無視されるため、バージョン管理が難しく、断片化しやすい。
.env
ファイルのバージョン管理は可能ですか?.env
ファイルを更新するのは便利です。.env
ファイルのバージョン管理はスナップショットを通じて行えます。.
..
...
....
それだけだと、記事が少し退屈に見えるかもしれませんね?もちろん、まだいくつかの問題が残っています。
S3を使用する際、ファイル構造の最適化やビジネス特有の分類のために多くのバケットが作成されることが一般的です。
aws s3 cp s3://something.service.com/enviroment/.env .env
もし.env
ファイルが見つからない場合、上記のようにAWS CLIを使用してダウンロードする必要があります。事前に誰かがバケットを共有してくれない限り、環境変数ファイルを見つけるためにすべてのバケットを検索する必要があり、不便です。共有を避けるつもりでしたが、再度共有するために何かを受け取るのは少し面倒に感じるかもしれません。
バケットが多すぎる。envはどこにあるのか?
S3内のバケットを探索して必要な.env
ファイルを見つけてダウンロードするプロセスを自動化すると、非常に便利です。これはfzfやgumのようなツールを使用してスクリプトを書くことで実現できます。
.env
ではない...一部の方はすでにお気づきかもしれませんが、Spring Bootはシステム環境変数を読み取ってYAMLファイルのプレースホルダーを埋めます。しかし、単に.env
ファイルを使用するだけではシステム環境変数が適用されず、Spring Bootの初期化プロセス中に拾われません。
簡単にその仕組みを見てみましょう。
# .env
HELLO=WORLD
# application.yml
something:
hello: ${HELLO} # OSのHELLO環境変数から値を取得します。
@Slf4j
@Component
public class HelloWorld {
@Value("${something.hello}")
private String hello;
@PostConstruct
public void init() {
log.info("Hello: {}", hello);
}
}
SystemEnvironmentPropertySource.java
@Value
のプレースホルダーが解決されず、Beanの登録が失敗し、エラーが発生します。
単に
.env
ファイルがあるだけでは、システム環境変数として登録されません。
.env
ファイルを適用するには、export
コマンドを実行するか、IntelliJの実行構成に.env
ファイルを登録する必要があります。しかし、export
コマンドを使用してローカルマシンに多くの変数をグローバルに登録すると、上書きなどの意図しない動作が発生する可能性があるため、IntelliJのGUIを通じて個別に管理することをお勧めします。
IntelliJはGUIを介して
.env
ファイルの設定をサポートしています。
プレースホルダーが解決され、正しく適用されました。
ふう、問題の特定と範囲設定の長いプロセスが終わりました。もう一度ワークフローをまとめ、スクリプトを紹介しましょう。
.env
ファイルを見つけてダウンロードします。.env
をシステム環境変数として設定します。シェルスクリプトはシンプルでありながら、gumを使用してスタイリッシュに書かれています。
#!/bin/bash
S3_BUCKET=$(aws s3 ls | awk '{print $3}' | gum filter --reverse --placeholder "Select...") # 1.
# デプロイ環境を選択
TARGET=$(gum choose --header "Select a environment" "Elastic Container Service" "EC2")
if [ "$TARGET" = "Elastic Container Service" ]; then
TARGET="ecs"
else
TARGET="ec2"
fi
S3_BUCKET_PATH=s3://$S3_BUCKET/$TARGET/
# envファイルを検索
ENV_FILE=$(aws s3 ls "$S3_BUCKET_PATH" | grep env | awk '{print $4}' | gum filter --reverse --placeholder "Select...") # 2.
# 確認
if (gum confirm "Are you sure you want to use $ENV_FILE?"); then
echo "You selected $ENV_FILE"
else
die "Aborted."
fi
ENV_FILE_NAME=$(gum input --prompt.foreground "#04B575" --prompt "Enter the name of the env file: " --value ".env" --placeholder ".env")
gum spin -s meter --title "Copying env file..." -- aws s3 cp "$S3_BUCKET_PATH$ENV_FILE" "$ENV_FILE_NAME" # 3.
echo "Done."
gum filter
を使用して、目的のS3バケットを選択します。env
という単語を含むアイテムを検索し、ENV_FILE
という変数に割り当てます。.env
ファイルのオブジェクトキーを最終決定し、ダウンロードを進めます。実行プロセスのデモビデオを作成しました。
デモ
これが終わったら、先ほど述べたように、現在のディレクトリにコピーされた.env
ファイルをIntelliJに適用するだけです。
direnvとIntelliJのdirenvプラグインを使用すると、さらに便利に適用できます。
この記事では、既存の非効率な実装について議論し、それを改善するために試みた方法を記録します。
複数のデータベースに分散されたテーブルを単一のクエリで結合することは不可能ではなかったが、困難だった...
データベース結合ができなかった主な理由が解決されたため、ジオメトリ処理にインデックススキャンを活用することを積極的に検討しました。
このプロセスをシミュレートするために、本番DBと同じデータを用意し、実験を行いました。
まず、インデックスを作成しました:
CREATE INDEX idx_port_geom ON port USING GIST (geom);
次に、PostGISのcontains
関数を実行しました:
SELECT *
FROM ais AS a
JOIN port AS p ON st_contains(p.geom, a.geom);
素晴らしい...
1分47秒から2分30秒
0.23ミリ秒から0.243ミリ秒
キャプチャは用意していませんが、インデックス適用前のクエリは1分30秒以上かかっていました。
結論から始めて、なぜこれらの結果が得られたのかを掘り下げていきましょう。
複雑なジオメトリデータのクエリに非常に有用なインデックスで、その内部構造は以下の通りです。
R-treeのアイデアは、平面を長方形に分割してすべてのインデックスされたポイントを包含することです。インデックス行は長方形を格納し、次のように定義できます:
"探しているポイントは指定された長方形の中にある。"
R-treeのルートには、いくつかの最大の長方形(交差することもある)が含まれます。子ノードには、親ノードに含まれる小さな長方形が含まれ、すべての基本ポイントを包含します。
理論的には、リーフノードにはインデックスされたポイントが含まれるべきですが、すべてのインデックス行は同じデータ型を持つ必要があるため、ポイントに縮小された長方形が繰り返し格納されます。
この構造を視覚化するために、R-treeの3つのレベルの画像を見てみましょう。ポイントは空港の座標を表しています。
レベル1:2つの大きな交差する長方形が見えます。
2つの交差する長方形が表示されています。
レベル2:大きな長方形が小さなエリアに分割されています。
大きな長方形が小さなエリアに分割されています。
レベル3:各長方形には1つのインデックスページに収まるだけのポイントが含まれています。
各長方形には1つのインデックスページに収まるポイントが含まれています。
これらのエリアはツリー構造になっており、クエリ中にスキャンされます。詳細な情報については、次の記事を参照することをお勧めします。
この記事では、具体的な条件、遭遇した問題、それを解決するために行った努力、およびこれらの問題に対処するために必要な基本概念を簡単に紹介しました。要約すると:
"Write once, Test anywhere"
Fixture Monkeyは、Naverがオープンソースとして開発しているテストオブジェクト生成ライブラリです。この名前は、NetflixのオープンソースツールであるChaos Monkeyにインスパイアされたようです。テストフィクスチャをランダムに生成することで、カオスエンジニアリングを実践的に体験できます。
約2年前に初めて出会って以来、私のお気に入りのオープンソースライブラリの一つとなりました。これまでに2つの記事も書きました。
バージョンアップごとに変更が多く、追加の記事は書いていませんでしたが、バージョン1.xがリリースされた今、新たな視点で再訪することにしました。
以前の記事はJavaをベースにしていましたが、今回は現在のトレンドに合わせてKotlinで書いています。この記事の内容は公式ドキュメントに基づいており、実際の使用経験から得た洞察も加えています。
従来のアプローチでどのような問題があるか、以下のコードを見てみましょう。
例ではJava開発者に馴染みのあるJUnit5を使用しましたが、個人的にはKotlin環境ではKotestをお勧めします。
data class Product (
val id: Long,
val productName: String,
val price: Long,
val options: List<String>,
val createdAt: Instant,
val productType: ProductType,
val merchantInfo: Map<Int, String>
)
enum class ProductType {
ELECTRONICS,
CLOTHING,
FOOD
}
@Test
fun basic() {
val actual: Product = Product(
id = 1L,
price = 1000L,
productName = "productName",
productType = ProductType.FOOD,
options = listOf(
"option1",
"option2"
),
createdAt = Instant.now(),
merchantInfo = mapOf(
1 to "merchant1",
2 to "merchant2"
)
)
// テスト目的に比べて準備プロセスが長い
actual shouldNotBe null
}
テストコードを見ると、アサーションのためにオブジェクトを生成するだけで多くのコードを書かなければならないと感じます。実装の性質上、プロパティが設定されていないとコンパイルエラーが発生するため、意味のないプロパティでも書かなければなりません。
テストコードでアサーションのための準備が長くなると、コード内のテスト目的の意味が不明瞭になることがあります。このコードを初めて読む人は、意味のないプロパティにも隠れた意味があるかどうかを確認する必要があり、このプロセスは開発者の疲労を増加させます。
プロパティを直接設定してオブジェクトを生成する場合、さまざまなシナリオで発生する可能性のある多くのエッジケースが見落とされがちです。
val actual: Product = Product(
id = 1L, // idが負の値になったらどうなる?
// ...省略
)
エッジケースを見つけるためには、開発者はプロパティを一つ一つ設定して確認する必要がありますが、実際にはランタイムエラーが発生して初めてエッジケースに気づくことが多いです。エラーが発生する前にエッジケースを簡単に発見するためには、オブジェクトのプロパティをある程度ランダムに設定する必要があります。
テストオブジェクトを再利用するために、オブジェクトマザーパターンと呼ばれるパターンでは、オブジェクトを生成するファクトリークラスを作成し、そのクラスから生成されたオブジェクトを使用してテストコードを実行します。
しかし、この方法はテストコードだけでなくファクトリーの管理も継続的に必要であり、エッジケースの発見には役立ちません。
Fixture Monkeyは、上記の再利用性とランダム性の問題をエレガントに解決します。どのようにこれらの問題を解決するか見てみましょう。
まず、依存関係を追加します。
testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter-kotlin:1.0.13")
Kotlin環境でFixture Monkeyがスムーズに動作するようにするためにKotlinPlugin()
を適用します。
@Test
fun test() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()
}
先ほど使用したProduct
クラスを使って再度テストを書いてみましょう。
data class Product (
val id: Long,
val productName: String,
val price: Long,
val options: List<String>,
val createdAt: Instant,
val productType: ProductType,
val merchantInfo: Map<Int, String>
)
enum class ProductType {
ELECTRONICS,
CLOTHING,
FOOD
}
@Test
fun test() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()
val actual: Product = fixtureMonkey.giveMeOne()
actual shouldNotBe null
}
不要なプロパティ設定なしにProduct
のインスタンスを作成できます。すべてのプロパティ値はデフォルトでランダムに埋められます。
複数のプロパティがうまく埋められる
しかし、ほとんどの場合、特定のプロパティ値が必要です。例えば、例ではid
が負の数として生成されましたが、実際にはid
は正の数として使用されることが多いです。次のようなバリデーションロジックがあるかもしれません:
init {
require(id > 0) { "idは正の数である必要があります" }
}
テストを数回実行した後、id
が負の数として生成されるとテストが失敗します。すべての値がランダムに生成されるため、予期しないエッジケースを見つけるのに特に役立ちます。
ランダム性を維持しつつ、バリデーションロジックが通るように範囲を少し制限しましょう。
@RepeatedTest(10)
fun postCondition() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()
val actual = fixtureMonkey.giveMeBuilder<Product>()
.setPostCondition { it.id > 0 } // 生成されたオブジェクトのプロパティ条件を指定
.sample()
actual.id shouldBeGreaterThan 0
}
テストを10回実行するために@RepeatedTest
を使用しました。
すべてのテストが通ることがわかります。
postCondition
を使用する際は、条件を狭めすぎるとオブジェクト生成がコスト高になることに注意してください。これは、条件を満たすオブジェクトが生成されるまで内部で生成が繰り返されるためです。このような場合、特定の値を固定するためにsetExp
を使用する方がはるかに良いです。
val actual = fixtureMonkey.giveMeBuilder<Product>()
.setExp(Product::id, 1L) // 指定された値のみ固定され、他はランダム
.sample()
actual.id shouldBe 1L
プロパティがコレクションの場合、sizeExp
を使用してコレクションのサイズを指定できます。
val actual = fixtureMonkey.giveMeBuilder<Product>()
.sizeExp(Product::options, 3)
.sample()
actual.options.size shouldBe 3
maxSize
とminSize
を使用すると、コレクションの最大サイズと最小サイズの制約を簡単に設定できます。
val actual = fixtureMonkey.giveMeBuilder<Product>()
.maxSizeExp(Product::options, 10)
.sample()
actual.options.size shouldBeLessThan 11
他にも様々なプロパティ設定方法があるので、必要に応じて探索してみてください。
Fixture Monkeyは、ユニットテストを書く際の不便さを本当に解消してくれます。この記事では触れませんでしたが、ビルダーに条件を作成して再利用したり、プロパティにランダム性を追加したり、開発者が見逃しがちなエッジケースを発見するのに役立ちます。その結果、テストコードが非常に簡潔になり、Object Motherのような追加コードが不要になり、メンテナンスが容易になります。
Fixture Monkey 1.xのリリース前でも、テストコードを書くのに非常に役立ちました。今や安定版となったので、ぜひ導入してテストコードを書く楽しさを味わってください。
前の章では、Javaをコンパイルし、バイトコードの構造を調べました。この章では、JVMが「Hello World」コードブロックをどのように実行するかを探ります。
Javaクラスがメモリにロードされ、初期化されるタイミング、場所、方法を理解するためには、まずJVMのクラスローダーを見てみる必要があります。
クラスローダーは、コンパイルされたJavaクラスファイル(.class)を動的にロードし、それをJVMのメモリアリアであるランタイムデータエリアに配置します。
クラスローダーによるクラスファイルのロードプロセスは、以下の3つのステージで構成されます:
重要なのは、クラスファイルは一度にすべてメモリにロードされるのではなく、アプリケーションが必要とするタイミングで動的にメモリにロードされるということです。
多くの人が誤解しているのは、クラスやクラス内の静的メンバーがメモリにロードされるタイミングです。多くの人は、ソースが実行されるとすぐにすべてのクラスと静的メンバーがメモリにロードされると誤解しています。しかし、静的メンバーは、クラスがメモリに動的にロードされ、そのクラス内のメンバーが呼び出されたときにのみメモリにロードされます。
verboseオプションを使用すると、メモリへのロードプロセスを観察できます。
java -verbose:class VerboseLanguage
VerboseLanguage
クラスが「Hello World」が印刷される前にロードされていることがわかります。
Java 1.8とJava 21では、コンパイル結果からログ出力形式が異なります。バージョンが進むにつれて最適化が行われ、コンパイラの動作が若干変わるため、バージョンを確認することが重要です。この記事では、デフォルトバージョンとしてJava 21を使用し、他のバージョンについては別途指定します。
ランタイムデータエリアは、プログラムの実行中にデータが保存される空間です。共有データエリアとスレッドごとのデータエリアに分かれています。
JVM内には、JVM内で実行される複数のスレッド間でデータを共有できるエリアがいくつかあります。これにより、さまざまなスレッドが同時にこれらのエリアにアクセスできます。
VerboseLanguage
クラスのインスタンスが存在する場所
ヒープエリアは、Javaオブジェクトや配列が作成されるときに割り当てられる場所です。JVMが起動するときに作成され、JVMが終了するときに破棄されます。
Java仕様によると、この空間は自動的に管理されるべきです。この役割はガベージコレクタ(GC)と呼ばれるツールによって実行されます。
JVM仕様にはヒープのサイズに制約はありません。メモリ管理もJVMの実装に任されています。しかし、ガベージコレクタが新しいオブジェクトを作成するための十分なスペースを確保できない場合、JVMはOutOfMemoryエラーをスローします。
メソッドエリアは、クラスやインターフェースの定義を保存する共有データエリアです。ヒープと同様に、JVMが起動するときに作成され、JVMが終了するときに破棄されます。
クラスのグローバル変数や静的変数はこのエリアに保存され、プログラムの開始から終了までどこからでもアクセス可能です。(= ランタイム定数プール)
具体的には、クラスローダーがクラスのバイトコード(.class)をロードし、それをJVMに渡します。JVMはオブジェクトの作成やメソッドの呼び出しに使用されるクラスの内部表現を生成します。この内部表現は、クラスやインターフェースのフィールド、メソッド、コンストラクタに関する情報を収集します。
実際、JVM仕様によると、メソッドエリアは「どのようにあるべきか」の明確な定義がないエリアです。これは論理的なエリアであり、実装によってはヒープの一部として存在することもあります。単純な実装では、GCや圧縮を行わずにヒープの一部として存在することもあります。
ランタイム定数プールはメソッドエリアの一部であり、クラスやインターフェースの名前、フィールド名、メソッド名へのシンボリック参照を含みます。JVMはランタイム定数プールを使用して、参照の実際のメモリアドレスを見つけます。
バイトコードを解析するときに見たように、定数プールはクラスファイルの中にありました。実行時には、クラスファイル構造の一部であった定数プールが読み取られ、クラスローダーによってメモリにロードされます。
「Hello World」文字列が保存される場所
前述のように、ランタイム定数プールはメソッドエリアの一部です。しかし、ヒープにも定数プールがあり、これを文字列定数プールと呼びます。
new String("Hello World")
を使用して文字列を作成すると、その文字列はオブジェクトとして扱われ、ヒープで管理されます。例を見てみましょう:
String s1 = "Hello World";
String s2 = new String("Hello World");
コンストラクタ内で使用される文字列リテラルは文字列プールから取得されますが、new
キーワードは新しい一意の文字列の作成を保証します。
0: ldc #7 // String Hello World
2: astore_1
3: new #9 // class java/lang/String
6: dup
7: ldc #7 // String Hello World
9: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
12: astore_2
13: return
バイトコードを調べると、invokespecial
命令を使用して文字列が「作成」されていることがわかります。
invokespecial
命令は、オブジェクトの初期化メソッドが直接呼び出されることを意味します。
なぜ文字列定数プールはメソッドエリアのランタイム定数プールとは異なり、ヒープに存在するのでしょうか?🤔
結論として、文字列定数プールはGCの影響下にあるため、ヒープに存在する必要があります。
文字列比較操作は、完全一致のために長さがNの場合、N回の操作が必要です。一方、プールを使用すると、equals比較は参照をチェックするだけで済み、コストはです。
new
を使用して文字列を作成することで、文字列定数プール外の文字列を文字列定数プールに移動することができます。
String greeting = new String("Hello World");
greeting.intern(); // 定数プールを使用
// これで、SCP内の文字列リテラルとの比較が可能になります。
assertThat(greeting).isEqualTo("Hello World"); // true
これは過去にはメモリを節約するためのトリックとして提供されていましたが、現在では必要ありませんので、文字列はリテラルとして使用するのが最善です。
要約すると:
コンストラクタ内の文字列リテラルは文字列定数プールから取得されますが、new
キーワードは独立した文字列の作成を保証します。その結果、文字列定数プール内の文字列とヒープ内の文字列の2つが存在します。
共有データエリアに加えて、JVMは個々のスレッドのデータを別々に管理します。JVMは実際にかなり多くのスレッドの同時実行をサポートしています。
各JVMスレッドにはPC(プログラムカウンタ)レジスタがあります。
PCレジスタは、CPUが命令の実行を続けるために現在の命令の位置を保存します。また、次に実行される命令のメモリアドレスを保持し、命令の実行を最適化するのに役立ちます。
PCの動作はメソッドの性質によって異なります:
PCレジスタのライフサイクルは基本的にスレッドのライフサイクルと同じです。
各JVMスレッドには独自のスタックがあります。JVMスタックはメソッド呼び出し情報を保存するデータ構造です。各メソッド呼び出しごとにスタックに新しいフレームが作成され、そのフレームにはメソッドのローカル変数と戻り値のアドレスが含まれます。プリミティブ型の場合はスタックに直接保存され、ラッパー型の場合はヒープに作成されたインスタンスへの参照を保持します。これにより、intやdouble型はIntegerやDoubleに比べてわずかにパフォーマンスが優れています。
JVMスタックのおかげで、JVMはプログラムの実行をトレースし、必要に応じてスタックトレースを記録できます。
printStackTrace
はその一例です。スタックのメモリサイズと割り当て方法はJVMの実装によって決定できます。通常、スレッドが開始されるときに約1MBのスペースが割り当てられます。
JVMメモリ割り当てエラーはスタックオーバーフローエラーを引き起こす可能性があります。しかし、JVMの実装がJVMスタックサイズの動的拡張を許可し、拡張中にメモリエラーが発生した場合、JVMはOutOfMemoryエラーをスローすることがあります。
ネイティブメソッドはJava以外の言語で書かれたメソッドです。これらのメソッドはバイトコードにコンパイルできないため(Javaではないため、javacを使用できません)、別のメモリアリアが必要です。
JVMの実装は、ネイティブメソッドスタックのサイズとメモリブロックの操作方法を決定できます。
ネイティブメソッドスタックに起因するメモリ割り当てエラーの場合、スタックオーバーフローエラーが発生します。しかし、ネイティブメソッドスタックのサイズを増やす試みが失敗した場合、OutOfMemoryエラーが発生します。
結論として、JVMの実装はネイティブメソッドの呼び出しをサポートしないことを決定でき、そのような実装はネイティブメソッドスタックを必要としないことを強調しています。
Javaネイティブインターフェースの使用については別の記事で取り上げます。
ロードとストレージのステージが完了すると、JVMはクラスファイルを実行します。これには3つの要素が含まれます:
プログラムが開始されると、インタープリタはバイトコードを1行ずつ読み取り、マシンが理解できる機械語に変換します。
インタープリタは一般的に遅いです。なぜでしょうか?
コンパイルされた言語は、実行前のコンパイルプロセス中にプログラムの実行に必要なリソースや型を定義できます。しかし、インタープリタ言語では、必要なリソースや変数の型は実行時までわからないため、最適化が難しくなります。
JIT(Just In Time)コンパイラは、インタープリタの欠点を克服するためにJava 1.1で導入されました。
JITコンパイラは、バイトコードを実行時に機械語にコンパイルし、Javaアプリケーションの実行速度を向上させます。頻繁に実行される部分(ホットコード)を検出してコンパイルします。
JIT関連の動作を確認する際には、以下のキーワードを使用できます。
-XX:+PrintCompilation: JIT関連のログを出力します -Djava.compiler=NONE: JITを無効にします。パフォーマンスの低下を観察できます。
ガベージコレクター ガベージコレクターは重要なコンポーネントであり、別のドキュメントに記載されていますので、今回は省略します。
GCの最適化は一般的ではありません。 しかし、GC操作による500ms以上の遅延が発生する場合があり、高トラフィックやキャッシュの厳しいTTLを扱うシナリオでは、500msの遅延が重大な問題となることがあります。
Javaは間違いなく複雑な言語です。
面接では、次のような質問をよく受けます。
Javaについてどのくらい詳しいですか?
これで、もっと自信を持って答えられるようになるでしょう。
えっと…🤔 ちょうど「Hello World」くらいです。
前回の記事から続けて、コードが「Hello World」を表示するまでの進化を探っていきましょう。
プログラミング言語にはレベルがあります。
プログラミング言語が人間の言語に近いほど高水準言語とされ、コンピュータが理解できる言語(機械語)に近いほど低水準言語とされます。高水準言語でプログラムを書くことは、人間にとって理解しやすく、生産性を向上させますが、機械語との間にギャップが生じ、そのギャップを埋めるプロセスが必要です。
高水準言語が低水準に降りていくプロセスをコンパイルと呼びます。
Javaは低水準言語ではないため、コンパイルプロセスが存在します。Javaのコンパイルプロセスがどのように機能するかを見てみましょう。
前述のように、Javaコードはコンピュータによって直接実行されることはできません。Javaコードを実行するためには、コンピュータが読み取り、解釈できる形に変換する必要があります。この変換には以下の主要なステップが含まれます:
コンパイルの結果得られる.class
ファイルはバイトコードです。しかし、これはまだコンピュータが実行できる機械語ではありません。Java仮想マシン(JVM)はこのバイトコードを読み取り、さらに機械語に変換します。このプロセスについては最終章で取り上げます。
まず、.java
ファイルをコンパイルして.class
ファイルを作成しましょう。javac
コマンドを使用してコンパイルできます。
// VerboseLanguage.java
public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java
クラスファイルが作成されたことが確認できます。java
コマンドを使用してクラスファイルを実行できます。これがJavaプログラムを実行する基本的な流れです。
java VerboseLanguage
// Hello World
クラスファイルの内容が気になりますか?コンピュータがどのように言語を読み取り、実行するのか疑問に思いますか?このファイルにはどんな秘密が隠されているのでしょうか?まるでパンドラの箱を開けるような気分です。
期待しながら開けてみると...
なんてこった!
ほんの少しのバイナリ内容が表示されるだけです。
待って、コンパイルの結果はバイトコードじゃなかったの?
そうです、それはバイトコードです。同時に、それはバイナリコードでもあります。この時点で、バイトコードとバイナリコードの違いについて簡単に触れておきましょう。
バイナリコード : 0と1で構成されたコード。機械語はバイナリコードで構成されていますが、すべてのバイナリコードが機械語というわけではありません。
バイトコード : 0と1で構成されたコード。ただし、バイトコードは機械ではなくVMを対象としています。JITコンパイラなどのプロセスを通じてVMによって機械語に変換されます。
それでも、この記事が深掘りを謳っている以上、私たちはこの変換を読み取ることに挑戦しました。
幸いなことに、私たちのパンドラの箱には0と1だけが含まれており、他の困難や挑戦はありません。
読み取ることには成功しましたが、0と1だけでは内容を理解するのは非常に難しいです 🤔
さて、このコードを解読してみましょう。
コンパイルプロセス中に、コードは0と1で構成されたバイトコードに変換されます。前述のように、バイトコードを直接解釈するのは非常に難しいです。幸いなことに、JDKには開発者がコンパイルされたバイトコードを読み取るのに役立つツールが含まれており、デバッグに役立ちます。
バイトコードを開発者にとってより読みやすい形に変換するプロセスを逆アセンブルと呼びます。このプロセスは時々デコンパイルと混同されることがありますが、デコンパイルはアセンブリ言語ではなく、より高水準のプログラミング言語に変換されます。また、javap
のドキュメントでは明確に逆アセンブルという用語が使用されているため、ここでもそれに従います。
デコンパイルは、バイナリコードを比較的高水準の言語で表現することを指します。一方、逆アセンブルはバイナリコードを最小限の人間が読みやすい形(アセンブリ言語)で表現します。
javap
を使用してバイトコードを逆アセンブルしてみましょう。出力は0と1だけよりもはるかに読みやすいです。
javap -c VerboseLanguage.class
Compiled from "VerboseLanguage.java"
public class VerboseLanguage {
public VerboseLanguage();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
ここから何が学べるでしょうか?
まず、この言語は仮想マシンアセンブリ言語と呼ばれます。
Java仮想マシンコードは、JDKリリースに含まれるOracleのjavapユーティリティによって出力される非公式の「仮想マシンアセンブリ言語」で書かれています。 - JVM Spec
フォーマットは次の通りです:
<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]
index : JVMコードバイト配列のインデックス。メソッドの開始オフセットと考えることができます。
opcode : 命令セットのオペコードを表すニーモニックシンボル。虹の色の順番を'ROYGBIV'として覚えるように、命令セットを区別するためのニーモニックシンボルです。虹の色が命令セットを表すとすれば、'ROYGBIV'の各音節がそれらを区別するために定義されたニーモニックシンボルと考えることができます。
operandN : 命令のオペランド。コンピュータ命令のオペランドはアドレスフィールドです。定数プールに格納されているデータの場所を指します。
逆アセンブル結果のmainメソッド部分を詳しく見てみましょう。
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
invokevirtual
: インスタンスメソッドを呼び出すgetstatic
: クラスから静的フィールドを取得するldc
: 実行時定数プールにデータをロードする3行目の3: ldc #13
は、インデックス13の項目をプールにロードすることを意味し、親切にもコメントでその項目が示されています。
Hello World
バイトコード命令であるgetstatic
やinvokevirtual
は、1バイトのオペコード番号で表されます。例えば、getstatic=0xb2
、invokevirtual=0xb6
などです。Javaバイトコード命令も最大で256種類のオペコードを持つことが理解できます。
invokevirtual
のバイトコードを示すJVM命令セット
mainメソッドのバイトコードを16進数で見ると、次のようになります:
b2 00 07 12 0d b6
まだパターンがわかりにくいかもしれません。ヒントとして、前述のようにオペコードの前の数値はJVM配列のインデックスであることを思い出してください。表現を少し変えてみましょう。
arr = [b2, 00, 07, 12, 0d, b6]
インデックスの意味が少し明確になります。いくつかのインデックスをスキップしている理由は非常に簡単です:getstatic
は2バイトのオペランドを必要とし、ldc
は1バイトのオペランドを必要とします。したがって、getstatic
の次の命令であるldc
はインデックス3に記録され、1と2をスキップします。同様に、4をスキップしてinvokevirtual
命令はインデックス5に記録されます。
最後に、4行目のコメント(Ljava/lang/String;)V
に注目してください。このコメントを通じて、JavaバイトコードではクラスがL;
として表され、voidがV
として表されることがわかります。他の型もそれぞれ独自の表現を持ち、次のようにまとめられます:
Javaバイトコード | 型 | 説明 |
---|---|---|
B | byte | 符号付きバイト |
C | char | Unicode文字 |
D | double | 倍精度浮動小数点値 |
F | float | 単精度浮動小数点値 |
I | int | 整数 |
J | long | 長整数 |
L<classname>; | reference | クラス<classname>のインスタンス |
S | short | 符号付きショート |
Z | boolean | 真または偽 |
[ | reference | 一次元配列 |
-verbose
オプションを使用すると、定数プールを含むより詳細な逆アセンブル結果を見ることができます。オペランドと定数プールを一緒に調べると興味深いでしょう。
"VerboseLanguage.java"からコンパイル
public class VerboseLanguage
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // VerboseLanguage
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World
#14 = Utf8 Hello World
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // VerboseLanguage
#22 = Utf8 VerboseLanguage
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 VerboseLanguage.java
{
public VerboseLanguage();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "VerboseLanguage.java"
前の章では、Hello Worldを出力するために冗長なプロセスが必要な理由を探りました。この章では、Hello Worldを出力する前のコンパイルと逆アセンブルのプロセスを見てきました。次に、JVMを使用してHello Worldを出力するメソッドの実行フローを最終的に調べます。