JVMのヒープサイズとコンテナ時代のチューニング

最近 JVM のヒープ領域とパラメータ、そしてコンテナの関係について調べてました。 案外まとまった情報が少なかったので簡単にまとめました。

Java のヒープサイズを設定

まずは Java のヒープサイズについて簡単なおさらいです。

本番環境で Java アプリケーションを運用する上で、JVM のヒープサイズを決定するのは非常に大事なポイントです。 ヒープ領域の最大サイズを大きくすればガベージコレクション (GC) の回数は減らすことができますが、 必要以上に大きくしすぎると無駄にリソースを消費したり、OOM killer で OS にプロセスを終了させられます。

JVM が使用できるヒープサイズは、Java API の Runtime.getRuntime().maxMemory() で確認できます。 また java の起動オプションに -XX:+PrintFlagsFinal オプションを付与すると、JVM の各種パラメータを取得できます。 この記事では後者の -XX:+PrintFlagsFinal オプションで取得する方法で解説を進めます。 また実行する Java プログラムが無くても、-versionオプションで JVM のパラメータを確認できます。

$ java -XX:+PrintFlagsFinal -version
[Global flags]
     intx ActiveProcessorCount                      = -1                                  {product}
    uintx AdaptiveSizeDecrementScaleFactor          = 4                                   {product}
    uintx AdaptiveSizeMajorGCDecayTimeScale         = 10                                  {product}
    uintx AdaptiveSizePausePolicy                   = 0                                   {product}
    uintx AdaptiveSizePolicyCollectionCostMargin    = 50                                  {product}
...

JVM のヒープサイズを設定するには、手動による明示的な設定と実行環境から自動に設定する 2 つの方法があります。 まずはそれぞれの方法について紹介します。

ヒープサイズの明示的な指定

明示的にヒープサイズを設定するには、MaxHeapSize パラメータを設定します。 このパラメータは JVM が確保する Java ヒープの最大サイズです。 世代別 GC の場合、New 領域や Old 領域の合計値がこの値を超えないようになります。

MaxHeapSize パラメータを設定するには、javaコマンドに-XX:MaxHeapSizeまたは-Xmxオプションを指定します。 メモリサイズの後ろにmを付けると、MB 単位で指定できます。 次の例は MaxHeapSize を 2048MB に設定します。 設定された MaxHeapSize は-XX:PrintFlagsFinalの結果から確認できます。

$ java -XX:MaxHeapSize=2048m -XX:+PrintFlagsFinal -version 2>/dev/null | grep -w MaxHeapSize
    uintx MaxHeapSize                              := 2147483648                          {product}

ヒープサイズの自動設定

MaxHeapSize を設定しない場合は、JVM は実行環境からピープサイズを決定します。 メモリがたくさんある環境では、より大きなヒープサイズが利用できます。 ヒープサイズは次の表に基づいて計算されます。

メモリサイズ M ヒープサイズ
M ≤ 248m M / 2
248m < M ≤ 496m; 124m
496m < M M / 4

境界値の 248m や 496m は 124m の倍数です。 かなり中途半端な数字ですが、これは 32bit 版では 96m でしたが、64bit 版では少し余分にヒープ領域が必要になるので若干大きくなったためです(詳しくはJDK-4967770)。

自分の手元で MaxHeapSize を指定しない場合は以下のとおりになりました。 自分の環境は 32GB メモリなので、その 1/4 の 8GB がヒープ領域として利用できます。

$ java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -w MaxHeapSize
    uintx MaxHeapSize                              := 8415870976                          {product}

コンテナ環境での問題

MaxHeapSize は実行環境にあわせていい感じにヒープサイズを設定するように見えます。 ただしコンテナだと少し事情が違います。

コンテナではカーネルの CGroup という機能を使って、コンテナ内のプロセスが利用できるメモリを制限できます。 しかしコンテナ上でメモリサイズを取得しても、見えるのはコンテナホスト側のメモリサイズです。 コンテナ内でfreeコマンドを打つと、なぜかホスト側のメモリサイズが表示されるといった経験をしたことがある人もいるでしょう。

