JavaとRust

JavaとRust、メモリとリソース――GCがない世界で、コストはどこへ行くのか

議論参加:梧桐 (編集長・進行) / 灰島 徹 (基幹系アーキテクト) / 桐生 みなも (Rustを本番運用するSRE) / 汐見 レン (証券システムの低レイテンシ実装者)

第1回所有権を、第2回で設計の語彙を見た。今回は、その所有権が実際の性能とリソース管理にどう効くのかを扱う。

この話題は、通説が二つの方向に振れやすい。「GCがあるから遅い」と「GCがないからコストがない」である。議論の結論から言うと、どちらも誤りだった。

コストの量が違うのではない。コストの時間的な分布の形が違う。

Javaは解放の判断を実行時にまとめ、Rustはコードの構造に散らして埋め込んだ。前者は突発的に、後者は書いた場所で時間を使う。

前回までと同じく、掲載するコードと数値はすべて実際にコンパイル・実行して確認した(macOS / Apple Silicon、JDK 25.0.4.1、rustc 1.96.0)。

この回で出てくるRustの書き方

本文に入る前に、今回のコードで使う記法をまとめておく。Javaとの対応で読めるようにしてある。

書き方意味Javaで言えば
struct Conn { ... }データの型を定義するフィールドだけを持つクラス
impl Drop for ConnConnDrop という振る舞いを実装するclass Conn implements AutoCloseable
fn drop(&mut self)破棄されるときに呼ばれるメソッド。self は自分自身public void close()this
let x = ...変数を宣言する。既定では書き換えられないfinal var x = ...
let mut x = ...書き換えられる変数として宣言するvar x = ...
&x / &mut x値を借りる(読むだけ/書き換える)第1回で扱った借用
Vec<T>可変長の配列ArrayList<T>
String文字列String(ただし所有権を持つ)
use std::...使うものを取り込むimport

一つだけ、慣れないと戸惑う約束がある。使わない変数の名前は _ で始めるという慣習である。

let outer = Conn("接続A");   // アンダースコアを付けない
println!("処理");
warning: unused variable: `outer`
  help: if this is intentional, prefix it with an underscore: `_outer`

「使っていない変数がある」と警告される。ただし今回のような後始末が目的の値は、変数として使わなくても持っているだけで意味がある。そこで _outer と書いて「意図的だ」と示す。以降のコードで _a_c という名前が出てくるのは、この理由による。

まず、三つの指標を分ける

議論で最初に合意されたのが、この分離だった。混同したまま話すと必ず読者を誤らせる、という指摘である。

指標意味効いてくる場面
テールレイテンシ(応答時間の裾)遅いほうから数えた最悪値。平均ではなく、100回に1回・1000回に1回の遅さを見る応答時間に契約上の基準があるシステム
スループット単位時間あたりの処理量バッチ、大量データ処理
フットプリント(占有量)常駐するメモリの量コンテナで多数のプロセスを詰め込む構成

この三つは独立に動く。片方を良くすると片方が悪くなることもある。「速い/遅い」で一括りにしないことが、この回を読む前提になる。

通説その1「GCがあるから割り当てが遅い」――誤り

まず片づけておきたい誤解がある。

Javaは、新しいオブジェクトのためのメモリを確保するとき、多くの場合ポインタを進めるだけで済ませる。スレッドごとに専用の割り当て領域を持ち、そこを先頭から順に使っていく(この領域はTLABと呼ばれる)。空き領域を探し回る処理は走らない。

GCがあることと、割り当てが遅いことは別である。 短命なオブジェクトを大量に作る処理では、この方式はむしろ速い。ゴミになった領域はまとめて回収されるので、一つずつ返す必要もない。

灰島徹(基幹系アーキテクト)

「JavaはnewするたびにOSに問い合わせている」と思っている人がいまだにいます。そうではありません。割り当ての速さで負けているという前提から入ると、その後の議論が全部ずれます。

