Old-School Backend / 2026 ・ No.02

いにしえのバックエンド入門

第2回 Java の準備 ── お金と日付と時刻を、正しく持つ

公開

端末の画面。jshell で、0.1 + 0.2 が 0.30000000000000004、1100 / 1.1 が 999.9999999999999、Math.floor(1100 / 1.1) が 999.0。BigDecimal の 1100 割る 1.1 は 1000、new BigDecimal(0.1) は 0.1000000000000000055511151231257827021181583404541015625、BigDecimal の 1280 と 1280.00 の equals は false
jshell(Java を1行ずつ試せる道具)で、double と BigDecimal を比べたところ。税込 1,100 円の税抜きを double で計算して切り捨てると 999 円。BigDecimal なら 1,000 円です(筆者がクラウド上の Linux で、2026年10月26日に撮影。ウインドウの上の帯は説明のために描き足したもの)

第1回の宿題は「申請日は 26 日なのに、返事の Date は 25 日、ログは 15 時台、DB は 0 時台。どれが正しい?」でした。
今回は Java の準備です。業務システムでいちばん事故が多いお金と日付・時刻を中心に、null、record、リスト、文字列の「知らないと1日溶ける」ところを、jshell と小さなプログラムで1つずつ実測します。

一

宿題の答え合わせの前に

新井宿題の答え、「全部正しい」でいいんですよね?

古井正解だ。ただし、なぜ正しいのかを説明できないと、いつか 9 時間ずれた請求書を出す。

新井9 時間……。

古井それと、1 円ずれた請求書もだ。今日はお金と時刻を、正しく持つ方法から始める。

