java.time vào Java 8 để thay DateCalendar — hai API mà gần như mọi người viết Java đều từng bị chúng làm khổ.

Nó tốt hơn nhiều, nhưng có nhiều kiểu hơn, và chọn sai kiểu là nguồn của một họ lỗi rất khó tái hiện.

Năm kiểu, cùng một thời điểm

  Instant       : 2026-06-30T22:15:30Z         (mốc trên trục thời gian, luôn UTC)
  ZonedDateTime : 2026-07-01T05:15:30+07:00[Asia/Ho_Chi_Minh]
  LocalDateTime : 2026-07-01T05:15:30          (không có múi giờ)
  LocalDate     : 2026-07-01
  LocalTime     : 05:15:30

Cách chọn, gói trong ba câu hỏi:

Đây có phải một khoảnh khắc cụ thể trong lịch sử không?Instant. Thời điểm tạo đơn hàng, thời điểm ghi log, thời điểm hết hạn token.

Có gắn với một địa điểm cụ thể không?ZonedDateTime. Cuộc họp lúc 9 giờ sáng giờ Hà Nội, chuyến bay khởi hành 14:30 giờ địa phương.

Có phải một khái niệm lịch không phụ thuộc thời điểm?LocalDate, LocalTime, LocalDateTime. Ngày sinh, ngày lễ, giờ mở cửa cửa hàng.

LocalDateTime là kiểu bị dùng sai nhiều nhất. Nó không đại diện cho một khoảnh khắc — "2026-07-01 lúc 5:15" là hai thời điểm khác nhau ở Hà Nội và ở London. Dùng nó để ghi thời điểm sự kiện là mất thông tin.

Cùng một Instant, ba nơi đọc ra ba giờ

  Asia/Ho_Chi_Minh   01/07/2026 05:15
  Europe/London      30/06/2026 23:15
  America/New_York   30/06/2026 18:15

Không chỉ giờ khác — ngày cũng khác. Đây là lý do nguyên tắc quan trọng nhất của bài:

Lưu Instant, hiển thị theo múi giờ người đọc. Cơ sở dữ liệu giữ mốc thời gian tuyệt đối; tầng giao diện đổi sang giờ địa phương ngay lúc hiển thị. Lưu giờ địa phương là mất thông tin, và không bao giờ khôi phục được nếu sau này cần đổi múi giờ.

Trong PostgreSQL, kiểu tương ứng là timestamptz. Trong JDBC, Instant ánh xạ thẳng — không cần đổi qua Timestamp nữa.

Một giờ không tồn tại

  yêu cầu 01:30 ngày 29/03 ở London: 2026-03-29T02:30+01:00[Europe/London]
  -> bị đẩy sang 02:30, không báo lỗi

Đêm chuyển sang giờ mùa hè, đồng hồ nhảy từ 01:00 thẳng lên 02:00. Giờ 01:30 hôm đó không tồn tại.

atZone không ném lỗi — nó lặng lẽ đẩy sang thời điểm hợp lệ gần nhất. Nếu người dùng đặt lịch hẹn vào đúng giờ đó, hệ thống của bạn im lặng đổi giờ hẹn.

Và một giờ lặp lại hai lần

  01:30 ngày 25/10 khớp với: [+01:00, Z]
  atZone chọn : 2026-10-25T01:30+01:00[Europe/London]  (lần đầu)

Đêm quay lại giờ mùa đông, đồng hồ nhảy từ 02:00 về 01:00 — nên 01:30 xảy ra hai lần, cách nhau một tiếng.

atZone chọn lần đầu. Muốn lần sau thì withLaterOffsetAtOverlap().

Muốn biết trước có nhập nhằng không:

zone.getRules().getValidOffsets(localDateTime)

Trả về danh sách rỗng nghĩa là giờ không tồn tại, hai phần tử nghĩa là lặp lại. Với hệ thống đặt lịch, đây là phép kiểm đáng có.

Việt Nam không có giờ mùa hè, nên nhiều người Việt viết Java chưa từng gặp chuyện này — cho tới khi làm sản phẩm cho thị trường châu Âu hoặc Mỹ.

Period khác Duration

Đây là hệ quả trực tiếp của hai mục trên, và là phần tôi thấy đáng nhớ nhất:

  gốc              : 2026-03-28T12:00Z[Europe/London]
  +1 ngày (Period)  : 2026-03-29T12:00+01:00   <- vẫn 12:00
  +24 giờ (Duration): 2026-03-29T13:00+01:00   <- thành 13:00!

