Loop tidak melihat nilai diubah oleh utas lain tanpa pernyataan cetak

91

Dalam kode saya, saya memiliki loop yang menunggu beberapa status diubah dari utas yang berbeda. Utas lainnya berfungsi, tetapi loop saya tidak pernah melihat nilai yang berubah. Itu menunggu selamanya. Namun, ketika saya meletakkan System.out.printlnpernyataan di loop, tiba-tiba berhasil! Mengapa?


Berikut ini adalah contoh kode saya:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (pizzaArrived == false) {
            //System.out.println("waiting");
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        pizzaArrived = true;
    }
}

Saat loop sementara berjalan, saya memanggil deliverPizza()dari utas yang berbeda untuk mengatur pizzaArrivedvariabel. Tetapi loop hanya berfungsi ketika saya menghapus komentar pada System.out.println("waiting");pernyataan tersebut. Apa yang sedang terjadi?

Boann
sumber

Jawaban:

152

JVM diizinkan untuk menganggap bahwa utas lain tidak mengubah pizzaArrivedvariabel selama pengulangan. Dengan kata lain, ini dapat mengangkat pizzaArrived == falsetes di luar loop, mengoptimalkan ini:

while (pizzaArrived == false) {}

ke dalam ini:

if (pizzaArrived == false) while (true) {}

yang merupakan loop tak terbatas.

Untuk memastikan bahwa perubahan yang dibuat oleh satu utas terlihat oleh utas lain, Anda harus selalu menambahkan beberapa sinkronisasi antar utas. Cara termudah untuk melakukannya adalah dengan membuat variabel bersama volatile:

volatile boolean pizzaArrived = false;

Membuat variabel volatilemenjamin bahwa utas yang berbeda akan melihat efek perubahan satu sama lain terhadapnya. Ini mencegah JVM dari menyimpan nilai pizzaArrivedatau mengangkat pengujian di luar loop. Sebaliknya, ia harus membaca nilai variabel riil setiap saat.

(Secara lebih formal, volatilemembuat hubungan terjadi-sebelum antara akses ke variabel. Ini berarti bahwa semua pekerjaan lain yang dilakukan utas sebelum mengirim pizza juga terlihat oleh utas yang menerima pizza, meskipun perubahan lain tersebut bukan pada volatilevariabel.)

Metode tersinkronisasi digunakan pada prinsipnya untuk menerapkan pengecualian timbal balik (mencegah dua hal terjadi pada saat yang sama), tetapi metode tersebut juga memiliki semua efek samping yang sama volatile. Menggunakannya saat membaca dan menulis variabel adalah cara lain untuk membuat perubahan terlihat ke utas lain:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (getPizzaArrived() == false) {}
        System.out.println("That was delicious!");
    }

    synchronized boolean getPizzaArrived() {
        return pizzaArrived;
    }

    synchronized void deliverPizza() {
        pizzaArrived = true;
    }
}

Efek dari pernyataan cetak

System.outadalah sebuah PrintStreamobjek. Metode PrintStreamdisinkronkan seperti ini:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

Sinkronisasi mencegah pizzaArrivedcache selama loop. Sebenarnya, kedua utas harus disinkronkan pada objek yang sama untuk menjamin bahwa perubahan pada variabel terlihat. (Misalnya, memanggil printlnsetelah menyetel pizzaArriveddan memanggilnya lagi sebelum membaca pizzaArrivedakan benar.) Jika hanya satu utas yang disinkronkan pada objek tertentu, JVM diizinkan untuk mengabaikannya. Dalam praktiknya, JVM tidak cukup pintar untuk membuktikan bahwa utas lain tidak akan memanggil printlnsetelah pengaturan pizzaArrived, jadi diasumsikan bahwa mereka mungkin. Oleh karena itu, ia tidak dapat menyimpan variabel selama loop jika Anda memanggil System.out.println. Itulah mengapa loop seperti ini berfungsi ketika mereka memiliki pernyataan cetak, meskipun itu bukan perbaikan yang benar.

Menggunakan System.outbukan satu-satunya cara untuk menyebabkan efek ini, tetapi ini adalah yang paling sering ditemukan orang, ketika mereka mencoba men-debug mengapa loop mereka tidak berfungsi!


Masalah yang lebih besar

