Java
OpenJDK

Javaバージョン別の改元(新元号)対応まとめ

はじめに

改元が2019年5月1日、新元号の発表が1ヵ月前の4月1日に予定されています。
関連する記事は「Javaで新元号に対応する」などがありますが、Java SE 8(以降、単にJava 8のように表記)からのDate and Time APIに対応していなかったり、それ以外のブログなどでも古い情報が多いため、改めてなるべく網羅的かつ実践的となるよう、まとめてみることにしました。

※本記事は改元対応が完了するであろう2019年5月くらいまでは随時更新する予定です。
 誤りや補足などあれば適宜コメントなどでご指摘ください。

前提

結論(対応方針)

以降は長くなるので、改元に対する標準APIでの対応方針としては、バージョン別に次のようになると思います。(1/15見直し)

  • Java 6, 7
    • java.util.Date/Calendarを使う場合、calendars.propertiesを修正する
  • Java 8
    • java.util.Date/Calendarを使う場合、calendars.propertiesを修正する
    • Date and Time APIを使う場合、calendars.propertiesの修正が反映されるよう最新版を利用する (元年表記は別途実装)
  • Java 9, 10
    • java.util.Date/Calendarを使う場合、jdk.calendar.japanese.supplemental.era, java.locale.providers システムプロパティを指定する
    • Date and Time APIを使う場合、jdk.calendar.japanese.supplemental.era システムプロパティを指定する (元年表記は別途実装)
  • Java 11以降
    • java.util.Date/Calendarを使う場合、java.locale.providers システムプロパティを指定し、改元対応が正式に入るであろう4月以降のリリースにバージョンアップ
    • Date and Time APIを使う場合、改元対応が正式に入るであろう4月以降のリリースにバージョンアップ

実際には標準APIだけではなく、業務用件に応じて独自実装が必要となる可能性も多々あるでしょうが、原則として本記事では扱いません。

実行例

和暦の対応のうち、一般的によく使われそうな日付の整形(フォーマット)や解析(パース)が基本的に行えることを目指します。
仮に新元号を「和平」とし、アルファベットは「W」であるとして扱います。(※4月1日の正式発表後に修正予定)

$ echo 和平 | native2ascii
\u548c\u5e73

となるため、こちらを基本的に{JRE_HOME}/lib/calendars.propertiesに指定するものとします。

calendar.japanese.eras: \
    name=Meiji,abbr=M,since=-3218832000000;  \
    name=Taisho,abbr=T,since=-1812153600000; \
    name=Showa,abbr=S,since=-1357603200000;  \
    name=Heisei,abbr=H,since=600220800000;   \
    name=\u548c\u5e73,abbr=W,since=1556668800000

※「1556668800000」は2019年5月1日(ローカル時間を指定するらしく9:00)を示すエポックミリ秒です。

java.util.Date/Calendarを使った例

旧来からの使いづらい実装とも言えるjava.util.Datejava.util.Calendarを使った例は次のようにしてみました。

DateCalendarTest.java
import java.text.*;
import java.util.*;

public class DateCalendarTest {

    public static void main(String... args) throws ParseException {
        Locale locale = new Locale("ja", "JP", "JP");
        DateFormat kanjiFormat = new SimpleDateFormat("GGGGy年M月d日", locale);
        DateFormat alphabetFormat = new SimpleDateFormat("Gyy.MM.dd", locale);

        // 現在日付出力
        Calendar now = Calendar.getInstance(locale);
        System.out.println(kanjiFormat.format(now.getTime()));
        System.out.println(alphabetFormat.format(now.getTime()));

        Calendar cal = Calendar.getInstance();
        cal.clear();

        // 改元前の日付出力
        cal.set(2019, Calendar.APRIL, 30, 23, 59, 59);
        Date lastHeiseiDate = cal.getTime();
        System.out.println(kanjiFormat.format(lastHeiseiDate));
        System.out.println(alphabetFormat.format(lastHeiseiDate));

        // 改元後の日付出力
        cal.set(2019, Calendar.MAY, 1, 0, 0, 0);
        Date firstNewEraDate = cal.getTime();
        System.out.println(kanjiFormat.format(firstNewEraDate));
        System.out.println(alphabetFormat.format(firstNewEraDate));

        // 厳密でない日付解析
        System.out.println(kanjiFormat.parse("平成31年5月1日"));
        System.out.println(alphabetFormat.parse("H31.05.01"));

        // 厳密に解析時はエラーになる
        kanjiFormat.setLenient(false);
        try {
            kanjiFormat.parse("平成31年5月1日");
        } catch (ParseException e) {
            System.err.println(e.getMessage());
        }
        alphabetFormat.setLenient(false);
        try {
            System.out.println(alphabetFormat.parse("H31.05.01"));
        } catch (ParseException e) {
            System.err.println(e.getMessage());
        }
    }
}

