Holmes開発者ブログ

契約マネジメントシステム「ホームズクラウド」の開発者ブログです

Spring Bootのgradle bootRunによる起動を高速化してみる

Holmesでエンジニアをしている山本です

Holmesでは、サーバーサイドアプリケーションをGradle管理のSpring Bootで実装しています。現在、ローカル環境での gradle bootRun によるSpring Bootアプリケーションの起動まで、数十秒かかっているため、多少なりとも短縮できないかと思い、調査を行いました。

参考としたのは、以下のページです。

bufferings.hatenablog.com

起動時間としては、利用可能メモリが4GBほどある状態で5回 gradle bootRun を実行し、起動ログに表示される Started Application in ... seconds の秒数を平均したものを使用します。

作業前

Started Application in 39.092 seconds
Started Application in 35.281 seconds
Started Application in 39.612 seconds
Started Application in 39.754 seconds
Started Application in 47.941 seconds # 外れ値
Started Application in 38.136 seconds

5回目はそれ以外の値と10秒ほど乖離があるため、外れ値として除外します。それ以外の平均は、 38.375 となりました。これを基準としていきます。

作業方針

実行環境がAmazon Corretto 8 64bit + Spring Boot v2.1のため、それらのバージョンで利用できるもの、かつ、本番など既存の環境には影響せず、ローカル環境での実行に閉じた方法を選択します。

1. Gradle起動オプションの調整

まずはGradleの起動オプションを調整します。

対象となるGradleプロジェクトの gradle.properties は、以下の通りです。

org.gradle.jvmargs=-Xmx2048M
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.configureondemand=true

デーモンが有効となっています。Gradleの起動オプションの設定は、デーモンが無効な場合は GRADLE_OPTS 、有効な場合は org.gradle.jvmargs で指定します。

ローカル環境にのみ設定を反映させたいので、グローバル設定ファイルの ~/.gradle/gradle.properties を作成し、以下のように記述します。

org.gradle.jvmargs=-Xms2g -Xmx2g -XX:TieredStopAtLevel=1 -noverify

この状態で実行した結果が、以下になります。

Started Application in 36.176 seconds
Started Application in 34.452 seconds
Started Application in 37.581 seconds
Started Application in 39.238 seconds
Started Application in 37.324 seconds

平均は 36.954 となりました。1.4秒程度、3.7%の改善です。

オプションはそれぞれ、以下の意味を持ちます。

Xms, Xmx

メモリ割り当てプール(Javaヒープサイズ)の最小値および最大値です。同じ値とすることで、実行中のメモリ再割り当てを回避します。

XX:TieredStopAtLevel

XX:+TieredCompilation にて階層型コンパイルが有効化されている場合に、JITコンパイラを指定します。

64bit Javaの場合、階層型コンパイルはデフォルトで有効化されます。

  • 0: インタプリタ
  • 1~3: C1。それぞれプロファイル利用の有無などが異なる
  • 4: C2

C1がHotSpot VMのClient VM、C2がHotSpot VMのServer VMに相当します。

以下のページに詳しいです。

Oracle JDK8 JavaVMオプション - ソフトウェアエンジニアリング - Torutk

noverify

バイトコードの検証なしに、classのロードを有効化します。

2. コンポーネントのインデックススキャンの有効化

Spring Framework 5より追加された、コンポーネントのインデックススキャンを有効化します。詳細は以下に詳しいです。

qiita.com

build.gradledependencies に、 annotationProcessor "org.springframework:spring-context-indexer:${SPRING_VERSION}" を追加してビルドすると、クラスファイル出力先のMETA-INF配下に、spring.components というテキストファイルが生成されます。

このファイルが存在すると、起動時にクラスパスをスキャンしてDIコンテナに登録するのではなく、ファイルを読んでDIコンテナに追加するという挙動になります。

ファイルの内容は、 コンテナ管理Beanの完全修飾クラス名=付与されているアノテーション となります。検証時点で、662クラスが出力されていました。

