Mengenal Thread Safe Pada Java


Hai, teman-teman! Kali ini kita akan membahas salah satu konsep penting dalam pemrograman Java, yaitu Thread Safe. Jika kamu baru belajar Java atau sedang mendalami konsep pemrograman multi-threading, artikel ini cocok buat kamu. Jika sebelumnya pada bahasan Objek Mutable dan Immutable pada Java ada poin tentang thread safe. kita akan bahas lengkap thread safe ini. Yuk, kita mulai!

Apa itu Thread Safe?

    Thread safe (atau thread safety) adalah istilah yang menggambarkan bahwa sebuah kode atau objek bisa digunakan oleh beberapa thread sekaligus tanpa menimbulkan masalah seperti data yang rusak atau hasil yang tidak diinginkan.

    Bayangkan kamu sedang menggunakan beberapa jalan tol yang semuanya menuju satu tujuan, tetapi kalau jalur yang kamu gunakan tidak diatur dengan baik, bisa terjadi tabrakan, kan? Nah, begitulah analogi sederhana dari multi-threading. Jika beberapa thread mengakses dan memodifikasi data secara bersamaan tanpa sinkronisasi yang tepat, akan ada tabrakan di dalam program kita.

Kenapa Perlu Memahami Thread Safe?

    Java mendukung multi-threading, yang artinya kamu bisa menjalankan beberapa proses (thread) secara bersamaan untuk meningkatkan efisiensi program. Misalnya, kalau kamu punya aplikasi yang harus mengolah banyak data secara paralel, menggunakan beberapa thread bisa membuat prosesnya lebih cepat.

    Namun, jika tidak hati-hati, thread bisa saling bertabrakan ketika mencoba mengakses dan memodifikasi data yang sama. Inilah alasan kenapa penting untuk memastikan bahwa kode yang berhubungan dengan thread adalah thread-safe.

Contoh Permasalahan Tanpa Thread Safe

Mari kita lihat contoh sederhana di bawah ini:

 
public class Counter {
    private int count = 0;

    public static void main(String[] args) {
        Counter counter = new Counter();
        
        for (int i = 1; i <= 5; i++) {
            System.out.print("running count at - " + i + " -> ");
            counter.countThread();
        }
    }

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
    
    public void resetCount() {
        count = 0;
    }

    public void countThread() {
        resetCount();
        
        // Membuat beberapa thread yang akan menjalankan increment secara bersamaan
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        // Menunggu kedua thread selesai
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final Count: " + getCount());
    }

} 

    Dalam kode di atas, kita punya sebuah kelas Counter yang menambah nilai count setiap kali metode increment() dipanggil. Pada code yang single-threading, kode ini mungkin berjalan lancar dan "sebagai mana mestinya". Tapi bagaimana jika berjalan pada skema dua atau lebih thread (multi-thread) yang mengakses metode increment() secara bersamaan? Ada kemungkinan bisa terjadi race condition yaitu dua thread atau lebih membaca nilai count  yang sama sebelum salah satunya sempat menambahkan nilainya. Akibatnya, nilai count  yang diharapkan tidak sesuai.

Dengan Hasilnya Seperi berikut :

running count at - 1 -> Final Count: 1533
running count at - 2 -> Final Count: 1731
running count at - 3 -> Final Count: 2000
running count at - 4 -> Final Count: 2000
running count at - 5 -> Final Count: 1712

Lalu bagiamana untuk mengatasinya ?

Berikut beberapa caranya

Cara Membuat Kode Thread Safe

1. Menggunakan synchronized

    Cara termudah untuk memastikan metode atau blok kode bersifat thread-safe adalah dengan menambahkan kata kunci synchronized. Ini akan memastikan bahwa hanya satu thread yang bisa mengakses metode atau blok kode tersebut pada satu waktu.

public class Counter {
    private int count = 0;
    private int counter2 = 0;

    public static void main(String[] args) {
        Counter counter = new Counter();

        for (int i = 1; i <= 5; i++) {
            System.out.print("running count at - " + i + " -> ");
            counter.countThread();
        }
    }

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public void resetCount() {
        count = 0;
        counter2 = 0;
    }

    public void countThread() {
        resetCount();

        // Membuat beberapa thread yang akan menjalankan increment secara bersamaan
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
                incrementSync();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
                incrementSync();
            }
        });

        thread1.start();
        thread2.start();

        // Menunggu kedua thread selesai
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        System.out.println();
        System.out.println("Final Count NOT SAFE : " + getCount());
        System.out.println("Final Count With Sync: " + getCountSync());
    }

    public synchronized void incrementSync() {
        counter2++;
    }

    public synchronized int getCountSync() {
        return counter2;
    }

} 

Dengan menambahkan synchronized, kita menjamin bahwa hanya satu thread yang bisa mengakses incrementSync() atau getCountSync() pada satu waktu. Sehingga perbandingannya akan seperti ini :

 
running count at - 1 -> 
Final Count NOT SAFE : 1996
Final Count With Sync: 2000
running count at - 2 -> 
Final Count NOT SAFE : 2000
Final Count With Sync: 2000
running count at - 3 -> 
Final Count NOT SAFE : 1999
Final Count With Sync: 2000
running count at - 4 -> 
Final Count NOT SAFE : 2000
Final Count With Sync: 2000
running count at - 5 -> 
Final Count NOT SAFE : 1997
Final Count With Sync: 2000 

