「Javaはメモリを食う」は思い込みだった?

「Javaはメモリを食う」は思い込みだった? AI

「Javaはメモリを食う」。
エンジニアなら、一度は耳にした評価でしょう。

ところが先日、おもしろい動画が公開されました。
「いや、Javaはむしろメモリ効率が良い」と主張する技術トークです。

これがRedditで大きな議論を呼びました。

トークの主張:死んだオブジェクトはタダ同然

発表者の出発点はシンプルでした。
トレーシング型・移動型のGCは、生きているヒープのサイズに比例して動きます。

だから死んだオブジェクトがいくら積み上がっても、GCはそれらに手を触れません。
放っておいて、好きなタイミングでまとめて片付ければいい。
そういうわけです。

土台にあるのは「RAMはCPUより安い」という考え方でした。
短命なオブジェクトを律儀に再利用しようとすれば、その分だけCPUを余計に使います。

それなら、メモリを多めに与えてGCの仕事を減らしたほうがいい。
そうすればトータルのコストは下がる。
これがトークの軸でした。

反論1:移動型GCは「解放」していない

最初に刺さった指摘がこれです。

移動型(コンパクション型)のGCは、実は死んだオブジェクトを解放してなどいません。
やっているのは、生きているオブジェクトを別の場所へ移すことです。

その副産物として、かつて死んだオブジェクトが占めていた領域が上書きされ、再利用される。
それだけのことです。

GCはオブジェクトが死んだことすら知りません。
生きているものだけを相手に動いています。

つまり「好きなときに解放できる」という言い回しは、厳密には正確ではないのです。
メモリがOSに返るのは、あくまで必要になったときの副次的な効果にすぎません。

ここで面白い切り返しが入りました。

ネイティブのmalloc/freeも、free()した瞬間にOSへメモリを返すわけではありません。
たとえばglibcのアロケータやjemallocです。

性能のために返却を遅らせたり、減衰(decay)の方針で少しずつ手放したりします。
だとすれば、「ネイティブには複雑なランタイムなどない」という主張のほうが、案外、神話なのかもしれませんね。

反論2:本当の問題はGCではない

コメント欄でいちばん支持を集めたのは、もっと根本を突く視点でした。
「オブジェクトが回収されるまでヒープに残ること」、これはそもそも問題の本質ではない、と。

Javaのメモリで実際に効いてくる問題は、次の三つに整理できます。

  • オブジェクト一つあたりのヘッダのオーバーヘッド
  • メモリがびっしり詰まったレイアウトを作れない、いわゆる「メモリの島」
  • JVMが同居する他アプリと折り合わず、ヒープを抱え込む運用上の癖

一つ目のヘッダ問題には、Project Lilliputが取り組んでいます。
二つ目のレイアウト問題は、Project Valhallaの値型が狙う領域です。

この二つが揃えば、オブジェクトヘッダはC++並みになる。
そういう話も出ました。

vテーブルを持つオブジェクトなら64ビット、持たないオブジェクトなら0ビットです。
意外なほど近いところまで来ているのですね。

三つ目は運用の話です。
JVMは、使っていなくてもヒープを抱えたまま手放さない傾向があります。

そのため、同じサーバーに載せた他のアプリとうまく共存できない。ここに不満を抱える人は少なくないようでした。

CPUとメモリは別物なのか

賛成派と反対派の溝は、結局この一点に行き着きます。

「CPUとメモリは比べようのない別資源だ」。
そう前提を置いてしまうと、議論はかみ合いません。

けれど両者は、金額という共通のものさしで比べられます。
リンゴとオレンジだって、値段の話なら並べて比較できますよね。
それと同じ理屈です。

キャッシュを思い出してください。
速度のためにメモリを差し出すことに、誰も文句は言いません。

だとすれば、同じ考え方はGCにもあてはまります。
メモリを多めに渡せばGCの仕事が減り、CPUが浮くからです。

とくに効くのが、若い世代(young generation)のオブジェクトです。
生成が激しく、寿命が短い。

だからこのトレードがよく効きます。
長く生き残るキャッシュデータのほうは、古い世代(old generation)に置かれます。

現場のJava:フレームワークという別の肥大

ここまではJVMそのものの話でした。

