Blog

Java 的 Optional、java.time 与 null 共处之道

Java 允许任何引用为 null,缺失的值可能在远离源头的地方让代码崩溃。Objects 和 Optional 让缺失变得看得见,java.time 则要你说清是哪个时刻、在哪座城市。

Java 的引用随时可能是 null,而类型本身不会告诉你什么时候该提防它。本文讲 JDK 为此提供的工具:Objects 里的辅助方法,用来表示“结果可能不存在”的 Optional,以及 Optional 在哪些地方反而让代码更糟。接着转到 java.time,在那里,缺了一个细节也会造成同一类 bug。日期、挂钟时间和时间线上的时刻是三回事,把它们混为一谈,就会得到一年出现两次的 bug。

下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。这些程序都不读取真实的时钟,也不读取你机器的时区,所以你会得到和我们一样的输出。

null 表示“没有对象”,崩溃却发生在后面

代码通过一个 null 引用调用方法或读取字段时,就会抛出 NullPointerException。麻烦在于,null 通常悄无声息地混进来,崩溃却发生在几行之后。键不存在时,Map.get 返回 null:

Map<String, String> settings = new HashMap<>();

void main() {
    settings.put("theme", "dark");
    IO.println(settings.get("theme").toUpperCase());
    IO.println(settings.get("font").toUpperCase());
}

输出后停止:

DARK
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because the return value of "java.util.Map.get(Object)" is null

这条消息就是讲值与引用的那一篇首次展示过的有帮助的 NullPointerException 信息。它准确点出了哪个表达式是 null,却没法告诉你这个键为什么不存在。这正是 null 真正的问题:它告诉你崩溃发生在哪里,而不是值在哪里丢失的。

用 Objects 检查 null

java.util.Objects 类里有一些小的静态方法,把对 null 的处理集中在一处,免得到处散落 if (x != null) 检查。下面是你最常用的三个:

record Account(String owner, String nickname) {
    Account {
        Objects.requireNonNull(owner, "owner is required");
        nickname = Objects.requireNonNullElse(nickname, owner);
    }
}

void main() {
    IO.println(new Account("Ana", "annie"));
    IO.println(new Account("Ben", null));

    try {
        new Account(null, "ghost");
    } catch (NullPointerException e) {
        IO.println("rejected: " + e.getMessage());
    }

    String typed = null;
    String stored = "Ana";
    IO.println(Objects.equals(typed, stored));
    IO.println(Objects.equals(null, null));
}

输出:

Account[owner=Ana, nickname=annie]
Account[owner=Ben, nickname=Ben]
rejected: owner is required
false
true
  • requireNonNull(value, message) 在构造器里当场抛出异常,而不是让一个为 null 的 owner 一路传下去,直到有代码在它上面调用方法。讲异常的那一篇说明了这条消息为什么重要。
  • requireNonNullElse(value, fallback) 返回这个值;值为 null 时返回备用值。Ben 没有昵称,所以用了他的名字。备用值本身不能是 null:requireNonNullElse(null, null) 会抛出异常。
  • Objects.equals(a, b) 在两者都是 null 时为 true,只有一个是 null 时为 false,其他情况调用 a.equals(b)。如果写成 typed.equals(stored),就会抛出异常,因为 typed 是 null。

Java 的类型系统没办法表达“这个 String 永远不是 null”。每种引用类型都允许 null,javac 也不检查。JSpecify 这类第三方库提供了 @Nullable 之类的注解,编辑器或构建里的工具会读取它们并发出警告。本系列只用 JDK,所以不用这些注解,但你会在真实的代码库里遇到它们。

Optional:表示“可能没有结果”的返回类型

Optional<T> 是一个小盒子,里面要么装着一个值,要么什么都没有。一个返回 Optional<User> 的方法,在签名里就说明了可能找不到用户,调用方就不会像忘记处理 null 那样忘记处理这种情况。

record User(String name, String email) {}

List<User> users = List.of(
        new User("ana", "ana@example.com"),
        new User("ben", null));

Optional<User> findUser(String name) {
    return users.stream()
            .filter(u -> u.name().equals(name))
            .findFirst();
}

