Cache menyembunyikan pekerjaan berat dari request, tetapi sebagian pekerjaan tidak dipicu request sama sekali: pengarsipan surel tiap malam, sinkronisasi stok tiap sepuluh detik, pengiriman metrik berkala. Pekerjaan berkala butuh scheduler — komponen yang membangunkan method pada waktunya tanpa ada yang memanggil. Spring menyediakannya lewat @Scheduled, satu anotasi dengan tiga semantik berbeda. Pada Chapter ini kita bedah ketiganya, membaca cron expression, menyiasati scheduler satu thread, lalu membandingkannya dalam waktu virtual.
Mengaktifkan Penjadwalan #
Dua bagian harus hadir: @EnableScheduling pada kelas konfigurasi untuk menyalakan infrastruktur, dan @Scheduled pada method yang dijadwalkan — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:
@SpringBootApplication
@EnableScheduling
public class AplikasiToko { ... }
@Component
public class PekerjaBerkala {
@Scheduled(fixedRate = 10_000) // tiap 10 detik sejak AWAL eksekusi
public void sinkronStok() { ... }
@Scheduled(fixedDelay = 10_000, initialDelay = 30_000)
public void arsipEmail() { ... } // 10 detik SETELAH eksekusi selesai
@Scheduled(cron = "0 15 8 * * *")
public void laporanPagi() { ... } // tiap hari pukul 08:15:00
}Method terjadwal wajib tanpa parameter dan bawaannya mengembalikan void; hasil balikan diabaikan. Nilai waktu pada atribut berformat durasi ISO-8601 (PT10S) atau milidetik; initialDelay menunda eksekusi pertama. Tiga semantiknya perlu dipisah tegas karena salah pilih mengubah ritme aplikasi:
| Mode | Jeda dihitung dari | Cocok untuk |
|---|---|---|
fixedRate | awal eksekusi sebelumnya | tugas pendek berirama tetap (metrik) |
fixedDelay | akhir eksekusi sebelumnya | tugas yang tidak boleh bertumpuk (arsip, sinkronisasi) |
cron | jam dinding | tugas berkalender (laporan harian, tagihan bulanan) |
fixedRate menjaga irama: bila satu eksekusi terlambat, jadwal berikutnya tetap jatuh pada waktunya. fixedDelay menjaga jarak aman: interval diukur setelah pekerjaan tuntas, sehingga pekerjaan lambat otomatis melonggarkan ritme dan tidak pernah bertumpuk.
Membaca Cron Expression #
Mode cron memakai enam field berurutan: detik menit jam tanggal bulan hari. Setiap field menerima angka tetap, rentang 8-18, daftar 1,15,30, langkah */15, dan * untuk “setiap nilai”. Beberapa pola yang sering dipakai:
| Ekspresi | Arti |
|---|---|
0 15 8 * * * | setiap hari pukul 08:15:00 |
*/15 * * * * * | setiap 15 detik |
0 0 9-17 * * MON-FRI | tiap jam 09.00–17.00 pada hari kerja |
0 0 2 1 * * | pukul 02:00 setiap tanggal 1 |
Ada juga makro khusus Spring: @Scheduled(cron = "@daily") setara 0 0 0 * * *, di samping @hourly dan @weekly. Cron dihitung terhadap zona waktu server — server di UTC menjalankan “laporan pagi 08:15” pada 15:15 WIB; tetapkan zone = "Asia/Jakarta" bila ritme mengikuti jam lokal.
Jebakan Satu Thread #
Secara bawaan, seluruh method @Scheduled berbagi satu thread scheduler. Selama sebuah tugas berjalan, tugas lain yang jadwalnya jatuh menunggu di antrean — eksekusi bisa tergeser beberapa detik tanpa satu pun error muncul. Saat beban bertambah, konfigurasikan pool:
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("jadwal-");
return scheduler;
}Alternatif modern: spring.threads.virtual.enabled=true membuat scheduler Boot memakai virtual threads — tiap eksekusi mendapat thread murah sendiri dan antrean menyusut. Untuk tugas berat, padanan tepatnya @Async — topik Chapter berikutnya.
Waktu Virtual sebagai Kaca Pembesar #
Membandingkan fixedRate dan fixedDelay dengan Thread.sleep berarti menunggu menit penuh di dunia nyata. Solusinya: jam virtual yang hanya bergerak atas perintah kita. Program berikut menjalankan tiga tugas pada scheduler satu thread selama “60 detik” — sinkron-stok (fixedRate 10 detik, durasi 2 detik), arsip-email (fixedDelay 10 detik, durasi 6 detik), dan metrik-kirim (cron */15, durasi 2 detik):
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class JadwalVirtual {
static final DateTimeFormatter JAM =
DateTimeFormatter.ofPattern("HH:mm:ss.SSS", Locale.ROOT);
// Satu tugas terjadwal: jenis, periode, durasi eksekusi, dan jadwal berikutnya.
static class Tugas {
final String nama;
final String jenis; // "fixedRate" | "fixedDelay" | "cron"
final long periodeMs;
final long durasiMs;
final String polaCron; // hanya untuk jenis cron
long berikutnya; // waktu jadwal berikutnya (ms virtual)
int eksekusi = 0;
Tugas(String nama, String jenis, long periodeMs, long durasiMs, String polaCron) {
this.nama = nama;
this.jenis = jenis;
this.periodeMs = periodeMs;
this.durasiMs = durasiMs;
this.polaCron = polaCron;
this.berikutnya = 0; // semua tugas dijadwalkan pada 08:00:00.000
}
}
// Cron mini: hanya field detik pola "*/n" (lainnya wajib "*").
static long cronBerikutnya(String pola, long kini) {
String[] field = pola.split(" ");
for (int i = 1; i < field.length; i++) {
if (!field[i].equals("*")) {
throw new IllegalArgumentException("contoh hanya mendukung */n pada detik");
}
}
long n = Long.parseLong(field[0].substring(2)) * 1000;
return (kini / n + 1) * n; // kelipatan n BERIKUTNYA setelah kini
}
public static void main(String[] args) {
List<Tugas> daftar = new ArrayList<>();
daftar.add(new Tugas("sinkron-stok", "fixedRate", 10_000, 2_000, null));
daftar.add(new Tugas("arsip-email", "fixedDelay", 10_000, 6_000, null));
daftar.add(new Tugas("metrik-kirim", "cron", 0, 2_000, "*/15 * * * * *"));
System.out.println("Scheduler virtual 1 thread, jendela 08:00:00 - 08:01:00");
System.out.println("sinkron-stok : fixedRate 10s, durasi 2s (dihitung dari AWAL)");
System.out.println("arsip-email : fixedDelay 10s, durasi 6s (dihitung dari AKHIR)");
System.out.println("metrik-kirim : cron \"*/15 * * * * *\", durasi 2s");
System.out.println();
long batas = 60_000;
long jam = 0; // jam virtual dalam milidetik sejak 08:00:00.000
while (true) {
// Pilih tugas dengan jadwal terkecil; seri mengikuti urutan pendaftaran.
Tugas terpilih = null;
for (Tugas t : daftar) {
if (t.berikutnya >= batas) {
continue;
}
if (terpilih == null || t.berikutnya < terpilih.berikutnya) {
terpilih = t;
}
}
if (terpilih == null) {
break;
}
// Eksekusi berjalan saat thread bebas, bukan saat jadwal jatuh.
long mulai = Math.max(jam, terpilih.berikutnya);
long keterlambatan = mulai - terpilih.berikutnya;
long selesai = mulai + terpilih.durasiMs;
jam = selesai;
System.out.printf("[%s] MULAI %s%s%n", fmt(mulai), terpilih.nama,
keterlambatan > 0 ? " (antre " + keterlambatan + " ms)" : "");
System.out.printf("[%s] SELESAI %s%n", fmt(selesai), terpilih.nama);
terpilih.eksekusi++;
terpilih.berikutnya = switch (terpilih.jenis) {
case "fixedRate" -> mulai + terpilih.periodeMs;
case "fixedDelay" -> selesai + terpilih.periodeMs;
case "cron" -> cronBerikutnya(terpilih.polaCron, selesai);
default -> throw new IllegalStateException();
};
}
System.out.println();
System.out.println("Rekap dalam 60 detik:");
for (Tugas t : daftar) {
System.out.printf(" %-12s %2d eksekusi%n", t.nama, t.eksekusi);
}
}
static String fmt(long ms) {
// 08:00:00.000 + ms (jam virtual beku, bukan jam dinding).
return LocalTime.ofNanoOfDay((8 * 3600_000L + ms) * 1_000_000).format(JAM);
}
}Output yang Dihasilkan:
Scheduler virtual 1 thread, jendela 08:00:00 - 08:01:00
sinkron-stok : fixedRate 10s, durasi 2s (dihitung dari AWAL)
arsip-email : fixedDelay 10s, durasi 6s (dihitung dari AKHIR)
metrik-kirim : cron "*/15 * * * * *", durasi 2s
[08:00:00.000] MULAI sinkron-stok
[08:00:02.000] SELESAI sinkron-stok
[08:00:02.000] MULAI arsip-email (antre 2000 ms)
[08:00:08.000] SELESAI arsip-email
[08:00:08.000] MULAI metrik-kirim (antre 8000 ms)
[08:00:10.000] SELESAI metrik-kirim
[08:00:10.000] MULAI sinkron-stok
[08:00:12.000] SELESAI sinkron-stok
[08:00:15.000] MULAI metrik-kirim
[08:00:17.000] SELESAI metrik-kirim
[08:00:18.000] MULAI arsip-email
[08:00:24.000] SELESAI arsip-email
[08:00:24.000] MULAI sinkron-stok (antre 4000 ms)
[08:00:26.000] SELESAI sinkron-stok
[08:00:30.000] MULAI metrik-kirim
[08:00:32.000] SELESAI metrik-kirim
[08:00:34.000] MULAI sinkron-stok
[08:00:36.000] SELESAI sinkron-stok
[08:00:36.000] MULAI arsip-email (antre 2000 ms)
[08:00:42.000] SELESAI arsip-email
[08:00:44.000] MULAI sinkron-stok
[08:00:46.000] SELESAI sinkron-stok
[08:00:46.000] MULAI metrik-kirim (antre 1000 ms)
[08:00:48.000] SELESAI metrik-kirim
[08:00:52.000] MULAI arsip-email
[08:00:58.000] SELESAI arsip-email
[08:00:58.000] MULAI sinkron-stok (antre 4000 ms)
[08:01:00.000] SELESAI sinkron-stok
Rekap dalam 60 detik:
sinkron-stok 6 eksekusi
arsip-email 4 eksekusi
metrik-kirim 4 eksekusiMari kita bedah peristiwa pentingnya:
Tugasdan pemilihanterpilih: loop utama memilih tugas denganberikutnyaterkecil; seri mengikuti urutan pendaftaran — persis antrean scheduler satu thread.berikutnyadihitung berbeda per jenis:mulai + periode(fixedRate),selesai + periode(fixedDelay), dan kelipatan cron berikutnya setelah selesai.Math.max(jam, terpilih.berikutnya): eksekusi dimulai saat thread bebas, bukan saat jadwal jatuh — selisihnya tercetak sebagai(antre N ms).arsip-emaildijadwalkan 08:00:00.000 tetapi baru berjalan 08:00:02.000 karenasinkron-stokmemegang thread — gejala scheduler satu thread dalam bentuk teramati.metrik-kirim (antre 8000 ms): cron*/15jatuh pada detik 0, 15, 30, 45, namun detik ke-0 bentrok dengan dua tugas lain — cron yang tepat waktu di atas kertas tergeser ketika scheduler sibuk. MenaikkanpoolSizeatau memindahkan tugas ke@Asyncmengembalikan ketepatan itu.- Ritme
arsip-email(fixedDelay): eksekusi selesai pukul 08:00:08.000, jadwal berikutnya 08:00:18.000 — jarak diukur dari selesai, sehingga durasi 6 detik tidak pernah mempersempit jeda 10 detik. Bandingkan dengansinkron-stok: eksekusi kedua dimulai tepat 08:00:10.000 meski sebelumnya selesai 08:00:02.000 — irama tetap kencang karena jeda diukur dari mulai. - Rekap 60 detik: 6 eksekusi
fixedRate(tiap 10 detik), 4 eksekusifixedDelay(16 detik efektif per siklus), 4 eksekusi cron — angka yang bisa dihitung tangan dari durasi dan mode masing-masing.
Penjadwalan menyuruh method berjalan pada waktu tertentu. Pertanyaan berikutnya: bagaimana method berjalan tanpa membuat pemanggil menunggu — pekerjaan latar seperti kirim surel konfirmasi atau proses unggahan besar. Itulah ranah @Async dan CompletableFuture pada Chapter Pemrosesan Asinkron.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
JadwalVirtual.java
Unduh berkasimport java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class JadwalVirtual {
static final DateTimeFormatter JAM =
DateTimeFormatter.ofPattern("HH:mm:ss.SSS", Locale.ROOT);
// Satu tugas terjadwal: jenis, periode, durasi eksekusi, dan jadwal berikutnya.
static class Tugas {
final String nama;
final String jenis; // "fixedRate" | "fixedDelay" | "cron"
final long periodeMs;
final long durasiMs;
final String polaCron; // hanya untuk jenis cron
long berikutnya; // waktu jadwal berikutnya (ms virtual)
int eksekusi = 0;
Tugas(String nama, String jenis, long periodeMs, long durasiMs, String polaCron) {
this.nama = nama;
this.jenis = jenis;
this.periodeMs = periodeMs;
this.durasiMs = durasiMs;
this.polaCron = polaCron;
this.berikutnya = 0; // semua tugas dijadwalkan pada 08:00:00.000
}
}
// Cron mini: hanya field detik pola "*/n" (lainnya wajib "*").
static long cronBerikutnya(String pola, long kini) {
String[] field = pola.split(" ");
for (int i = 1; i < field.length; i++) {
if (!field[i].equals("*")) {
throw new IllegalArgumentException("contoh hanya mendukung */n pada detik");
}
}
long n = Long.parseLong(field[0].substring(2)) * 1000;
return (kini / n + 1) * n; // kelipatan n BERIKUTNYA setelah kini
}
public static void main(String[] args) {
List<Tugas> daftar = new ArrayList<>();
daftar.add(new Tugas("sinkron-stok", "fixedRate", 10_000, 2_000, null));
daftar.add(new Tugas("arsip-email", "fixedDelay", 10_000, 6_000, null));
daftar.add(new Tugas("metrik-kirim", "cron", 0, 2_000, "*/15 * * * * *"));
System.out.println("Scheduler virtual 1 thread, jendela 08:00:00 - 08:01:00");
System.out.println("sinkron-stok : fixedRate 10s, durasi 2s (dihitung dari AWAL)");
System.out.println("arsip-email : fixedDelay 10s, durasi 6s (dihitung dari AKHIR)");
System.out.println("metrik-kirim : cron \"*/15 * * * * *\", durasi 2s");
System.out.println();
long batas = 60_000;
long jam = 0; // jam virtual dalam milidetik sejak 08:00:00.000
while (true) {
// Pilih tugas dengan jadwal terkecil; seri mengikuti urutan pendaftaran.
Tugas terpilih = null;
for (Tugas t : daftar) {
if (t.berikutnya >= batas) {
continue;
}
if (terpilih == null || t.berikutnya < terpilih.berikutnya) {
terpilih = t;
}
}
if (terpilih == null) {
break;
}
// Eksekusi berjalan saat thread bebas, bukan saat jadwal jatuh.
long mulai = Math.max(jam, terpilih.berikutnya);
long keterlambatan = mulai - terpilih.berikutnya;
long selesai = mulai + terpilih.durasiMs;
jam = selesai;
System.out.printf("[%s] MULAI %s%s%n", fmt(mulai), terpilih.nama,
keterlambatan > 0 ? " (antre " + keterlambatan + " ms)" : "");
System.out.printf("[%s] SELESAI %s%n", fmt(selesai), terpilih.nama);
terpilih.eksekusi++;
terpilih.berikutnya = switch (terpilih.jenis) {
case "fixedRate" -> mulai + terpilih.periodeMs;
case "fixedDelay" -> selesai + terpilih.periodeMs;
case "cron" -> cronBerikutnya(terpilih.polaCron, selesai);
default -> throw new IllegalStateException();
};
}
System.out.println();
System.out.println("Rekap dalam 60 detik:");
for (Tugas t : daftar) {
System.out.printf(" %-12s %2d eksekusi%n", t.nama, t.eksekusi);
}
}
static String fmt(long ms) {
// 08:00:00.000 + ms (jam virtual beku, bukan jam dinding).
return LocalTime.ofNanoOfDay((8 * 3600_000L + ms) * 1_000_000).format(JAM);
}
}