けれど現場のJavaは、フレームワークと外部ライブラリの上に成り立っています。
起動した時点で、何千ものクラスがメモリに読み込まれるのです。

Eclipse MATのようなメモリアナライザを覗くと、果てしないコールツリーが続いています。
「早すぎる最適化はするな」。

そう割り切って、依存を気軽に足してきた人も多いでしょう。
ところがここ数年は、「これ、本当に要るのか?」と立ち止まり始めているようでした。

もっとも、状況は変わりつつあります。
肥大の主犯として名前が挙がったのは、Springくらいでした。

Micronaut、Quarkus、Avaje、Helidonはずっと身軽だという声が目立ちます。
リフレクションへの依存も小さい。

とくにQuarkusは、独自のビルド工程で不要物を削ぎ落とします。
ただし、その代わりに抱えるものもあります。

ビルドの複雑さです。
さらに、依存をその工程に合わせなければならない「壁に囲われた庭」の窮屈さもついてきます。

ORMへの苦言も飛び出しました。
長いオブジェクトのリストを延々と引き回すやり方は、GCにもCPUキャッシュにも優しくありません。

Javaの実用上の下限は、だいたい200MBあたり。
そんな肌感覚を語る人もいました。
80年代を知る身には引っかかるけれど、現代の文脈ならまあ妥当だ、と。

C++と比べてどうなのか

「CやRust、C++と比べたら、Javaは遅いしメモリも食う」。
これも定番の主張です。

これに対して、両方を長く触ってきた立場からの反論がありました。
小さなプログラムなら、確かにC++のほうが効率的です。

けれど規模が大きくなると、事情が変わります。
仮想呼び出しが増え、寿命のばらばらなオブジェクトを抱えることになるからです。

そして、そうした状況をさばくのは、むしろ移動型GCの得意分野だというのです。
もちろん、C++でその効率を出せないわけではありません。

ただし自分の手で書く必要があります。
しかも、規模がふくらむほど手間は増え、保守のあいだずっとついて回ります。

低レベル言語の値打ちは、性能そのものより「完全な制御」にある。
この整理にはうなずけます。

「軍隊よりも保守的な業界があるとすればAAAゲームだ」。
そんな皮肉まじりのコメントには、思わず笑ってしまいました。

理論面では、古典的な論文も引かれました。
GCがスタック割り当てより速くなりうると論じた、Appelの “Garbage Collection Can Be Faster Than Stack Allocation” です。

ただし、ここにも冷静な突っ込みが添えられていました。
「前提が非現実的で、理論寄りだ」。

あるいは、こうです。
「関数呼び出しごとにポインタを二回進めるだけで済むスタック割り当てを、現実に上回るGCは見たことがない」。

懐疑派の声

もちろん、最後まで納得しない人もいました。

ある人は、こう言います。
「GCがある以上、その分メモリアクセスが増える。だから本質的に非効率だ」。

別の人は、こう指摘します。
「Apache Sparkがヒープの外に出て、sun.misc.Unsafeで自前のオブジェクト管理をしている。これこそJavaのメモリ効率の悪さの証拠だ」。

さらには、「エイプリルフールはもう過ぎたぞ」と一蹴する反応まであり、温度差はさまざまでした。

実用的な情報も共有されていました。
メモリ消費を抑えたいなら、OpenJDKよりEclipse OpenJ9のほうが軽い。

その代わり、走らせるプログラムによっては少し遅くなるかもしれない。
そういう話です。

まとめ

このスレッドは、ひとつのことを教えてくれます。
「Javaはメモリ効率が良いか悪いか」という二択そのものが、あまり実りを生まないということです。

GCの設計だけを見れば、筋は通っています。
メモリとCPUを交換する仕組みとして、合理的に動いているからです。

死んだオブジェクトを抱えること自体は、メモリが足りなくならない限り困りません。
一方で、現場で効いてくる要素は別にあります。

オブジェクトヘッダの大きさ、フレームワークの肥大、そしてヒープを手放さないJVMの癖です。
LilliputやValhallaは、まさにその課題へ正面から取り組んでいる途中です。

気になる論点があれば、まずは自分の環境で計測してみてください。
重いのはJava本体なのか。

それとも、積み上げたフレームワークなのか。
切り分けてみると、見えてくる景色が変わるはずです。

タイトルとURLをコピーしました