PAYATHON 2026

低分配 JSON 序列化:java.sql.Time、java.sql.Date、java.sql.Timestamp

技术老齐

可以用公开的 getTime() 读取毫秒值,再通过整数运算拆出日期和时间,直接写入现有缓冲区,进一步减少分配。这样就能省去提取分量时使用的 Calendar 和中间字符串。

更新后的代码需要先修正三处问题:

  • Date、Timestamp 从 UTC 改成了默认时区,改变了最初的输出语义。
  • Timestamp 只把毫秒值传给 writeSqlDate(),会丢掉不足一毫秒的纳秒部分。
  • 直接读取 fastTime 不保证与 getTime() 等价,不能作为通用替换。

先固定时区和输出约定

按最初代码的行为,各类型的输出约定如下:

类型提取分量使用的时区小数秒
java.sql.Time默认时区不输出
java.sql.DateUTC不输出
java.sql.TimestampUTC使用 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 的测量结果为准,不能只凭源码中的构造调用判断。

备注:内容仅供参考。