全15回の路線図(いまここ:第2回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
  • お金 ── double で税込 1,100 円の税抜きを出すと 999 円。BigDecimal の作り方、比べ方、丸め方(実測)
  • 日付と時刻 ── 同じ瞬間でも、土地によって日付が変わる。JVM と DB の時差の設定(実測)
  • 古い道具の落とし穴 ── SimpleDateFormat を共有すると、8万回中 4,763 回日付が化けた(実測)
  • null・record・文字列 ── 3文字の「交通費」は 9 バイト(実測と Wireshark)

実験は、JDK 25 の jshell(Java を1行ずつ試せる道具)と、java Lesson2.java のように、ソースファイルをそのまま実行する小さなプログラムで行いました。DB は第1回の PostgreSQL です。

二

お金に double を使わない

Java で小数を扱うとき、最初に覚える型は double です。経費の計算に使うとどうなるか、試しました。

# double(2進数の小数)で、お金を計算すると
    0.1 + 0.2                                    → 0.30000000000000004
    0.1 を10回足す                               → 0.9999999999999999
# 税込 1,100 円(10%)の税抜き金額は?
    1100 / 1.1                                   → 999.9999999999999
    Math.floor(1100 / 1.1)                       → 999.0
    Math.floor(1080 / 1.08)                      → 999.0
# 0.29 ドルをセントに
    (int) (0.29 * 100)                           → 28

0.1 + 0.2 が 0.3 になりません。税込 1,100 円の税抜き金額を「1.1 で割って切り捨て」で出すと、1,000 円のはずが 999 円。0.29 ドルをセントにすると 28 セントです。1 円から 10 万円までの税込金額で同じ計算をすると、税 10% では 4,174 件、税 8% では 818 件が 1 円ずれました。

0.1 は 2 進数で割り切れない

10 進数 1 / 3 = 0.3333333333… 「3」が永遠にくり返す 2 進数 0.1 = 0.0001100110011001100… 「0011」が永遠にくり返す double は 52 ビットで打ち切り、いちばん近い値を持つ 0.10000000000000000555… ずれは小さいが、足したり割ったりするうちに表に出てくる 1円のずれが許されないお金には、10進数のまま数える BigDecimal を使う

double は数を 2 進数で持ちます。10 進数で 1/3 が割り切れないように、2 進数では 0.1 が割り切れず、52 ビットで打ち切った「いちばん近い値」になっています。普段は表示のときに丸められて見えませんが、足し算や割り算を重ねると顔を出します。科学計算なら問題にならない小ささでも、お金は 1 円のずれが許されません。

三

BigDecimal の作法

お金には BigDecimal を使います。10 進数のまま、桁を必要なだけ持てる型です。ただし、使い方に作法があります。

# BigDecimal の作り方
    new BigDecimal(0.1)                          → 0.1000000000000000055511151231257827021181583404541015625
    new BigDecimal("0.1")                        → 0.1
    BigDecimal.valueOf(0.1)                      → 0.1
# 計算
    0.1 + 0.2                                    → 0.3
    1100 / 1.1                                   → 1.00E+3
    (1100 / 1.1).toPlainString()                 → 1000
# 1280 と 1280.00 は同じ?
    a.equals(b)                                  → false
    a.compareTo(b)                               → 0
    new HashSet<>(List.of(a, b)).size()          → 2 個
  • 文字列から作る ── new BigDecimal(0.1) は、double の「いちばん近い値」をそのまま写すので、長い尻尾がつく。new BigDecimal("0.1") か BigDecimal.valueOf(0.1) を使う
  • 比べるのは compareTo ── equals は桁数(scale)まで比べるので、1280 と 1280.00 は「違う」。第1回で DB から返ってきた金額は 1280.00 だった。画面から来た 1280 と equals で比べると、一致しない
  • 表示は toPlainString ── 割り算の結果が 1.00E+3 のような指数の形になることがある

新井HashSet に 1280 と 1280.00 を入れたら 2 個……。重複チェックが効かないってことですか。

古井そうだ。金額をキーにするなら、先に stripTrailingZeros() か setScale で桁をそろえる。いにしえの帳簿でも、桁をそろえてから突き合わせた。

四

端数をどう丸めるか

お金の計算には、必ず端数が出ます。BigDecimal は、丸め方を自分で選ぶようにできています。

# 税 8%:1,285 円 × 0.08
    tax = 1285 × 0.08                            → 102.80
    tax.setScale(0, RoundingMode.DOWN)           → 102
    tax.setScale(0, RoundingMode.HALF_UP)        → 103
# 端数の丸め方:HALF_UP / HALF_EVEN / DOWN / FLOOR
    2.5                                          → 3 / 2 / 2 / 2
    3.5                                          → 4 / 4 / 3 / 3
    -2.5                                         → -3 / -2 / -2 / -3
# 割り勘:10,000 円を3人で
    10000 / 3                                    → ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
    divide(3, 0, RoundingMode.DOWN)              → 3333
    divideAndRemainder(3)                        → [3333, 1]
# 合計(stream で)
    1280 + 3300 + 880.50                         → 5460.50

同じ数でも、丸め方で結果が変わる(実測)

丸め方 2.5 3.5 -2.5 HALF_UP いわゆる四捨五入 3 4 -3 HALF_EVEN ちょうど半分なら偶数へ 2 4 -2 DOWN 0 に向かって切り捨て 2 3 -2 FLOOR 小さい方へ切り捨て 2 3 -3 赤は四捨五入と違う結果。マイナスで DOWN と FLOOR が分かれる どれを使うかは技術ではなく決まりの問題。税の端数は社内の規程や請求書の単位で決める BigDecimal は丸め方を書かないと割り算で止まる(10000 / 3 は例外)

1,285 円の 8% は 102.80 円。切り捨てなら 102 円、四捨五入なら 103 円です。どちらが正しいかは技術ではなく決まりの問題で、社内の規程や取引先との約束で決めます。決めたら、コードの1か所にまとめておきます。

10,000 円を3人で割り勘にすると、BigDecimal は「割り切れない」と例外を投げて止まりました。黙って丸めないのは、むしろ親切です。整数に切り捨てるよう指示すると 3,333 円、divideAndRemainder なら余りの 1 円も出ます。その 1 円をだれが払うかも、業務の決まりです。

五

日付と時刻:どこの時計か

宿題の答え合わせです。第1回の返事の Date ヘッダーと同じ瞬間を、4つの土地の時計で表しました。

# 第1回の返事の Date と同じ瞬間(Instant)を、4つの土地の時刻で
    Instant                                      → 2026-10-25T15:47:43Z
    UTC                                          → 2026-10-25T15:47:43 Z  日付 2026-10-25
    Asia/Tokyo                                   → 2026-10-26T00:47:43 +09:00  日付 2026-10-26
    Asia/Shanghai                                → 2026-10-25T23:47:43 +08:00  日付 2026-10-25
    America/New_York                             → 2026-10-25T11:47:43 -04:00  日付 2026-10-25
    HTTP Date                                    → Sun, 25 Oct 2026 15:47:43 GMT

世界で1つの瞬間を、土地ごとの時計で読む

世界で1つの瞬間(Instant) 2026-10-25T15:47:43Z UTC 15:47 10-25 Asia/Shanghai 23:47 10-25 Asia/Tokyo 00:47 10-26 America/New_York 11:47 10-25 時刻は「瞬間」と「土地の時計」の組。どれも同じ瞬間で、日付まで違う 第1回の宿題:返事の Date とアプリのログは UTC、DB のログは東京。全部正しい 申請日のように「その土地の暦の1日」は LocalDate、記録の時刻は瞬間で持つ

時刻は、世界で1つの瞬間と、どこの時計で読むかの組です。同じ瞬間が、東京では 26 日の 0 時 47 分、UTC では 25 日の 15 時 47 分。HTTP の Date は決まりで GMT(UTC)、第1回の申請サーバーは時差の設定がない状態で起動したので UTC、DB は作ったときの設定が東京だったので東京の時計。全部同じ瞬間を指していました。

怖いのは、LocalDate.now() のように「この JVM の時計で今日は何日?」と聞く書き方です。同じ瞬間に、時差の設定だけ違う2つの JVM で聞きました。

# 同じ瞬間に、時差の設定だけ違う2つの JVM で「いま」を聞く
# JVM の時差の設定が UTC
    LocalDate.now()          → 2026-10-25
    LocalDateTime.now()      → 2026-10-25T16:05:59
    Instant.now()            → 2026-10-25T16:05:59Z
# JVM の時差の設定が Asia/Tokyo
    LocalDate.now()          → 2026-10-26
    LocalDateTime.now()      → 2026-10-26T01:06
    Instant.now()            → 2026-10-25T16:06:00Z

片方は 25 日、片方は 26 日。サーバーの設定しだいで、深夜 0 時から 9 時の間に出した申請の「申請日」が前日になります。申請日のように、利用者がカレンダーで選ぶ日付は、画面から LocalDate として受け取るのが基本で、サーバーの「今日」で決めてはいけません。

六

DB に時刻をしまう

DB に時刻をしまうときも同じ落とし穴があります。PostgreSQL には、時差を持たない timestamp 型と、瞬間として持つ timestamptz 型があります。同じ瞬間に、UTC の JVM と東京の JVM から1行ずつ書きました。

# 同じ瞬間に、UTC の JVM と Asia/Tokyo の JVM が1行ずつ書く
# wall=timestamp 型に LocalDateTime、moment=timestamptz 型に OffsetDateTime
ps.setObject(2, LocalDateTime.now());   // wall
ps.setObject(3, OffsetDateTime.now());  // moment
# 読み返す(どちらの JVM で読んでも同じ結果)
    Asia/Tokyo  wall=2026-10-26T01:06:41  moment=2026-10-25T16:06:41Z
    UTC         wall=2026-10-25T16:06:41  moment=2026-10-25T16:06:41Z
端末の画面。psql で clock 表を2回表示。jvm 列が Asia/Tokyo の行は wall が 2026-10-26 01:06:41、UTC の行は 2026-10-25 16:06:41。moment 列は、1回目はどちらも 2026-10-26 01:06:41+09、PGTZ=UTC を付けた2回目はどちらも 2026-10-25 16:06:41+00
同じ瞬間に、時差の設定が違う2つの JVM が「いま」を書いた表。timestamp 型の wall は 9 時間ずれていますが、timestamptz 型の moment は同じ瞬間で、表示する土地を変えると時計の数字だけが変わります(筆者がクラウド上の Linux で、2026年10月26日に撮影。ウインドウの上の帯は説明のために描き足したもの)

LocalDateTime を timestamp にしまった wall は、同じ瞬間なのに 9 時間ずれました。中に入っているのは「壁の時計の数字」だけで、どこの時計かを捨ててしまったからです。timestamptz にしまった moment は、どちらも同じ瞬間を指し、表示する土地を変えると数字の見え方だけが変わります。

Java の型と DB の型の組み合わせ

Java の型 持っているもの PostgreSQL の型 Instant 世界で1つの瞬間 timestamptz OffsetDateTime 瞬間+その土地の時差 timestamptz LocalDate 暦の日付だけ(申請日) date LocalDateTime 壁の時計の数字だけ timestamp 記録の時刻は「瞬間」で持つ。LocalDateTime には、どこの時計かが入っていない 実測:同じ瞬間に書いた timestamp は 9 時間ずれ、timestamptz はぴたり同じ(写真) サーバーの時差は起動時に決めておく(例:-Duser.timezone=UTC)。表示は利用者の土地で
POINT

記録の時刻(作った日時、承認した日時)は Instant か OffsetDateTime で持ち、DB は timestamptz。申請日のような暦の日付は LocalDate と date。サーバーの時差は起動時に決めて(たとえば -Duser.timezone=UTC)、利用者に見せるときに利用者の土地の時計に直します。

七

いにしえの Date と SimpleDateFormat

古いコードでは、日付に java.util.Date、書式に SimpleDateFormat を使っていることがよくあります。現場の古いシステムにも、きっといます。この2つには有名な落とし穴があります。1つの SimpleDateFormat を8つのスレッドで共有して、日付を1万回ずつ読み書きしました。

# 1つの書式を8スレッドで共有して、1万回ずつ日付を読み書き(回によって数は変わる)
    SimpleDateFormat     → 違う日付 4763 回、例外 590 回
    DateTimeFormatter    → 違う日付 0 回、例外 0 回

8 万回のうち 4,763 回は別の日付になり、590 回は例外で止まりました(回数は動かすたびに変わります)。SimpleDateFormat は中に作業用の状態を持っていて、同時に使うと互いに書きかえてしまうのです。第1回で見たとおり、サーバーは1件に1本のスレッドで同時に動くので、これは「たまにおかしい日付が入る」という、いちばん見つけにくい不具合になります。Java 8 からの java.time(LocalDate、DateTimeFormatter など)は、作ったら変わらない(イミュータブル)ので、共有しても 0 回でした。

新井「いにしえ」の連載なのに、古いのを使うなって言うんですね。

古井いにしえの職人は、道具の手入れを怠らない。古い道具の癖を知ったうえで、新しい道具に替えるのも腕のうちだ。

八

null と Optional

Java の変数は、「何も入っていない」という意味の null を持てます。null のまま使うと、NullPointerException で止まります。

# 品目が null の申請
    r.item().length()  → NullPointerException: Cannot invoke "String.length()" because the return value of "ExpenseRequest.item()" is null
# ない受付番号を引く
    status.get("EXP-2026-9999") → null
    Optional.ofNullable(…).orElse("見つからない") → 見つからない

いまの Java は、どこが null だったかを文で教えてくれます。「ExpenseRequest.item() の戻り値が null だったので、String.length() を呼べない」。昔は行番号しか出なかったので、これだけでも調べる時間が大きく減ります。

「ないかもしれない」結果を返すときは、null の代わりに Optional で包むと、受け取る側が「ないとき」を書き忘れにくくなります。受付番号で申請を探す処理(第8回)で使います。そして、入ってきた値が null でないかを最初に確かめるのが、第6回の入力チェックです。

九

record・リスト・文字列

第1回の ExpenseRequest は record で書きました。中身を入れて作ったら変えられない、値を運ぶための型です。

# record:中身で比べる
    x                              → ExpenseRequest[date=2026-10-26, item=交通費, amount=1280]
    x.equals(y)                    → true
    x == y                         → false
    x.equals(z)  // z は 1280.00   → false
# List.of で作ったリストは、あとから変えられない
    List.of("交通費", "会議費").add("宿泊費") → UnsupportedOperationException: null
# 文字列は == ではなく equals で
    "交通費" == new String("交通費") → false
    "交通費".equals(new String("交通費")) → true

record は中身で比べるので、同じ中身なら equals が true になります。ただし金額が 1280 と 1280.00 なら、BigDecimal の equals に従って false。== は「同じ物か」を比べるので、中身が同じでも false です。文字列も同じで、文字列どうしは必ず equals で比べます。List.of で作ったリストは変えられず、足そうとすると例外になります。

# 文字の数とバイトの数
    "交通費".length()              → 3
    "交通費".getBytes(UTF_8).length → 9
    "🚃".length()                  → 2
    "🚃".codePointCount(…)         → 1
    "🚃".getBytes(UTF_8).length    → 4
# 数字に見える文字列
    Integer.parseInt("1,280")      → NumberFormatException: For input string: "1,280"
    new BigDecimal("1280")     → 1280
# 環境変数 LANG を消して同じプログラムを動かすと
pc$ env -u LANG java Lesson2.java text
    "???".length() = 3
    "???" ? UTF-8 ???? = 9
Wireshark の画面。第1回の記録の13番で、PostgreSQL(Type: Bind)の Parameter values を開いたところ。Column length: 13、10、9、10 と並び、3つ目の Data: e4baa4e9809ae8b2bb が選ばれている。下の帯に Parameter data (pgsql.val.data), 9 byte(s)
第1回の記録をもう一度開いて、品目「交通費」が DB へ送られたときのバイトを見たところ。Java では3文字の「交通費」が、線の上では UTF-8 の 9 バイトです(筆者がクラウド上の Linux で、2026年10月26日に撮影。ウインドウの上の帯は説明のために描き足したもの)

「交通費」は Java では 3 文字、UTF-8 では 9 バイトです。第1回の記録をもう一度開くと、DB へは確かに 9 バイトで送られていました。PostgreSQL の varchar(100) は文字の数で数えますが、バイトで数える仕組みや、画面の入力欄の文字数とずれることもあります。絵文字の「🚃」は Java の length() では 2、文字としては 1 つです。

全角の「1280」は、BigDecimal がそのまま 1280 として読みました。親切ですが、入力チェックで想定していないと、思わぬ値が通ります(第6回)。最後の2行は、同じプログラムを環境変数 LANG なしで動かしたもので、日本語が「?」に化けました。サーバーの文字化けは、まず LANG と文字コードの設定を疑います。

十

自分の PC で確かめる

試したいことコマンド
jshell を起動する(終わるときは /exit)jshell
double のずれMath.floor(1100 / 1.1)
BigDecimal の比べ方new BigDecimal("1280").compareTo(new BigDecimal("1280.00"))
丸め方new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN)
同じ瞬間を別の土地でInstant.now().atZone(ZoneId.of("Asia/Shanghai"))
JVM の時差の設定java.time.ZoneId.systemDefault()
時差を決めて起動するjshell -R-Duser.timezone=UTC
文字の数とバイトの数"交通費".getBytes("UTF-8").length

