Java 文字列とUnicode のあやうい関係

毎度混乱するJava文字列のUnicode対応のはなし。


Java 内部の文字エンコードは UTF-16

Java 内部の文字エンコードには UTF-16 を使っているので、 char型 は 16 bit で定義されています。

この 16 bit で、 Unicode の基本多言語面(Basic Multilingual Plane)(BMP)、つまり U+0000 から U+FFFF の範囲の65,536個の文字を表現できます。

例えば以下のようになります。

System.out.println('\u0041'); // U+0041 -> A
System.out.println('\u3042'); // U+3042 -> あ

ASCII の 41(A) = U+0041 のようにUnicode の基本多言語面の先頭から128まではASCIIと同等となっています。


Unicode Supplementary Character

Unicode 2.0 からは、BMP(第0面)に加え U+10000 から U+10FFFF の24 bit 範囲で補助文字(Supplementary Character)の区画が定義され、Unicode 3.1 より補助文字区画が利用され始めました。

1面を65,536個の文字で分割して第0面から第16面まで存在します。 主に利用されるのは以下の4面になります。

範囲 名称
第0面 U+0000 - U+FFFF BMP(Basic Multilingual Plane) 基本多言語面
第1面 U+10000 - U+1FFFF SMP(Supplementary Multilingual Plane) 追加多言語面
第2面 U+20000 - U+2FFFF SIP(Supplementary Ideographic Plane) 追加漢字面
第3面 U+30000 - U+3FFFF TIP(Tertiary Ideographic Plane) 第三漢字面

第0面のBMPの範囲であれば、1文字は1つの 16 bit char で扱えましたが、補助文字を扱うには24bit幅(3バイト)が必要になります。


Java 5 の Unicode サポート

ずいぶん古い話になりますが Java5 で JSR-204 として Unicode 4.0 サポートが追加されました。 前述の通り、Unicode の補助文字を表現するには 16 bit の char型 には収まりません。

Java の Unicode サポートでは、char 型の bit 幅を拡張する案もありましたが、後方互換性やメモリ使用効率から、char 型は 16bit 幅のままとし、APIの拡張を行い、32 bit 幅の int 値として codePoint を扱う という対応になりました。

つまり String#charAt() は、(補助文字の場合)文字の一部しか返しませんし、String#length は文字数ではなく文字列に必要なchar配列のサイズしか返せないようになりました。


Unicode の各エンコード

Unicode のエンコードには UTF-8 か UTF-16 が使われることがほとんどかと思います。

それぞれの例を以下に示します。

Unicode 文字 UTF-8 UTF-16 codepoint(10進) 備考
U+0041 A 41 00 41 65 第0面(BMP)
U+03A9 Ω ce a9 03 a9 937 第0面(BMP)
U+3042 e3 81 82 30 42 12,354 第0面(BMP)
U+9AD9 e9 ab 99 9a d9 39,641 第0面(BMP)
U+1F60A 😊 f0 9f 98 8a d8 3d de 0a 128,522 第1面(SMP)
U+2000B 𠀋 f0 a0 80 8b d8 40 dc 0b 131,083 第2面(SIP)

表中の codepoint 値 は、Unicode値の16進を10進で表記したものとなります。

上部4つのBMP区画の文字は、UTF-16で2バイト(16bit)となりますが、下の2つは補助文字区画に位置しておりサロゲートペアにより4バイト(32bit)要していることが分かります。

UTF-8の場合は領域毎に分割ルールが定められており、1~4バイトの可変長になります。


各値は以下のように取得することができます。

  • UTF-8のバイト表現
var hex = HexFormat.ofDelimiter(", ");
hex.formatHex("A".getBytes(StandardCharsets.UTF_8)); // -> 41
hex.formatHex("Ω".getBytes(StandardCharsets.UTF_8)); // -> ce, a9
hex.formatHex("あ".getBytes(StandardCharsets.UTF_8)); // -> e3, 81, 82
hex.formatHex("髙".getBytes(StandardCharsets.UTF_8)); // -> e9, ab, 99
hex.formatHex("😊".getBytes(StandardCharsets.UTF_8)); // -> f0, 9f, 98, 8a
hex.formatHex("𠀋".getBytes(StandardCharsets.UTF_8)); // -> f0, a0, 80, 8b
  • UTF-16のバイト表現
