Panduan untuk Kata Kunci yang Mudah Menguap di Jawa

1. Ikhtisar

Jika tidak ada sinkronisasi yang diperlukan, compiler, runtime, atau prosesor dapat menerapkan semua jenis pengoptimalan. Meskipun pengoptimalan ini bermanfaat di sebagian besar waktu, terkadang pengoptimalan tersebut dapat menyebabkan masalah kecil.

Caching dan penyusunan ulang adalah di antara pengoptimalan yang mungkin mengejutkan kita dalam konteks yang berbarengan. Java dan JVM menyediakan banyak cara untuk mengontrol urutan memori, dan kata kunci volatile adalah salah satunya.

Pada artikel ini, kita akan fokus pada konsep dasar tetapi sering disalahpahami dalam bahasa Java - kata kunci yang mudah menguap . Pertama, kita akan mulai dengan sedikit latar belakang tentang bagaimana arsitektur komputer yang mendasarinya bekerja, dan kemudian kita akan terbiasa dengan urutan memori di Java.

2. Arsitektur Multiprosesor Bersama

Prosesor bertanggung jawab untuk menjalankan instruksi program. Oleh karena itu, mereka perlu mengambil instruksi program dan data yang diperlukan dari RAM.

Karena CPU mampu menjalankan sejumlah besar instruksi per detik, mengambil dari RAM tidaklah ideal bagi mereka. Untuk memperbaiki situasi ini, prosesor menggunakan trik seperti Eksekusi Rusak, Prediksi Cabang, Eksekusi Spekulatif, dan, tentu saja, Caching.

Di sinilah hierarki memori berikut berperan:

Saat inti yang berbeda menjalankan lebih banyak instruksi dan memanipulasi lebih banyak data, mereka mengisi cache mereka dengan data dan instruksi yang lebih relevan. Ini akan meningkatkan kinerja keseluruhan dengan mengorbankan tantangan koherensi cache .

Sederhananya, kita harus berpikir dua kali tentang apa yang terjadi ketika satu utas memperbarui nilai yang di-cache.

3. Kapan Menggunakan volatile

Untuk memperluas lebih banyak tentang koherensi cache, mari kita pinjam satu contoh dari buku Java Concurrency in Practice:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

Kelas TaskRunner memiliki dua variabel sederhana. Dalam metode utamanya, ini membuat utas lain yang berputar pada variabel siap selama itu salah. Ketika variabel menjadi benar, utas hanya akan mencetak variabel angka .

Banyak yang berharap program ini hanya mencetak 42 setelah jeda singkat. Namun, kenyataannya, penundaan itu bisa jadi lebih lama. Bahkan mungkin menggantung selamanya, atau bahkan mencetak nol!

Penyebab anomali ini adalah kurangnya visibilitas dan penataan ulang memori yang tepat . Mari kita evaluasi lebih detail.

3.1. Visibilitas Memori

Dalam contoh sederhana ini, kami memiliki dua thread aplikasi: thread utama dan thread pembaca. Mari kita bayangkan skenario di mana OS menjadwalkan utas tersebut pada dua inti CPU yang berbeda, di mana:

  • Utas utama memiliki salinan variabel siap dan angka di cache intinya
  • Untaian pembaca berakhir dengan salinannya juga
  • Utas utama memperbarui nilai yang di-cache

Pada sebagian besar prosesor modern, permintaan tulis tidak akan langsung diterapkan setelah dikeluarkan. Faktanya, prosesor cenderung mengantrekan penulisan tersebut dalam buffer tulis khusus . Setelah beberapa saat, mereka akan menerapkan semua penulisan tersebut ke memori utama sekaligus.

Dengan semua yang dikatakan, ketika utas utama memperbarui jumlah dan variabel siap , tidak ada jaminan tentang apa yang dapat dilihat utas pembaca. Dengan kata lain, rangkaian pembaca dapat langsung melihat nilai yang diperbarui, atau dengan beberapa penundaan, atau tidak pernah sama sekali!

Visibilitas memori ini dapat menyebabkan masalah kehidupan dalam program yang mengandalkan visibilitas.

3.2. Menyusun ulang

Lebih buruk lagi, thread pembaca dapat melihat penulisan tersebut dalam urutan apa pun selain urutan program yang sebenarnya . Misalnya, sejak pertama kali kita memperbarui variabel angka :

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Kita mungkin mengharapkan thread pembaca mencetak 42. Namun, sebenarnya mungkin untuk melihat nol sebagai nilai yang dicetak!

Penataan ulang merupakan teknik pengoptimalan untuk peningkatan kinerja. Menariknya, berbagai komponen dapat menerapkan pengoptimalan ini:

  • Prosesor dapat membersihkan buffer tulisnya dalam urutan apa pun selain urutan program
  • Prosesor dapat menerapkan teknik eksekusi out-of-order
  • Kompilator JIT dapat mengoptimalkan melalui penyusunan ulang

3.3. Urutan Memori volatile

Untuk memastikan bahwa pembaruan pada variabel menyebar secara terprediksi ke utas lain, kita harus menerapkan pengubah volatile ke variabel tersebut:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

Dengan cara ini, kami berkomunikasi dengan runtime dan prosesor untuk tidak menyusun ulang instruksi apa pun yang melibatkan variabel volatil . Selain itu, pemroses memahami bahwa mereka harus segera menghapus pembaruan apa pun ke variabel ini.

4. volatile dan sinkronisasi benang

Untuk aplikasi multithread, kita perlu memastikan beberapa aturan untuk perilaku yang konsisten:

  • Pengecualian Reksa - hanya satu utas yang menjalankan bagian kritis pada satu waktu
  • Visibilitas - perubahan yang dibuat oleh satu utas ke data bersama dapat dilihat oleh utas lain untuk menjaga konsistensi data

metode dan blok tersinkronisasi menyediakan kedua properti di atas, dengan mengorbankan kinerja aplikasi.

volatile adalah kata kunci yang cukup berguna karena dapat membantu memastikan aspek visibilitas perubahan data tanpa, tentu saja, saling mengecualikan . Jadi, ini berguna di tempat-tempat di mana kita baik-baik saja dengan banyak utas yang mengeksekusi satu blok kode secara paralel, tetapi kita perlu memastikan properti visibilitas.

5. Terjadi-Sebelum Memesan

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Dengan menggunakan semantik ini, kami hanya dapat mendefinisikan beberapa variabel di kelas kami sebagai variabel yang mudah berubah dan mengoptimalkan jaminan visibilitas.

6. Kesimpulan

Dalam tutorial ini, kami telah menjelajahi lebih banyak tentang kata kunci yang mudah menguap dan kemampuannya, serta peningkatan yang dilakukan padanya dimulai dengan Java 5.

Seperti biasa, contoh kode dapat ditemukan di GitHub.