Cộng "1 ngày" và cộng "24 giờ" ra hai kết quả khác nhau, vì hôm đó chỉ có 23 giờ.

Period làm việc theo lịch: ngày, tháng, năm. "Cùng giờ ngày mai" giữ nguyên giờ trên đồng hồ.

Duration làm việc theo thời gian tuyệt đối: giây, phút, giờ. "24 giờ sau" là đúng 86400 giây.

Chọn theo ý định: nhắc nhở "mỗi ngày lúc 8 giờ sáng" là Period; token "hết hạn sau 24 giờ" là Duration. Dùng nhầm thì lệch một tiếng hai lần mỗi năm — đủ hiếm để không ai tìm ra nguyên nhân.

Cộng tháng thì tự kẹp ngày

  cộng 1 tháng từ 31/01: 2026-02-28  (tự kẹp)

31 tháng 1 cộng một tháng không thể là 31 tháng 2, nên java.time kẹp về ngày cuối tháng.

Hệ quả cần biết: phép cộng tháng không giao hoán. plusMonths(1).minusMonths(1) từ 31/01 cho ra 28/01, không phải 31/01. Với logic tính kỳ hạn hay chu kỳ thanh toán, đây là chỗ phải cẩn thận.

Khoảng cách: hai cách đo

  Period.between  : P5M15D          (5 tháng 15 ngày)
  ChronoUnit.DAYS : 166 ngày

Period.between cho khoảng cách theo lịch, chia thành năm/tháng/ngày — hợp để hiển thị "3 tháng 2 ngày".

ChronoUnit.DAYS.between cho một con số duy nhất — hợp để tính toán.

Với Instant thì dùng Duration.between, và nó có toDays, toHours, toMinutes tiện dụng.

Tính toán theo lịch

  đầu tháng       : 2026-06-01
  cuối tháng      : 2026-06-30
  thứ Hai kế tiếp : 2026-07-06

TemporalAdjusters có sẵn cả chục phép: firstDayOfMonth, lastDayOfMonth, next(DayOfWeek), firstInMonth, lastDayOfYear. Chúng thay cho những đoạn tính toán thủ công rất dễ sai.

Tự viết cũng được — TemporalAdjuster là một giao diện hàm, nên "ngày làm việc kế tiếp" chỉ là một lambda.

Định dạng

DateTimeFormatter.ofPattern("EEEE, dd 'tháng' MM yyyy", Locale.of("vi"))
  Thứ Ba, 30 tháng 06 2026

Chú ý Locale.of("vi") — từ Java 19, new Locale("vi") đã bị đánh dấu lỗi thời và javac sẽ cảnh báo.

Phần chữ cố định phải đặt trong dấu nháy đơn, nếu không các chữ cái sẽ bị hiểu là mã định dạng.

DateTimeFormatter an toàn luồng — khác hẳn SimpleDateFormat của API cũ, vốn là nguồn của một lớp lỗi kinh điển khi dùng chung giữa các luồng. Nên khai static final thoải mái.

Vài chỗ khác cần biết

ZoneId.of("Asia/Ho_Chi_Minh") chứ không phải "GMT+7". Tên vùng IANA mang theo cả lịch sử thay đổi múi giờ và quy tắc giờ mùa hè; một độ lệch cố định thì không.

Dữ liệu múi giờ nằm trong JDK và được cập nhật theo bản vá. Chính phủ đổi quy tắc giờ mùa hè thì bạn cần nâng JDK — đây là lý do thực tế để không dùng bản Java quá cũ.

Clock để test. Đừng gọi Instant.now() rải rác trong mã nghiệp vụ; tiêm một Clock vào, rồi trong test dùng Clock.fixed(...). Nhờ đó test được logic phụ thuộc thời gian mà không phải chờ.

Chuyển từ API cũ: date.toInstant(), Date.from(instant), calendar.toZonedDateTime(). Nhưng đừng để Date lọt vào mã mới.

Thử ba mươi giây

Chạy đoạn plusDays(1)plusHours(24) ở trên với một ngày chuyển giờ mùa hè của bất kỳ múi giờ nào có DST.

Hai kết quả lệch nhau một tiếng. Nếu hệ thống của bạn có tính năng nhắc nhở hay hẹn giờ, đây là lỗi sẽ xảy ra đúng hai lần mỗi năm — và không ai nghĩ tới việc tìm nguyên nhân ở đó.

Ngày mai ta đi sâu hơn vào múi giờ: những cái bẫy khi lưu trữ, khi so sánh, và khi cron chạy sai giờ vì máy chủ để UTC.