こんにちは。新規事業推進室の石田です。
今日からDAブログに書くことになりました。よろしくお願いします。
さて、私が担当するのは、DA製品を開発する中でハマった技術的なあれこれです。
今日は、Javaのガベージコレクション(GC)について書いてみたいと思います。
DA製品でも、ひびきSm@artDBや、ひびきSALES、そして私が担当している店舗maticはJavaで開発されています。Javaを使うことで開発者はメモリリークに頭を悩ましノイローゼになることもなくなりますが、その代償として散らかしたメモリを片付ける処理が必要になります。これガベージコレクション(GC)です。
今日の本題は、GCとキャッシュの微妙な関係についてです。
さて、Webアプリケーションのパフォーマンスを向上させる常套手段としてキャッシュがあります。アプリケーションサーバーのメモリにオブジェクトをキャッシュしている場合、その情報の取得には0.01msぐらいしかかかりません。ところが同じ情報をデータベースから持ってくると、コネクションプールされているデータベース上でクエリーキャッシュにヒットするような最高の条件であっても10msぐらいはかかってしまいます。
なんと1000倍もの時間差があります。
小規模なシステムであれば10msであっても十分速くユーザーはその違いに気がつかないぐらいですが、大規模なシステムになるとリクエストされる回数が桁違いに多くなるのでこの10msが命取りになります。
CPUが1つで、プロセスも1つという単純なモデルで10msの仕事を逐次処理するとすると、1秒間に最大で100回までしか処理することができません。実際にはDBサーバーはマルチプロセッサで、マルチプロセスでマルチスレッドでと並行して処理をする仕組みがあるのでここまでひどいことにはならないのですが、だいたいのそこらのIAサーバー上で動くデータベースは、1秒間にせいぜい数百回の処理ぐらいしかできません。
ですから、毎回データベースに問い合わせるようなシステムだとユーザー数が大規模になったときにまともに動かないということになります。
そこで、アプリケーションサーバーのメモリ上にデータベースから読み込んだ結果のオブジェクトをキャッシュするという戦略をとっています。
キャッシュすることで、パフォーマンスは劇的に向上するのですが、キャッシュしたデータは限られたメモリ上にあるわけで、データが更新されてキャッシュが古くなるか、メモリが満杯になるかした場合はキャッシュを破棄しなければなりません。
このキャッシュの破棄とGCの組み合わせがイマイチなのです。
現在主流のJVMは、みな世代別GCというGCアルゴリズムを採用しています。世代別GCはほとんどのオブジェクトは、生成してすぐに消滅し、長時間生き残るオブジェクトは稀であるということを前提にしています。
一般的なプログラムであればこれは正しく、世代別GCは優れたアルゴリズムなのですが、上記のように積極的にキャッシュしようという戦略をとった場合に世代別GCは暗黒面を見せ始めます。
キャッシュするようなオブジェクトは短くても数分、長いと何日も生き残るようなものが多く、そのほとんどは世代別GCの旧世代領域に送られてしまいます。
旧世代領域を片付けるには、”Stop The World”と呼ばれるすべてのアプリケーションのスレッドを停止させて実行するFullGCが必要になります。FullGCが発生するとヒープメモリのサイズにもよりますが、数秒から長くて1分以上もの間アプリケーションが停止してしまいます。
大規模なシステムではたいていの場合、複数のアプリケーションサーバーをロードバランスしているので、GCによる停止が発生したサーバーをロードバランス対象から切り離したりして対応することもできますが、不幸にもリクエスト処理中にGCに巻き込まれたユーザーはGCの完了まで待たされてしまいます。
どうしようもない事態に見えますが、この問題はGCのチューニングでほとんど回避することが可能です。
まず、以下のJVMの起動オプションを見てください。
これは、私が設定した店舗maticの本番運用環境におけるTomcatを起動するときのJVMのパラメータです。(わかりやすいように1行に1個ずつ書いていますが実際にはひと続きです)
-server
-XX:+DisableExplicitGC
-verbose:gc
-XX:+PrintGCTimeStamps
-XX:+PrintGCDetails
-Xmx1280m
-Xms768m
-Xss512k
-XX:NewSize=512m
-XX:MaxNewSize=512m
-XX:SurvivorRatio=2
-XX:MaxTenuringThreshold=32
-XX:TargetSurvivorRatio=90
-XX:+UseConcMarkSweepGC
-XX:+CMSIncrementalMode
-XX:+CMSIncrementalPacing
-XX:CMSIncrementalDutyCycleMin=0
-XX:+CMSParallelRemarkEnabled
-XX:+CMSClassUnloadingEnabled
-XX:+CMSPermGenSweepingEnabled
-XX:+UseParNewGC
-XX:MaxPermSize=128m
-Djava.awt.headless=true
- -server
HotSpotをサーバー版で起動します。起動時間は長くなりますがよりアグレッシブに最適化コンパイラーがJavaのバイトコードをマシンコードに変換します。
- -XX:+DisableExplicitGC
System.GC()によるFullGCの起動を抑止します。お行儀の悪いライブラリが内部でSystem.GC()を呼び出しているので必ず指定します。
- -verbose:gc -XX:+PrintGCTimeStams -XX:+PrintGCDetails
GCの動作状況を標準出力に出力します。Tomcatの場合、標準出力は catalina.out に出力されますので、このログファイルがローテーションされるように注意が必要です。
- -Xmx1280m
ヒープメモリの最大サイズを1.2Gに設定しています。32bit版のLinuxですのでこのあたりが最大値になります。キャッシュするデータの量を増やしたいのでもう少し取れるといいのですが・・・。
- -Xms768m起動時のヒープメモリのサイズを指定します。
- -Xss512k
スタックサイズです。これはこのままでいいと思います。
- -XX:NewSize=512m
世代別GCの新世代領域のサイズを512Mに設定しています。セオリーに比べると非常に大きなサイズですがなるべく旧世代に送らないことを目的に大きめに指定します。
- -XX:MaxNewSize=512m
世代別GCの新世代領域の最大サイズを512Mに設定しています。新世代領域のサイズは固定されたことになります。
- -XX:SurvivorRatio=2
新世代領域の中でもEden領域を大きめにします。ほとんどのオブジェクトはEdenで一生を終えるようになります。
- -XX:MaxTenuringTHreshold=32
Survivor領域にいる期間を長く指定してなるべくOld領域に送られないようにします。
- -XX:TargetSurvivorRatio=90
新世代領域が満杯だと判断される閾値を90%にします。これもなるべくOld領域に送られないようにするためです。
- -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled
FullGCで全スレッド停止時間を最小限にしてなるべくアプリケーションスレッドと並行して実行するように指定します。スループットは落ちますが完全停止時間がごくわずかになります。
- -XX:+CMSIncrementalMode -XX:+CMSIncrementalPacing -XX:CMSIncrementalDutyCycleMin=0
FullGCをインクリメンタルモードで動かします。一気にすべてのメモリを片付けないで細かく何度も片付けることで停止時間を小さくします。
- -XX:+CMSParallelRemarkEnabled
FullGCのremarkフェーズをマルチスレッドで実行します。FullGCの停止時間がCPU数に応じて短くなります。
- -XX:+UseParNewGC
新世代領域のGCをマルチスレッドで実行します。
- -XX:MaxPermSize=128m
パーマネント領域の最大サイズを128Mに設定します。
利用する機能やアクセスの頻度で最適値は変わりますが、この設定を参考に、是非お客様の環境でもDA製品のパフォーマンスを最大限に引き出してあげてください。よろしくお願いします。
では。