Có hai cách trả lời câu hỏi "khi nào?". Cách thứ nhất là một cái đồng hồ bấm giây đếm số giây tuyệt đối kể từ một mốc cố định — nó không bao giờ nói dối, và ai ở đâu cũng đọc ra cùng một con số. Cách thứ hai là đồng hồ treo tường: con số nó chỉ phụ thuộc vào bức tường nào bạn treo nó lên, và mỗi năm hai lần nó còn nhảy cóc mất một giờ hoặc lặp lại một giờ. Gần như mọi lỗi thời gian trong phần mềm đều nảy ra từ việc lẫn lộn hai thứ đó. java.time tách chúng ra rạch ròi, và bài này là cách chọn cho đúng.
java.time vào Java 8 để thay Date và Calendar — 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. Đây là "đồng hồ bấm giây".
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. Nó là con số trên mặt đồng hồ treo tường, và 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:
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ồ — đây là đồng hồ treo tường.
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 — đây là đồng hồ bấm 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.
Muốn tự thấy cái bẫy đầu bài trong ba mươi giây: chạy đoạn plusDays(1) và plusHours(24) 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 ở đó.
Mẫu số chung
Cái ranh "đồng hồ bấm giây so với đồng hồ treo tường" không phải chuyện riêng của Java — mọi ngôn ngữ đều học đúng bài học ấy, và những cái học muộn thì trả giá đắt. JavaScript nổi tiếng có kiểu Date tệ tới mức phải làm lại từ đầu: đề xuất Temporal mới chia thành Instant, ZonedDateTime, PlainDate — sao y thiết kế của java.time, kể cả cái tên. Python phân biệt datetime "aware" (có múi giờ) với "naive" (không) — và "naive" chính là LocalDateTime, cái bẫy y hệt; lời khuyên chuẩn là lưu UTC rồi dùng zoneinfo khi hiển thị. Go thì time.Time luôn mang theo một Location, và còn gắn sẵn cả đồng hồ đơn điệu để đo khoảng thời gian không bị nhảy khi chỉnh giờ. Và ở tầng cơ sở dữ liệu, timestamptz so với timestamp của PostgreSQL là đúng cái ranh Instant so với LocalDateTime.
Điểm chung, và là thứ đáng mang theo, gồm ba luật mà ngôn ngữ nào cũng hội tụ về. Một — một mốc thời gian không kèm múi giờ chỉ là con số trên mặt đồng hồ, không phải một khoảnh khắc: muốn ghi "khi nào điều này xảy ra" thì lưu một instant tuyệt đối (UTC), rồi đổi sang giờ địa phương ở tầng ngoài cùng lúc hiển thị. Hai — dùng tên vùng IANA (Asia/Ho_Chi_Minh), đừng dùng độ lệch cố định (GMT+7), vì chỉ tên vùng mới mang theo lịch sử đổi giờ và quy tắc giờ mùa hè. Ba — "một ngày" (theo lịch) không bằng "24 giờ" (theo đồng hồ): hai lần mỗi năm ở xứ có DST, một ngày dài 23 hoặc 25 giờ, và chọn nhầm giữa Period với Duration là cỗ máy sinh lỗi đúng hai lần một năm, đủ hiếm để không ai lần ra. Ai nắm ba luật đó thì xử lý thời gian đúng ở mọi ngôn ngữ — kể cả cái đồng hồ treo tường vốn thích nói dối.
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.