この機能について検索すると、大して変わらない、かえって遅くなった、という情報が散見されます。これだけのクラス数ではどうなるでしょうか...

Started Application in 35.902 seconds
Started Application in 39.535 seconds
Started Application in 41.434 seconds
Started Application in 40.117 seconds
Started Application in 39.228 seconds

平均は 39.243 となりました。 36.954 と比べると約2.3秒、6%ほど遅くなっています。

また、Mavenであれば <optional>true</optional> を設定しておくことで、 spring.components をWARやJARから除外することができますが、Gradleではoptionalに該当するオプションがないため、プロパティ spring.index.ignore=true を設定するなどの作業が別途必要となります。

改善効果が薄そうなため、インデックススキャンについては除外することとしました。

3. コンテナ管理Beanの遅延初期化

コンテナ管理Beanは、デフォルトではアプリケーション起動時にすべてDIコンテナに登録されます。遅延初期化を有効化することで、コンテナ登録のタイミングをBeanの初回利用時に変更できます。

Spring Boot v2.2.0 からは、プロパティとして spring.main.lazy-initialization=true を指定することで、一律で遅延初期化が適応されますが、v2.1では使えないため、以下のクラスを追加します。

@Configuration
@Profile("local")
@Slf4j
public class LazyInitBeanFactoryPostProcessor implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        log.debug("bean lazy load setting start.");

        for (String beanName : beanFactory.getBeanDefinitionNames()) {
            beanFactory.getBeanDefinition(beanName).setLazyInit(true);
        }

        log.debug("bean lazy load setting end.");
    }

}

@Profile("local") を付与しているのは、本番環境などに影響を与えないためです。

GradleでSpring Bootのプロファイル名を指定するには、環境変数 SPRING_PROFILES_ACTIVE を設定するか、Java起動オプションの -Dspring.profiles.active を設定します。

export SPRING_PROFILES_ACTIVE=local してから、bootRunを実行します。

Started Application in 29.95 seconds
Started Application in 28.846 seconds
Started Application in 30.67 seconds
Started Application in 30.493 seconds
Started Application in 29.685 seconds

平均は 29.929 となりました。 36.954 から約6秒、19%の短縮です! これまでに比べ、大きく短縮できました。

その他の改善策

Thin Launcher化 + AppCDSを行えば効果がありそうです。

また、bootRunによる起動であれば、JARやWARを生成しないため、AppCDSの効果があるかもしれないですが、OpenJDKベースのJavaではv10以降でないとAppCDSが使えません。

nowokay.hatenablog.com

試しに、 org.gradle.jvmargs-XX:+UseAppCDS を追加してみましたが、 Unrecognized VM option 'UseAppCDS' で起動に失敗しました。

もっとシンプルな高速化の方法としては、Java8からはJavaヒープからPermanent領域が廃止され、ネイティブメモリ上にMetaspace領域が取られるようになったため、空きメモリを確保しておくことも重要です。空きメモリが少ない状態だと、倍近く遅くなることもありました。

Javaのメモリ管理については、以下のページが詳しいです。

equj65.net

奇跡の一枚

Webブラウザ、ビデオ会議アプリ、IDE、エディタ、その他もろもろを終了し、空きメモリを12GB程度にした状態では、20秒で起動できました。

f:id:h-yamamoto_holmescloud:20200618183045p:plain

振り返り

Gradle起動オプションの調整と、コンテナ管理Beanの遅延初期化で、 38.375 から 29.929 と約8.4秒、22%の起動時間短縮を行えました。

コンテナ管理Beanが662件と多いため、インデックススキャンの有効化と遅延初期化はそれぞれ効果があるのでは推測していましたが、遅延初期化は予想通り大きな時間短縮につながった一方、インデックススキャンはかえって遅くなってしまいました。

また、改善できたとはいえ、まだまだ30秒程度かかってしまいます。Spring Bootの場合、起動時間は @SpringBootTest などを使用したテストの実行時間にも関係するので、もう少し短縮したいところです。

最後に

Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com