hasil menggunakan synchronized sesuai dan tidak mengalami race-condition.

2. Menggunakan Kelas dari java.util.concurrent

    Java menyediakan beberapa kelas di dalam package java.util.concurrent yang dirancang untuk membantu kita dalam menangani masalah thread-safety. Pada kasus di atas akan digunakan java.util.concurrent.atomic.AtomicInteger. Menggunakan kelas ini bisa menjadi solusi praktis untuk menagangi masalah penambahan nilai secara bersamaan oleh beberapa thread.

 
import java.util.concurrent.atomic.AtomicInteger;

public class CounterAtomic {
    private AtomicInteger count = new AtomicInteger(0);
    
    public static void main(String[] args) {
        CounterAtomic counter = new CounterAtomic();

        for (int i = 1; i <= 5; i++) {
            System.out.print("running count at - " + i + " -> ");
            counter.countThread();
        }
    }
    
    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
    
    public void resetCount() {
        count = new AtomicInteger();
    }
    
    public void countThread() {
        resetCount();
        // Membuat beberapa thread yang akan menjalankan increment secara bersamaan
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        // Menunggu kedua thread selesai
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        System.out.println("Final Count With Atomic : " + getCount());
    }
} 

Di sini, kita menggunakan AtomicInteger yang menjamin operasi penambahan aman dilakukan pada multi-threaded tanpa perlu menggunakan synchronized. Hasil running programmnya : 

running count at - 1 -> Final Count With Atomic : 2000
running count at - 2 -> Final Count With Atomic : 2000
running count at - 3 -> Final Count With Atomic : 2000
running count at - 4 -> Final Count With Atomic : 2000
running count at - 5 -> Final Count With Atomic : 2000 

3. Menggunakan Collections yang Thread Safe

Jika kamu bekerja dengan koleksi data (seperti List, Mapdll.), ada beberapa versi thread-safe yang disediakan oleh Java, seperti CopyOnWriteArrayList atau ConcurrentHashMap.

Contohnya:

 
import java.util.concurrent.CopyOnWriteArrayList;

public class CollectionSafe {
    private CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();

    public static void main(String[] args) {
        CollectionSafe coSafe = new CollectionSafe();

        for (int i = 1; i <= 5; i++) {
            System.out.print("running count at - " + i + " -> ");
            coSafe.countThread();
        }
    }

    public void addElement(Integer element) {
        list.add(element);
    }

    public Integer getElement(int index) {
        return list.get(index);
    }

    public void resetData() {
        list.clear();
    }

    public void countThread() {
        resetData();
        // Membuat beberapa thread yang akan menjalankan increment secara bersamaan
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                addElement(i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                addElement(i);
            }
        });

        thread1.start();
        thread2.start();

        // Menunggu kedua thread selesai
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final size collection : " + list.size());
    }
} 

Kelas-kelas seperti `CopyOnWriteArrayList` memastikan bahwa semua operasi pada list aman diakses oleh banyak thread secara bersamaan. Hasilnya adalah sebagai berikut : 

 
running count at - 1 -> Final size collection : 2000
running count at - 2 -> Final size collection : 2000
running count at - 3 -> Final size collection : 2000
running count at - 4 -> Final size collection : 2000
running count at - 5 -> Final size collection : 2000 

4. Menggunakan volatile

    Selain synchronized dan kelas dari java.util.concurrent, Java juga menyediakan kata kunci volatile yang bisa membantu dalam kasus tertentu. volatile adalah kata kunci yang memberi tahu JVM (Java Virtual Machine) bahwa nilai suatu variabel dapat diubah oleh beberapa thread secara bersamaan. Ini memastikan bahwa setiap thread selalu membaca nilai terbaru dari memori utama, bukan dari cache CPU. Tanpa volatile, mungkin saja sebuah thread membaca nilai lama yang tersimpan di cache, meskipun thread lain sudah memperbarui nilai tersebut di memori utama.

Contohnya:

 
package sufyan97_blog.thread_safety;

public class VolatileEx {
    // Deklarasi variabel volatile
    private static volatile boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        // Thread 1: Menunggu perubahan nilai flag menjadi true
        Thread readerThread = new Thread(() -> {
            System.out.println("Reader Thread: Menunggu flag berubah menjadi true...");
            while (!flag) {
                // Busy-waiting hingga flag menjadi true
            }
            System.out.println("Reader Thread: Flag telah berubah menjadi true!");
        });

        // Thread 2: Mengubah nilai flag setelah beberapa waktu
        Thread writerThread = new Thread(() -> {
            try {
                Thread.sleep(2000); // Simulasi jeda sebelum mengubah flag
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Writer Thread: Mengubah flag menjadi true...");
            flag = true; // Mengubah flag
        });

        // Memulai kedua thread
        readerThread.start();
        writerThread.start();

        // Menunggu kedua thread selesai
        readerThread.join();
        writerThread.join();
    }
}
 