通説その2「GCがないからコストがない」――これも誤り

では逆はどうか。Rustは解放位置がコンパイル時に決まるから、実行時のコストはないのか。

100万個の文字列を持つベクタを捨てる処理を測ってみる。

use std::time::Instant;
fn main() {
    let v: Vec<String> = (0..1_000_000).map(|i| format!("項目{i}")).collect();
    println!("作成完了。要素数 {}", v.len());
    let t = Instant::now();
    drop(v);                       // ここで100万回の解放が走る
    println!("dropに要した時間: {:?}", t.elapsed());
}
作成完了。要素数 1000000
dropに要した時間: 7.39825ms

コードの読み方を補足しておく。(0..1_000_000) は0から100万未満までの範囲で、.map(...) で各要素を文字列に変え、.collect() でまとめて Vec にしている(JavaのStream APIとほぼ同じ流れである)。|i| format!("項目{i}") は引数 i を取る無名関数で、Javaのラムダ i -> ... にあたる。そして drop(v) は、その値をその場で破棄するための標準の関数である。

約7〜8ミリ秒、その場で止まる。 100万個の要素をひとつずつ解放しているのだから当然である。Rustはこの処理を「いつやるか」を隠さないが、「やらなくてよい」ことにしてくれるわけではない。

※本稿の執筆環境での実測。環境で変わるので、判断に使うなら自分の環境で測ること。

Arc の参照カウント更新はアトミック操作であり、これもタダではない。「GCがない=コストがない」は成り立たない。

桐生みなも(Rustを本番運用するSRE)

Rustに移して「止まらなくなる」と期待すると裏切られます。巨大な構造を落とす瞬間は、やはり止まる。違うのは、それがコードのどこで起きるかを読めば分かることです。GCの停止は、ヒープ全体の実行時の状態に依存するので、コードを読んでも分かりません。

では、何が違うのか

議論がたどり着いた整理はこうだった。

Javaは、解放の判断を実行時に集約した。 その代わり、いつ止まるかがヒープ全体の状態に依存する。確率的に、予期しないタイミングで来る。

Rustは、解放を書いた場所に散らした。 その代わり、止まる場所がコードから追跡できる。ただし完全に静的ではない――条件分岐で所有権が移る場合は、実行時の目印で解放の要否を判定する。正確には「コードの構造から追跡できる不確定性」である。

停止時間について、Oracleの公式文書はZGCについて「アプリケーションスレッドを1ミリ秒を超えて止めることなく」と書いている。ただしOpenJDKのZGC解説は、低遅延が重要ならCPU使用率が70%を超えないようにせよという前提条件を置く。JEP 439が挙げる「1/4のヒープで4倍のスループット」も、Apache Cassandraベンチマークで非世代別ZGCと比較した場合という限定つきの数字である。

そしてJDK 27でも既定のガベージコレクタはG1であり、ZGCはオプトインである。「JavaはZGCで1ミリ秒」と書くときは、この三つの条件を落とさないほうがいい。

リソースの解放は、メモリとは別の話

ここで話を一段広げる。閉じなければならないのはメモリだけではない。ファイル、ソケット、データベース接続――これらはメモリの話ではなくリソースの話であり、GCの有無とは別の仕組みが担当している。

Javaの答えは try-with-resources である。

class Conn implements AutoCloseable {
    private final String name;
    Conn(String name) { this.name = name; System.out.println("  [開く] " + name); }
    public void close() { System.out.println("  [閉じる] " + name); }
}

static void withTry() {
    try (Conn a = new Conn("接続A"); Conn b = new Conn("接続B")) {
        System.out.println("処理");
    }
}
  [開く] 接続A
  [開く] 接続B
処理
  [閉じる] 接続B
  [閉じる] 接続A

宣言と逆順に、確実に閉じられる。これはよくできた仕組みで、使っている限り閉じ忘れは起きない。

Rustは、同じことを型のライフサイクルとして行う。Drop を実装すれば、スコープを抜けるときに自動で走る。