$ docker run --memory 1024m --rm busybox free -m
              total        used        free      shared  buff/cache   available
Mem:          32099        4416       21111         295        6571       27414
Swap:          8188           0        8188

この問題を解決するために、メモリサイズではなく CGroup からヒープサイズを取得するオプションが Java 9 から追加されました。

コンテナ上でのメモリ領域の判定

UseCGroupMemoryLimitForHeap というオプションを使うと、ヒープサイズをメモリサイズではなく CGroup のメモリ制限値から設定します。 しかし Java 10 以降を使ってるなら、 UseCGroupMemoryLimitForHeap を使うべきではありません。 このオプションは後述の UseContainerSupport オプションで置き換えられ、Java10 からは deprecated になりました。

もう 1 つのコンテナアプリケーションの特徴に、VM と違い 1 コンテナで 1 アプリケーションを動かすことが多くなりました。 KVM などの実行環境では、Java プロセス以外の OS プロセスやデーモンプロセスが立ち上がってることがほとんどです。 一方コンテナアプリケーションでは、コンテナ内で 1 プロセスのみ立ち上がるということもあります。 そのためコンテナが利用可能なメモリ容量の殆どをヒープ領域に割り当てることができます。

UseCGroupMemoryLimitForHeap (deprecated)

UseCGroupMemoryLimitForHeap は Java 9 に追加されたオプションです(JDK-8170888)。 また Java 8u121 などにもバックポートされました。 CGroup のメモリの制限値は、コンテナ内では /sys/fs/cgroup/memory/memory.limit_in_bytes から確認できます。 UseCGroupMemoryLimitForHeap もこのファイルをチェックして、コンテナが利用できるメモリ容量を取得します。

$ docker run --memory 1024m --rm busybox cat /sys/fs/cgroup/memory/memory.limit_in_bytes
1073741824

UseCGroupMemoryLimitForHeap を利用するには、-XX:+UnlockExperimentalVMOptions-XX:+UseCGroupMemoryLimitForHeap オプションを Java 起動時に渡します。 すると JVM は(ホストの)メモリ容量ではなく、CGroup のメモリ容量を使用します。

# ホストのメモリ容量から計算する
$ docker run --rm --memory 2048mb openjdk:8u181 java -XX:+PrintFlagsFinal -version 2>/dev/null | \
      grep -w MaxHeapSize
    uintx MaxHeapSize                              := 8415870976                          {product}

# CGroupのメモリ容量から計算する
$ docker run --rm --memory 2048mb openjdk:8u181 java -XX:+PrintFlagsFinal \
          -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -version 2>/dev/null | \
      grep MaxHeapSize
    uintx MaxHeapSize                              := 536870912                           {product}

しかし先程も述べたとおり、UseCGroupMemoryLimitForHeap オプションは非推奨となり、Java 11 では廃止されました。 かわりに Java 10 で追加された UseContainerSupport オプションを利用します。

UseContainerSupport

UseContainerSupport は Java 10 に追加されたオプションです(JDK-8146115)。 また Java 8u191 などにもバックポートされました。

UseContainerSupport は CGroup からメモリ制限を取得するだけでなく、次の機能もあります。

  • CGroup の CPU の制限値も使用する
  • CGropu 上のメモリの利用率も取得できる

UseContainerSupport オプションはデフォルトで有効になっています。 そのため特に何も指定しなくても、コンテナが利用できるメモリ容量の 1/4 がヒープサイズとして割り当てられます。

$ docker run --rm --memory 1024m openjdk:10.0 java -XX:+PrintFlagsFinal -version 2>/dev/null |\
      grep -w MaxHeapSize
   size_t MaxHeapSize                              = 268435456                                {product} {ergonomic}

メモリサイズとヒープサイズの割合を調整する

さて、ここまではヒープサイズはメモリ容量の 1/4 という仮定で話してきました。 この割合を調整するには MaxRAMFraction および MaxRAMPercentage オプションを利用します。

MaxRAMFraction は Java 10 で deprecated になりました。 なぜなら MaxRAMPercentage の方がより細やかに設定できるようになり、MaxRAMFraction は不要となったためです。

