Tanggal dan waktu hadir hampir di setiap aplikasi: stempel waktu transaksi, tanggal kedaluwarsa token, jadwal tugas, hingga rentang periode tagihan. Java mengelola kebutuhan itu lewat dua generasi API — generasi lama berbasis java.util.Date dan java.util.Calendar yang hadir pada akhir 1990-an, serta generasi modern java.time yang dirilis bersama Java 8 pada tahun 2014. Sepanjang Section ini kita akan memakai java.time, tetapi API lama tetap perlu kita pahami karena banyak kode produksi, tutorial lawas, dan signature method pustaka masih memakainya. Pada Chapter ini kita bedah desain API lama, melihat letak masalahnya dari contoh nyata, dan menyiapkan jalur konversi menuju java.time.

Mengapa Masih Perlu Mengenal API Lama #

Class java.util.Date hadir sejak JDK 1.0 (1996), disusul java.util.Calendar pada JDK 1.1 (1997). Selama hampir dua dekade keduanya menjadi satu-satunya pilihan bawaan, sehingga jutaan baris kode lama masih menyimpan dan mengoper objek bertipe Date. Penggantinya, package java.time, dirancang melalui JSR 310 dan JEP 150 di bawah kepemimpinan Stephen Colebourne — pencipta pustaka Joda-Time yang menjadi rujukan desainnya — dan masuk ke Java 8 pada Maret 2014. Ketika kita memelihara sistem lawas atau memakai pustaka yang belum bermigrasi, kemampuan membaca API lama lalu mengubahnya ke java.time adalah kebutuhan nyata, bukan pengetahuan cadangan.

Anatomi java.util.Date #

Di balik layar, Date hanya membungkus satu bilangan long: jumlah milidetik sejak epoch Unix, yaitu 1 Januari 1970 pukul 00:00:00 UTC. Objek Date tidak menyimpan zona waktu maupun komponen kalender; method toString() menerjemahkan angka tersebut memakai zona waktu bawaan JVM, sehingga tampilan Date berubah tergantung mesin yang menjalankan program. Format keluarannya pun memakai singkatan bahasa Inggris dan kode zona tiga huruf seperti WIB — bentuk yang tidak cocak untuk ditampilkan langsung ke pengguna akhir. Mari kita amati perilaku nyatanya:

java DateLama.java
import java.util.Date;

public class DateLama {
    public static void main(String[] args) {
        // Date hanya menyimpan satu angka: milidetik sejak 1 Januari 1970 00:00:00 UTC (epoch).
        // 1768438800000L mewakili 2026-01-15T01:00:00Z.
        Date rilis = new Date(1768438800000L);

        System.out.println("toString()       : " + rilis);
        System.out.println("getTime()        : " + rilis.getTime());

        // Date mutable: setTime mengubah objek yang sama, bukan membuat objek baru.
        Date salinan = rilis;
        salinan.setTime(0L);
        System.out.println("setelah setTime(0): " + salinan);
        System.out.println("objek rilis ikut berubah: " + rilis);
    }
}

Output yang Dihasilkan:

text
toString()       : Thu Jan 15 08:00:00 WIB 2026
getTime()        : 1768438800000
setelah setTime(0): Thu Jan 01 07:00:00 WIB 1970
objek rilis ikut berubah: Thu Jan 01 07:00:00 WIB 1970

Mari kita telaah baris-baris kode di atas:

  1. Baris 7 (Date rilis = new Date(1768438800000L);): membuat objek Date dari epoch millis. Angka ini mewakili titik waktu 2026-01-15T01:00:00Z.
  2. Baris 9-10 (toString() dan getTime()): toString() merender titik waktu memakai zona bawaan JVM, sedangkan getTime() mengembalikan angka mentah epoch millis tanpa format apa pun.
  3. Baris 13-14 (Date salinan = rilis; salinan.setTime(0L);): variabel salinan menunjuk objek yang sama dengan rilis; setTime(0) menggeser nilai objek tersebut ke epoch.
  4. Baris 15-16: kedua variabel kini mencetak Thu Jan 01 07:00:00 WIB 1970 — bukti bahwa Date bersifat mutable. Objek yang dibagikan ke banyak bagian program dapat diubah dari satu titik mana pun.

Pada keluaran di atas zona JVM adalah Asia/Jakarta (offset +07:00), sehingga pukul 01:00 UTC tampil sebagai 08:00 WIB. Di mesin dengan zona berbeda, toString() menghasilkan teks berbeda untuk objek Date yang sama.

Calendar dan Bulan Berbasis Nol #

Calendar lahir untuk menambal keterbatasan Date: class ini memecah titik waktu menjadi field tahun, bulan, hari, jam, dan seterusnya, lengkap dengan operasi penjumlahan. Masalah desainnya yang paling terasa adalah penomoran bulan yang berbasis nol — Januari bernilai 0 dan Desember bernilai 11. Akibatnya, konstruktor new GregorianCalendar(2026, 1, 15) menghasilkan 15 Februari 2026, bukan 15 Januari, dan kesalahan semacam ini lolos dari pemeriksaan compiler karena parameternya bertipe int biasa:

