#chiroito ’s blog

Java を中心とした趣味の技術について

Spring Boot REST APIがそのままQuarkusで動くって本当?移行手順とパフォーマンス比較を公開

Java のクラウドネイティブ化が当たり前になり、軽量・高速なランタイムとして Quarkus を採用するケースが増えてきています。 一方で、既存アプリケーションの多くは Spring Boot で構築されており、

  • 本当に Quarkus へ移行できるのか
  • コードを書き直さないといけないのでは

といった印象を持たれがちです。

しかし実は、REST API アプリケーションであれば驚くほど簡単に動作します。 理由は、Quarkus が Spring Boot 互換 API を公式に提供しているためです。

今回は、次の公開サンプル(著者許諾済み)を題材に、Spring Boot の REST API を Quarkus へ移行する方法と、実際の性能差を紹介します。

https://github.com/projava17/examples/tree/main/part-6/chapter-21-spring-boot


結論:REST API アプリは“ほぼ変更なし”で Quarkus 上で動く

今回の移行検証では、REST API 部分は メソッドに public を追加するだけでそのまま Quarkus 上で動作しました。

Quarkus 側では Spring Web 互換レイヤー(quarkus-spring-web)が提供されているため、

  • @RestController
  • @RequestMapping
  • @GetMapping / @PostMapping

といったアノテーションはほぼ Spring Boot と同じように記述できます。

BFF のような薄い REST API アプリなら、コードを書き換える量は最小限で済みます


性能はどう変わったのか(実測値)

実際に計測すると、Quarkus へ移行するだけで次の改善が見られました。

項目 Spring Boot Quarkus 差分
起動時間 1.2 秒 0.58 秒 52% 短縮
起動後のメモリ使用量 12 MB 8 MB 33% 削減

つまり 書き換えほぼゼロで高速化が得られるということです。 これはクラウド環境ではコスト削減にも直結します。 メモリ使用量は、GCを何度か実行した後の値を取得しています。


移行の全体像

今回の移行ステップはとてもシンプルです。

  1. pom.xml を Spring Boot から Quarkus の BOM に書き換える
  2. Spring Boot 固有の起動クラス(TaskListApplication)を削除
  3. REST API のメソッドに public を付ける(Spring Boot は package-private でも動くが、Quarkus は public 必須)
  4. Thymeleaf を Qute に置き換える(今回は“おまけ”として後述)

まずは最も重要な pom.xml から見ていきます。


Spring Boot → Quarkus への pom.xml の変更ポイント

元の Spring Boot(抜粋)

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.3</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

Quarkus 用に書き換えた pom.xml(BOM + Spring Web 互換 API)

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-spring-web</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-jackson</artifactId>
</dependency>

ここが最大のポイントです。 Spring Boot の Web API を提供する部分を Quarkus の Spring Web 互換レイヤーに差し替えるのみで、実際のコントローラ実装はそのまま利用できます。

