Mengapa aliran paralel dengan lambda di penginisialisasi statis menyebabkan kebuntuan?

86

Saya menemukan situasi yang aneh di mana menggunakan aliran paralel dengan lambda di penginisialisasi statis membutuhkan waktu yang tampaknya selamanya tanpa pemanfaatan CPU. Berikut kodenya:

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Ini tampaknya mereproduksi kasus uji minimum untuk perilaku ini. Jika saya:

  • letakkan blok di metode utama alih-alih penginisialisasi statis,
  • hapus paralelisasi, atau
  • hapus lambda,

kode tersebut langsung selesai. Adakah yang bisa menjelaskan perilaku ini? Apakah ini bug atau ini dimaksudkan?

Saya menggunakan OpenJDK versi 1.8.0_66-internal.

Kembalikan Monica
sumber
4
Dengan rentang (0, 1) program berakhir secara normal. Dengan hang (0, 2) atau lebih tinggi.
Laszlo Hirdi
5
pertanyaan serupa: stackoverflow.com/questions/34222669/…
Alex - GlassEditor.com
2
Sebenarnya ini adalah pertanyaan / masalah yang persis sama, hanya dengan API yang berbeda.
Didier L
3
Anda mencoba menggunakan sebuah kelas, di thread latar belakang, saat Anda belum selesai menginisialisasi kelas tersebut sehingga tidak dapat digunakan di thread latar belakang.
Peter Lawrey
4
@ Solomonoff'sSecret sebagai i -> ibukan referensi metode itu static methoddiimplementasikan di kelas Deadlock. Jika diganti i -> idengan Function.identity()kode ini seharusnya baik-baik saja.
Peter Lawrey

Jawaban:

71

Saya menemukan laporan bug dari kasus yang sangat mirip ( JDK-8143380 ) yang ditutup sebagai "Bukan Masalah" oleh Stuart Marks:

Ini adalah kebuntuan inisialisasi kelas. Utas utama program pengujian mengeksekusi penginisialisasi statis kelas, yang menyetel tanda inisialisasi dalam proses untuk kelas; tanda ini tetap disetel sampai penginisialisasi statis selesai. Penginisialisasi statis menjalankan aliran paralel, yang menyebabkan ekspresi lambda dievaluasi di utas lain. Utas tersebut memblokir menunggu kelas menyelesaikan inisialisasi. Namun, utas utama diblokir menunggu tugas paralel selesai, yang mengakibatkan kebuntuan.

Program pengujian harus diubah untuk memindahkan logika aliran paralel di luar penginisialisasi statis kelas. Menutup sebagai Bukan Masalah.


Saya dapat menemukan laporan bug lain dari itu ( JDK-8136753 ), juga ditutup sebagai "Bukan Masalah" oleh Stuart Marks:

Ini adalah kebuntuan yang terjadi karena penginisialisasi statis enum Buah berinteraksi buruk dengan inisialisasi kelas.

Lihat Spesifikasi Bahasa Java, bagian 12.4.2 untuk detail tentang inisialisasi kelas.

http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

Secara singkat, yang terjadi adalah sebagai berikut.

  1. Utas utama mereferensikan kelas Buah dan memulai proses inisialisasi. Ini menyetel tanda inisialisasi sedang berlangsung dan menjalankan penginisialisasi statis di thread utama.
  2. Penginisialisasi statis menjalankan beberapa kode di utas lain dan menunggu hingga selesai. Contoh ini menggunakan aliran paralel, tetapi ini tidak ada hubungannya dengan aliran itu sendiri. Menjalankan kode di utas lain dengan cara apa pun, dan menunggu kode itu selesai, akan memiliki efek yang sama.
  3. Kode di thread lain mereferensikan kelas Fruit, yang memeriksa flag in-progress. Ini menyebabkan utas lainnya diblokir hingga bendera dihapus. (Lihat langkah 2 dari JLS 12.4.2.)
  4. Utas utama diblokir menunggu utas lainnya berhenti, sehingga penginisialisasi statis tidak pernah selesai. Karena tanda inisialisasi dalam proses tidak dihapus hingga penginisialisasi statis selesai, utasnya akan menemui jalan buntu.

Untuk menghindari masalah ini, pastikan bahwa inisialisasi statis kelas selesai dengan cepat, tanpa menyebabkan utas lain mengeksekusi kode yang mengharuskan kelas ini menyelesaikan inisialisasi.

Menutup sebagai Bukan Masalah.


Perhatikan bahwa FindBugs memiliki masalah terbuka untuk menambahkan peringatan untuk situasi ini.