while (pizzaArrived == false) {}adalah loop tunggu sibuk. Itu buruk! Sementara menunggu, itu memonopoli CPU, yang memperlambat aplikasi lain, dan meningkatkan penggunaan daya, suhu, dan kecepatan kipas sistem. Idealnya, kami ingin utas loop tidur sementara menunggu, sehingga tidak membebani CPU.

Berikut beberapa cara untuk melakukannya:

Menggunakan wait / notify

Solusi tingkat rendah adalah dengan menggunakan metode tunggu / beri tahu dariObject :

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        synchronized (this) {
            while (!pizzaArrived) {
                try {
                    this.wait();
                } catch (InterruptedException e) {}
            }
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        synchronized (this) {
            pizzaArrived = true;
            this.notifyAll();
        }
    }
}

Dalam versi kode ini, thread loop memanggil wait(), yang membuat thread tersebut tidur. Ini tidak akan menggunakan siklus CPU apa pun saat tidur. Setelah utas kedua menyetel variabel, ia memanggil notifyAll()untuk membangunkan setiap / semua utas yang menunggu di objek itu. Ini seperti menyuruh tukang pizza membunyikan bel pintu, jadi Anda bisa duduk dan beristirahat sambil menunggu, alih-alih berdiri dengan canggung di depan pintu.

Saat memanggil wait / notify pada suatu objek, Anda harus menahan kunci sinkronisasi dari objek itu, yang dilakukan oleh kode di atas. Anda dapat menggunakan objek apa pun yang Anda suka selama kedua utas menggunakan objek yang sama: di sini saya menggunakan this(contoh MyHouse). Biasanya, dua utas tidak akan dapat masuk ke blok tersinkronisasi dari objek yang sama secara bersamaan (yang merupakan bagian dari tujuan sinkronisasi) tetapi berfungsi di sini karena utas melepaskan kunci sinkronisasi untuk sementara ketika berada di dalam wait()metode.

BlockingQueue

A BlockingQueuedigunakan untuk mengimplementasikan antrian produsen-konsumen. "Konsumen" mengambil item dari depan antrean, dan "produsen" mendorong item di belakang. Sebuah contoh:

class MyHouse {
    final BlockingQueue<Object> queue = new LinkedBlockingQueue<>();

    void eatFood() throws InterruptedException {
        // take next item from the queue (sleeps while waiting)
        Object food = queue.take();
        // and do something with it
        System.out.println("Eating: " + food);
    }

    void deliverPizza() throws InterruptedException {
        // in producer threads, we push items on to the queue.
        // if there is space in the queue we can return immediately;
        // the consumer thread(s) will get to it later
        queue.put("A delicious pizza");
    }
}

Catatan: Metode putand takedari BlockingQueuecan throw InterruptedExceptions, yang merupakan pengecualian yang diperiksa yang harus ditangani. Dalam kode di atas, untuk mempermudah, pengecualian dicantumkan kembali. Anda mungkin lebih suka menangkap pengecualian dalam metode dan mencoba kembali put atau take untuk memastikannya berhasil. Terlepas dari satu titik keburukan itu, BlockingQueuesangat mudah digunakan.

Tidak ada sinkronisasi lain yang diperlukan di sini karena a BlockingQueuememastikan bahwa semua yang dilakukan utas sebelum meletakkan item dalam antrean terlihat oleh utas yang mengeluarkan item tersebut.

Pelaksana

Executors seperti s siap pakai BlockingQueueyang menjalankan tugas. Contoh:

// A "SingleThreadExecutor" has one work thread and an unlimited queue
ExecutorService executor = Executors.newSingleThreadExecutor();

Runnable eatPizza = () -> { System.out.println("Eating a delicious pizza"); };
Runnable cleanUp = () -> { System.out.println("Cleaning up the house"); };

// we submit tasks which will be executed on the work thread
executor.execute(eatPizza);
executor.execute(cleanUp);
// we continue immediately without needing to wait for the tasks to finish

Untuk rincian lihat doc untuk Executor, ExecutorService, dan Executors.

Penanganan acara

Pengulangan sambil menunggu pengguna mengklik sesuatu di UI adalah salah. Sebagai gantinya, gunakan fitur penanganan acara dari toolkit UI. Di Swing , misalnya:

