java.time: LocalDate, ZonedDateTime und Duration
1. Übersicht
Datumsverwaltung ist eine bekannte Fehlerquelle: Rechnungen verschoben um einen Tag, Termine in der falschen Zeitzone, Jobs laufen doppelt beim Wechsel zur Sommerzeit. Das Package java.time (Java 8+) löst die meisten dieser Probleme, wenn du den richtigen Datentyp wählst.
Dieser Artikel konzentriert sich auf die drei häufigsten Typen: LocalDate, ZonedDateTime und Duration. Danach wirst du wissen, wann man welchen Typ einsetzt, wie man Zeitzonen wechselt und mit der Sommerzeit (DST) umgeht. Du wirst auch lernen, wie man Tests schreibt, die unabhängig vom Ausführungszeitpunkt sind.
2. Warum java.time verwenden
java.util.Date und Calendar haben viele Probleme. Sie sind mutable und daher nicht thread-safe. Monate werden ab 0 gezählt, und der Name Date führt in die Irre, da das Objekt eigentlich einen Zeitpunkt darstellt. java.time behebt diese Punkte:
- Alle Objekte sind unveränderlich und thread-safe. Methoden wie
plusDays()geben immer ein neues Objekt zurück. - Jedes Konzept hat seinen eigenen Typ:
| Typ | Bedeutung | Beispiel |
|---|---|---|
LocalDate |
Datum, keine Uhrzeit, keine Zeitzone | Geburtsdatum, Ablaufdatum |
LocalDateTime |
Datum + Uhrzeit, keine Zeitzone | “Im Kalender” ohne Ortsbindung |
ZonedDateTime |
Datum + Uhrzeit + ZoneId |
Treffen um 9 Uhr in Hanoi |
Instant |
Ein Punkt auf der Zeitachse (UTC) | Timestamp in DB, Logs |
Duration |
Zeitspanne in Sekunden/Nanosekunden | 30-Sekunden-Timeout |
Period |
Zeitspanne in Jahren/Monaten/Tagen | 3-Monats-Abo |
Keine zusätzlichen Dependencies nötig – alles ist im JDK enthalten. Um die Test-Beispiele auszuführen, benötigst du JUnit 5 und AssertJ (enthalten in spring-boot-starter-test):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
3. LocalDate: Mit Daten arbeiten
3.1. Erstellen und Parsen
LocalDate dueDate = LocalDate.of(2024, 5, 15);
LocalDate parsed = LocalDate.parse("2024-02-29");
DateTimeFormatter vnFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate fromForm = LocalDate.parse("15/05/2024", vnFormat);
System.out.println(dueDate);
System.out.println(fromForm.equals(dueDate));
System.out.println(dueDate.format(vnFormat));
2024-05-15
true
15/05/2024
LocalDate.of() erwartet Monate von 1 bis 12. Du kannst auch Month.MAY übergeben, um es lesbarer zu machen. parse() ohne Formatter nutzt das ISO-8601-Format (yyyy-MM-dd). Für andere Formate erstellst du einen DateTimeFormatter und solltest ihn als static final-Konstante deklarieren, da der Formatter auch thread-safe ist.
Hinweis: Schreib nicht
YYYYstattyyyy.YYYYist das week-based year. Der 30.12.2024 wird mitYYYYzum Jahr 2025 formatiert. Dieser Fehler tritt normalerweise nur Ende des Jahres auf.
3.2. Addieren und Subtrahieren – Verhalten am Monatsende
LocalDate orderDate = LocalDate.of(2024, 1, 31);
System.out.println(orderDate.plusDays(10));
System.out.println(orderDate.plusMonths(1));
System.out.println(orderDate.plusMonths(1).plusMonths(1));
System.out.println(orderDate.plusMonths(2));
2024-02-10
2024-02-29
2024-03-29
2024-03-31
Februar 2024 hat keinen 31. Tag, daher wird plusMonths(1) auf den gültigen Monatstag zurückgesetzt: 29. Das Ergebnis ist, dass zweimal einen Monat addieren nicht gleich ist wie einmal zwei Monate addieren. Wenn du Rechnungszyklen berechnest, solltest du immer vom Ursprungsdatum aus rechnen, z.B. start.plusMonths(n). Addiere nicht schrittweise vom Ergebnis der vorherigen Periode.
3.3. TemporalAdjusters
Fragen wie “letzter Tag des Monats” oder “nächster Montag” haben vordefinierte Adjuster:
LocalDate today = LocalDate.of(2024, 5, 15); // Mittwoch
LocalDate endOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate firstOfNextMonth = today.with(TemporalAdjusters.firstDayOfNextMonth());
System.out.println(endOfMonth + " " + nextMonday + " " + firstOfNextMonth);
2024-05-31 2024-05-20 2024-06-01
3.4. Abstand zwischen zwei Daten: Period und ChronoUnit
LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2024, 3, 10);
Period period = Period.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);
System.out.println(period);
System.out.println(period.getDays());
System.out.println(days);
P1M24D
24
55
Das ist ein sehr häufiger Fehler. period.getDays() gibt nur den Tageanteil (24) von “1 Monat 24 Tagen” zurück, nicht die Gesamtzahl der Tage. Um die Gesamtzahl zu bekommen, nutzt du ChronoUnit.DAYS.between().
4. ZonedDateTime: Datum und Uhrzeit gebunden an eine Zeitzone
4.1. Erstellen und Zeitzonen wechseln
ZoneId hcm = ZoneId.of("Asia/Ho_Chi_Minh");
ZoneId newYork = ZoneId.of("America/New_York");
ZonedDateTime meetingHcm = ZonedDateTime.of(
LocalDateTime.of(2024, 6, 1, 9, 0), hcm);
ZonedDateTime sameMoment = meetingHcm.withZoneSameInstant(newYork);
ZonedDateTime sameClock = meetingHcm.withZoneSameLocal(newYork);
System.out.println(meetingHcm);
System.out.println(sameMoment);
System.out.println(sameClock);
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-06-01T09:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]
Moment, das ist noch nicht die vollständige Ausgabe. Die richtige Ausgabe ist:
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]
Diese beiden Methoden unterscheiden sich wie folgt:
withZoneSameInstant()behält den Zeitpunkt. 9 Uhr morgens in Ho-Chi-Minh-Stadt ist 22 Uhr abends am Vortag in New York (mit Sommerzeit UTC-4). Das ist die Methode, die du in 95 % der Fälle brauchst, z.B. um ein Treffen für jemanden in einer anderen Zeitzone anzuzeigen.withZoneSameLocal()behält die Uhrzeit auf der Uhr und wechselt zu einem anderen Zeitpunkt. Diese Methode wird nur verwendet, wenn du absichtlich die Zeitzone neu zuordnest, z.B. wenn Eingabedaten fälschlicherweise einer Zone zugeordnet wurden.
Nutze immer IDs in der Form Region/Stadt wie Europe/Berlin. Vermeide Abkürzungen wie EST oder ICT, da sie mehrdeutig sind. Feste Offsets wie +07:00 berücksichtigen auch keine Sommerzeit-Regeln.
4.2. Sommerzeit: Zeiten, die “nicht existieren”
Am 31.03.2024 in Deutschland springt die Uhr von 02:00 direkt auf 03:00. Daher existiert 02:30 an diesem Tag nicht:
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime inGap = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 31, 2, 30), berlin);
System.out.println(inGap);
2024-03-31T03:30+02:00[Europe/Berlin]
java.time wirft keine Exception, sondern verschiebt die Zeit nach vorne um die Länge der Lücke (1 Stunde). Wenn die Sommerzeit endet, wird eine Stunde auf der Uhr zweimal angezeigt. Standardmäßig wählt java.time den früheren Offset. Du kannst das mit withLaterOffsetAtOverlap() ändern.
4.3. Instant: Was man in der Datenbank speichern sollte
Wenn du “wann hat ein Ereignis stattgefunden” speichern musst, konvertiere zu Instant (oder zu TIMESTAMP WITH TIME ZONE in der DB):
Instant createdAt = meetingHcm.toInstant();
System.out.println(createdAt);
System.out.println(createdAt.atZone(newYork));
2024-06-01T02:00:00Z
2024-05-31T22:00-04:00[America/New_York]
Die allgemeine Regel lautet: Speichere Instant und konvertiere nur zu ZonedDateTime, wenn du es für den Benutzer in ihrer Zeitzone anzeigst.
5. Duration: Zeitspaanen genau messen
5.1. Duration erstellen und auslesen
Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);
System.out.println(delivery);
System.out.println(delivery.toMinutes());
System.out.printf("%d Stunden %d Minuten%n",
delivery.toHoursPart(), delivery.toMinutesPart());
Instant start = Instant.parse("2024-05-15T08:00:00Z");
Instant end = Instant.parse("2024-05-15T08:00:42.5Z");
System.out.println(Duration.between(start, end).toMillis());
PT2H15M
135
2 Stunden 15 Minuten
42500
toMinutes() gibt die Gesamtzahl der Minuten zurück. Aber toHoursPart() und toMinutesPart() (Java 9+) zerlegen es in Teile zur Anzeige. Das ist die gleiche Falle wie Period.getDays() aus Abschnitt 3.4.
5.2. Duration und Period: “1 Tag” ist nicht immer 24 Stunden
Das ist der wichtigste Unterschied des ganzen Artikels:
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime beforeDst = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 30, 12, 0), berlin);
ZonedDateTime calendarDay = beforeDst.plusDays(1);
ZonedDateTime exact24h = beforeDst.plus(Duration.ofDays(1));
System.out.println(calendarDay);
System.out.println(exact24h);
System.out.println(Duration.between(beforeDst, calendarDay));
2024-03-31T12:00+02:00[Europe/Berlin]
2024-03-31T13:00+02:00[Europe/Berlin]
PT23H
Die Ausgabe zeigt drei Dinge:
plusDays(1)addiert nach dem Kalender, behält also 12:00 Mittag am nächsten Tag. Tatsächlich liegen zwischen den beiden Zeitpunkten aber nur 23 Stunden.plus(Duration.ofDays(1))addiert genau 24 Stunden, daher landet es um 13:00.Duration.between()bestätigt, dass der tatsächliche Abstand 23 Stunden ist.
Die Regel zur Wahl des Typs ist sehr einfach. Wenn der Benutzer nach Kalender denkt (“erinnere mich jeden Tag um 9 Uhr”, “3-Monats-Abo”), nutzt du Period oder plusDays/plusMonths. Wenn das System die verstrichene Zeit misst (Timeout, 24-Stunden-SLA, Rate Limit), nutzt du Duration.
Hinweis: Du kannst
Durationnicht zuLocalDateaddieren, daLocalDate.plus(Duration.ofHours(5))eineUnsupportedTemporalTypeExceptionwirft.LocalDatehat keine Uhrzeit, daher sind kleinere Einheiten bedeutungslos.
6. Code mit Clock testbar schreiben
Das direkte Aufrufen von LocalDate.now() in der Geschäftslogik macht Tests vom Ausführungsdatum abhängig. Der richtige Weg ist, Clock zu injizieren:
public class SubscriptionService {
private static final int TRIAL_DAYS = 14;
private final Clock clock;
public SubscriptionService(Clock clock) {
this.clock = clock;
}
public LocalDate trialEndDate() {
return LocalDate.now(clock).plusDays(TRIAL_DAYS);
}
public boolean isExpired(LocalDate expiryDate) {
return LocalDate.now(clock).isAfter(expiryDate);
}
public Duration timeUntil(Instant deadline) {
return Duration.between(Instant.now(clock), deadline);
}
}
Alle now()-Methoden von java.time haben eine Überladung, die Clock akzeptiert. In Spring Boot 3.x deklarierst du ein Bean:
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
}
}
Nutze Clock.system(...) mit expliziter Zone statt systemDefaultZone(). So bleibt das Ergebnis gleich, egal ob der Server in UTC läuft oder dein Entwicklungs-PC in Hanoi-Zeit.
Im Test fixierst du die Zeit mit Clock.fixed():
class SubscriptionServiceTest {
private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
// 03:00 UTC = 10:00 Uhr am 15.05.2024 in Ho-Chi-Minh-Stadt
private final Clock fixedClock =
Clock.fixed(Instant.parse("2024-05-15T03:00:00Z"), HCM);
private final SubscriptionService service = new SubscriptionService(fixedClock);
@Test
void trialEndsAfterFourteenDays() {
assertThat(service.trialEndDate()).isEqualTo(LocalDate.of(2024, 5, 29));
}
@Test
void expiredOnlyAfterExpiryDate() {
assertThat(service.isExpired(LocalDate.of(2024, 5, 15))).isFalse();
assertThat(service.isExpired(LocalDate.of(2024, 5, 14))).isTrue();
}
@Test
void calculatesRemainingTime() {
Instant deadline = Instant.parse("2024-05-15T05:30:00Z");
assertThat(service.timeUntil(deadline)).isEqualTo(Duration.ofMinutes(150));
}
@Test
void calendarDayAcrossDstIsTwentyThreeHours() {
ZonedDateTime start = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 30, 12, 0), ZoneId.of("Europe/Berlin"));
assertThat(Duration.between(start, start.plusDays(1))).isEqualTo(Duration.ofHours(23));
}
}
Der zweite Test prüft die Grenze: Ein Abo, das am 15.05 abläuft, ist am 15.05 noch gültig. Der letzte Test “verriegelt” das DST-Verhalten aus Abschnitt 5.2. Falls jemand plusDays in plus(Duration.ofDays(1)) ändert, schlägt der Test sofort fehl.
Ein Detail, das leicht übersehen wird: Wenn du Instant.parse("2024-05-14T18:00:00Z") als festen Punkt setzt, wird LocalDate.now(clock) in Ho-Chi-Minh-Stadt 15.05 sein (01:00 Uhr morgens). In UTC ist es immer noch 14.05. Daher bestimmt die Zone der Clock, welcher Tag “heute” ist.
7. Häufige Fehler und wann man nicht java.time verwenden sollte
LocalDateTimefür Ereignis-Timestamps verwenden.LocalDateTimekennt keine Zeitzone, daher weißt du beim Lesen nicht, ob “10:00” in welcher Stadt ist. Für Ereignisse nutzeInstantoderOffsetDateTime/ZonedDateTime.ZonedDateTimefür reine Daten verwenden. Geburtsdatum oder Feiertag sindLocalDate. Wenn du eine Zeitzone hinzufügst, kann das Datum beim Zonenwechsel auf den Vortag “rutschen”.ZoneIdals Offset speichern.+07:00ändert sich nicht saisonal, aberEurope/Berlinschon. Für zukünftige Termine speichereLocalDateTimezusammen mit demZoneId-Namen als String. DST-Regeln können sich vor dem Termin ändern, und diese Speicherung erlaubt dir, das Datum neu zu berechnen.getDays()/toMinutesPart()mit Gesamtsummen verwechseln. NutzeChronoUnit.X.between()odertoMinutes().equals()zwischenZonedDateTimein verschiedenen Zonen vergleichen.equals()vergleicht auch die Zone, daher geben 9 Uhr in Ho-Chi-Minh-Stadt und 22 Uhr in New York zum gleichen Zeitpunktfalsezurück. Um Zeitpunkte zu vergleichen, nutzeisEqual(),isBefore(),isAfter().now()ohneClock-Parameter in Geschäftslogik aufrufen, was Tests vom Ausführungsdatum oder -uhrzeit abhängig macht.
Hinweis:
java.timeersetzt nicht spezialisierte Kalender-Bibliotheken. Arbeitstage, vietnamesische Feiertage oder Mondkalender benötigen eigene Logik oder spezialisierte Bibliotheken. SelbstgeschriebeneTemporalAdjustersind der richtige Ort, um solche Logik zu kapseln.
8. Fazit
Die Wahl des richtigen Datentyps ist der wichtigste Schritt. Nutze LocalDate für Daten im Kalender, ZonedDateTime für Uhrzeiten gebunden an einen Ort, und Instant für Zeitpunkte im Speicher. Bei Zeitspaanen gehört Period zum Kalender und Duration zur Uhr – und wie du in Abschnitt 5.2 gesehen hast, geben sie bei Sommerzeit unterschiedliche Ergebnisse.
Du solltest auch Clock in Services injizieren und Zeitzonen explizit deklarieren. Schreibe Tests für Grenzzonen wie Monatsende, Sommerzeitwechsel und Mitternacht UTC. Mit diesen Praktiken werden die meisten Datums-Fehler bereits vor dem Produktivgang gefunden.