void main() {
    IO.println(findUser("ana"));
    IO.println(findUser("zoe"));
    IO.println(findUser("zoe").isPresent());

    Optional<String> anaEmail = Optional.ofNullable(findUser("ana").orElseThrow().email());
    Optional<String> benEmail = Optional.ofNullable(findUser("ben").orElseThrow().email());
    IO.println(anaEmail);
    IO.println(benEmail);
    IO.println(Optional.empty().equals(benEmail));
}

输出:

Optional[User[name=ana, email=ana@example.com]]
Optional.empty
false
Optional[ana@example.com]
Optional.empty
true

讲流的那一篇提到过,findFirst 本来就返回 Optional。自己创建一个有三种方式:

  • Optional.of(value):你确定值不是 null 时用。
  • Optional.ofNullable(value):值可能是 null 时用。null 会变成空的 Optional,Ben 缺失的邮箱就是这样。
  • Optional.empty():表示什么都没有。

不带参数的 orElseThrow() 返回里面的值,没有值就抛出异常。我们对 Ana 和 Ben 用了它,因为我们知道他们存在。

orElse 即使用不上,也会先求值它的参数

orElse 和 orElseGet 都为空的 Optional 提供备用值,看起来可以互换。其实不行,因为 Java 在调用方法之前会先求值方法的参数:

String loadDefault() {
    IO.println("  loading the default name...");
    return "guest";
}

void main() {
    Optional<String> name = Optional.of("ana");

    IO.println("orElse:");
    IO.println(name.orElse(loadDefault()));

    IO.println("orElseGet:");
    IO.println(name.orElseGet(this::loadDefault));
}

输出:

orElse:
  loading the default name...
ana
orElseGet:
ana

两次 Optional 里都有值,所以两个备用值都没用上。但 orElse(loadDefault()) 先调用了 loadDefault(),好拿到一个参数传进去。orElseGet 接收的是一个 Supplier,也就是一个只在 Optional 为空时才调用的函数。如果备用值是 "guest" 这样的常量,用 orElse 没问题。如果它要读文件、查数据库或构建什么开销大的东西,就用 orElseGet。

向空的 Optional 要值

get() 返回 Optional 里的值,没有值时抛出异常。这样一来,它并不比一个你忘了写的 null 检查更安全:

void main() {
    Optional<String> email = Optional.empty();

    try {
        email.orElseThrow(() -> new IllegalStateException("ben has no email on file"));
    } catch (IllegalStateException e) {
        IO.println("caught: " + e.getMessage());
    }

    IO.println(email.get());
}

输出后停止:

caught: ben has no email on file
Exception in thread "main" java.util.NoSuchElementException: No value present

orElseThrow(supplier) 让你抛出一个说明缺了什么的异常。get() 和 orElseThrow() 都抛出 NoSuchElementException,消息是 No value present。两者做的事完全一样,但 orElseThrow() 的名字就说明了它会做什么,这也是 Java 10 加入它的原因。优先用它,而不是 get()。

Optional.of(null) 会抛出异常

Optional.of 拒绝 null,而且抛出的异常根本不带消息:

String nickname(String name) {
    return name.equals("ana") ? "annie" : null;
}

void main() {
    IO.println(Optional.ofNullable(nickname("ben")));
    IO.println(Optional.of(nickname("ben")));
}

输出后停止:

Optional.empty
Exception in thread "main" java.lang.NullPointerException

这里没有有帮助的信息,因为 Optional.of 是在 JDK 内部用 Objects.requireNonNull 检查的,而不是在 null 上调用方法。栈跟踪的第一帧是 Objects.requireNonNull。如果你在那里看到一个光秃秃的 NPE,通常改用 ofNullable 就能解决。

不拆开也能变换 Optional

map、filter 和 flatMap 作用于 Optional 里的值,为空时就跳过,所以一连串的步骤完全不需要 if:

record User(String name, String email) {}

Map<String, User> users = new HashMap<>();
Map<String, String> cities = new HashMap<>();

Optional<User> findUser(String name) {
    return Optional.ofNullable(users.get(name));
}

Optional<String> cityOf(User user) {
    return Optional.ofNullable(cities.get(user.name()));
}

