Alat yang kita rakit sepanjang section ini — utilitas CLI, pipeline batch, perapit file system — semuanya berjalan ketika dipanggil. Otomasi sungguhan justru bekerja ketika tidak ada yang memanggil: laporan tercetak setiap pukul 02.00, folder unduhan dirapikan tiap malam. Ada dua lapis untuk mengatur waktu seperti itu: di dalam JVM lewat ScheduledExecutorService, atau di tingkat sistem operasi lewat cron. Chapter ini membahas keduanya — termasuk satu jebakan yang membuat job berkala berhenti diam-diam tanpa pesan apa pun.
Penjadwalan dalam JVM #
ScheduledExecutorService adalah ExecutorService dengan kemampuan waktu: method schedule() menerima jeda awal dan satuan waktu, lalu menjalankan task tepat setelah jeda itu terlewati. Nilai baliknya adalah ScheduledFuture — janji hasil yang bisa ditunggu dengan get(). Program berikut menjadwalkan tiga task sekali jalan dengan jeda 0, 300, dan 150 milidetik, lalu membandingkan urutan pendaftaran dengan urutan kenyataan:
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public class JadwalSekali {
record Tugas(String nama, long jedaMilidetik) {}
public static void main(String[] args) throws Exception {
ScheduledExecutorService penjadwal = Executors.newScheduledThreadPool(2);
// Tiga task sekali jalan: jeda menentukan kapan masing-masing berjalan.
List<Tugas> jadwal = List.of(
new Tugas("cetak-laporan", 0),
new Tugas("kirim-pengingat", 300),
new Tugas("arsipkan-transaksi", 150));
Queue<String> urutanSelesai = new ConcurrentLinkedQueue<>();
List<ScheduledFuture<String>> hasil = new ArrayList<>();
for (Tugas t : jadwal) {
hasil.add(penjadwal.schedule(
() -> {
urutanSelesai.add(t.nama());
return t.nama() + " selesai";
},
t.jedaMilidetik(), TimeUnit.MILLISECONDS));
}
// get() menunggu tiap task tuntas; nilai baliknya utuh.
for (ScheduledFuture<String> f : hasil) {
f.get();
}
System.out.println("urutan didaftarkan: "
+ jadwal.stream().map(Tugas::nama).toList());
System.out.println("urutan selesai : " + urutanSelesai.stream().toList());
System.out.println("nilai get() ke-2 : " + hasil.get(1).get());
// Thread pekerja bukan daemon: tanpa shutdown, JVM tidak akan keluar.
penjadwal.shutdown();
}
}Output yang Dihasilkan:
urutan didaftarkan: [cetak-laporan, kirim-pengingat, arsipkan-transaksi]
urutan selesai : [cetak-laporan, arsipkan-transaksi, kirim-pengingat]
nilai get() ke-2 : kirim-pengingat selesaiMari kita telaah baris-baris kuncinya:
- Baris
Executors.newScheduledThreadPool(2): pool berisi dua thread pekerja yang bukan daemon — JVM menolak keluar selama penjadwal belum dimatikan. - Baris
penjadwal.schedule(() -> {...}, jeda, MILLISECONDS): task berupaCallablesehingga bisa mengembalikan nilai; jeda 0 berarti segera. - Baris
urutanSelesai.add(t.nama()): setiap task mencatat namanya keConcurrentLinkedQueuesaat berjalan — urutan penyelesaian terekam dari sisi task, bukan dari urutanget(). - Baris
hasil.get(1).get(): nilai balik task kedua dalam urutan pendaftaran tersedia seketika karena seluruhget()sudah selesai. - Baris
penjadwal.shutdown(): tanpa baris ini JVM tidak keluar: dua thread non-daemon tetap menunggu. Bentuktry (penjadwal)juga sah karenaExecutorServicekiniAutoCloseable.
Urutan selesai yang berbeda membuktikan bahwa penjadwal bekerja dengan waktu, bukan dengan antrian.
Job Berkala dan Jebakan Kegagalannya #
Dua method mengatur job berulang — scheduleAtFixedRate dan scheduleWithFixedDelay — yang dibedakan pada bagian berikutnya. Jebakan keduanya sama: bila task melempar exception yang lolos keluar, JVM membatalkan seluruh jadwal dan job berhenti tanpa stack trace di konsol. Program berikut memperagakan versi rentan lalu versi aman:
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
public class JadwalBerkala {
public static void main(String[] args) throws Exception {
ScheduledExecutorService penjadwal = Executors.newScheduledThreadPool(1);
System.out.println("== job rentan: exception lolos ke luar ==");
AtomicInteger hitung = new AtomicInteger();
Queue<String> riwayat = new ConcurrentLinkedQueue<>();
ScheduledFuture<?> rentan = penjadwal.scheduleAtFixedRate(() -> {
int ke = hitung.incrementAndGet();
if (ke == 3) {
riwayat.add("GALAT");
throw new RuntimeException("pelaporan gagal");
}
riwayat.add("OK");
}, 0, 100, TimeUnit.MILLISECONDS);
String laporan;
try {
rentan.get(); // menunggu sampai job gagal atau dibatalkan
laporan = "selesai normal";
} catch (ExecutionException e) {
laporan = e.getClass().getSimpleName() + " -> " + e.getCause();
}
System.out.println("get() memberi : " + laporan);
// Setelah kegagalan, tidak ada eksekusi baru sama sekali.
Thread.sleep(250);
System.out.println("riwayat eksekusi : " + riwayat.stream().toList());
System.out.println("total setelah 250 ms : " + hitung.get()
+ " -- job mati diam-diam");
System.out.println();
System.out.println("== job aman: exception ditangkap di dalam ==");
AtomicInteger hitungAman = new AtomicInteger();
Queue<String> riwayatAman = new ConcurrentLinkedQueue<>();
AtomicReference<ScheduledFuture<?>> referensi = new AtomicReference<>();
ScheduledFuture<?> aman = penjadwal.scheduleAtFixedRate(() -> {
int ke = hitungAman.incrementAndGet();
try {
if (ke == 3) {
throw new RuntimeException("pelaporan gagal");
}
riwayatAman.add("OK");
} catch (RuntimeException e) {
// Job tetap hidup: galat dicatat, eksekusi berikutnya lanjut.
riwayatAman.add("GALAT(" + e.getMessage() + ")");
}
if (ke >= 5) {
referensi.get().cancel(false); // berhenti terkontrol
}
}, 0, 100, TimeUnit.MILLISECONDS);
referensi.set(aman);
try {
aman.get();
} catch (CancellationException e) {
System.out.println("get() memberi : " + e.getClass().getSimpleName()
+ " (pembatalan terkontrol)");
}
System.out.println("riwayat eksekusi : " + riwayatAman.stream().toList());
System.out.println("total eksekusi : " + hitungAman.get()
+ " -- job melewati kegagalan");
penjadwal.shutdown();
}
}Output yang Dihasilkan:
== job rentan: exception lolos ke luar ==
get() memberi : ExecutionException -> java.lang.RuntimeException: pelaporan gagal
riwayat eksekusi : [OK, OK, GALAT]
total setelah 250 ms : 3 -- job mati diam-diam
== job aman: exception ditangkap di dalam ==
get() memberi : CancellationException (pembatalan terkontrol)
riwayat eksekusi : [OK, OK, GALAT(pelaporan gagal), OK, OK]
total eksekusi : 5 -- job melewati kegagalanMari kita telaah perbedaan kedua versinya:
- Baris
if (ke == 3) { throw ... }pada job rentan: eksekusi ketiga melemparRuntimeException; JVM membatalkanScheduledFuturedan meniadakan seluruh eksekusi berikutnya — hitungan bertahan di angka 3 meski sudah ditunggu 250 milidetik. - Baris
rentan.get():get()memblok sampai job gagal atau dibatalkan; hasilnyaExecutionExceptionyang membungkus penyebab aslinya — satu-satunya jejaknya. - Blok
try/catchdi dalam job aman: karena exception ditangkap di dalam methodrun, jadwal tidak pernah terganggu; riwayat[OK, OK, GALAT(...), OK, OK]membuktikan eksekusi berikutnya tetap berjalan. - Baris
referensi.get().cancel(false): job membatalkan dirinya sendiri setelah lima eksekusi — pola berhenti terkontrol;cancel(false)tidak menyela eksekusi yang sedang berjalan, danget()kemudian melaporkanCancellationException.
Fixed Rate atau Fixed Delay #
Pilihan antara kedua method berkala menentukan ritme saat task lambat:
| Aspek | scheduleAtFixedRate | scheduleWithFixedDelay |
|---|---|---|
| Jeda dihitung sejak | awal eksekusi sebelumnya | akhir eksekusi sebelumnya |
| Task lebih lambat dari periode | eksekusi berikutnya menyusul rapat | jeda penuh tetap terjaga |
| Karakter ritme | denyut tetap (tiap jam pas) | istirahat tetap (anti tumpang tindih) |
| Cocok untuk | agregasi berjalan tiap jam pas | pembersihan yang durasinya berubah-ubah |
Penjadwalan di Sistem Operasi #
Job dalam JVM mati bersama prosesnya. Untuk jadwal yang harus bertahan melewati restart aplikasi, lapisan yang tepat adalah cron milik sistem operasi. Satu baris crontab menjalankan utilitas JAR kita setiap malam:
30 2 * * * /opt/rapikan/bin/java -jar rapikan.jar >> /var/log/rapikan.log 2>&1Lima kolom di awal baris itu adalah jadwalnya:
| Kolom | Contoh | Makna |
|---|---|---|
| menit | 30 | menit ke-30 |
| jam | 2 | pukul 02.00 |
| hari dalam bulan | * | setiap tanggal |
| bulan | * | setiap bulan |
| hari dalam pekan | * | setiap hari |
Perbandingan dua lapis penjadwalan dirangkum berikut:
| Aspek | ScheduledExecutorService | cron sistem operasi |
|---|---|---|
| Masa hidup | selama JVM berjalan | tidak tergantung JVM |
| Bila aplikasi berhenti | job hilang bersama proses | cron memulai JVM baru saat waktunya tiba |
| Granularitas jeda | milidetik | menit |
| Wilayah kerja | satu aplikasi | satu server, lintas aplikasi |
Job terjadwal, utilitas CLI, dan alat batch yang kita bangun akhirnya harus sampai ke tangan pengguna — sebagai satu berkas yang bisa dibagikan dan berjalan di mesin tanpa JDK terpasang. Chapter penutup section ini mengurusnya: mengemas aplikasi Java menjadi JAR, image runtime, dan pemasang.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
JadwalBerkala.java
Unduh berkasimport java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
public class JadwalBerkala {
public static void main(String[] args) throws Exception {
ScheduledExecutorService penjadwal = Executors.newScheduledThreadPool(1);
System.out.println("== job rentan: exception lolos ke luar ==");
AtomicInteger hitung = new AtomicInteger();
Queue<String> riwayat = new ConcurrentLinkedQueue<>();
ScheduledFuture<?> rentan = penjadwal.scheduleAtFixedRate(() -> {
int ke = hitung.incrementAndGet();
if (ke == 3) {
riwayat.add("GALAT");
throw new RuntimeException("pelaporan gagal");
}
riwayat.add("OK");
}, 0, 100, TimeUnit.MILLISECONDS);
String laporan;
try {
rentan.get(); // menunggu sampai job gagal atau dibatalkan
laporan = "selesai normal";
} catch (ExecutionException e) {
laporan = e.getClass().getSimpleName() + " -> " + e.getCause();
}
System.out.println("get() memberi : " + laporan);
// Setelah kegagalan, tidak ada eksekusi baru sama sekali.
Thread.sleep(250);
System.out.println("riwayat eksekusi : " + riwayat.stream().toList());
System.out.println("total setelah 250 ms : " + hitung.get()
+ " -- job mati diam-diam");
System.out.println();
System.out.println("== job aman: exception ditangkap di dalam ==");
AtomicInteger hitungAman = new AtomicInteger();
Queue<String> riwayatAman = new ConcurrentLinkedQueue<>();
AtomicReference<ScheduledFuture<?>> referensi = new AtomicReference<>();
ScheduledFuture<?> aman = penjadwal.scheduleAtFixedRate(() -> {
int ke = hitungAman.incrementAndGet();
try {
if (ke == 3) {
throw new RuntimeException("pelaporan gagal");
}
riwayatAman.add("OK");
} catch (RuntimeException e) {
// Job tetap hidup: galat dicatat, eksekusi berikutnya lanjut.
riwayatAman.add("GALAT(" + e.getMessage() + ")");
}
if (ke >= 5) {
referensi.get().cancel(false); // berhenti terkontrol
}
}, 0, 100, TimeUnit.MILLISECONDS);
referensi.set(aman);
try {
aman.get();
} catch (CancellationException e) {
System.out.println("get() memberi : " + e.getClass().getSimpleName()
+ " (pembatalan terkontrol)");
}
System.out.println("riwayat eksekusi : " + riwayatAman.stream().toList());
System.out.println("total eksekusi : " + hitungAman.get()
+ " -- job melewati kegagalan");
penjadwal.shutdown();
}
}
JadwalSekali.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public class JadwalSekali {
record Tugas(String nama, long jedaMilidetik) {}
public static void main(String[] args) throws Exception {
ScheduledExecutorService penjadwal = Executors.newScheduledThreadPool(2);
// Tiga task sekali jalan: jeda menentukan kapan masing-masing berjalan.
List<Tugas> jadwal = List.of(
new Tugas("cetak-laporan", 0),
new Tugas("kirim-pengingat", 300),
new Tugas("arsipkan-transaksi", 150));
Queue<String> urutanSelesai = new ConcurrentLinkedQueue<>();
List<ScheduledFuture<String>> hasil = new ArrayList<>();
for (Tugas t : jadwal) {
hasil.add(penjadwal.schedule(
() -> {
urutanSelesai.add(t.nama());
return t.nama() + " selesai";
},
t.jedaMilidetik(), TimeUnit.MILLISECONDS));
}
// get() menunggu tiap task tuntas; nilai baliknya utuh.
for (ScheduledFuture<String> f : hasil) {
f.get();
}
System.out.println("urutan didaftarkan: "
+ jadwal.stream().map(Tugas::nama).toList());
System.out.println("urutan selesai : " + urutanSelesai.stream().toList());
System.out.println("nilai get() ke-2 : " + hasil.get(1).get());
// Thread pekerja bukan daemon: tanpa shutdown, JVM tidak akan keluar.
penjadwal.shutdown();
}
}