hex.formatHex("A".getBytes(StandardCharsets.UTF_16)); // -> fe, ff, 00, 41
hex.formatHex("Ω".getBytes(StandardCharsets.UTF_16)); // -> fe, ff, 03, a9
hex.formatHex("あ".getBytes(StandardCharsets.UTF_16)); // -> fe, ff, 30, 42
hex.formatHex("髙".getBytes(StandardCharsets.UTF_16)); // -> fe, ff, 9a, d9
hex.formatHex("😊".getBytes(StandardCharsets.UTF_16)); // -> fe, ff, d8, 3d, de, 0a
hex.formatHex("𠀋".getBytes(StandardCharsets.UTF_16)); // -> fe, ff, d8, 40, dc, 0b

「fe, ff」 は UTF-16BE の BOM(Byte Order Mark) となります。

  • コードポイント値
"A".codePointAt(0); // -> 65
"Ω".codePointAt(0); // -> 937
"あ".codePointAt(0); // -> 12354
"髙".codePointAt(0); // -> 39641
"😊".codePointAt(0); // -> 128522
"𠀋".codePointAt(0); // -> 131083
  • コードポイントの16進表現 = Unicode
hex.toHexDigits("A".codePointAt(0)); // -> 00000041
hex.toHexDigits("Ω".codePointAt(0)); // -> 000003a9
hex.toHexDigits("あ".codePointAt(0)); // -> 00003042
hex.toHexDigits("髙".codePointAt(0)); // -> 00009ad9
hex.toHexDigits("😊".codePointAt(0)); // -> 0001f60a
hex.toHexDigits("𠀋".codePointAt(0)); // -> 0002000b


UTF-8 エンコード

すこし脇道にそれますが、UTF-8 のエンコードについて見てみましょう。

Unicode 文字 UTF-8(HEX) UTF-8(BIN)
U+0041 A 41 0100 0001
U+03A9 Ω ce a9 1100 1110 1010 1001
U+3042 e3 81 82 1110 0011 1000 0001 1000 0010
U+9AD9 e9 ab 99 1110 1001 1010 1011 1001 1001
U+1F60A 😊 f0 9f 98 8a 1111 0000 1001 1111 1001 1000 1000 1010
U+20000 𠀋 f0 a0 80 8b 1111 0000 1010 0000 1000 0000 1000 1011

UTF-8 では、対象文字のUnicode位置に応じてサイズが決まっています。

  • U+0000-U+007Fの範囲は(ASCIIコードの範囲)1バイト

    • 例) U+0041 [0]100 0001
    • 先頭ビットが0で始まる
  • U+0080-U+07FFの範囲は2バイト

    • 例) U+03A9 [110]0 1110 [10]10 1001
    • 先頭ビットが110で始まる
    • 以降のバイトは10で開始する
  • U+0800-U+FFFFの範囲は3バイト

    • 例) U+3042 [1110] 0011 [10]00 0001 [10]00 0010
    • 先頭ビットが1110で始まる
    • 以降のバイトは10で開始する
  • U+10000-U+10FFFFの範囲は4バイト

    • 例) U+1F60A [1111 0]000 [10]01 1111 [10]01 1000 [10]00 1010
    • 先頭ビットが11110で始まる
    • 以降のバイトは10で開始する

先頭ビットのパターンで続くバイト数が決定できるように符号化されています。


UTF-16 エンコード

UTF-16は以下のようになります。

Unicode 文字 UTF-16(HEX) UTF-16(BIN)
U+0041 A 00 41 0000 0000 0100 0001
U+03A9 Ω 03 a9 0000 0011 1010 1001
U+3042 30 42 0011 0000 0100 0010
U+9AD9 9a d9 1001 1010 1101 1001
U+1F60A 😊 d8 3d de 0a 1101 1000 0011 1101 1101 1110 0000 1010
U+20000 𠀋 d8 40 dc 0b 1101 1000 0100 0000 1101 1100 0000 1011
  • 第0面(BMP)の場合はそのまま(16ビットの符号なし整数)
  • 第0面(BMP)以降の場合 U+D800U+DFFF(110110xxxxxxxxxx - 110111xxxxxxxxxx) の範囲を代用符号位置として使う
    • 例) U+1F60A
    • 上位サロゲート [1101 10]00 0011 1101
    • 下位サロゲート [1101 11]10 0000 1010
    • 各サロゲートの下10桁の合計20ビットで符号を表現


Java 9 の Compact Strings

Java 9 では JEP 254 として Compact Strings が導入されました。

Java 9 以前の String は以下のように char[] として文字列を保持していました。

public final class String ... {

    private final char value[];
    ...
}

Java 9 以降では以下のように byte[] として値を保持するようになりました。

public final class String ... {

    private final byte[] value;

    private final byte coder;
    ...
}