void main() {
    users.put("ana", new User("ana", "ana@example.pt"));
    users.put("ben", new User("ben", null));
    cities.put("ana", "Lisbon");

    IO.println(findUser("ana").map(User::email));
    IO.println(findUser("ben").map(User::email));
    IO.println(findUser("zoe").map(User::email));

    IO.println(findUser("ana").map(User::email).filter(e -> e.endsWith(".com")));

    IO.println(findUser("ana").map(this::cityOf));
    IO.println(findUser("ana").flatMap(this::cityOf));
    IO.println(findUser("ben").flatMap(this::cityOf));
}

输出:

Optional[ana@example.pt]
Optional.empty
Optional.empty
Optional.empty
Optional[Optional[Lisbon]]
Optional[Lisbon]
Optional.empty
  • map 把一个函数应用到值上。如果函数返回 null,就像 Ben 的 email() 那样,你得到的是空的 Optional,而不是装着 null 的 Optional。
  • filter 只在测试通过时保留这个值。Ana 的邮箱以 .pt 结尾,所以结果为空。
  • flatMap 用于本身就返回 Optional 的函数。map(this::cityOf) 把一个 Optional 套进了另一个里面,flatMap 不会。

再加三个方法,就凑齐了:

record User(String name) {}

Map<String, User> users = new HashMap<>();

Optional<User> findUser(String name) {
    return Optional.ofNullable(users.get(name));
}

void main() {
    users.put("ana", new User("ana"));
    users.put("ben", new User("ben"));

    findUser("ana").ifPresentOrElse(
            u -> IO.println("hello, " + u.name()),
            () -> IO.println("no such user"));
    findUser("zoe").ifPresentOrElse(
            u -> IO.println("hello, " + u.name()),
            () -> IO.println("no such user"));

    IO.println(findUser("zoe").or(() -> findUser("ana")));

    List<String> found = Stream.of("ana", "zoe", "ben")
            .map(this::findUser)
            .flatMap(Optional::stream)
            .map(User::name)
            .toList();
    IO.println(found);
}

输出:

hello, ana
no such user
Optional[User[name=ana]]
[ana, ben]
  • ifPresentOrElse 有值时执行一个动作,没值时执行另一个。
  • or 提供的备用值本身也是 Optional,适合第二次查找也可能失败的情况。orElse 会逼你选一个普通的值。
  • stream() 把一个值变成只有一个元素的流,把“没有”变成空流。配合 flatMap,它能从一批查找结果里去掉没找到的,所以 Zoe 干脆不出现。

Optional 不该出现的地方

Optional 是作为返回类型设计的,用在其他大多数地方都会让代码更糟。最明显的是 Optional 类型的参数,因为调用方照样可以传 null:

String greet(Optional<String> name) {
    return "Hello, " + name.orElse("guest");
}

void main() {
    IO.println(greet(Optional.of("Ana")));
    IO.println(greet(Optional.empty()));
    IO.println(greet(null));
}

输出后停止:

Hello, Ana
Hello, guest
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" because "<parameter1>" is null

这个参数现在有三种状态,而不是两种,每个调用方还得把参数包一层。greet(String name) 和 greet() 两个普通方法,能把同样的意思说得更清楚。消息里写的是 <parameter1> 而不是 name,因为这个类编译时没带调试信息。我们用 javac -g 重新编译了一次,消息里就出现了 name。

其他要避免的地方:

  • 字段。 字段可以存 null,而且由你的类掌控,所以改在构造器里检查。Optional 也不是 Serializable:Serializable.class.isAssignableFrom(Optional.class) 为 false。
  • Optional 的集合。 List<Optional<User>> 让每个读代码的人都得逐个拆开元素。把空的直接去掉,就像上面 flatMap(Optional::stream) 做的那样。
  • Optional<List<T>>。 列表本来就能表达“什么都没有”:它是空的。返回 List.of(),每个调用方的 for 循环照常工作,不需要特殊处理。

对于基本类型,OptionalInt、OptionalLong 和 OptionalDouble 能避免装箱。讲流的那一篇展示过 average() 返回的 OptionalDouble:

