低分配 JSON 序列化:java.sql.Time、java.sql.Date、java.sql.Timestamp
可以用公开的 getTime() 读取毫秒值,再通过整数运算拆出日期和时间,直接写入现有缓冲区,进一步减少分配。这样就能省去提取分量时使用的 Calendar 和中间字符串。
更新后的代码需要先修正三处问题:
Date、Timestamp从 UTC 改成了默认时区,改变了最初的输出语义。Timestamp只把毫秒值传给writeSqlDate(),会丢掉不足一毫秒的纳秒部分。- 直接读取
fastTime不保证与getTime()等价,不能作为通用替换。
先固定时区和输出约定
按最初代码的行为,各类型的输出约定如下:
| 类型 | 提取分量使用的时区 | 小数秒 |
|---|---|---|
java.sql.Time | 默认时区 | 不输出 |
java.sql.Date | UTC | 不输出 |
java.sql.Timestamp | UTC | 使用 getNanos() |
这是当前序列化器的约定。java.sql.Date 在 SQL 语义中表示日期,将它输出为带 T、时间和 Z 的字符串,是额外的格式选择。
Jackson 的输出取决于版本、实际使用的序列化器和 ObjectMapper 配置。不同 SQL 类型可能走不同的序列化路径,不能凭一次对比就认定上述时区组合是统一的默认规则。需要兼容 Jackson 时,应先固定这些配置,再逐项比较输出。
Z 表示 UTC。如果已经加上本地时区偏移,结尾就不能继续写 Z。
用整数运算拆分毫秒
一天的毫秒数为:
private static final long MILLIS_PER_DAY = 86_400_000L;
UTC 日期和日内毫秒数可以这样计算:
long epochDay = Math.floorDiv(epochMillis, MILLIS_PER_DAY);
int millisOfDay =
(int) Math.floorMod(epochMillis, MILLIS_PER_DAY);
这里必须使用 floorDiv()、floorMod(),因为 Java 的整数除法向零截断。例如,epochMillis == -1 应当属于前一天,日内时间为 23:59:59.999。
计算本地时间时,还需要加上该时刻的时区偏移。直接计算下面的表达式,可能在接近 long 边界时溢出:
epochMillis + offset
先取日内余数,再加偏移,就能避开这个问题:
long localMillisOfDay =
Math.floorMod(epochMillis, MILLIS_PER_DAY) + offset;
long localEpochDay =
Math.floorDiv(epochMillis, MILLIS_PER_DAY)
+ Math.floorDiv(localMillisOfDay, MILLIS_PER_DAY);
localMillisOfDay =
Math.floorMod(localMillisOfDay, MILLIS_PER_DAY);
如果只需要 Time 的时、分、秒,就不必计算 localEpochDay。
一个保留原始时区约定的实现
下面的代码可以放进现有 serializer,沿用原来的输出钩子。日期算法在 UTC 下采用默认 GregorianCalendar 的混合历法:1582-10-15 之前使用 Julian calendar,此后使用 Gregorian calendar。
private static final long MILLIS_PER_DAY = 86_400_000L;
private static final long GREGORIAN_CUTOVER_EPOCH_DAY = -141_427L;
// 在初始化时保存默认时区。
private final TimeZone localTimeZone = TimeZone.getDefault();
public void write(final java.sql.Time time) {
final long millis = time.getTime();
final int offset = localTimeZone.getOffset(millis);
final int millisOfDay = (int) Math.floorMod(
Math.floorMod(millis, MILLIS_PER_DAY) + offset,
MILLIS_PER_DAY
);
final int secondsOfDay = millisOfDay / 1_000;
final int hour = secondsOfDay / 3_600;
final int minute = (secondsOfDay / 60) % 60;
final int second = secondsOfDay % 60;
appendByte('"');
writeTime(hour, minute, second, 0);
appendByte('"');
}
public void write(final java.sql.Date date) {
writeUtc(date.getTime(), 0);
}
public void write(final java.sql.Timestamp timestamp) {
final long millis = timestamp.getTime();
final int nanos = timestamp.getNanos();
writeUtc(millis, nanos);
}
private void writeUtc(final long epochMillis, final int nanos) {
final long epochDay =
Math.floorDiv(epochMillis, MILLIS_PER_DAY);
final int millisOfDay =
(int) Math.floorMod(epochMillis, MILLIS_PER_DAY);
final int year;
final int month;
final int day;
if (epochDay < GREGORIAN_CUTOVER_EPOCH_DAY) {
// Julian calendar
final long jdn = epochDay + 2_440_588L;
final long c = jdn + 32_082L;
final long d = Math.floorDiv(4L * c + 3L, 1_461L);
final long e = c - Math.floorDiv(1_461L * d, 4L);
final long m = Math.floorDiv(5L * e + 2L, 153L);
day = (int) (
e - Math.floorDiv(153L * m + 2L, 5L) + 1L
);
month = (int) (
m + 3L - 12L * Math.floorDiv(m, 10L)
);
year = (int) (
d - 4_800L + Math.floorDiv(m, 10L)
);
} else {
// Gregorian calendar,按 400 年周期分解。
final long z = epochDay + 719_468L;
final long era = Math.floorDiv(z, 146_097L);
final long dayOfEra = z - era * 146_097L;
final long yearOfEra = (
dayOfEra
- dayOfEra / 1_460L
+ dayOfEra / 36_524L
- dayOfEra / 146_096L
) / 365L;
final long y = yearOfEra + era * 400L;
final long dayOfYear = dayOfEra - (
365L * yearOfEra
+ yearOfEra / 4L
- yearOfEra / 100L
);
final long m = (5L * dayOfYear + 2L) / 153L;
day = (int) (
dayOfYear - (153L * m + 2L) / 5L + 1L
);
month = (int) (m + (m < 10L ? 3L : -9L));
year = (int) (y + (month <= 2 ? 1L : 0L));
}
final int secondsOfDay = millisOfDay / 1_000;
final int hour = secondsOfDay / 3_600;
final int minute = (secondsOfDay / 60) % 60;
final int second = secondsOfDay % 60;
appendByte('"');
writeDate(year, month, day);
appendByte('T');
writeTime(hour, minute, second, nanos);
appendByte('Z');
appendByte('"');
}
分量计算只使用基本类型局部变量,没有显式创建临时对象。整个调用能否做到零分配,还取决于 getTime()、具体的 TimeZone 实现和输出缓冲区。
示例中的年份采用天文编号:1 表示公元 1 年,0 表示公元前 1 年。原代码只读取 Calendar.YEAR,没有读取 Calendar.ERA,无法区分同一年号的公元前后。若要支持这些日期,必须明确 writeDate() 的年份格式;超过四位数的年份也需要单独处理。
如果业务采用 ISO 前推 Gregorian calendar,所有日期都应使用 Gregorian 分支。它与默认 GregorianCalendar 在切换日前的计算结果不同,两套规则不能混用。
Timestamp 的纳秒必须单独保留
Timestamp.getTime() 返回毫秒精度的值,getNanos() 返回当前秒内的纳秒值,范围为 0 到 999_999_999。
例如,小数秒为:
.123456789
仅靠毫秒值只能恢复到:
.123
毫秒值用于确定日期和时、分、秒,getNanos() 提供完整的小数秒。不要把 getNanos() 再加到 getTime() 上,因为后者已经包含纳秒字段折算出的毫秒部分。
代码假定 writeTime() 最后一个参数的单位是纳秒。是否保留尾随零、值为零时是否省略小数点,都应按输出约定处理。
为什么不建议直接读取 fastTime
fastTime 是 java.util.Date 的私有实现字段。部分 OpenJDK 实现还会维护日期字段缓存。旧式 setter 修改这些字段后,归一化可能延迟到后续访问时才执行。此时,直接读取 fastTime 可能拿到尚未同步的值,而 getTime() 会执行必要的处理。
因此,以下两个表达式不能无条件互换:
date.getTime()
(long) DATE_FASTTIME.get(date)
Timestamp 还维护了额外的 nanos 字段,只读父类字段也不能直接得到它的完整毫秒值。
使用 VarHandle 访问该字段,通常还需要开放模块:
--add-opens=java.base/java.util=ALL-UNNAMED
具名模块需要把 ALL-UNNAMED 换成调用方模块名。这只能解决访问权限问题,不能保证字段语义与公开 API 等价。
如果确实要采用这种方式,必须限定 JDK 实现、对象的构造及修改方式,并验证相关不变量。一般序列化器保留 getTime() 更容易维护。getTime() 也并非每次调用都会创建日历对象,是否分配取决于实现和对象当前的状态。
时区查询和分配量应分别验证
UTC 路径的偏移固定为零,可以省去 TimeZone.getOffset()。本地 Time 路径仍需查询实际时刻的偏移,因为夏令时和历史规则可能改变结果,getRawOffset() 无法替代它。
getOffset() 是否分配对象,取决于具体实现和执行路径,不能一概认定它必然分配或完全无法优化。如果它仍是主要开销,应在目标 JDK 和实际使用的时区上继续分析。
保存 TimeZone.getDefault() 得到的是初始化时的时区设置。运行期间如果调用 TimeZone.setDefault(),已保存的实例不会自动跟随变化。这与复用最初创建的本地 Calendar 类似,需要明确保留这一约定。
验证时,优先覆盖以下情况:
-1、0等跨越 Epoch 的值,以及跨日、闰日和历法切换边界。- 夏令时切换前后、非整小时偏移,以及实际使用到的历史日期。
Timestamp的1、999_999、123_456_789等纳秒值。- 年份格式边界,以及支持范围内的极端毫秒值。
性能比较应同时记录耗时和每次操作的分配量。可以用 JMH 的 -prof gc 比较复用 Calendar 与整数算法,再用 JFR 定位剩余的分配。toLocalDate() 等方法产生的对象是否最终进入堆,也应以目标 JDK 的测量结果为准,不能只凭源码中的构造调用判断。
备注:内容仅供参考。