JLabel label = new JLabel();
JButton button = new JButton("Click me");
button.addActionListener((ActionEvent e) -> {
    // This event listener is run when the button is clicked.
    // We don't need to loop while waiting.
    label.setText("Button was clicked");
});

Karena event handler berjalan pada event dispatch thread, melakukan pekerjaan yang lama di event handler memblokir interaksi lain dengan UI hingga pekerjaan selesai. Operasi lambat dapat dimulai pada thread baru, atau dikirim ke thread menunggu menggunakan salah satu teknik di atas (tunggu / beri tahu, a BlockingQueue, atau Executor). Anda juga dapat menggunakan a SwingWorker, yang dirancang persis untuk ini, dan secara otomatis menyediakan thread pekerja latar belakang:

JLabel label = new JLabel();
JButton button = new JButton("Calculate answer");

// Add a click listener for the button
button.addActionListener((ActionEvent e) -> {

    // Defines MyWorker as a SwingWorker whose result type is String:
    class MyWorker extends SwingWorker<String,Void> {
        @Override
        public String doInBackground() throws Exception {
            // This method is called on a background thread.
            // You can do long work here without blocking the UI.
            // This is just an example:
            Thread.sleep(5000);
            return "Answer is 42";
        }

        @Override
        protected void done() {
            // This method is called on the Swing thread once the work is done
            String result;
            try {
                result = get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
            label.setText(result); // will display "Answer is 42"
        }
    }

    // Start the worker
    new MyWorker().execute();
});

Timer

Untuk melakukan tindakan berkala, Anda dapat menggunakan file java.util.Timer. Ini lebih mudah digunakan daripada menulis putaran waktu Anda sendiri, dan lebih mudah untuk memulai dan menghentikan. Demo ini mencetak waktu saat ini sekali per detik:

Timer timer = new Timer();
TimerTask task = new TimerTask() {
    @Override
    public void run() {
        System.out.println(System.currentTimeMillis());
    }
};
timer.scheduleAtFixedRate(task, 0, 1000);

Masing java.util.Timer-masing memiliki utas latar belakangnya sendiri yang digunakan untuk menjalankan jadwal yang dijadwalkan TimerTask. Secara alami, utas tidur di antara tugas-tugas, sehingga tidak membebani CPU.

Dalam kode Swing, ada juga a javax.swing.Timer, yang serupa, tetapi menjalankan listener pada thread Swing, sehingga Anda dapat berinteraksi dengan aman dengan komponen Swing tanpa perlu mengganti thread secara manual:

JFrame frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
Timer timer = new Timer(1000, (ActionEvent e) -> {
    frame.setTitle(String.valueOf(System.currentTimeMillis()));
});
timer.setRepeats(true);
timer.start();
frame.setVisible(true);

Cara lain

Jika Anda menulis kode multithread, ada baiknya menjelajahi kelas-kelas dalam paket ini untuk melihat apa yang tersedia:

Dan juga lihat bagian Concurrency dari tutorial Java. Multithreading itu rumit, tetapi ada banyak bantuan yang tersedia!

Boann
sumber
Jawaban yang sangat profesional, setelah membaca ini tidak ada kesalahpahaman yang tersisa di pikiran saya, terima kasih
Humoyun Ahmad
1
Jawaban yang luar biasa. Saya bekerja dengan utas Java cukup lama dan masih mempelajari sesuatu di sini ( wait()melepaskan kunci sinkronisasi!).
Brimborium
Terima kasih, Boann! Jawaban bagus, ini seperti artikel lengkap dengan contoh! Ya, juga menyukai "tunggu () rilis kunci sinkronisasi"
Kiryl Ivanou
java public class ThreadTest { private static boolean flag = false; private static class Reader extends Thread { @Override public void run() { while(flag == false) {} System.out.println(flag); } } public static void main(String[] args) { new Reader().start(); flag = true; } } @ Boann, kode ini tidak mengangkat pizzaArrived == falsetes di luar loop, dan loop dapat melihat bendera diubah oleh utas utama, mengapa?
gaussclb
1
@gaussclb Jika yang Anda maksud Anda mendekompilasi file kelas, benar. Kompilator Java hampir tidak melakukan pengoptimalan. Pengangkatan dilakukan oleh JVM. Anda perlu membongkar kode mesin asli. Coba: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann