Beberapa bug tidak pernah menampilkan gejala saat development, tetapi menunggu kondisi tertentu untuk muncul: perbandingan string dengan == yang benar di komputer uji lalu salah di produksi, sambungan database yang tidak ditutup karena satu jalur exception, method equals tanpa pasangan hashCode yang membuat HashMap berperilaku aneh. SpotBugs membidik kelas bug semacam ini dengan cara yang berbeda dari Checkstyle: alat ini memeriksa bytecode hasil kompilasi, bukan teks sumber, dan mencocokkannya dengan katalog pola bug yang telah dikumpulkan dari puluhan tahun pengalaman lapangan. Pada Chapter ini kita akan memahami pendekatan bytecode, mengenal kategori temuan, lalu menjalankan dua kasus klasiknya sendiri.

Membedah Bytecode #

Pilihan SpotBugs membaca bytecode (berkas .class) punya dua konsekuensi praktis. Pertama, alat ini tidak membutuhkan kode sumber — pustaka pihak ketiga berbentuk JAR pun dapat diperiksa. Kedua, yang dianalisis adalah apa yang benar-benar dihasilkan compiler: pemanggilan method, akses field, alokasi objek — bukan sekadar teks yang kita tulis. Pola seperti perbandingan referensi (if_acmpeq pada bytecode) terlihat berbeda dari pemanggilan equals (invokevirtual), sehingga SpotBugs dapat membedakan keduanya dengan pasti.

Analisisnya juga menelusuri aliran data sederhana: apakah nilai null mungkin mengalir ke pemanggilan method, apakah nilai balik method yang mengabaikan nilai tertentu. Karena itu SpotBugs sering menemukan bug pada jalur eksekusi yang jarang dilalui — tepat jalur yang luput dari unit test.

Kategori Temuan #

Temuan SpotBugs dikelompokkan agar prioritas perbaikannya mudah diputuskan:

KategoriArtinyaContoh temuan
CorrectnessKemungkinan besar bug nyata== pada String, equals tanpa hashCode
Bad practicePraktik berisikoException ditelan kosong, resource tak ditutup
Dodgy codeMencurigakan, perlu diperiksaField tidak terbaca, pembagian bilangan bulat mencurigakan
PerformanceBoros tanpa perluPemakaian String.concat di loop ketat
SecurityBerpotensi dimanfaatkanPembacaan berkas dari path eksternal tanpa validasi

Temuan Correctness berperingkat tinggi sebaiknya dibesarkan ke prioritas perbaikan tertinggi; kategori Dodgy layak ditinjau namun tidak selalu berujung perubahan. Setiap temuan membawa nama unik yang stabil — misalnya ES_COMPARING_STRINGS_WITH_EQ — sehingga mudah dicari di dokumentasi maupun dibahas dalam review.

Kasus Klasik Perbandingan String #

Temuan paling terkenal SpotBugs adalah ES_COMPARING_STRINGS_WITH_EQ: membandingkan string dengan operator == yang membandingkan alamat objek, bukan isi. Program berikut memperagakan mengapa bug ini senyap:

public class BugPerbandinganString {
    public static void main(String[] args) {
        String kotaA = "jakarta";             // literal masuk string pool
        String kotaB = "jakarta";             // menunjuk literal yang sama
        String kotaC = new String("jakarta"); // objek baru di heap

        System.out.println("kotaA == kotaB      : " + (kotaA == kotaB));
        System.out.println("kotaA == kotaC      : " + (kotaA == kotaC));
        System.out.println("kotaA.equals(kotaC) : " + (kotaA.equals(kotaC)));
    }
}

Output yang Dihasilkan:

text
kotaA == kotaB      : true
kotaA == kotaC      : false
kotaA.equals(kotaC) : true

Mari kita bedah hasil ketiga pembandingan tersebut:

  1. kotaA == kotaB menghasilkan true: kedua literal "jakarta" ditempatkan compiler di string pool dan menunjuk objek yang sama, sehingga perbandingan alamat kebetulan berhasil — inilah yang membuat bug terasa benar saat diuji.
  2. kotaA == kotaC menghasilkan false: new String("jakarta") secara eksplisit membuat objek baru di heap; alamatnya berbeda meski isinya identik. Bandingkan alamat, dan jawabannya salah.
  3. kotaA.equals(kotaC) menghasilkan true: method equals membandingkan isi karakter demi karakter — perilaku yang hampir selalu kita maksud.
  4. Pelajarannya: == pada string dapat benar hari ini dan salah besok, tergantung bagaimana string dibuat (literal, new, hasil baca berkas, hasil concat runtime). SpotBugs menandai setiap pemakaian == pada tipe String tanpa perlu menunggu kondisi buruk itu tiba.

Kasus Klasik Resource Tak Tertutup #

Temuan kedua yang sama klasiknya adalah kewajiban sumber daya yang tidak terpenuhi (OBL_UNSATISFIED_OBLIGATION): objek seperti Connection, InputStream, atau Reader dibuka tetapi jalur tertentu tidak menutupnya. Program berikut memperagakan kebocoran itu dengan class sumber daya buatan sendiri:

public class SumberDayaDemo {
    public static void main(String[] args) {
        System.out.println("-- penutupan manual saat exception mengganggu --");
        try {
            SumberDaya koneksi = new SumberDaya("basisdata");
            koneksi.gunakan();
            koneksi.lemparGangguan();
            koneksi.close();   // baris ini tidak pernah tercapai
        } catch (IllegalStateException e) {
            System.out.println("tertangkap: " + e.getMessage());
        }

        System.out.println();
        System.out.println("-- try-with-resources saat exception mengganggu --");
        try (SumberDaya koneksi = new SumberDaya("jaringan")) {
            koneksi.gunakan();
            koneksi.lemparGangguan();
        } catch (IllegalStateException e) {
            System.out.println("tertangkap: " + e.getMessage());
        }
    }
}

class SumberDaya implements AutoCloseable {
    private final String nama;

    SumberDaya(String nama) {
        this.nama = nama;
        System.out.println("buka " + nama);
    }

    void gunakan() {
        System.out.println("gunakan " + nama);
    }

    void lemparGangguan() {
        throw new IllegalStateException("sambungan terputus");
    }

    @Override
    public void close() {
        System.out.println("tutup " + nama);
    }
}

Output yang Dihasilkan:

text
-- penutupan manual saat exception mengganggu --
buka basisdata
gunakan basisdata
tertangkap: sambungan terputus

-- try-with-resources saat exception mengganggu --
buka jaringan
gunakan jaringan
tutup jaringan
tertangkap: sambungan terputus

Poin penting dari keluaran tersebut:

  1. Bagian pertama — penutupan manual: urutan keluaran adalah buka, gunakan, lalu langsung tertangkap; baris tutup basisdata tidak pernah muncul karena lemparGangguan() melempar exception sebelum pemanggilan close() tercapai. Sumber daya bocor.
  2. Bagian kedua — try-with-resources: keluaran menampilkan tutup jaringan sebelum tertangkap. Compiler menambahkan blok finally tersembunyi yang memanggil close() otomatis — penutupan tetap terjadi apa pun jalur keluar, termasuk exception.
  3. Peran SpotBugs: pada bentuk pertama, alat melihat pembukaan resource tanpa jaminan penutupan pada semua jalur dan melaporkannya; bentuk kedua lolos karena pola try-with-resources memenuhi kewajiban tersebut.

Konfigurasi dan Pengecualian #

SpotBugs berjalan dari CLI, plugin Maven/Gradle, maupun integrasi IDE. Temuan yang memang disengaja dapat dikecualikan lewat berkas filter XML:

xml
<FindBugsFilter>
  <!-- Kode hasil generator: polanya disengaja oleh pembuatnya -->
  <Match>
    <Or>
      <Class name="~.*\.generated\..*"/>
      <Class name="~.*Dto$"/>
    </Or>
  </Match>

  <!-- Satu temuan spesifik dengan alasan terdokumentasi -->
  <Match>
    <Class name="com.toko.legacy.KonverterLama"/>
    <Bug pattern="ES_COMPARING_STRINGS_WITH_EQ"/>
  </Match>
</FindBugsFilter>

Filter di atas membeaskan dua kasus: class hasil generator dan satu pola == pada kode warisan yang memang membandingkan identitas. Sebagaimana suppression pada Checkstyle, pengecualian yang terdokumentasi sehat; pengecualian massal tanpa alasan hanya memindahkan masalah ke masa depan.

SpotBugs membaca bytecode jadi — hasil akhir proses kompilasi. Alat berikutnya justru bekerja lebih awal lagi: Error Prone menjadi plugin bagi compiler javac itu sendiri, menandai pola bermasalah pada saat kompilasi berlangsung. Chapter berikutnya membahas pendekatan tersebut beserta temuan khasnya.

Referensi #

Berkas Sumber

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

BugPerbandinganString.java

Unduh berkas
java
public class BugPerbandinganString {
    public static void main(String[] args) {
        String kotaA = "jakarta";             // literal masuk string pool
        String kotaB = "jakarta";             // menunjuk literal yang sama
        String kotaC = new String("jakarta"); // objek baru di heap

        System.out.println("kotaA == kotaB      : " + (kotaA == kotaB));
        System.out.println("kotaA == kotaC      : " + (kotaA == kotaC));
        System.out.println("kotaA.equals(kotaC) : " + (kotaA.equals(kotaC)));
    }
}

SumberDayaDemo.java

Unduh berkas
java
public class SumberDayaDemo {
    public static void main(String[] args) {
        System.out.println("-- penutupan manual saat exception mengganggu --");
        try {
            SumberDaya koneksi = new SumberDaya("basisdata");
            koneksi.gunakan();
            koneksi.lemparGangguan();
            koneksi.close();   // baris ini tidak pernah tercapai
        } catch (IllegalStateException e) {
            System.out.println("tertangkap: " + e.getMessage());
        }

        System.out.println();
        System.out.println("-- try-with-resources saat exception mengganggu --");
        try (SumberDaya koneksi = new SumberDaya("jaringan")) {
            koneksi.gunakan();
            koneksi.lemparGangguan();
        } catch (IllegalStateException e) {
            System.out.println("tertangkap: " + e.getMessage());
        }
    }
}

class SumberDaya implements AutoCloseable {
    private final String nama;

    SumberDaya(String nama) {
        this.nama = nama;
        System.out.println("buka " + nama);
    }

    void gunakan() {
        System.out.println("gunakan " + nama);
    }

    void lemparGangguan() {
        throw new IllegalStateException("sambungan terputus");
    }

    @Override
    public void close() {
        System.out.println("tutup " + nama);
    }
}