Java は内部的 に UTF-16 を使用するため、全ての文字に対して 2 バイト(サロゲートペアの場合は4バイト)を消費していました。 主に英語圏では、アルファベットなどの利用が主であり、1バイトで表現できる文字(ISO/IEC 8859-1の範囲)は1バイトで保持する(LATIN-1 表現)ように改善されました。これによりパフォーマンスとメモリ消費の改善を目指したものです。

保持する文字が全て1バイト表現可能な場合は、内部的に1バイトで保持し、2バイト必要な文字が一つでもあったら、内部的に2バイトとして扱う(UTF-16表現)ようになっています。

この変更は、内部的な改善であるため、APIとして外部から見た場合の挙動に変更はありません。


char 型は2 バイト (16ビット) で、UTF-16 big-endian のバイト配列で文字を表現します。 ですので、LATIN-1 のような1バイト文字列を扱う場合、char[] として扱うより String 型で扱った方がメモリ効率が良くなります。


補助文字の扱い

以下のような文字列 "A😊" を例に、各APIの挙動について確認しておきましょう。

var str = "A😊";
var hex = HexFormat.ofDelimiter(", ");

System.out.println(hex.formatHex(str.getBytes(StandardCharsets.UTF_16)));
// -> fe, ff, 00, 41, d8, 3d, de, 0a

fe, ff は BOM となるので、00, 41, d8, 3d, de, 0a が実際の文字列に該当します。

Compact Strings で述べたように文字列は byte で保持するように変更されていますが、ここでは簡便のため char と考え、文字列は以下のようなレイアウトになります。

A 😊上位サロゲート 😊下位サロゲート
char[0] char[1] char[2]
00 41 d8 3d de 0a
00000000 01000001 11011000 00111101 11011110 00001010


文字の長さ

String#length() は char 配列の長さを返します。

"A😊".length(); // -> 3

文字数を知るには codePointCount() を使います。

codePointCount("A😊"); // -> 2

int codePointCount(String str) {
    return str.codePointCount(0, str.length());
}

このあたりが混乱ポイントなのですが、codePointCount() の引数には、char 配列のインデックスを使用します。

A 😊上位サロゲート 😊下位サロゲート
char[0] char[1] char[2]

インデックス位置が、(上位サロゲートではなく)下位サロゲートだけを示す場合も 1 が返ります。

"A😊".codePointCount(1, 2); // -> 1
"A😊".codePointCount(2, 3); // -> 1

コードポイント値の取得

String#codePointCount() でコードポイント値をint型で取得できます。

"A😊".codePointAt(1); // -> 128522 -> U+1F60A

インデックス位置が、(上位サロゲートではなく)下位サロゲートだけを示す場合は、下位サロゲート 1101 1110 0000 1010 がそのまま返ります。

"A😊".codePointAt(2); // -> 56842

つまりサロゲートペアを自身で判断して適切なインデックスを指定してやる必要があるということです。

Characterクラスの以下も利用できます。

int codePoint = Character.toCodePoint(char high, char low)

コードポイントからの文字生成

String のコンストラクタが利用できます。

int[] codePoints = ...

var str = new String(codePoints, 0, codePoints.length);

サロゲートの判定

Character の以下のstaticメソッドで判定できます。

boolean isSurrogate = Character.isSurrogate(char ch);

上位サロゲート、下位サロゲートの判定は以下があります。

boolean isHight = Character.isHighSurrogate(char ch),
boolean isLow = Character.isLowSurrogate(char ch);

コードポイントが基本多言語面、つまりサロゲートペア不要かどうかを以下で判定できます。

boolean isBmp = isBmpCodePoint(int codePoint);

部分文字列の取得

これが厄介で、例えば以下

int[] ints = "A😊".codePoints().skip(1).toArray();
var str  = new String(ints, 0, ints.length);

または以下。

var str = "A😊".codePoints().skip(1)
    .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
    .toString();

以下のようなユーティリティを用意しておけば、サロゲートペアを意識しない文字数指定でsubstringすることができます。

public static String substring(String str, int beginIndex, int endIndex) {
    return str.substring(
        str.offsetByCodePoints(0, beginIndex),
        str.offsetByCodePoints(0, endIndex));
}
System.out.println(subString(str, 0, 1)); // A
System.out.println(subString(str, 0, 2)); // A😊
System.out.println(subString(str, 1, 2)); // 😊


まとめ

Unicode に踊らされた Java の文字列の扱いの変遷について見てきました。

たまに触ると必ず混乱するので、メモとして残しておきました。