void main() {
    OptionalInt best = IntStream.of(72, 95, 88).max();
    IO.println(best);
    IO.println(best.getAsInt());

    OptionalInt none = IntStream.empty().max();
    IO.println(none);
    IO.println(none.orElse(0));
}

输出:

OptionalInt[95]
95
OptionalInt.empty
0

它们的方法比 Optional 少:没有 map、filter 和 flatMap。取值方法是 getAsInt(),而不是 get()。

java.time 的类型:日期、时间和地点

java.time 包是 Java 8 加入的,关于时间的每一种问题都有单独的类型来回答。同一个日期和挂钟时间,放在不同城市,可能是两个不同的时刻:

void main() {
    var date = LocalDate.of(2026, 3, 29);
    var time = LocalTime.of(9, 30);
    var dateTime = LocalDateTime.of(date, time);

    var lisbon = ZonedDateTime.of(dateTime, ZoneId.of("Europe/Lisbon"));
    var tokyo = ZonedDateTime.of(dateTime, ZoneId.of("Asia/Tokyo"));

    IO.println(date + " is a " + date.getDayOfWeek());
    IO.println(time);
    IO.println(dateTime);
    IO.println(lisbon);
    IO.println(tokyo);
    IO.println(lisbon.toInstant());
    IO.println(tokyo.toInstant());
}

输出:

2026-03-29 is a SUNDAY
09:30
2026-03-29T09:30
2026-03-29T09:30+01:00[Europe/Lisbon]
2026-03-29T09:30+09:00[Asia/Tokyo]
2026-03-29T08:30:00Z
2026-03-29T00:30:00Z
  • LocalDate 是没有时间的日期:比如生日。
  • LocalTime 是没有日期的时间:比如“商店 09:30 开门”。
  • LocalDateTime 两者都有,但没有时区。
  • ZonedDateTime 加上了 Europe/Lisbon 这样的时区,以及当时适用的 UTC 偏移量,这里是 +01:00。
  • Instant 是 UTC 时间线上的一个点,打印时带一个 Z。分处不同大洲的两台计算机,对同一个 Instant 的理解是一致的。

两个带时区的值在挂钟上都显示 09:30,但相差八个小时。本文里的每个 ZoneId 都是明确写出来的。ZoneId.systemDefault() 返回机器设置的时区,所以用它的代码在笔记本和服务器上会给出不同的答案。

用十岁孩子能懂的话说

LocalDateTime 就像一张挂钟的照片。照片上是 3 月 29 日星期日 09:30,但它不知道这面钟挂在哪座城市。如果你问“那是在东京的午饭前还是午饭后?”,照片答不上来。

ZonedDateTime 是同一张照片,背面写着城市名:“里斯本”。现在,任何地方的任何人都能算出这张照片是在他们自己城市的什么时候拍的。

准确的说法

LocalDateTime 由年、月、日、时、分、秒和纳秒组成。它不能确定一个时刻,所以它的 toInstant 方法要求你传入一个偏移量。ZonedDateTime 由一个 LocalDateTime、一个 ZoneId 和一个 ZoneOffset 组成。时区里存着规则,这些规则来自 JDK 自带的 tz 数据库,规定哪个时刻适用哪个偏移量。toInstant() 减去偏移量,得到 UTC。

这个比喻的局限:光有城市名并不总是够用。在一座城市里,秋天时钟回拨时,有些挂钟时间会出现两次;春天时钟拨快时,有些挂钟时间根本不会出现。所以 ZonedDateTime 除了时区,还会存下偏移量。讲夏令时的那一节会展示这两种情况。

java.time 对象永远不会改变

每个 java.time 类型都是不可变的,所以 plusDays 返回一个新对象,原来的对象不动。忽略返回值是经典 bug,而 javac 不会对此发出警告:

void main() {
    var due = LocalDate.of(2026, 3, 29);

    due.plusDays(14);
    IO.println("ignored the result: " + due);

    due = due.plusDays(14);
    IO.println("kept the result:    " + due);
}

输出:

ignored the result: 2026-03-29
kept the result:    2026-04-12

第一次 plusDays(14) 构建了一个新日期,然后把它扔掉了。如果你习惯了老的 Calendar.add,它会修改对象本身,那这行代码看起来没错,实际上什么也没做。不可变的好处是,你可以在线程之间共享一个日期,或者把它当作 map 的键,不用担心有人在你背后改掉它。