Tunaki
sumber
20
"Hal ini dianggap ketika kita merancang fitur" dan "Kami tahu apa yang menyebabkan bug ini tetapi tidak bagaimana memperbaikinya" melakukan tidak berarti "ini bukan bug" . Ini benar-benar bug.
BlueRaja - Danny Pflughoeft
13
@ bayou.io Masalah utama adalah menggunakan utas dalam penginisialisasi statis, bukan lambda.
Stuart Marks
5
BTW Tunaki terima kasih telah menggali laporan bug saya. :-)
Stuart Marks
13
@ bayou.io: itu adalah hal yang sama pada tingkat kelas seperti pada konstruktor, membiarkan thismelarikan diri selama konstruksi objek. Aturan dasarnya adalah, jangan gunakan operasi multi-utas di penginisialisasi. Saya rasa ini tidak sulit untuk dipahami. Contoh Anda mendaftarkan fungsi yang diimplementasikan lambda ke dalam registri adalah hal yang berbeda, itu tidak membuat kebuntuan kecuali Anda akan menunggu salah satu utas latar belakang yang diblokir ini. Namun demikian, saya sangat tidak menyarankan untuk melakukan operasi seperti itu di penginisialisasi kelas. Bukan untuk apa mereka dimaksudkan.
Holger
9
Saya kira pelajaran gaya pemrogramannya adalah: jaga agar initalizers statis tetap sederhana.
Raedwald
16

Bagi mereka yang bertanya-tanya di mana utas lain yang mereferensikan Deadlockkelas itu sendiri, lambda Java berperilaku seperti yang Anda tulis ini:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Dengan kelas anonim biasa tidak ada kebuntuan:

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}
Tamas Hegedus
sumber
5
@Solomonoff'sSecret Ini adalah pilihan implementasi. Kode di lambda harus pergi ke suatu tempat. Javac mengkompilasinya menjadi metode statis di kelas yang memuatnya (analogi dengan lambda1contoh ini). Menempatkan setiap lambda ke dalam kelasnya sendiri akan jauh lebih mahal.
Stuart Marks
1
@StuartMarks Mengingat bahwa lambda membuat kelas yang mengimplementasikan antarmuka fungsional, bukankah akan seefisien menempatkan implementasi lambda dalam implementasi lambda antarmuka fungsional seperti pada contoh kedua dari posting ini? Itu pasti cara yang jelas untuk melakukan sesuatu, tetapi saya yakin ada alasan mengapa hal itu dilakukan sebagaimana adanya.
Kembalikan Monica
6
@Solomonoff'sSecret Lambda mungkin membuat kelas pada saat runtime (melalui java.lang.invoke.LambdaMetafactory ), tetapi badan lambda harus ditempatkan di suatu tempat pada waktu kompilasi. Kelas lambda dengan demikian dapat memanfaatkan beberapa sihir VM agar lebih murah daripada kelas normal yang dimuat dari file .class.
Jeffrey Bosboom
1
@Solomonoff'sSecret Ya, jawaban Jeffrey Bosboom benar. Jika di JVM mendatang dimungkinkan untuk menambahkan metode ke kelas yang ada, metafactory mungkin melakukannya alih-alih memutar kelas baru. (Spekulasi murni.)
Stuart Marks
3
@Solomonoff's Secret: jangan menilai dengan melihat ekspresi lambda sepele seperti Anda i -> i; mereka tidak akan menjadi norma. Ekspresi Lambda dapat menggunakan semua anggota kelas sekitarnya, termasuk privatesatu, dan itu membuat kelas pendefinisian itu sendiri menjadi tempat alami mereka. Membiarkan semua kasus penggunaan ini menderita akibat implementasi yang dioptimalkan untuk kasus khusus penginisialisasi kelas dengan penggunaan multi-threaded ekspresi lambda sepele, tidak menggunakan anggota kelas yang menentukan, bukanlah opsi yang dapat dijalankan.
Holger
14

Ada penjelasan yang sangat bagus tentang masalah ini oleh Andrei Pangin , tertanggal 07 Apr 2015. Ini tersedia di sini , tetapi ditulis dalam bahasa Rusia (saya sarankan untuk meninjau contoh kode juga - mereka internasional). Masalah umum adalah kunci selama inisialisasi kelas.

Berikut beberapa kutipan dari artikel tersebut:


Menurut JLS , setiap kelas memiliki kunci inisialisasi unik yang ditangkap selama inisialisasi. Saat thread lain mencoba mengakses kelas ini selama inisialisasi, thread tersebut akan diblokir di kunci sampai inisialisasi selesai. Ketika kelas diinisialisasi secara bersamaan, mungkin saja terjadi kebuntuan.

Saya menulis program sederhana yang menghitung jumlah bilangan bulat, apa yang harus dicetak?

public class StreamSum {
    static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

    public static void main(String[] args) {
        System.out.println(SUM);
    }
} 

Sekarang hapus parallel()atau ganti lambda dengan Integer::sumpanggilan - apa yang akan berubah?

Di sini kita melihat kebuntuan lagi [ada beberapa contoh kebuntuan di penginisialisasi kelas sebelumnya di artikel]. Karena parallel()operasi aliran dijalankan di kumpulan utas terpisah. Utas ini mencoba menjalankan lambda body, yang ditulis dalam bytecode sebagai private staticmetode di dalam StreamSumkelas. Tetapi metode ini tidak dapat dijalankan sebelum penyelesaian penginisialisasi statis kelas, yang menunggu hasil penyelesaian streaming.

Apa yang lebih mengejutkan: kode ini bekerja secara berbeda di lingkungan yang berbeda. Ini akan bekerja dengan benar pada satu mesin CPU dan kemungkinan besar akan digantung pada mesin multi CPU. Perbedaan ini berasal dari implementasi kumpulan Fork-Join. Anda dapat memverifikasi sendiri dengan mengubah parameter-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

AdamSkywalker
sumber