pom.xmlのファイルは以下のようになります。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>jp.gihyo.projava</groupId>
    <artifactId>tasklist</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>TaskList</name>
    <description>Spring Bootを使ったタスク管理アプリケーション</description>
    <properties>
        <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
        <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
        <quarkus.platform.version>3.30.2</quarkus.platform.version>
        <surefire-plugin.version>3.1.2</surefire-plugin.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>${quarkus.platform.artifact-id}</artifactId>
                <version>${quarkus.platform.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-spring-web</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest-jackson</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkiverse.qute.web</groupId>
            <artifactId>quarkus-qute-web</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>${surefire-plugin.version}</version>
                <configuration>
                    <systemPropertyVariables>
                        <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
                        <maven.home>${maven.home}</maven.home>
                    </systemPropertyVariables>
                </configuration>
            </plugin>
            <plugin>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>quarkus-maven-plugin</artifactId>
                <version>${quarkus.platform.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>build</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

アプリケーション起動クラスは不要

Spring Boot の典型的な起動クラスは以下のような構造です。

@SpringBootApplication
public class TaskListApplication {
    public static void main(String[] args) {
        SpringApplication.run(TaskListApplication.class, args);
    }
}

Quarkus は ランタイムが起動処理を持つため、アプリ側で明示的に記述する必要がありません。 そのため、このクラスは削除します。


REST API の修正点:本当に “public を付けるだけ”

実際に変更したコードがこちらです。

Before(Spring Boot)

@RequestMapping(value = "/resthello")
String hello() {
    return """
            Hello.
            It works!
            現在時刻は%sです。
            """.formatted(LocalDateTime.now());
}

After(Quarkus)

@RequestMapping(value = "/resthello")
public String hello() {
    return """
            Hello.
            It works!
            現在時刻は%sです。
            """.formatted(LocalDateTime.now());
}

本当にこれだけです。 Spring Boot がメソッド可視性に寛容なのに対して、Quarkus は CDI の仕様上 public を要求するため、この差だけが必要となります。


まとめ:既存 Spring Boot REST API は “そのまま高速なランタイムで動かせる”

今回の検証で分かったのは次のポイントです。

  • REST API アプリなら ほぼ変更なしで Quarkus に移行可能
  • メソッドに public を付ける程度で動作
  • 互換 API により Spring Boot の資産を維持できる
  • 起動時間 50% 削減、メモリ 30% 削減と効果が大きい
  • BFF のような薄い API 層との相性が特に良い

“コードは Spring のまま、ランタイムだけ Quarkus で高速化” というアプローチは、既存プロジェクトの延命・最適化において非常に有効です。


おまけ:画面(HTML テンプレート)の移行

今回のアプリケーションには Thymeleaf の画面が含まれていますが、テンプレートエンジンの移行は別問題です。

  • Quarkus は @Controller を提供していない
  • Quarkus ではテンプレートエンジンとして Qute を使用する
  • Thymeleaf の名前空間や式はそのままでは使えない

アプリを変えず Java の起動時間が 60% 短くなった!

Java 24に導入されたJEP 483: Ahead-of-Time Class Loading & Linkingを試してみました。 この機能は、アプリケーションのクラスをロードおよびリンクされた状態で即座に利用できるようにすることで、起動時間を改善します。

3行で

  • アプリの起動時間のうちクラスローディングとクラスのリンクが支配的な場合は速くなる(実測およそ60%減)
  • アプリの変更は必要なく、キャッシュを作成するだけ。
  • OpenJDKとフレームワークがきちんとサポートするようになったら良さそう。

検証

今回は、マイクロサービスを作るフレームワークであるQuarkusと、Jakarta EEのアプリケーションサーバであるWildFlyで起動速度がどうなるか検証してみます。

現時点では、Quarkus、WildFlyのどちらでも正式にAhead-of-Time Class Loading & Linkingをサポートしていないのでご注意ください。

検証方法は、5回起動し、起動後に表示される起動時間の平均を取りました。

起動方法は、以下の3通りです。

  • 普通にJavaを起動
  • AOTキャッシュ(ロードまたはリンクなし)を使ってJavaを起動
  • AOTキャッシュ(ローディングとリンクを含む )を使ってJavaを起動

起動時間は、ミリ秒で記載しています。 また、Javaを普通に起動したときと比べてどれくらい起動時間が減少したかを赤字で記載しています。

Quarkus

まずはマイクロサービスを作るフレームワークであるQuarkusです。

Quarkusは、普通に起動しても500ミリ秒で起動しました。 その起動時間は、ロードとリンクが無くても半分になり、ロードとリンクがあるとおよそ60%短縮しました!!

WildFly

次に、Jakarta EEのアプリケーションサーバであるWildFlyです。 WildFlyは、普通に起動しても2,400ミリ秒で起動しました。 その起動時間は、ロードとリンクが無くても4.3%短縮し、ロードとリンクがあるとおよそ10.1%短縮しています。

使い方

Ahead-of-Time Class Loading & Linking を使うには、キャッシュを作ります。 このキャッシュを使って、起動すると起動時間が短縮します。

詳しくは JEP を見てください。JEP 483: Ahead-of-Time Class Loading & Linking

キャッシュを作ることをトレーニングと呼びます。 トレーニングは、Javaを2回起動します。 1回目で設定ファイルを作り、2回目で設定ファイルを使ってキャッシュを作ります。

java -XXX:AOTMode=record -XXX:AOTConfiguration=app.aotconf -cp .jar Main
java -XXX:AOTMode=create -XXX:AOTConfiguration=app.aotconf -XXX:AOTCache=app.aot -cp xxx.jar Main

これで、設定ファイル app.aotconf、キャッシュ app.aotができました。

キャッシュを使ってアプリを起動します。ここでは設定ファイルは不要です。

java -XX:AOTCache=app.aot -cp xxx.jar Main

注意事項として、すべての実行は、以下が同じでなければいけません。

  • JDKリリース
  • ハードウェア・アーキテクチャ
  • オペレーティング・システム

まとめ

Javaの起動時間を短縮させる Project Leyden が進み、Java 24ではJEP 483: Ahead-of-Time Class Loading & Linking が導入されました。とはいえ起動速度が短縮する量はアプリケーションにより異なります。起動処理のうち、クラスローディングが支配的なアプリケーションは速くなりますが、それ以外の処理が支配的なアプリケーションでは短縮量が相対的に小さくなりますのでご注意ください。

Java 24のVirtual Threadでsynchronizedの注意点はずっと昔と同じになっただけ

Java 24で導入された、JEP 491: Synchronize Virtual Threads without Pinningwithout Pinningです!ここが重要です。

3行で

  • Java 24からでもVirtualThread上でsynchronizedを使ってもいいわけではなく、これまでどおりできるだけ使わない。
  • JEP 491はPlatform Threadがsynchronizedを含むVirtual Threadによって占有されることを防ぎ、ほかのVirtual Threadを処理できるようにする。
  • 複数のVirtual Threadで同じオブジェクトをモニタ(≒ロック)しているsynchronized 処理はこれまで通りロックの取り合いで止まる。

背景

Java 24は、JEP 491: Synchronize Virtual Threads without Pinningが導入されました。 Java 24がリリースされた日にJavaOneがやっていたのですが、そこでの質問や会話、SNSなどでこのJEPによってsynchronizedを使っても良いと理解している人が多いなと思うことがありました。 おおくの人がVirtual Threadに注目・期待していることもあり、これは危険だなと思ったので、実際の振る舞いも紹介します。

synchronizedの振る舞いについて

Virtual Threadが登場する前の並列処理

まずはじめに、昔からある普通のスレッドでsynchronized を使うとどうなるかを見てみます。Synchronized Taskは、synchronizedの中でブロッキング処理をするタスクです。この例では、Task 1 と Task 2 が同じオブジェクトでロックするとします。これらを2つのスレッドでそれぞれ実行してみます。

すると、先に実行したTask 1がロックを取得し、後から実行したタスク 2はTask 1がロックを解除するまでロック待ちになります。点線のある位置で、Task 2 がロックを取得すると、同様に処理を行います。

Java 23 までのVirtual Thread

Java 23 までは、 Virtual Thread 上で synchronizedを使うと、そのVirtual Threadは別のVirtual ThreadにPlatform Threadを譲りません。synchronizedの中でブロッキング処理をすると、実際に処理しているPlatform Threadをブロックします。 そのため、このPlatform Threadはその間に他のVirtualThreadを処理できません。

詳細を知りたい方はJEP 491: Synchronize Virtual Threads without PinningVirtual threads are pinned in synchronized methodsThe reason for pinningをごらん下さい。

実際に2つの例を見てみます。 1つ目は同じPlatform Thread上に、同じオブジェクトをロックするタスクがVirtual Threadとして連続で来た例です。 2つ目は異なるPlatform Thread 上に、同じオブジェクトをロックするタスクがVirtual Threadとして同じタイミングで来た例です。

これらの例を図解します。 図は、Platform Thread からの視点と、各Virtual Treadからの視点の両方をまとめて記載しています。

1つ目の例は、2つのタスクがそれぞれ処理されます。 synchronizedなのでブロッキングしていても他の Virtual Thread に処理を受け渡さないため、タスクがそれぞれ順番に処理されます。 順番に処理されるのでロック待ちはありません。 これらのタスクが終わるまで、他の種類のタスクも同様に待たされます。

2つ目の例は、2つのタスクが異なるPlatform Threadで処理されます。 これは、普通のスレッドで処理される場合と同じで、ロックを取れなかったタスクはロックを取得するまで待ちます。 Virtual Threadはsynchronizedの間、Platform Threadを占有し続けるため、ほかのVirtual Threadは待ち続けます。 また、ロック待ちをしているほうの Virtual Thread では、Platform Thread がロック待しています。 そのため、そのPlatform Threadは、ほかのVirtual Threadを処理する期間が短くなります。 並列度が上がるとロック待ちはどんどん長くなっていくため、そうなるとPlatform ThreadはVirtual Threadを処理できなくなります。

Java 24 からのVirtual Thread

Java 24は JEP 491: Synchronize Virtual Threads without Pinning が導入されました。 これはVirtual Threadが synchronized 内でブロッキング処理をしても、Platform Threadを他のVirtual Threadに譲ります。 (重要)しかし、 synchronizedのロックの動きはこれまで通り有効で、Virtual Thread 内では、ロック待ちが行われます。

検証

次のソースコードは、先ほどまで図に記載していたものです。 synchronizedの中でブロッキング処理をするタスクと、ただログを出力するタスクがあります。 synchronizedの中でブロッキング処理をするタスクは、SynchronizedTask です。 ログを出力するタスクは、NoSynchronizedTaskです。

ブロッキング処理はスレッドを3秒間スリープさせます。

このコードは、SynchronizedTask を 2つ、NoSynchronizedTask を 1 つ、この順番で実行します。

package org.example;

import jdk.jfr.Configuration;
import jdk.jfr.Recording;

import java.nio.file.Paths;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

// java -Djdk.virtualThreadScheduler.maxPoolSize=1 org.example.Main
public class Main {
    public static void main(String[] args) throws Exception {
        try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
            executorService.submit(new SynchronizedTask());
            executorService.submit(new SynchronizedTask());
            executorService.submit(new NoSynchronizedTask());

            System.out.println("Main finished putting tasks");
        }
    }
}

class SynchronizedTask implements Runnable {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");
    private static final Object monitorObject = new Object();

    @Override
    public void run() {
        System.out.println("Synchronized Task start at " + formatter.format(LocalTime.now()) + " @" + Thread.currentThread());
        synchronized (monitorObject) {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        System.out.println("Synchronized Task end at " + formatter.format(LocalTime.now()) + " @" + Thread.currentThread());
    }
}

class NoSynchronizedTask implements Runnable {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @Override
    public void run() {
        System.out.println("No Synchronized Task run at " + formatter.format(LocalTime.now()) + " @" + Thread.currentThread());
    }
}

今回は、Platform Threadが詰まるのを容易に再現するため、Platform Threadを1つにします。これは、JVM起動引数に-Djdk.virtualThreadScheduler.maxPoolSize=1を付与します。

Java 23の結果

Java 23 では、 SynchronizedTask 同士が順番に処理されています。 1つ目のタスクが11:58:57に開始し11:59:00に終わってから、同時刻に2つ目のタスクが開始し、11:59:03に終わります。 NoSynchronizedTaskは、2つ目のタスクが終わった11:59:03に実行されてます。

java-23/bin/java.exe -Djdk.virtualThreadScheduler.maxPoolSize=1 org.example.Main
Main finished putting tasks
Synchronized Task start at 11:58:57 @VirtualThread[#38]/runnable@ForkJoinPool-1-worker-1
Synchronized Task end at 11:59:00 @VirtualThread[#38]/runnable@ForkJoinPool-1-worker-1
Synchronized Task start at 11:59:00 @VirtualThread[#40]/runnable@ForkJoinPool-1-worker-1
Synchronized Task end at 11:59:03 @VirtualThread[#40]/runnable@ForkJoinPool-1-worker-1
No Synchronized Task run at 11:59:03 @VirtualThread[#41]/runnable@ForkJoinPool-1-worker-1

Java 24の結果

Java 24 では、 すべてのタスクが12:25:38に開始しています。 1つ目の Synchronized Task は、12:25:41に終了し、2つ目は 1 つ目の終了時間から3秒後である12:25:44に終わっています。 Synchronized Task の終了を待つこと無く No Synchronized Task を実行できています。

java-24\bin\java.exe -Djdk.virtualThreadScheduler.maxPoolSize=1 org.example.Main
Main finished putting tasks
Synchronized Task start at 12:25:38 @VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1
Synchronized Task start at 12:25:38 @VirtualThread[#39]/runnable@ForkJoinPool-1-worker-1
No Synchronized Task run at 12:25:38 @VirtualThread[#40]/runnable@ForkJoinPool-1-worker-1
Synchronized Task end at 12:25:41 @VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1
Synchronized Task end at 12:25:44 @VirtualThread[#39]/runnable@ForkJoinPool-1-worker-1

まとめ

Java 24は、JEP 491: Synchronize Virtual Threads without Pinningが導入されました。without Pinningです。依然としてロック待ちは発生しますのでご注意ください。 Virtual Threadは、ブロッキングを意識せず、大量のタスクを実行しやすくなりました。 ですが、Virtual Thread で大量に`synchronized‘なタスクを実行すると、タイムアウトするまで無限に待たされることになるかもしれないので注意が必要です。