MaxRAMFraction (deprecated)

MaxRAMFraction はメモリサイズに対するヒープサイズを 1/MaxRAMFraction で設定します。 デフォルト値は 4 なので、特に指定が無ければメモリサイズの 1/4 が MaxHeapSize になります。

MaxRAMFraction は -XX:MaxRAMFraction オプションで設定できます。 次の例は MaxRAMFraction=8 に設定してるので、1024MB の 1/8 である 128MB をヒープサイズに割り当てます。

$ docker run --rm --memory 1024m openjdk:10.0 java \
          -XX:+PrintFlagsFinal -XX:MaxRAMFraction=8 -version 2>/dev/null | \
      grep -w MaxHeapSize
   size_t MaxHeapSize                              = 134217728                                {product} {ergonomic}

MaxRAMFraction に対して MinRAMFraction パラメータもあります。 MaxRAMFraction はメモリサイズに対するヒープサイズの上限で、MinRAMFraction はヒープサイズの下限です。 ヒープサイズのサイズとメモリサイズの関係を表で書きましたが、これは MaxRAMFraction=4、MinRAMFraction=2 という前提です。 JVM はメモリサイズの 1/2 から 1/4 の間でヒープサイズを決定します。

さて、MaxRAMFraction はメモリの大半をヒープサイズに割り当てたいという場合に利用できません。 なぜなら MaxRAMFraction は整数しか指定できないためです。 MaxRAMFraction=1 にするとメモリの全てをヒープ領域に使うため、ネイティブヒープやページキャッシュに利用できるメモリがなくなります。 なので MaxRAMFraction の最小値は実質 2 となり、メモリの高々半分までしかヒープ領域に利用できません。

これではコンテナアプリケーションなど、メモリの大半をヒープ領域に利用したい場合に都合が悪いです。 そのため分数ではなくパーセンテージで指定できる MaxRAMPercentage パラメータが登場しました。

MaxRAMPercentage

MaxRAMPercentage は Java 10 に追加されたオプションです(JDK-8186248)。 同時に MaxRAMFraction は deprecated になりました。 MaxRAMFraction はパーセンテージでヒープサイズを指定できるので、MaxRAMFraction で指定できなかった 1/2 以上の領域をヒープ領域として確保できます。

MaxRAMPercentage は-XX:MaxRAMPercentageオプションで指定できます。 次の例は MaxRAMPercentage=75 を指定してます。 CGroup のメモリサイズが 1024MB なので、75%までの 768MB をヒープ領域として利用できます。

$ docker run --rm --memory 1024m openjdk:10.0 \
          java -XX:+PrintFlagsFinal -XX:MaxRAMPercentage=75 -version 2>/dev/null |\
      grep -w MaxHeapSize
   size_t MaxHeapSize                              = 805306368                                {product} {ergonomic}

まとめ

さて長くなりましたが、Java のバージョンとコンテナサポート事情については以下のとおりです。

Java バージョン メモリ領域の取得 ヒープサイズの割合
Java 8u121 - Java 8u181 UseCGroupMemoryLimitForHeap MaxRAMFraction
Java 8u191 - Java 8u222 UseContainerSupport MaxRAMFraction
Java 10 - UseContainerSupport MaxRAMPercentage

もしも Java 8 を利用して、Docker などのコンテナ環境を利用してる場合は特に注意が必要です。 なぜなら MaxRAMPercentage は Java 8 にバックポートされてないので、メモリの 1/2 以上のヒープサイズは確保できません。 その場合は素朴に MaxHeapSize を固定値にするか、起動時にシェルスクリプトなどで MaxHeapSize を決定するのが良いでしょう。

さて、今回珍しく Java について探求しましたが、JDK プロジェクトの流れも追えて役に立つ情報が多かったです。 まだまだ Java 周りは深堀できそうな部分があるので、機会があればまた記事を書きたいと思います。


Profile picture

Shin'ya Ueoka

B2B向けSaaSを提供する会社の、元Webエンジニア。今はエンジニアリング組織のマネジメントをしている。