このとき期待する動作としては次のような出力となるはずです。

平成31年1月5日
H31.01.05
平成31年4月30日
H31.04.30
和平1年5月1日
W01.05.01
Wed May 01 00:00:00 JST 2019
Wed May 01 00:00:00 JST 2019
Unparseable date: "平成31年5月1日"
Unparseable date: "H31.05.01"

なお、厳密でない日付解析(デフォルト)は、"13月"や"-1日"など明らかにおかしい日付フィールドでも許容してしまいますので、業務用件に応じて上記のようにDateFormat#setLenient(false)したり、別途フィールドごとに有効範囲をチェックする必要もあるでしょう。

それでは、バージョン別に動作や注意点を見てみましょう。

Java 6

基本的にどのマイナーバージョンでも期待通りに動作します。
ただし、Locale("ja", "JP", "JP")を指定していても、OSによっては漢字が文字化けする可能性があります。試した限り、Windowsの言語設定が日本語の場合は正しく動作しました。Windowsの言語設定を英語にしていたり、macOS X環境では??31?4?30?のようになってしまいました。
これは全般的にコンソールに日本語を出力する際の問題のようですが、念のためご注意ください。

Java 7, 8

基本的にどのマイナーバージョンでも期待通りに動作します。

Java 9以降

JDK-8048123による変更で、calendars.propertiesがなくなり、システムプロパティ jdk.calendar.japanese.supplemental.era を設定することで代替とするようになっています。
次のように実行します。

$ java -Djdk.calendar.japanese.supplemental.era="name=和平,abbr=W,since=1556668800000" DateCalendarTest

実行すると次のような結果となります。
(実際には後述するDate and Time APIと同様に、Java 11以降では上記のようなシステムプロパティの指定は無視されます。)

平成31年1月5日
平成31.01.05
平成31年4月30日
平成31.04.30
和平1年5月1日
W01.05.01
Wed May 01 00:00:00 JST 2019
Exception in thread "main" java.text.ParseException: Unparseable date: "H31.05.01"
        at java.base/java.text.DateFormat.parse(DateFormat.java:388)
        at DateCalendarTest.main(DateCalendarTest.java:36)

例外が発生してしまいましたね。
実際にその前の出力を見ると"H31.04.30"となるべき箇所が"平成31.04.30"となっています。
(明示的にシステムプロパティを指定した"W01.05.01"は出力されています)

(1/8見直し)
Java 12-ea+25や13-ea+1(Early Accessでビルド番号まで表記)まで一通り試しましたが同様の結果になるので、Bugとして報告しておきましたが、現状では「NYY.MM.DD」のような形式 (JIS X 0301表記)は、Java 9以降はSimpleDateFormatでは整形・解析できないと考えたほうがよいでしょう。
元号の漢字のみを利用するのであれば問題ありません。
また、次のDate and Time APIを使うのが、当面の回避策になると考えられます。

"[JDK-8216204] Wrong SimpleDateFormat behavior with Japanese Imperial Calendar" でコメントいただいたのですが、不具合ではなくJava 9からの仕様変更が原因でした。
Java 8以前と同様の出力にしたい場合は、java.locale.providersシステムプロパティを「COMPAT,CLDR」のように設定すればよいとのことです。

$ java -Djava.locale.providers=COMPAT,CLDR -Djdk.calendar.japanese.supplemental.era="name=和平,abbr=W,since=1556668800000" DateCalendarTest

(1/13追記)
Java 8ではCLDRがデフォルト有効ではなく、互換設定としては-Djava.locale.providers=COMPAT,SPIとなるため、気になる場合はそのように設定してもよいでしょう。

参考情報

Date and Time APIを使った例

Java 8で導入されたDate and Time APIは、旧来のjava.util.Dateやjava.util.Calendarを置換したり移行するために、ISO 8601をベースとして設計されたものです。
これを使った例は次のようにしてみました。

DateAndTime8Test.java
import java.util.Locale;
import java.time.LocalDate;
import java.time.chrono.*;
import java.time.format.*;

public class DateAndTime8Test {