import java.util.Calendar;
import java.util.GregorianCalendar;

public class CalendarLama {
    public static void main(String[] args) {
        // Cara benar: gunakan konstanta Calendar.JANUARY, bukan angka mentah.
        Calendar rapat = new GregorianCalendar(2026, Calendar.JANUARY, 15, 8, 0, 0);
        System.out.println("rapat              : " + rapat.getTime());
        System.out.println("nilai field MONTH  : " + rapat.get(Calendar.MONTH) + " (Januari = 0)");
        System.out.println("konstanta DECEMBER: " + Calendar.DECEMBER);

        // Jebakan klasik: angka 1 pada field bulan berarti FEBRUARI, bukan Januari.
        Calendar salahBulan = new GregorianCalendar(2026, 1, 15);
        System.out.println("new GregorianCalendar(2026, 1, 15): " + salahBulan.getTime());

        // Calendar juga mutable: add() menggeser tanggal pada objek yang sama.
        rapat.add(Calendar.MONTH, 1);
        System.out.println("setelah add MONTH 1: " + rapat.getTime());
    }
}

Output yang Dihasilkan:

text
rapat              : Thu Jan 15 08:00:00 WIB 2026
nilai field MONTH  : 0 (Januari = 0)
konstanta DECEMBER: 11
new GregorianCalendar(2026, 1, 15): Sun Feb 15 00:00:00 WIB 2026
setelah add MONTH 1: Sun Feb 15 08:00:00 WIB 2026

Bedah singkatnya: baris 7 memakai konstanta Calendar.JANUARY agar niat terbaca jelas; baris 9-10 membuktikan Januari bernilai 0 dan Desember bernilai 11; baris 13-14 menampilkan jebakan angka 1 yang berarti Februari; dan baris 17-18 menegaskan Calendar juga mutable — method add() mengubah objek rapat dari 15 Januari menjadi 15 Februari secara langsung.

SimpleDateFormat dan Thread Safety #

Pasangan klasik Date untuk pemformatan teks adalah java.text.SimpleDateFormat. Class tersebut menyimpan status internal yang berubah saat setiap pemanggilan format(), sehingga tidak thread-safe: dua thread yang berbagi satu objek SimpleDateFormat dapat saling menimpa status dan menghasilkan tanggal salah atau exception tak terduga. Pola yang sering dijumpai pada aplikasi web lama — satu field static SimpleDateFormat dipakai seluruh request secara bersamaan — merupakan sumber bug konkurensi yang sudah lama terdokumentasi. Solusi di masa itu adalah mengunci akses dengan synchronized atau membuat objek formatter baru setiap kali, dua pendekatan yang menambah beban dan memperpanjang kode.

java.time Dirancang Lewat JSR-310 #

JSR 310 menulis ulang API date-time dari nol dengan tiga prinsip: satu tipe untuk satu domain (tanggal saja, waktu saja, tanggal-waktu, hingga versi berzona), semua tipe inti immutable, dan operasi bernama eksplisit seperti plusDays(1) alih-alih add(Calendar.DATE, 1) dengan magic number. Padanan praktisnya dapat dirangkum begini:

API lamaPadanan java.timePerbaikan utama
java.util.DateInstanttitik waktu UTC, tanpa ketergantungan zona default
java.util.CalendarZonedDateTime / LocalDateTimebulan bernilai 1-12, method bernama
java.text.SimpleDateFormatDateTimeFormatterimmutable dan thread-safe
date.getYear() (deprecated)localDate.getYear()tahun penuh, bukan offset dari 1900

Konversi Dari API Lama ke java.time #

Kedua generasi dijembatani method konversi bawaan sehingga migrasi dapat berjalan bertahap. Date.toInstant() mengubah epoch millis menjadi Instant, sedangkan GregorianCalendar.toZonedDateTime() membaca seluruh field kalender beserta zonanya menjadi ZonedDateTime. Arah balik juga tersedia melalui Date.from() dan GregorianCalendar.from() untuk kebutuhan pustaka lama:

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class KonversiKeInstant {
    public static void main(String[] args) {
        // Date -> Instant: titik waktu absolut tanpa tergantung zona default.
        Date tanggalLama = new Date(1768438800000L);
        Instant instant = tanggalLama.toInstant();
        System.out.println("Date                 : " + tanggalLama);
        System.out.println("Date.toInstant()     : " + instant);

        // Instant -> Date: untuk API lama yang masih meminta java.util.Date.
        Date kembali = Date.from(instant);
        System.out.println("Date.from(instant)   : " + kembali);

        // GregorianCalendar -> ZonedDateTime.
        GregorianCalendar kalender =
                new GregorianCalendar(2026, Calendar.FEBRUARY, 15, 8, 0, 0);
        ZonedDateTime zdt = kalender.toZonedDateTime();
        System.out.println("toZonedDateTime()    : " + zdt);

        // ZonedDateTime -> GregorianCalendar: arah sebaliknya tetap tersedia.
        ZonedDateTime jadwal =
                ZonedDateTime.of(LocalDateTime.of(2026, 3, 15, 9, 0), ZoneId.of("Asia/Jakarta"));
        GregorianCalendar hasil = GregorianCalendar.from(jadwal);
        System.out.println("GregorianCalendar.from(): " + hasil.getTime());
    }
}

