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:

text
urutan didaftarkan: [cetak-laporan, kirim-pengingat, arsipkan-transaksi]
urutan selesai    : [cetak-laporan, arsipkan-transaksi, kirim-pengingat]
nilai get() ke-2  : kirim-pengingat selesai

Mari kita telaah baris-baris kuncinya:

  1. Baris Executors.newScheduledThreadPool(2): pool berisi dua thread pekerja yang bukan daemon — JVM menolak keluar selama penjadwal belum dimatikan.
  2. Baris penjadwal.schedule(() -> {...}, jeda, MILLISECONDS): task berupa Callable sehingga bisa mengembalikan nilai; jeda 0 berarti segera.
  3. Baris urutanSelesai.add(t.nama()): setiap task mencatat namanya ke ConcurrentLinkedQueue saat berjalan — urutan penyelesaian terekam dari sisi task, bukan dari urutan get().
  4. Baris hasil.get(1).get(): nilai balik task kedua dalam urutan pendaftaran tersedia seketika karena seluruh get() sudah selesai.
  5. Baris penjadwal.shutdown(): tanpa baris ini JVM tidak keluar: dua thread non-daemon tetap menunggu. Bentuk try (penjadwal) juga sah karena ExecutorService kini AutoCloseable.

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:

text
== 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 kegagalan

Mari kita telaah perbedaan kedua versinya:

  1. Baris if (ke == 3) { throw ... } pada job rentan: eksekusi ketiga melempar RuntimeException; JVM membatalkan ScheduledFuture dan meniadakan seluruh eksekusi berikutnya — hitungan bertahan di angka 3 meski sudah ditunggu 250 milidetik.
  2. Baris rentan.get(): get() memblok sampai job gagal atau dibatalkan; hasilnya ExecutionException yang membungkus penyebab aslinya — satu-satunya jejaknya.
  3. Blok try/catch di dalam job aman: karena exception ditangkap di dalam method run, jadwal tidak pernah terganggu; riwayat [OK, OK, GALAT(...), OK, OK] membuktikan eksekusi berikutnya tetap berjalan.
  4. Baris referensi.get().cancel(false): job membatalkan dirinya sendiri setelah lima eksekusi — pola berhenti terkontrol; cancel(false) tidak menyela eksekusi yang sedang berjalan, dan get() kemudian melaporkan CancellationException.

Fixed Rate atau Fixed Delay #

Pilihan antara kedua method berkala menentukan ritme saat task lambat:

AspekscheduleAtFixedRatescheduleWithFixedDelay
Jeda dihitung sejakawal eksekusi sebelumnyaakhir eksekusi sebelumnya
Task lebih lambat dari periodeeksekusi berikutnya menyusul rapatjeda penuh tetap terjaga
Karakter ritmedenyut tetap (tiap jam pas)istirahat tetap (anti tumpang tindih)
Cocok untukagregasi berjalan tiap jam paspembersihan 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:

text
30 2 * * * /opt/rapikan/bin/java -jar rapikan.jar >> /var/log/rapikan.log 2>&1

Lima kolom di awal baris itu adalah jadwalnya:

KolomContohMakna
menit30menit ke-30
jam2pukul 02.00
hari dalam bulan*setiap tanggal
bulan*setiap bulan
hari dalam pekan*setiap hari

Perbandingan dua lapis penjadwalan dirangkum berikut:

AspekScheduledExecutorServicecron sistem operasi
Masa hidupselama JVM berjalantidak tergantung JVM
Bila aplikasi berhentijob hilang bersama prosescron memulai JVM baru saat waktunya tiba
Granularitas jedamilidetikmenit
Wilayah kerjasatu aplikasisatu 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 berkas
java
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();
    }
}

JadwalSekali.java

Unduh berkas
java
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();
    }
}