加月份:月末会移动

给日期加一个月时,能保留几号就保留几号,保留不了就用那个月最后一个有效的日子。1 月 31 日最能看出这一点:

void main() {
    var jan31 = LocalDate.of(2026, 1, 31);

    IO.println(jan31.plusMonths(1));
    IO.println(LocalDate.of(2028, 1, 31).plusMonths(1));
    IO.println(jan31.plusMonths(1).plusMonths(1));
    IO.println(jan31.plusMonths(2));
    IO.println(LocalDate.of(2026, 3, 31).minusMonths(1));
}

输出:

2026-02-28
2028-02-29
2026-03-28
2026-03-31
2026-02-28

2026 年不是闰年,所以 2 月到 28 日结束。2028 年是闰年,所以到 29 日结束。第三行和第四行出人意料:一个月加一个月不等于两个月。第一步之后,31 已经变成了 28,没有任何东西记得它原来是 31。如果你在每月最后一天计费,就像 plusMonths(2) 那样从起始日期算出每个日期,而不是从上一个日期算。

Duration 和 Period

Duration 以秒和纳秒计量时间,Period 以年、月、日计量。听起来是一回事,但一个月并没有固定的秒数:

void main() {
    var start = LocalDate.of(2026, 1, 31);
    var end = LocalDate.of(2026, 3, 29);
    IO.println(Period.between(start, end));
    IO.println(ChronoUnit.DAYS.between(start, end));

    var boarding = LocalDateTime.of(2026, 3, 28, 22, 45);
    var landing = LocalDateTime.of(2026, 3, 29, 1, 0);
    IO.println(Duration.between(boarding, landing));
    IO.println(Duration.ofMinutes(135).toHours());
}

输出:

P1M29D
57
PT2H15M
2

两者都以 ISO 8601 格式打印。P1M29D 是一个月零 29 天。PT2H15M 是两小时十五分钟,其中 T 把日期部分和时间部分分开。需要一个简单的天数时,用 ChronoUnit.DAYS.between。

牵涉到时区时,这个区别最要紧,因为日历上的一天并不总是 24 小时。

解析和格式化日期

每个 java.time 类型都以 ISO 8601 格式打印,parse 也能把同样的格式读回来。其他任何格式,都要用模式构建一个 DateTimeFormatter:

void main() {
    var date = LocalDate.parse("2026-03-29");
    var meeting = LocalDateTime.parse("2026-03-29T14:05");
    IO.println(date.plusDays(1));
    IO.println(meeting);

    var pretty = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu, HH:mm", Locale.US);
    IO.println(meeting.format(pretty));

    var european = DateTimeFormatter.ofPattern("dd/MM/uuuu");
    IO.println(LocalDate.parse("05/04/2026", european));
    IO.println(date.format(european));
}

输出:

2026-03-30
2026-03-29T14:05
Sunday 29 March 2026, 14:05
2026-04-05
29/03/2026

EEEE 是星期几的全称,MMMM 是月份的全称。这些词取决于语言,所以 pretty 格式化器指定了 Locale.US。不指定的话,Java 会用机器的语言环境,同一个程序在设置成葡萄牙语的计算机上会打印 domingo。european 模式里只有数字,所以不需要语言环境。05/04/2026 被解析成 4 月 5 日,因为模式规定日在前。

不符合格式的文本会抛出 DateTimeParseException:

void main() {
    try {
        LocalDate.parse("29/03/2026");
    } catch (DateTimeParseException e) {
        IO.println("caught: " + e.getMessage());
    }

    IO.println(LocalDate.parse("2026-02-30"));
}

输出后停止:

caught: Text '29/03/2026' could not be parsed at index 0
Exception in thread "main" java.time.format.DateTimeParseException: Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30'

第一段文本格式不对,消息指向索引 0,ISO 解析器在那里期望的是四位数的年份。第二段格式对,日期却不可能存在。parse 两样都检查,所以你没法把 2 月 30 日偷偷塞进数据里。

夏令时:1 天不等于 24 小时