Hasil
 
Reader Thread: Menunggu flag berubah menjadi true...
Writer Thread: Mengubah flag menjadi true...
Reader Thread: Flag telah berubah menjadi true! 

Penjelasan:
  1. Variabel volatile: Variabel flag dideklarasikan sebagai volatile, yang berarti setiap perubahan pada flag oleh satu thread akan terlihat langsung oleh thread lain.
  2. Thread 1 (Reader): Thread readerThread menjalankan loop yang menunggu hingga flag berubah menjadi true. Karena flag adalah volatile, pembaruan yang dilakukan oleh thread lain akan terlihat segera.
  3. Thread 2 (Writer): Thread writerThread menunggu selama 2 detik sebelum mengubah flag menjadi true. Ketika perubahan ini terjadi, readerThread akan segera mendeteksi perubahan dan keluar dari loop.
  4. Sinkronisasi Memori: Tanpa volatile, thread readerThread mungkin tidak akan melihat perubahan pada flag karena nilai flag bisa disimpan dalam cache lokal dari CPU thread yang menjalankan readerThread.

Mengapa Menggunakan volatile:

  • Memastikan Konsistensi Data: Dengan volatile, pembaruan variabel langsung ditulis ke memori utama, dan setiap thread lain yang membaca variabel akan membaca dari memori utama, bukan dari cache lokal thread.
  • Kegunaan: volatile berguna ketika hanya ada satu thread yang menulis (writer) dan beberapa thread yang membaca (readers), tanpa memerlukan sinkronisasi yang lebih kompleks seperti synchronized.

Kapan Tidak Cukup Menggunakan volatile:

  • volatile tidak menjamin atomicity. Jika Anda perlu melakukan operasi yang melibatkan beberapa langkah (misalnya, operasi peningkatan nilai), Anda tetap perlu menggunakan mekanisme sinkronisasi lain seperti synchronized atau kelas seperti AtomicInteger.

5. Menggunakan final untuk Immutability

Salah satu teknik lain untuk mencapai thread safety adalah dengan menggunakan final. Kelas atau objek yang bersifat immutable (tidak bisa diubah) secara otomatis menjadi thread-safe karena nilainya tidak bisa diubah setelah objek dibuat. Jika tidak ada yang bisa mengubah data, tidak ada potensi untuk terjadinya race condition.

Contoh Penggunaan final

 
package sufyan97_blog.thread_safety;

public class ImmutableExample {
    private final int value;
    
    public ImmutableExample(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
} 

    Dalam contoh di atas, setelah objek ImmutableExample dibuat, nilai value tidak bisa diubah lagi. Karena objek ini tidak bisa dimodifikasi, maka secara otomatis thread-safe. Adapun jika kita ingin membuat kelas yang benar-benar immutable, kita harus memastikan semua variabel kelas tersebut final dan tidak ada metode yang bisa memodifikasi nilai tersebut setelah objek dibuat.

 
package sufyan97_blog.thread_safety;

public final class ImmutablePerson {
    private final String name;
    private final int age;

    public ImmutablePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
} 

Kelas ImmutablePerson di atas tidak bisa dimodifikasi setelah objek dibuat. Ini membuatnya thread-safe tanpa perlu sinkronisasi eksplisit atau penggunaan volatile.

Kesimpulan

Dalam pengembangan aplikasi multi-threading di Java, penting untuk memahami berbagai cara memastikan keamanan data ketika thread bekerja secara bersamaan. Beberapa metode yang bisa digunakan adalah:

  1. synchronized: Mengatur agar hanya satu thread yang dapat mengakses blok kode atau metode tertentu dalam satu waktu, menghindari race condition.

  2. volatile: Memastikan visibilitas perubahan variabel antar thread, namun tidak menjamin atomisitas.

  3. final: Membuat objek immutable, yang secara otomatis thread-safe karena tidak dapat dimodifikasi setelah inisialisasi.

  4. Kelas Atomic seperti AtomicInteger: Menyediakan operasi atomik (tanpa perlu synchronized) untuk memastikan modifikasi nilai dilakukan dengan aman dan konsisten antar thread. Kelas-kelas ini berguna untuk operasi sederhana seperti penambahan atau pengurangan nilai tanpa risiko intervensi antar thread.

Menggabungkan berbagai teknik ini, kamu bisa memastikan bahwa program Java yang menggunakan beberapa thread akan bekerja dengan aman dan efisien, menghindari masalah seperti race conditions dan inkonsistensi data.

Mudah-mudahan sekarang kamu punya gambaran lebih jelas tentang apa itu thread safety dan bagaimana cara mengatasinya di Java. Semoga artikel ini bermanfaat! Selamat ngoding!

 untuk source code dalam bentuk githubnya bisa dilihat disini

Referensi

Posting Komentar untuk "Mengenal Thread Safe Pada Java"