JDK 25 の jshell は、最初から java.base モジュールをまるごと読みこんでいる(/imports で確かめられます)ので、BigDecimal も Instant も import なしで書けます。

結 — 一枚まとめと、次回

  1. お金に double を使わない。税込 1,100 円の税抜きが 999 円になる。
  2. BigDecimal は文字列から作り、compareTo で比べる。1280 と 1280.00 は equals では別物。
  3. 丸め方は自分で選び、決まりとして1か所にまとめる。割り切れない割り算は止まる。
  4. 時刻は瞬間+どこの時計か。宿題の4つの時刻は、全部同じ瞬間だった。
  5. 記録の時刻は Instant と timestamptz、暦の日付は LocalDate と date。サーバーの「今日」で申請日を決めない。
  6. SimpleDateFormat は共有しない。java.time へ。
  7. null は Optional と入力チェックで防ぐ。record は中身で比べる。「交通費」は 3 文字で 9 バイト。

新井準備はできました。次は Spring Boot ですか?

古井まだだ。次回は Spring を使わず、JDK だけで HTTP を受ける。第1回で Tomcat と Jackson がやってくれていたことを、素手でやってみる。

参考

記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、OpenJDK 25.0.4 の jshell と java、第1回の PostgreSQL 16 を使って 2026年10月26日に取ったものです。double の税抜き計算のずれの件数は、1 円から 100,000 円までの税込金額すべてについて、double で割って切り捨てた値と整数で計算した値を比べて数えました。SimpleDateFormat の回数は、動かすたびに変わります。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。

いにしえのバックエンド入門 第2回 — Java の準備 / 2026.10

← 記事一覧へ