    public static void main(String... args) {
        DateTimeFormatter kanjiFormat = DateTimeFormatter.ofPattern("GGGGy年M月d日", Locale.JAPAN);
        DateTimeFormatter alphabetFormat = DateTimeFormatter.ofPattern("GGGGGyy.MM.dd", Locale.JAPAN);

        // 現在日付出力
        JapaneseDate today = JapaneseDate.now();
        System.out.println(kanjiFormat.format(today));
        System.out.println(alphabetFormat.format(today));

        // 改元前の日付出力
        JapaneseDate lastHeiseiDate = JapaneseDate.of(2019, 4, 30);
        System.out.println(lastHeiseiDate.format(kanjiFormat));
        System.out.println(lastHeiseiDate.format(alphabetFormat));

        // 改元後の日付出力
        JapaneseDate firstNewEraDate = JapaneseDate.of(2019, 5, 1);
        System.out.println(firstNewEraDate.format(kanjiFormat));
        System.out.println(firstNewEraDate.format(alphabetFormat));

        // 厳密でない日付解析
        DateTimeFormatter kanjiParseFormat = kanjiFormat.withChronology(JapaneseChronology.INSTANCE)
                .withResolverStyle(ResolverStyle.LENIENT);
        System.out.println(kanjiParseFormat.parse("平成31年5月1日", LocalDate::from));
        DateTimeFormatter alphabetParseFormat = alphabetFormat.withChronology(JapaneseChronology.INSTANCE)
                .withResolverStyle(ResolverStyle.LENIENT);
        System.out.println(alphabetParseFormat.parse("H31.05.01", LocalDate::from));

        // 厳密に解析時はエラーになる
        kanjiParseFormat = kanjiParseFormat.withResolverStyle(ResolverStyle.STRICT);
        try {
            System.out.println(kanjiParseFormat.parse("平成31年5月1日"));
        } catch (DateTimeParseException e) {
            System.err.println(e.getMessage());
        }
        alphabetParseFormat = alphabetParseFormat.withResolverStyle(ResolverStyle.STRICT);
        try {
            System.out.println(alphabetParseFormat.parse("H31.05.01"));
        } catch (DateTimeParseException e) {
            System.err.println(e.getMessage());
        }
    }
}

念のためですが、JapaneseEra.HEISEIのようにハードコーディングされている箇所は、改元対応が正式に入るリリースまで対応できません。
ちなみに、DateTimeFormatterではLocaleを省略できますが、OSや言語設定によっては"平成"の代わりに"Heisei"と出力されてしまうため、明示的に指定することをお勧めします。

このとき期待する動作としては次のような出力となるはずです。

平成31年1月5日
H31.01.05
平成31年4月30日
H31.04.30
和平1年5月1日
W01.05.01
2019-05-01
2019-05-01
Text '平成31年5月1日' could not be parsed: year, month, and day not valid for Era
Text 'H31.05.01' could not be parsed: year, month, and day not valid for Era

こちらも先のjava.util.Date/Calendarの例と同様に、解析には注意が必要です。
デフォルトはResolverStyle.SMARTであり、"13月"や"-1日"などは許容しませんが、"平成32年"なども解析できなくなってしまうため、業務用件には合わないことも多いと思います。
ここでは厳密でない日付解析(LENIENT)と厳密な日付解析(STRICT)のみ指定していますが、やはり状況に応じてもっと細かいチェックが必要になるでしょう。

それでは、バージョン別に動作や注意点を見てみましょう。
なお、calendars.propertiesやJava 9以降のjdk.calendar.japanese.supplemental.eraシステムプロパティは、java.util.Date/Calendarを使った例と同様に適宜指定するものとします。

Java 8

8GA~8u31

Exception in thread "main" java.lang.NullPointerException
        at java.time.chrono.JapaneseEra.privateEraFrom(JapaneseEra.java:271)
        at java.time.chrono.JapaneseDate.toPrivateJapaneseDate(JapaneseDate.java:502)
        at java.time.chrono.JapaneseDate.<init>(JapaneseDate.java:333)
        at java.time.chrono.JapaneseDate.now(JapaneseDate.java:197)
        at java.time.chrono.JapaneseDate.now(JapaneseDate.java:166)
        at DateAndTime8Test.main(DateAndTime8Test.java:12)

いきなり例外が発生してしまいましたね。
(1/15見直し)Java 8リリース当初は和暦を正しく扱えなかったようです。
(JapaneseDate.ofやJapaneseDate.fromでも同様にNullPointerExceptionが発生します。)

Java 8リリース当初は、JDK-8044671の不具合により、calendars.propertiesに元号を追加すると、和暦を正しく扱えなくなってしまうようです。