struct Conn { name: String }

impl Drop for Conn {
    fn drop(&mut self) { println!("  [閉じる] {}", self.name); }
}

fn main() {
    let _outer = Conn { name: "接続A".to_string() };
    {
        let _inner = Conn { name: "接続B".to_string() };
        println!("内側の処理");
    }
    println!("外側の処理");
}
内側の処理
  [閉じる] 接続B
外側の処理
  [閉じる] 接続A

やはり逆順である。ここまでは同じに見える。

※ただし逆順になるのは同じスコープに並んだローカル変数の話で、構造体のフィールドは宣言した順に解放される。細かい点だが、後始末の順序に依存する設計をするなら押さえておく価値がある。

差は「書き忘れたとき」に出る

違いは、その構文を使わなかったときに何が起きるかである。Javaで try-with-resources を書き忘れると、こうなる。

static void withoutTry() {
    Conn c = new Conn("接続C");        // try-with-resources を使わない
    System.out.println("処理");
}                                       // ← 閉じられないが、コンパイルは通る
  [開く] 接続C
処理
(接続Cは閉じられていない)

コンパイルは通り、実行も正常に終わり、接続Cは閉じられないまま残る。閉じるかどうかは、書き手が構文を選んだかどうかに依存する。

Rustでは、Drop は型そのものに付いている。閉じる処理を呼び出し側が書くのではなく、値がスコープを抜ければ自動で走る。しかもpanicで異常終了する経路でも走る。

fn main() {
    let _c = Conn { name: "接続A".to_string() };
    println!("これから異常終了する");
    panic!("処理に失敗");
}
これから異常終了する
thread 'main' panicked at drop_panic.rs:8:5:
処理に失敗
  [閉じる] 接続A

異常終了の途中で、きちんと閉じられている。

念のため強調しておくと、これは「Javaのリソース管理が不完全」という話ではまったくないtry-with-resources を使えば保証される。差は保証の強さではなく、保証が既定でオンか、書き手の選択かという一点にある。

そしてRust側も絶対ではない。標準ライブラリには「この値の後始末を意図的に省く」関数が用意されている。

let d = Conn { name: "接続B".to_string() };
std::mem::forget(d);           // 意図的に忘れる → Dropが走らない
println!("接続Bはforgetした(閉じられない)");
接続Bはforgetした(閉じられない)

閉じるメッセージは出ない。しかもこれは unsafe を使わない、安全なコードである。Rustでも「閉じない」を選べる。 違うのは、それが既定の振る舞いではなく、明示的に書いたときにだけ起きるという点である。

データの並び方――速さではなく、配置の話

もう一つ、実務で効く差がある。

Rustの Vec<Point> は、要素の実データを連続して並べる。アドレスで確認できる。

struct Point { x: f64, y: f64 }

let v: Vec<Point> = vec![Point { x: 1.0, y: 2.0 }; 3];
let base = v.as_ptr() as usize;
for (i, p) in v.iter().enumerate() {
    println!("  要素{} のアドレス差 : +{} バイト", i, (p as *const Point as usize) - base);
}
Point 1個            : 16 バイト
  要素0 のアドレス差 : +0 バイト
  要素1 のアドレス差 : +16 バイト
  要素2 のアドレス差 : +32 バイト

16バイトずつ、隙間なく並んでいる。順番に舐めていく処理では、CPUが次に読む場所を先読みできるため有利になる。

Javaの List<Point> が持っているのは参照であり、実体はヒープに散らばる。ただし int[] のようなプリミティブ配列は連続する。

ここも「Rustが速い」と書くと不正確になる。議論での整理はこうだった。

連続アクセスのパターンでは、平坦なデータ構造が物理的に有利である。

そして散在配置には散在配置の自由がある。個々のオブジェクトが独立した寿命を持てるということだ。逆に Vec<T> の平坦さは、Tのサイズが揃っていることを前提にした制約と表裏一体である。