时钟拨快的那一天,日历上的一天只有 23 小时,java.time 要你选清楚你指的是哪一种。2026 年的里斯本,这一天是 3 月 29 日星期日,01:00 变成 02:00:

void main() {
    var lisbon = ZoneId.of("Europe/Lisbon");
    var saturdayNoon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, lisbon);

    IO.println("start:              " + saturdayNoon);
    IO.println("plusDays(1):        " + saturdayNoon.plusDays(1));
    IO.println("plusHours(24):      " + saturdayNoon.plusHours(24));
    IO.println("Period.ofDays(1):   " + saturdayNoon.plus(Period.ofDays(1)));
    IO.println("Duration.ofDays(1): " + saturdayNoon.plus(Duration.ofDays(1)));

    var hours = Duration.between(saturdayNoon, saturdayNoon.plusDays(1)).toHours();
    IO.println("hours from noon to noon: " + hours);
}

输出:

start:              2026-03-28T12:00Z[Europe/Lisbon]
plusDays(1):        2026-03-29T12:00+01:00[Europe/Lisbon]
plusHours(24):      2026-03-29T13:00+01:00[Europe/Lisbon]
Period.ofDays(1):   2026-03-29T12:00+01:00[Europe/Lisbon]
Duration.ofDays(1): 2026-03-29T13:00+01:00[Europe/Lisbon]
hours from noon to noon: 23

第一行有个怪癖:里斯本冬季的偏移量是零,而零偏移量打印成 Z,不是 +00:00。

plusDays(1) 按日历计算。它保留挂钟时间,也就是中午,让偏移量随之改变。plusHours(24) 按时间线计算。它不多不少加上 24 小时的真实时间,结果落在新偏移量下的 13:00。

最后两行是陷阱。Duration.ofDays(1) 听起来是一天,但 Duration 是秒数,所以它是 24 小时。Period.ofDays(1) 才是日历上的一天。“明天同一时间”用 Period 或 plusDays,“从现在起整整 24 小时”用 Duration 或 plusHours。

Europe/Lisbon,真实时间从左向右流逝 plusDays(1):周日 12:00 +01:00,23h 周六 12:00 +00:00 周日 01:00 跳到 02:00 plusHours(24):周日 13:00 +01:00,24h

从里斯本的星期六中午出发,时钟在星期日从 01:00 直接跳到 02:00。plusDays(1) 保留挂钟时间,所以落在星期日 12:00,只过了 23 个真实小时。plusHours(24) 加上 24 个真实小时,所以落在星期日 13:00。

一个从未出现的时间,和一个出现了两次的时间

2026 年 3 月 29 日,里斯本没有一面钟显示过 01:30。2026 年 10 月 25 日时钟回拨,01:30 出现了两次。这两种情况下,Java 都得选一个结果:

void main() {
    var lisbon = ZoneId.of("Europe/Lisbon");
    var rules = lisbon.getRules();

    var inGap = LocalDateTime.of(2026, 3, 29, 1, 30);
    IO.println("offsets for " + inGap + ": " + rules.getValidOffsets(inGap));
    IO.println(ZonedDateTime.of(inGap, lisbon));

    var inOverlap = LocalDateTime.of(2026, 10, 25, 1, 30);
    IO.println("offsets for " + inOverlap + ": " + rules.getValidOffsets(inOverlap));
    var first = ZonedDateTime.of(inOverlap, lisbon);
    IO.println(first);
    IO.println(first.withLaterOffsetAtOverlap());
}

输出:

offsets for 2026-03-29T01:30: []
2026-03-29T02:30+01:00[Europe/Lisbon]
offsets for 2026-10-25T01:30: [+01:00, Z]
2026-10-25T01:30+01:00[Europe/Lisbon]
2026-10-25T01:30Z[Europe/Lisbon]
  • 在空隙里,没有有效的偏移量,所以 Java 把时间往后推一段空隙的长度。01:30 变成了 02:30。它不抛出异常,这意味着定在 01:30 的闹钟会悄悄在 02:30 响起。
  • 在重叠里,有两个有效的偏移量,Java 选较早的那个,也就是夏令时偏移量 +01:00。withLaterOffsetAtOverlap() 给你第二个 01:30,按真实时间晚一小时。