8u40~8u151

平成31年1月5日
H31.01.05
平成31年4月30日
H31.04.30
31年5月1日
301.05.01
2019-05-01
2019-05-01
Text '平成31年5月1日' could not be parsed: year, month, and day not valid for Era
Text 'H31.05.01' could not be parsed: year, month, and day not valid for Era

"和平1年5月1日"となってほしいのに"31年5月1日"となったり、"W01.05.01"となってほしいのに"301.05.01"となっていますね。calendars.propertiesのsinceは使われているようですが、それ以外は正しく読み込めず元号が"3"として扱われています。
(1/15見直し) JDK-8054214の不具合により、追加された元号を数値として出力してしまっているようです。

8u152~8u202

平成31年1月5日
H31.01.05
平成31年4月30日
H31.04.30
和平1年5月1日
W01.05.01
2019-05-01
2019-05-01
Text '平成31年5月1日' could not be parsed: year, month, and day not valid for Era
Text 'H31.05.01' could not be parsed: year, month, and day not valid for Era

期待通りの出力となりました!
このため、Java 8のDate and Time APIで和暦を扱うには、なるべく新しいバージョンを利用したほうがよさそうです。

ただし、1つ注意点があります。サンプルコードではDateTimeFormatter.ofPattern("GGGGy年M月d日")と"G"が4つのフル形式で指定し、"和平1年5月1日"のように出力されました。
"G"が3つまでだと短縮形式扱いとなるのですが、"W1年5月1日"と出力されてしまいます。("平成"までは問題なし)

ではJava 9以降で動作を見ていきましょう。次のように実行します。

java -Djdk.calendar.japanese.supplemental.era="name=和平,abbr=W,since=1556668800000" DateAndTime8Test

Java 9

基本的にどのマイナーバージョンでも期待通りに動作します。
ただし、Java 9は先の8u152~8u202と同様の注意点が存在するため、漢字表記をする際はDateTimeFormatterで"GGGG"と4つ指定しましょう。

Java 10

基本的にどのマイナーバージョンでも期待通りに動作します。
DateTimeFormatterで"G"が3つまでの短縮形式でも、"和平1年5月1日"と出力されるように修正されています。

Java 11, 12-ea, 13-ea

平成31年1月5日
H31.01.05
平成31年4月30日
H31.04.30
元号1年5月1日
N01.05.01
2019-05-01
2019-05-01
Text '平成31年5月1日' could not be parsed: year, month, and day not valid for Era
Text 'H31.05.01' could not be parsed: year, month, and day not valid for Era

システムプロパティで指定した「和平」や「W」の代わりに、"元号"や"N"が出力されていますね。
これはプレースホルダとして実装された内容によりオーバーライドされているようです。
(逆に言えば、jdk.calendar.japanese.supplemental.eraシステムプロパティを指定しなくても同じです。)
正式に元号が発表後には修正されて、その次のバージョンで利用できるようになるはずです。

Tips (裏技?)

今まではシステムプロパティ jdk.calendar.japanese.supplemental.era に、2019年5月1日開始とみなされる「1556668800000」を設定するのを前提としてきましたが、1ミリ秒でも先を指定すると、プレースホルダ実装をさらにオーバーライドすることができました。
JShellで動作を見てみましょう。次のように起動します。

$ jshell -s -R-Djdk.calendar.japanese.supplemental.era="name=和平,abbr=W,since=1556668800001"
-> import java.time.chrono.*
-> import java.time.format.*
-> var kanjiFormat = DateTimeFormatter.ofPattern("GGGGy年M月d日", Locale.JAPAN)
-> var alphabetFormat = DateTimeFormatter.ofPattern("GGGGGyy.MM.dd", Locale.JAPAN)
-> var firstNewEraDate = JapaneseDate.of(2019, 5, 1)
-> System.out.println(firstNewEraDate.format(kanjiFormat))
和平151
-> System.out.println(firstNewEraDate.format(alphabetFormat))
W01.05.01
-> /exit

なお、旧来のjava.util.Date/Calendarではオーバーライドできず、プレースホルダ実装のままとなりました。(もちろん翌日の5月2日を指定すれば動きますが意味はありません)
Date and Time APIとしても、このようなイレギュラーなシステムプロパティの指定を考慮している訳ではないはずです。そのため、基本的には、正式に元号が発表された後の修正リリースを待ったほうがよいでしょうが、どうしても正式対応していない(かつJava 11以降の)リリースを使いたい場合には、推奨できませんが、応急処置的に利用することは可能かもしれません。