Javaもこの方向へ動いている。JDK 27でオブジェクトヘッダが96ビットから64ビットに縮み(JEP 534)、値クラス(JEP 401)はJDK 28でプレビューとして入る予定である。ただしJEP 401の原文は、フラット化を「最適化であって言語機能ではない。直接制御することはできない」とし、ジェネリクスは型消去のため対象外だと明記している。近づくが、性質としては別のままである。

Rustが防がないもの――メモリリーク

第3回でいちばん誤解を断ち切っておきたいのがここである。

Rustは不正なメモリアクセスを防ぐが、メモリリークは防がない。 第1回で見たとおり、Rc 同士が輪になると、互いのカウンタが 0 にならず解放されない。しかもこれは unsafe を使わない、安全なコードの範囲で起こる。Rustはメモリリークを未定義動作として扱っていない。

一方、Javaのガベージコレクタは到達不能になった循環を回収できる。 この一点では、GCのほうが強い。

Rust側の定石は、循環の片側をカウントを増やさない弱い参照(Weak)にすることである。

Weak は「持ち主として数えない参照」である。Rc::downgrade(&x) で作り、相手がまだ生きているかは使うときに確かめる。下のコードでは、親から子へは通常の Rc(持ち主として数える)、子から親へは Weak(数えない)にすることで、輪を断ち切っている。

struct Node { name: String, parent: RefCell<Weak<Node>>, child: RefCell<Option<Rc<Node>>> }

let parent = Rc::new(Node { name: "親".into(), /* ... */ });
let child  = Rc::new(Node { name: "子".into(), /* ... */ });
*parent.child.borrow_mut() = Some(Rc::clone(&child));    // 親→子は強い参照
*child.parent.borrow_mut() = Rc::downgrade(&parent);     // 子→親は弱い参照
親の持ち主: 1 人
  [解放] 親
  [解放] 子
スコープを抜けた

今度は両方とも解放される。ただしこれは、設計者が循環に気づいている場合に限る。気づかなければ、静かに漏れる。

汐見レン(証券システムの低レイテンシ実装者)

「Rustは全部面倒を見てくれる」という印象で入ってくると、ここで足をすくわれます。防いでくれるのは、解放済みの領域を触ることであって、解放しそこねることではありません。

Java経験者が最初に詰まる場所

議論で挙がったのは二点だった。

  1. 循環参照によるリーク。 Javaでは意識する必要がなかったので、油断が生じやすい。親子や相互参照を持つデータ構造を素直に書くと踏む。
  2. 非同期処理とdropタイミングのずれ。 スコープという概念が非同期タスクの寿命と噛み合わない場面があり、設計の意図と実際に解放されるタイミングが乖離する。ここは静的に追うのが難しくなる領域で、第4回で扱う。

この回で断定していないこと

  • どちらが速いかは扱っていない。示したのは、コストが時間軸のどこに現れるかという分布の違いである。スループットやテールレイテンシの優劣はワークロード次第で、本稿では測っていない。
  • drop連鎖の7〜8ミリ秒は本稿の環境での一例であり、一般化できる数値ではない。
  • フットプリントの比較も測っていない。GCの管理構造の分だけJavaが不利になる一方、解放を遅らせることが短期の負荷変動を吸収する余白として働く面もある、という指摘が議論で出たが、数値では確認していない。
  • 値クラス(JEP 401)はJDK 28でのプレビュー予定であり、本稿執筆時点でどのGA済みJDKにも入っていない。

梧桐(編集長・進行)

今回の持ち帰りは一つです。GCとRAIIの差は、コストの有無ではなく、コストをいつ・どこで払うかの分布の差です。そして安全性についても、防いでいる範囲が両者で違う。次回は並行処理で、この「防ぐ範囲の違い」がもっとはっきり出ます。

出典を読むときのポイント

キーワード:#RAII#テールレイテンシ#ガベージコレクタ

出典

コメント