Old-School Backend / 2026 ・ No.02
古法后端入门
第2讲 Java 准备 ── 正确地存放钱、日期和时刻
发布
第 1 讲的作业是:“申请日期是 26 日,可回复的 Date 是 25 日,日志是 15 点多,数据库是 0 点多。哪个是对的?”
这一讲是 Java 的准备。以业务系统里最容易出事故的钱和日期、时刻为中心,再加上 null、record、列表、字符串这些“不知道就会白白浪费一天”的地方,用 jshell 和小程序逐一实测。
对答案之前
新井作业的答案是“全都对”,没错吧?
古井对。不过说不清为什么对,总有一天你会开出差了 9 小时的账单。
新井9 小时……
古井还有差了 1 日元的账单。今天从正确地存放钱和时刻开始。
全 15 讲路线图(当前位置:第 2 讲)
- 钱 ── 用 double 算含税 1,100 日元的不含税金额得 999 日元。BigDecimal 的创建、比较、舍入(实测)
- 日期和时刻 ── 同一瞬间,日期也会因地方而变。JVM 和数据库的时区设置(实测)
- 旧工具的陷阱 ── 共用 SimpleDateFormat,8 万次中有 4,763 次日期出错(实测)
- null、record、字符串 ── 3 个字符的“交通費”是 9 字节(实测与 Wireshark)
实验用的是 JDK 25 的 jshell(可以一行一行试 Java 的工具),以及像 java Lesson2.java 这样直接运行源文件的小程序。数据库是第 1 讲的 PostgreSQL。
钱不要用 double
在 Java 里处理小数,最先学到的类型是 double。用它来算报销会怎样?试一试。
# 用 double(二进制小数)算钱的话
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.1 再舍去”求含税 1,100 日元的不含税金额,本该是 1,000 日元,却成了 999 日元。0.29 美元换成美分是 28 美分。对 1 日元到 10 万日元的所有含税金额做同样的计算,税率 10% 有 4,174 件、税率 8% 有 818 件差了 1 日元。
0.1 在二进制里除不尽
double 用二进制存数。就像十进制里 1/3 除不尽一样,二进制里 0.1 也除不尽,存的是在 52 位截断后“最接近的值”。平时显示时会被舍入看不出来,但加减乘除多了就会露出来。对科学计算来说小到可以忽略,可钱差 1 日元都不行。
BigDecimal 的规矩
钱要用 BigDecimal。它按十进制存数,位数要多少有多少。不过用法有规矩。
# 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 讲从数据库返回的金额是 1280.00,和页面发来的 1280 用 equals 比,就不一致
- 显示用 toPlainString ── 除法的结果有时会变成 1.00E+3 这样的指数形式
新井把 1280 和 1280.00 放进 HashSet 是 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 日元。哪个对不是技术问题,而是规则问题,按公司规定或与客户的约定来定。定了之后,就集中写在代码的一处。
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
世界上唯一的瞬间,用各地的时钟来读
时刻是世界上唯一的瞬间和用哪里的时钟读的组合。同一瞬间,在东京是 26 日 0 点 47 分,在 UTC 是 25 日 15 点 47 分。HTTP 的 Date 按规定是 GMT(UTC);第 1 讲的申请服务器在没有时区设置的状态下启动,所以是 UTC;数据库创建时的设置是东京,所以是东京的时钟。全都指向同一瞬间。
可怕的是 LocalDate.now() 这种“按这个 JVM 的时钟,今天几号?”的写法。在同一瞬间,用只有时区设置不同的两个 JVM 来问。
# 在同一瞬间,用只有时区设置不同的两个 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 接收,不能由服务器的“今天”来决定。
把时刻存进数据库
把时刻存进数据库也有同样的陷阱。PostgreSQL 有不带时差的 timestamp 类型,和作为瞬间存放的 timestamptz 类型。在同一瞬间,分别从 UTC 的 JVM 和东京的 JVM 各写一行。
# 在同一瞬间,UTC 的 JVM 和 Asia/Tokyo 的 JVM 各写一行
# 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 类型与数据库类型的对应
记录的时刻(创建时刻、批准时刻)用 Instant 或 OffsetDateTime,数据库用 timestamptz。申请日这种日历日期用 LocalDate 和 date。服务器的时区在启动时定好(比如 -Duser.timezone=UTC),给用户看时再换成用户所在地的时钟。
古老的 Date 和 SimpleDateFormat
旧代码里,日期常用 java.util.Date,格式常用 SimpleDateFormat。现场的旧系统里,一定也有。这两个有著名的陷阱。让 8 个线程共用一个 SimpleDateFormat,各读写日期 1 万次。
# 8 个线程共用一个格式对象,各读写日期 1 万次(次数每次运行都不同)
SimpleDateFormat → 日期不对 4763 次,异常 590 次
DateTimeFormatter → 日期不对 0 次,异常 0 次
8 万次里有 4,763 次变成了别的日期,590 次因异常停下(次数每次运行都不同)。SimpleDateFormat 内部有工作用的状态,同时使用就会互相改写。正如第 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()”。以前只给行号,光这一点就能大大减少排查时间。
返回“可能没有”的结果时,用 Optional 包起来代替 null,接收方就不容易忘写“没有时”的处理。按受理编号查申请时(第 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 讲的记录,发往数据库的确实是 9 字节。PostgreSQL 的 varchar(100) 按字符数计,但也有按字节计的系统,还可能和页面输入框的字数对不上。表情符号“🚃”在 Java 的 length() 里是 2,作为字符是 1 个。
全角的“1280”被 BigDecimal 直接读成了 1280。很体贴,但输入检查若没考虑到,意想不到的值就会通过(第 6 讲)。最后两行是去掉环境变量 LANG 运行同一程序的结果,日语变成了“?”。服务器出现乱码,先怀疑 LANG 和字符编码的设置。
在自己的电脑上确认
| 想试的事 | 命令 |
|---|---|
| 启动 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 是两回事。
- 舍入方式自己选,作为规则集中在一处。除不尽的除法会停下。
- 时刻是瞬间+哪里的时钟。作业里的 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