補足

元年表記について

先の実行例では"和平1年"のように出力されていました。ただ漢字で出力する際は"和平元年"のように出力したい場合も多いと思います。
この場合、旧来のjava.text.DateFormatではFULLスタイルを指定した日付フォーマッタを利用することで実現できます。

FirstYearTest.java
import java.text.*;
import java.util.Calendar;
import java.util.Locale;

public class FirstYearTest {
    public static void main(String... args) throws ParseException {
        DateFormat fullFormat = DateFormat.getDateInstance(DateFormat.FULL, new Locale("ja", "JP", "JP"));
        Calendar cal = Calendar.getInstance();
        cal.clear();
        cal.set(2019, Calendar.MAY, 1, 0, 0, 0);
        System.out.println(fullFormat.format(cal.getTime()));
        fullFormat.parse("平成元年1月8日"); // 解析も問題ないが平成を便宜的に利用
    }
}

上記を実行すると "和平元年5月1日" のように出力されます。
(Java 11以降は先に示した実行例と同様に、現状はプレースホルダが優先され "元号元年5月1日" となります。)
ちなみにSHORTスタイルなどを指定すると "W1.05.01" と年が1桁となるため、(JIS X 0301表記)とは異なります。

(1/13 見直し)
なお、Java 8からのDate and Time APIでもFULLスタイルを指定することが可能なのですが、残念ながら現状は"元年"に対応しておらず、またバージョンによって出力もまちまちです。JDK-8068571により"元年"には対応しておらず、代わりにDateTimeFormatterBuilderで対応するとのことです。
ただし、整形はJava 11以降解析はJava 12以降での対応となります。

FirstYear8Test.java
import java.util.*;
import java.time.format.*;
import java.time.chrono.*;
import java.time.LocalDate;
import java.time.temporal.ChronoField;

public class FirstYear8Test {
    public static void main(String... args) {
        DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
        builder.appendText(ChronoField.ERA, TextStyle.FULL);
        builder.appendText(ChronoField.YEAR_OF_ERA, Collections.singletonMap(1L, "元"));
        builder.appendLiteral("年");
        builder.appendValue(ChronoField.MONTH_OF_YEAR);
        builder.appendLiteral("月");
        builder.appendValue(ChronoField.DAY_OF_MONTH);
        builder.appendLiteral("日");
        DateTimeFormatter formatter = builder.toFormatter(Locale.JAPAN)
                .withChronology(JapaneseChronology.INSTANCE);

        JapaneseDate firstNewEraDate = JapaneseDate.of(2019, 5, 1);
        System.out.println(firstNewEraDate.format(formatter));
        formatter.parse("平成元年1月8日"); // Java 11までは例外発生
    }
}

このため、現状はJava 11以降のプレースホルダ実装により "元号元年5月1日" のように出力されます。
それより前のバージョンでは、Date and Time APIだけでは"元年"に対応できないため、"和平1年5月1日" のようなパターンから、正規表現などを利用して置換したほうが楽だとは思います。

まとめ

表にまとめると次のようになります。(1/15見直し)

バージョン java.util.Date/Calendar Date and Time API 共通の注意点
6 ○: OSによっては漢字が文字化け N/A (非対応) calendars.properties要修正
7 N/A (非対応) calendars.properties要修正
8 △: 最新バージョンを利用すれば○ (元年表記は別途実装) calendars.properties要修正
9 ◎: java.locale.providers指定 ○: 漢字表記は"GGGG"とパターン指定、元年表記は独自実装 jdk.calendar.japanese.supplemental.era要指定
10 ◎: java.locale.providers指定 ○: 元年表記は別途実装 jdk.calendar.japanese.supplemental.era要指定
11以降 △: java.locale.providers指定 △: 元年表記に対応 プレースホルダ実装であり、改元後リリースにバージョンアップすれば◎(のはず)

さいごに

ご利用のJDK/JREを改元後の最新版にバージョンアップいただくのが本筋ですが、Oracle JDK/JRE 8を利用している場合、商用ユーザーに対するPublic Updatesは2019年1月で終了する予定となっています。そのため、Oracle Java SE Subscriptionや、別のJDKディストリビューションへの切替が必要になるケースもあると想定されます。
もっと早く(遅くても2019年始に)新元号が発表されれば、2019年1月のPublic Updatesに正式な改元対応が入ったかもしれず、この記事を書く必要もなかったかもしれません。
Javaには限りませんが、和暦を扱っているシステムの改元対応が無事に完了することを願っております。