Output yang Dihasilkan:

text
Date                 : Thu Jan 15 08:00:00 WIB 2026
Date.toInstant()     : 2026-01-15T01:00:00Z
Date.from(instant)   : Thu Jan 15 08:00:00 WIB 2026
toZonedDateTime()    : 2026-02-15T08:00+07:00[Asia/Jakarta]
GregorianCalendar.from(): Sun Mar 15 09:00:00 WIB 2026

Baris 12-13 menunjukkan toInstant() yang membebaskan nilai dari zona default; baris 18 memakai Date.from() saat pustaka lama masih mengharuskan java.util.Date; baris 22-24 membaca GregorianCalendar menjadi ZonedDateTime berikut zona JVM-nya; dan baris 28-31 melakukan arah sebaliknya dengan GregorianCalendar.from().

API lama memang masih berfungsi, tetapi tiga kelemahan intinya — objek mutable, bulan berbasis nol, dan formatter yang tidak aman untuk thread — semuanya telah terjawab di java.time. Perjalanan kita dimulai dari tipe paling dasar pada bab berikutnya: LocalDate, representasi tanggal bersih tanpa jam dan tanpa zona waktu.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

CalendarLama.java

Unduh berkas
java
import java.util.Calendar;
import java.util.GregorianCalendar;

public class CalendarLama {
    public static void main(String[] args) {
        // Cara benar: gunakan konstanta Calendar.JANUARY, bukan angka mentah.
        Calendar rapat = new GregorianCalendar(2026, Calendar.JANUARY, 15, 8, 0, 0);
        System.out.println("rapat              : " + rapat.getTime());
        System.out.println("nilai field MONTH  : " + rapat.get(Calendar.MONTH) + " (Januari = 0)");
        System.out.println("konstanta DECEMBER: " + Calendar.DECEMBER);

        // Jebakan klasik: angka 1 pada field bulan berarti FEBRUARI, bukan Januari.
        Calendar salahBulan = new GregorianCalendar(2026, 1, 15);
        System.out.println("new GregorianCalendar(2026, 1, 15): " + salahBulan.getTime());

        // Calendar juga mutable: add() menggeser tanggal pada objek yang sama.
        rapat.add(Calendar.MONTH, 1);
        System.out.println("setelah add MONTH 1: " + rapat.getTime());
    }
}

DateLama.java

Unduh berkas
java
import java.util.Date;

public class DateLama {
    public static void main(String[] args) {
        // Date hanya menyimpan satu angka: milidetik sejak 1 Januari 1970 00:00:00 UTC (epoch).
        // 1768438800000L mewakili 2026-01-15T01:00:00Z.
        Date rilis = new Date(1768438800000L);

        System.out.println("toString()       : " + rilis);
        System.out.println("getTime()        : " + rilis.getTime());

        // Date mutable: setTime mengubah objek yang sama, bukan membuat objek baru.
        Date salinan = rilis;
        salinan.setTime(0L);
        System.out.println("setelah setTime(0): " + salinan);
        System.out.println("objek rilis ikut berubah: " + rilis);
    }
}

KonversiKeInstant.java

Unduh berkas
java
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class KonversiKeInstant {
    public static void main(String[] args) {
        // Date -> Instant: titik waktu absolut tanpa tergantung zona default.
        Date tanggalLama = new Date(1768438800000L);
        Instant instant = tanggalLama.toInstant();
        System.out.println("Date                 : " + tanggalLama);
        System.out.println("Date.toInstant()     : " + instant);

        // Instant -> Date: untuk API lama yang masih meminta java.util.Date.
        Date kembali = Date.from(instant);
        System.out.println("Date.from(instant)   : " + kembali);

        // GregorianCalendar -> ZonedDateTime.
        GregorianCalendar kalender =
                new GregorianCalendar(2026, Calendar.FEBRUARY, 15, 8, 0, 0);
        ZonedDateTime zdt = kalender.toZonedDateTime();
        System.out.println("toZonedDateTime()    : " + zdt);

        // ZonedDateTime -> GregorianCalendar: arah sebaliknya tetap tersedia.
        ZonedDateTime jadwal =
                ZonedDateTime.of(LocalDateTime.of(2026, 3, 15, 9, 0), ZoneId.of("Asia/Jakarta"));
        GregorianCalendar hasil = GregorianCalendar.from(jadwal);
        System.out.println("GregorianCalendar.from(): " + hasil.getTime());
    }
}