Old-School Backend / 2026 ・ No.02
いにしえのバックエンド入門
第2回 Java の準備 ── お金と日付と時刻を、正しく持つ
公開
第1回の宿題は「申請日は 26 日なのに、返事の Date は 25 日、ログは 15 時台、DB は 0 時台。どれが正しい?」でした。
今回は Java の準備です。業務システムでいちばん事故が多いお金と日付・時刻を中心に、null、record、リスト、文字列の「知らないと1日溶ける」ところを、jshell と小さなプログラムで1つずつ実測します。
宿題の答え合わせの前に
新井宿題の答え、「全部正しい」でいいんですよね?
古井正解だ。ただし、なぜ正しいのかを説明できないと、いつか 9 時間ずれた請求書を出す。
新井9 時間……。
古井それと、1 円ずれた請求書もだ。今日はお金と時刻を、正しく持つ方法から始める。
全15回の路線図(いまここ:第2回)
- お金 ── 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 進数で割り切れない
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
同じ数でも、丸め方で結果が変わる(実測)
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つの瞬間と、どこの時計で読むかの組です。同じ瞬間が、東京では 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
LocalDateTime を timestamp にしまった wall は、同じ瞬間なのに 9 時間ずれました。中に入っているのは「壁の時計の数字」だけで、どこの時計かを捨ててしまったからです。timestamptz にしまった moment は、どちらも同じ瞬間を指し、表示する土地を変えると数字の見え方だけが変わります。
Java の型と DB の型の組み合わせ
記録の時刻(作った日時、承認した日時)は 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
「交通費」は 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 なしで書けます。
結 — 一枚まとめと、次回
- お金に double を使わない。税込 1,100 円の税抜きが 999 円になる。
- BigDecimal は文字列から作り、compareTo で比べる。1280 と 1280.00 は equals では別物。
- 丸め方は自分で選び、決まりとして1か所にまとめる。割り切れない割り算は止まる。
- 時刻は瞬間+どこの時計か。宿題の4つの時刻は、全部同じ瞬間だった。
- 記録の時刻は Instant と timestamptz、暦の日付は LocalDate と date。サーバーの「今日」で申請日を決めない。
- SimpleDateFormat は共有しない。java.time へ。
- null は Optional と入力チェックで防ぐ。record は中身で比べる。「交通費」は 3 文字で 9 バイト。
新井準備はできました。次は Spring Boot ですか?
古井まだだ。次回は Spring を使わず、JDK だけで HTTP を受ける。第1回で Tomcat と Jackson がやってくれていたことを、素手でやってみる。
参考
- Java SE 25 API — BigDecimal
- Java SE 25 API — RoundingMode
- Java SE 25 API — java.time
- Java SE 25 API — SimpleDateFormat(Synchronization)
- Java SE 25 — Introduction to JShell
- JEP 358 — Helpful NullPointerExceptions
- JEP 330 — Launch Single-File Source-Code Programs
- JEP 400 — UTF-8 by Default
- JEP 511 — Module Import Declarations
- PostgreSQL 16 — Date/Time Types
- RFC 9110 — HTTP Semantics(Date)
記録は、筆者がクラウド上の 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