如果一个定时任务必须恰好运行一次,就把它的时间存成 Instant,或者在把本地时间转成带时区的时间时检查 getValidOffsets。

遇到 Date 和 Calendar,在边界处转换

java.util.Date 和 Calendar 是 Java 8 之前的日期类。它们是可变的,月份从零开始数,而且 Date.toString() 会悄悄使用机器的时区。你在老的库里还会遇到它们。它们一进入你的代码就转成 java.time,只在把值交给那个库时才转回去:

Date lastLoginFromOldLibrary() {
    return new Date(1774779330000L);
}

void main() {
    Instant lastLogin = lastLoginFromOldLibrary().toInstant();
    IO.println(lastLogin);
    IO.println(lastLogin.atZone(ZoneId.of("Europe/Lisbon")));

    Date backForTheLibrary = Date.from(lastLogin);
    IO.println(backForTheLibrary.getTime());
}

输出:

2026-03-29T10:15:30Z
2026-03-29T11:15:30+01:00[Europe/Lisbon]
1774779330000

toInstant() 和 Date.from 是双向的桥梁。Date 是从 1970 年起算的毫秒数,所以转成 Instant 不会丢失任何东西。反过来转则会丢掉比毫秒更细的部分:我们试了一个以 .123456789 结尾的 Instant,转回来得到的是 .123。对于 GregorianCalendar,toZonedDateTime() 做同样的事,并保留它的时区。

Clock:可以测试的代码

调用 LocalDate.now() 的方法每天给出的答案都不同,这让它很难测试。改为传入一个 Clock,由调用方决定“现在”是什么时候:

record Subscription(String owner, LocalDate lastDay) {}

boolean isExpired(Subscription subscription, Clock clock) {
    return LocalDate.now(clock).isAfter(subscription.lastDay());
}

void main() {
    var ana = new Subscription("ana", LocalDate.of(2026, 3, 29));
    var lisbon = ZoneId.of("Europe/Lisbon");

    var lateSunday = Clock.fixed(Instant.parse("2026-03-29T22:30:00Z"), lisbon);
    var justAfter = Clock.fixed(Instant.parse("2026-03-29T23:30:00Z"), lisbon);

    IO.println(LocalDate.now(lateSunday) + " expired: " + isExpired(ana, lateSunday));
    IO.println(LocalDate.now(justAfter) + " expired: " + isExpired(ana, justAfter));
}

输出:

2026-03-29 expired: false
2026-03-30 expired: true

Clock.fixed 返回一个停在某个 Instant、某个时区上的时钟。UTC 22:30 是里斯本的 23:30,仍然是星期日。一小时后,里斯本已是星期一 00:30,所以订阅过期了,尽管按 UTC 算还是星期日。测试不用等到午夜,就能检查午夜前后两种情况。

在生产环境里,你会传入 Clock.system(ZoneId.of("Europe/Lisbon")),或者你的用户所在的时区。java.time 里的每个 now 方法都有一个接收 Clock 的重载,所以一个参数就能覆盖全部。

要点

  • 任何引用都可能是 null,javac 不检查。在入口处用 Objects.requireNonNull,用 requireNonNullElse 提供默认值,比较可能为 null 的值时用 Objects.equals。
  • 方法可能没有结果时,返回 Optional。不要把它用在字段、参数或集合上,返回空列表而不是 Optional<List>。
  • orElse 每次都会求值它的备用值,orElseGet 只在需要时才求值。优先用 orElseThrow() 而不是 get(),值可能为 null 时用 ofNullable。
  • java.time 对象是不可变的。plusDays 返回一个新日期,所以要保存结果。
  • LocalDateTime 没有时区,也不是一个时刻。时刻要紧时,用带明确 ZoneId 的 ZonedDateTime,或者用 Instant。
  • 跨越夏令时切换时,plusDays(1) 和 Period 保留挂钟时间,而 plusHours(24) 和 Duration 加上的是真实的小时数。
  • 需要“现在”的代码,传入一个 Clock,再用 Clock.fixed 测试它。

每次都说清你指的是哪个时区,并让类型说明一个值是否可能缺失。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。