本日、ついに JavaSE 9 がリリースされました!
そこで、かねてから噂になっていた JEP 254: Compact Strings がどのように実装されているのか調べてみました。
Compact Strings の概要
これまで String クラスや StringBuilder クラスなどの内部では、文字列を UTF-16 でエンコードして char 配列で保持していました。
つまり、一文字あたり*1常に char ひとつ = 2バイト分のメモリを使っていました。
しかし、これだと 1 バイトで表せる LATIN1(ASCII コード + ラテン文字)の文字列の場合、その半分が 0x00 になるという無駄がありました。
そこで、内部表現を変更し、文字列が LATIN1 のみで構成されるときは 1 文字を 1 バイトで保持するようにリファクタリングされました。
ちなみに、LATIN1 以外の文字(日本語など)があるときは、これまで通り 1 文字 2 バイトの UTF-16 で保持します。
これを踏まえて、ソースコードを追ってみました。
文字列の中身
String クラスでは、各オブジェクトごとに以下の3つのフィールド変数 (value, coder, hash) を持つようになっていました。
このうち、value の型が char 配列から byte 配列に変更されたところがポイント。
この配列を、LATIN1 なら 1byte ずつ、 UTF-16 なら 2byte ずつ使用しています。
// 文字列の中身を保持する配列 // Java 8 までは byte[] ではなく char[] だった private final byte[] value; // value のエンコード方式(Java 9 で追加) // 0 なら LATIN1, 1 なら UTF-16 private final byte coder; // この文字列のハッシュコードのキャッシュ(既存) // 最初に hashCode メソッドが呼び出されたときに遅延初期化される。 private int hash; // Default to 0
それと、 定数 COMPACT_STRINGS (boolean) が追加されていました。
この値が true だと LATIN1/UTF16 の切り替えが有効になり、false だと無効(常に UTF16)になります。
デフォルトは有効 (true) です。
static final boolean COMPACT_STRINGS; static { COMPACT_STRINGS = true; }
この値は static final な定数ですが、VMオプションで変更が可能です*2。
-XX:-CompactStrings
ちなみに、このオプションはドキュメント化されていないっぽいです。
ググったのですが、JDK のテストにしか記述が見つかりませんでした。
jdk9/jdk9/jdk: 4f6e52f9dc79 test/java/lang/String/CompactString/VMOptionsTest.java
String クラスの処理
さて、String クラスの処理がどうなっているかというと…。
一部の比較処理を除いて、大体の処理が StringLatin1 クラスと StringUTF16 クラスに移譲されていました。
たとえば、charAt メソッドがやっているのは Latin1 かどうか判定してそれぞれのクラスに処理を投げるだけになっていました。
public char charAt(int index) { if (isLatin1()) { return StringLatin1.charAt(value, index); } else { return StringUTF16.charAt(value, index); } }
StringUTF16 クラスの実装は、だいたいこれまでの String クラスの実装と同じでした。
一方、StringLatin1 クラスの方はサローゲートペアを考える必要がなく、codePointAt などのメソッドの実装がシンプルになっていました。
そのため、Latin1 の文字列のときはこれまでよりも少しパフォーマンスが良くなっていそうです。
(具体的にどのぐらい良くなったかまでは分からず…。何処かに資料あるのかな…。)
char 配列の取り扱い
読んでいて気になったになったのは、従来からある char 配列を受け取るコンストラクタの処理。
以前はただ単に char 配列をコピーするだけだったのですが、今回 LATIN1/UTF16 を切り替えるために処理が増えていました。
- char の長さが0なら、空配列をコピーする
- そうでなければ、LATIN1 前提で byte 配列を作成し、LATIN1 かどうかを確認しながら 1 文字ずつコピーする
- もし、すべての文字が LATIN1 ならば、その byte 配列を使う
- LATIN1 の範囲外の文字が見つかったら、その byte 配列を破棄し UTF16 の byte 配列を新たに作成する
String(char[] value, int off, int len, Void sig) { if (len == 0) { this.value = "".value; this.coder = "".coder; return; } if (COMPACT_STRINGS) { // ↓ byte 配列に1文字ずつコピーしている。 // コピー中に LATIN1 範囲外の文字があった場合は null が返ってくる。 byte[] val = StringUTF16.compress(value, off, len); if (val != null) { this.value = val; this.coder = LATIN1; return; } } this.coder = UTF16; this.value = StringUTF16.toBytes(value, off, len); }
ポイントは、投機的に LATIN1 で配列作って処理しているところ。
このような処理は、StringDecoderUTF8#decode にもあります。
このコンストラクタはそれなりに使われている(少なくとも、BufferedReader#readLine から使われているのを確認)ので、すぐに破棄しているとは言え余分な配列生成のオーバーヘッドが大丈夫なのか気になりました。
もし、ほとんどの文字列で LATIN1 範囲外を使うことが確定している場合は、前述の VM オプションで Compact Strings を無効化してパフォーマンスを比較してみたほうがいいかもしれません。
StringBuilder での取り扱い
この Compact Strings は String クラスだけではなく、StringBuilder クラスでも実装されています。
例えば、 new StringBuilder().append("abc") だと、内部的には LATIN1 で文字列を保持しています。
では、 new StringBuilder().appned("abc").append("あいう"); のように、LATIN1 文字列のあとに UTF-16 文字列を追加したらどうなるのか。
ソースコードを確認したとろ、この場合は LATIN1 を UTF-16 に変換して新たな配列に格納し、その後に文字を付け足すようになっていました。
最初から UTF-16 にしておく方法はないかなーと思ったんですが、Compact Strings を無効化するしか方法はありませんでした。
AbstractStringBuilder(int capacity) { if (COMPACT_STRINGS) { value = new byte[capacity]; coder = LATIN1; } else { value = StringUTF16.newBytesFor(capacity); coder = UTF16; } }
また、たとえ new StringBuilder("あいう"); であっても配列を作り直す処理が発生します。
これは、内部的には new StringBuilder() + append("あいう") という処理になっており、コンストラクタを呼んだ時点では LATIN1、その後の append で追加する文字列が UTF16 になるので上記と同じフローになってしまうためです。
この場合は中身が空なので、そのまま使ってくれてもいいのになーと思いました。
この点は、将来のバージョンアップでまたリファクタリングされるのでしょうか…。
まとめ
全体としては、英語圏であれば確実にパフォーマンスが良くなりそうだなーと思った一方、日本語圏だとパフォーマンスが大丈夫なのか気になりました。
日本語が含まれた文字列は思っているほど多くないと思いますが、もし徹底的にチューニングしたいということであれば Compact Strings の有効/無効を切り替えて、どちらがパフォーマンスが良いか調べてみるのもありかもしれません。
ちなみに、Java 9 では JEP 250: Store Interned Strings in CDS Archives や JEP 280: Indify String Concatenation と言った文字列関連の改善が他にもあります。
それらの実装についても、またの機会に紹介できたらなと思います!