Blog
GCメモリ初心者の新卒がScalaでGCメモリを勉強してみた
この記事はCyberAgent エンジニア Advent Calendar 2015の14日目の記事です。 昨日は13卒の鈴木さんのブログでした、明日は同期のchoheyさんです。
はじめに
こんにちは、サイバーエージェントアドテクスタジオ15新卒のフィ(@dxhuy)と申します。 アドテク新卒の中で「YYY初心者の新卒がXXXでYYYを勉強してみた」というタイトルが流行っていますので、パクらせていただきました。
ネットワーク初心者の新卒がDockerでネットワークの勉強をしてみた
現在RightSegmentという子会社でData Management Platform (DMP)を作っています。データの会社なのでHadoopのバッチの開発とか、フロントサーバでJavaの開発などをしています。
GCメモリについて
会社に入る前にはスクリプト系の言語を使うことが多く、メモリを自前で管理するなども気にしなくていい 時代でした。会社に配属され、javaとscalaがメインで開発 を行いますが、java/scalaではメモリ管理もほとんどいらないですが、GCをちゃんと理解して、そこで性能に影響しないようにGCチューニング、また コードでGCにプレッシャーをかけすぎないように気にしないといけない時代になりました。
GCはJVMの運用者にとって、特にデータベース、大事なウェブサーバなどの「Pause Time(止める時間)が長くなれば死ぬ」のようなサービスはとても怖い言葉です。このの記事を参考すればGCの運用の大変さがよくわかると思いますので是非ごらんください: これがCassandra
つまり、GC上のメモリをちゃんとチューニングしないと、性能が安定しない、普段は軽い処理なのに、ピーク時などにレスポンス遅延が発生することが 多く発生します。「GCをうまくチューニングすればいいじゃん」と思っている方がいると思いますが、アプリケーションの特徴により、たとえばヒープ がでかくて、ライフサイクルが短いオブジェクトが多いアプリとか、ヒープが小さくて、長く生きているオブジェクトが多いなどでたくさんのパターンもあって、それぞれチューニング仕方も違うし、とても大変です。
そこでGCにもう頼りたくない、自前でメモリを扱いたい方がいるでしょう。JVMでは、GCを使わずに、メモリを直接する方法が「オフヒープメモリ」と呼び、この記事では、Scalaでオフヒープメモリの検証などを紹介したいと思います(はじめがだいぶ長くになってしまい申し訳ありません m(_ _;)m)
JVMのオフヒープについて
Java アプリケーションは Java ランタイムの仮想環境で稼働しますが、ランタイム自体は、ネイティブ・メモリーをはじめとするネイティブ・リソースを使用する言語 (C など) で作成されたネイティブ・プログラムです。ネイティブ・メモリーとは、ランタイム・プロセスが使用できるメモリーのことで、Java アプリケーションが使用する Java ヒープ・メモリーとは区別されます。オフヒープはそのネイティブメモリを利用するという意味です。ネイティブヒープはJVMヒープとは違い、基本的には明示的な制限がないのが特徴です。(制限した方がよいかは別として)
JVMではオフヒープを使うために:DirectByteBufferを利用、misc.UNSAFEを利用、JNIを利用の三つの種類があります。
- DirectByteBuffer
1 2 3 |
val alloc: java.nio.ByteBuffer = java.nio.ByteBuffer.allocateDirect(size) alloc.putInt(0, 10) //アロケートの0インデックスに10を入れます alloc.putInt(4, 10) //アロケートの4インデックスに20を入れます(最初の4バイトは10が入っていますから) |
このコードを見ると使い方の簡単さがわかりますね、すごくCでmallocの使い方のそっくりですね。 アロケートされたメモリ領域にputInt, putDouble, putLong, putShort, putChar
という五つのデータ型を入れることができます。 使いやすいですが、DirectByteBufferを使うデメリットも幾つかあります:
- アロケートされたバッファーは最大2GB
- アクセスされるたびにバウンドチェックが行われ、大きなパフォーマンス影響ができます(メモリサーフのため)
- sun.misc.Unsafe
1 2 3 |
val unsafe = sun.misc.Unsafe.getUnsafe() val addr: Long = unsafe.allocateMemory(size) unsafe.putInt(addr, 10) |
上のDirectByteBufferの使い方を比較すると:Unsafeがより低いAPIを使うことがわかりますね。 その代わりに、アロケートされたメモリアドレスもわかって、ページサイズを決めることもできます。(C懐かしい!) アロケートされた領域にデータ入れる方法はDirectByteBufferと一緒です。 ただUnsafeの大きなデメリットは、そのクラスの名前とそっくりです、セーフじゃないです!!!! まだ解放されないメモリをつかっちゃたりしてとか、まだ使っているメモリ領域を解放しちゃったりとかいろいろ強いケースがあります。 また気にしないとメモリリークになる可能性も高いです。
- Cコードでjniを通して使うこと
1 2 3 4 |
JNIEXPORT jlong JNICALL Offheap_allocate(JNIEnv *env, jobject jpoint) { struct *point = (*point) malloc(sizeof(point)); return (jlong) point; } |
Cコードを見るだけではjavaでやるメリットがわからなくなりますww。またメモリセーフもなく、低レイヤのAPIを使わないといけないなどのデメリットもあります。
Scalaでオフヒープを使いたい!
じゃ本題にもどりますが、Scalaでオフヒープを使いたい場合は、何が推奨でしょう。もちろん上の三つの方法もありますが、scalaぽくのやり方があるなら使いたいでしょうね。scalaぽくの使い方があります! どん!https://github.com/densh/scala-offheap
scala-offheapはオフヒープをscalaぽく使えるようになるライブラリです。下ではUnsafeを使いますが、macroを使ってscalaぽく、メモリセーフの使い方ができるようになるライブラリです。 そのライブラリがやってくれることは大きく三つ:
- @data のクラスマクロにより、自分の好きなメモリレイアウトを定義でき、シリアライズ、デシリアライズをしてくれます
- オフヒープ配列をサポートしてくれます
- Regionという概念の導入により、Regionの中のオブジェクトはRegion使い終わったら解放してくれて、メモリセーフでとても便利です
じゃ簡単なコードを書いてみましょう:
まずは@dataマクロの使い方から:
1 2 3 |
@data class Point(x: Int, y: Int) implicit val memory = NativeMemory() val point = Point(10, 20) |
上の例ではInt,Intの8バイトのメモリレイアウトを定義して、それをアロケートするコードです。簡単でしょうね。次はArrayをオフヒープでアロケートしてみましょう:
1 2 3 4 5 6 7 |
implicit val memory = NativeMemory() var arr = scala.offheap.Array(1, 2, 3) arr(0) == 1 arr(1) == 2 arr(2) == 3 arr(3) // ここでOutOfBoundsExceptionをスローされ、メモリセーフでいいですね val arr2 = arr.map(_ * 2) //普通の配列のようにマップもできます |
次はRegionを使ってみましょう:
1 2 3 4 5 6 |
implicit val pool = Pool(NativeMemory()) var point: Point = _ Region { implicit r => point = Point(10, 20) } point.x // ここでRegionの外なのでInaccessibleRegionExceptionがスローされます |
じゃもうちょっと手を動かしたいですので、オフヒープを検証するために、オンメモリのKVSで作って検証しましょう。
Scala-offheapを使ってKVSを作ってみよう
今回作ったKVSの仕様は簡単で、キーがString、値がStringで put
, putIfAbscent
, get
, delete
の簡単な4つの操作をサポートするものを作ってみましょう:
1 2 3 4 5 6 |
trait StorageBase[K,V] { def put(key: K, value: V): Unit def putIfAbscent(key: K, value: V): Unit def get(key: K): Option[V] def delete(key: K): Unit } |
今回はメモリの方の検証だけをしたいため、並列のアクセスとかも気にしなかったので、Atomic操作もサポートしないKVSです。
scala-offheapではStringのデフォルトでシリアライズをサポートもなく、マップのサポートもないので、そこが問題ですね。 じゃ、どうせちょっとオンヒープを使わないといけなさそうなので、putしたキーと値をキーをハッシュして、そして値をOffheapにアロケートして、 アロケートされたアドレスを、ハッシュキーとペアとしてオンヒープのMapで管理すればいいですね。 それだとオンヒープのマップがMap[Long,Long]
となってだいぶメモリオーバーヘッドを軽減することもできるじゃないかと考えます。 じゃ実装を進めましょう:
まずはハッシュですが簡単にMurMur3を使います:
1 2 3 4 5 6 7 |
object HashTactics { implicit val stringHash: Hasher[String] = new Hasher[String] { override def hash(key: String): Long = { MurmurHash3.stringHash(key) } } } |
次はput
の操作は以下となります:scala-offheapはStringのシリアライズをサポートしないなので@dataマクロ使えないですが、配列サポートしますのでArray[Char]をオフヒープでアロケートすればよいですね。ただ元の文字列からオフヒープの配列にコピーする必要がありますので、put
する値が大きければ大きくほどオーバーヘッドが大きくなりますので、そこがだいぶパフォーマンスダウンする原因になりそうですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class OffHeapStorage extends StorageBase[String, String] { implicit val alloc = malloc implicit val props = Region.Props(Pool(malloc, pageSize=81920, chunkSize=81920)) import HashTactics._ val hashKeyAddress = new ConcurrentHashMap[Long, Long]() private[this] def hash(key: String)(implicit ev: Hasher[String]): Long = { ev.hash(key) } override def put(key: String, value: String) : Unit = { val hashKey = hash(key) if (hashKeyAddress.contains(hashKey)) { delete(key) } val arr = offheap.Array.uninit[Char](value.length) for (i <- 0 to value.length - 1) arr(i) = value(i) hashKeyAddress.put(hashKey, arr.addr) } } |
のこりのputIfAbscent
,delete
,get
も実装簡単なのでコードだけを貼って説明を飛ばします >.<:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
override def putIfAbscent(key: String, value: String): Unit = { val keyHash = hash(key) if (!hashKeyAddress.contains(keyHash)) { put(key, value) } } override def get(key: String): Option[String] = { val keyHash = hash(key) val addr = hashKeyAddress.get(keyHash) if (addr != 0) { val arrayFromOffheap = offheap.Array.fromAddr[Char](addr) Some(arrayFromOffheap.toArray.mkString("")) } else None } override def delete(key: String): Unit = { val keyHash = hash(key) if (hashKeyAddress.contains(keyHash)) { val addr = hashKeyAddress.get(keyHash) alloc.free(addr) } hashKeyAddress.remove(key) } |
実装完了しました!!
でオンヒープと比較したいのでConcurrentHashMapを使ってオンヒープの実装も簡単に書きました
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class OnHeapStorage extends StorageBase[String, String] { val store = new ConcurrentHashMap[String, String]() override def put(key: String, value: String): Unit = { store.put(key, value) } override def putIfAbscent(key: String, value: String): Unit = { store.putIfAbsent(key, value) } override def get(key: String): Option[String] = { store.get(key) match { case str if str != null => Some(str) case null => None } } override def delete(key: String): Unit = { store.remove(key) } } |
じゃ性能比較してみましょう。性能比較するために、まず実行速度を検証してみます。50000回のループの中で、長さ10000の文字列(オフヒープに乗せるメモリが大きけれあ大きくほど検証しやすいのでちょっと大き目にしました)をランダムのキーにインサート・ゲットします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import scala.util.Random object Boot extends App with RandomStringGenerator { val node = new OffHeapStorage(100L) val node2 = new OnHeapStorage def perf(node: StorageBase[String, String]) : Unit = { val loop = 50000 val start = System.currentTimeMillis() for (i <- 0 to loop) { if (isGet) { node.get(nextKey) } else { node.put(nextKey, next(10000)) } } val end = System.currentTimeMillis() println(s"cost ${end-start}") } perf(node) perf(node2) } trait RandomStringGenerator { def next(len: Int): String = { Random.alphanumeric.take(len).mkString("") } def isGet: Boolean = { Random.nextBoolean() } def nextKey: String = { "key" + Random.nextInt() % 100000 } } |
結果は、どん!
1 2 |
[info] OffHeap: cost 42547 [info] OnHeap: cost 32524 |
つまりオフヒープを使うと性能が1.4倍落ちますね。 シリアライズも自分でしないといけないのでその結果はおかしくないと思います。オフヒープってメリットなくない?って思っている方もいるでしょう。
じゃOffHeapのメリットをわかるように別の検証を行いたいですね。OffHeapのメリットはメモリがきつくなるときにGCの安定さですよね。そこで 上の検証をもう一度実行しますが、ヒープをきつくしてもう一度やってみましょう。ヒープの調整はbuild.sbtでメモリをちっちゃくしてみました(ちっちゃくしすぎるとOffHeapは大丈夫ですがOnHeapのほうはOOMで死んでしまうので検証にならないため、OOMにならないようにぎりぎりの700mbに設定してみました)
1 2 |
javaOptions in run += "-Xmx700m" javaOptions in run += "-Xmn700m" |
もう一回リランしてみると、どん!
1 2 |
[info] OffHeap: cost 42547 [info] OnHeap: cost 51643 |
おお、ヒープをきつくなるとOnHeapの方が遅くなることがわかりましたね。gc.logを出してみたらOnHeapの方は圧倒的にFullGC回数が発生し、そこで性能が落ちることもわかりました。gc.logはgistに置いておきました:https://gist.github.com/huydx/cf1b647db7bdab07404c . 回数を数えてみると:
1 2 3 4 5 |
$cat onheap.gc | grep "Full GC" | wc -l $173 $cat offheap.gc | grep "Full GC" | wc -l $55 |
オフヒープの方のメモリのフラグメンテーションが発生するかも時間があれば検証したいですが、今回は時間足りず検証できませんでした、申し訳ありません >.<.
最後に
scalaではオフヒープメモリを使いたければscala-offheapというライブラリがすごく便利ですので是非使ってみてください。 オフヒープを使うと:
上の特徴があるため、いつもオフヒープがいいというわけではありませんので、必要なケースに使えばよいかと思います。
Author