Mengapa program Java ini berakhir meskipun tampaknya tidak seharusnya (dan tidak)?

205

Operasi sensitif di lab saya hari ini benar-benar salah. Aktuator pada mikroskop elektron melewati batasnya, dan setelah serangkaian peristiwa saya kehilangan $ 12 juta peralatan. Saya mempersempit lebih dari 40 ribu baris dalam modul yang salah untuk ini:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Beberapa sampel dari output yang saya dapatkan:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Karena tidak ada aritmatika floating point di sini, dan kita semua tahu bilangan bulat yang ditandatangani berperilaku baik pada overflow di Jawa, saya pikir tidak ada yang salah dengan kode ini. Namun, meskipun output menunjukkan bahwa program tidak mencapai kondisi keluar, ia mencapai kondisi keluar (keduanya tercapai dan tidak tercapai?). Mengapa?


Saya perhatikan ini tidak terjadi di beberapa lingkungan. Saya menggunakan OpenJDK 6 di Linux 64-bit.

Anjing
sumber
41
12 juta peralatan? saya benar-benar ingin tahu bagaimana itu bisa terjadi ... mengapa Anda menggunakan blok sinkronisasi kosong: disinkronkan (ini) {}?
Martin V.
84
Ini bahkan tidak aman untuk benang jarak jauh.
Matt Ball
8
Menarik untuk diperhatikan: menambahkan final kualifikasi (yang tidak berpengaruh pada bytecode yang dihasilkan) ke bidang xdan y"memecahkan" bug. Meskipun tidak memengaruhi bytecode, bidang ditandai dengan itu, yang membuat saya berpikir ini adalah efek samping dari optimasi JVM.
Niv Steingarten
9
@Eugene: Seharusnya tidak berakhir. Pertanyaannya adalah "mengapa itu berakhir?". A Point pdikonstruksi yang memuaskan p.x+1 == p.y, lalu a referensi dilewatkan ke tempat pemungutan suara. Akhirnya utas pemungutan suara memutuskan untuk keluar karena menganggap kondisinya tidak terpenuhi untuk salah satu yang Pointditerimanya, tetapi kemudian output konsol menunjukkan bahwa ia seharusnya sudah puas. Kurangnya di volatilesini berarti bahwa tempat pemungutan suara mungkin macet, tapi itu jelas bukan masalah di sini.
Erma K. Pizarro
21
@JohnNicholas: Kode asli (yang jelas bukan ini) memiliki cakupan uji 100% dan ribuan tes, banyak yang menguji berbagai hal dalam ribuan pesanan dan permutasi ... Pengujian tidak secara ajaib menemukan setiap kasus tepi yang disebabkan oleh non deterministik JIT / cache / scheduler. Masalah sebenarnya adalah bahwa pengembang yang menulis kode ini tidak tahu bahwa konstruksi tidak terjadi sebelum menggunakan objek. Perhatikan bagaimana menghapus yang kosong synchronizedmembuat bug tidak terjadi? Itu karena saya harus menulis kode secara acak sampai saya menemukan kode yang akan mereproduksi perilaku ini secara deterministik.
Anjing

Jawaban:

140

Jelas menulis ke currentPos tidak terjadi-sebelum membacanya, tapi saya tidak melihat bagaimana itu bisa menjadi masalah.

currentPos = new Point(currentPos.x+1, currentPos.y+1);melakukan beberapa hal, termasuk menulis nilai default ke xdan y(0) dan kemudian menulis nilai awal mereka di konstruktor. Karena objek Anda tidak diterbitkan dengan aman, 4 operasi penulisan tersebut dapat diatur ulang secara bebas oleh kompiler / JVM.

Jadi dari perspektif utas bacaan, itu adalah eksekusi hukum untuk membaca xdengan nilai baru tetapi ydengan nilai default 0 misalnya. Pada saat Anda mencapai printlnpernyataan (yang dengan cara disinkronkan dan karena itu mempengaruhi operasi baca), variabel memiliki nilai awal dan program mencetak nilai yang diharapkan.

Ditandai currentPossebagai volatileakan memastikan publikasi yang aman karena objek Anda tidak dapat diubah secara efektif - jika dalam kasus penggunaan nyata objek tersebut dimutasi setelah konstruksi,volatile jaminan tidak akan cukup dan Anda dapat melihat objek yang tidak konsisten lagi.

Atau, Anda dapat membuat Point abadi yang juga akan memastikan publikasi yang aman, bahkan tanpa menggunakan volatile. Untuk mencapai kekekalan, Anda hanya perlu menandai xdan yfinal.

Sebagai catatan tambahan dan sebagaimana telah disebutkan, synchronized(this) {}dapat diperlakukan sebagai larangan oleh JVM (saya mengerti Anda memasukkannya untuk mereproduksi perilaku).

assylias
sumber
4
Saya tidak yakin, tetapi tidakkah membuat x dan y final memiliki efek yang sama, menghindari penghalang memori?
Michael Böckling
3
Desain yang lebih sederhana adalah objek titik tidak berubah yang menguji invarian pada konstruksi. Jadi, Anda tidak pernah mengambil risiko menerbitkan konfigurasi berbahaya.
Ron
@ BuddyCasino Ya memang - saya telah menambahkan itu. Sejujurnya saya tidak ingat seluruh diskusi 3 bulan yang lalu (menggunakan final diusulkan dalam komentar jadi tidak yakin mengapa saya tidak memasukkannya sebagai pilihan).
assylias
2
Ketidakmampuan itu sendiri tidak menjamin publikasi yang aman (jika x dan y adalah pribadi tetapi terpapar dengan getter saja, masalah publikasi yang sama akan tetap ada). final atau volatile tidak menjaminnya. Saya lebih suka final daripada volatile.
Steve Kuo
@SteveKuo Immutability membutuhkan final - tanpa final, yang terbaik yang bisa Anda dapatkan adalah immutability efektif yang tidak memiliki semantik yang sama.
assylias
29

Karena currentPossedang diubah di luar utas itu harus ditandai sebagai volatile:

static volatile Point currentPos = new Point(1,2);

Tanpa volatile, utas tidak dijamin untuk membaca pembaruan pada currentPos yang sedang dibuat di utas utama. Jadi nilai baru terus ditulis untuk currentPos tetapi utas terus menggunakan versi cache sebelumnya untuk alasan kinerja. Karena hanya satu utas yang memodifikasi currentPos Anda bisa lolos tanpa kunci yang akan meningkatkan kinerja.

Hasilnya terlihat jauh berbeda jika Anda membaca nilai hanya sekali dalam utas untuk digunakan dalam perbandingan dan selanjutnya menampilkannya. Ketika saya melakukan hal berikut ini xselalu ditampilkan sebagai 1dan ybervariasi antara 0dan beberapa bilangan bulat besar. Saya pikir perilaku itu pada saat ini agak tidak terdefinisi tanpa volatilekata kunci dan mungkin saja kompilasi kode JIT berkontribusi untuk bertindak seperti ini. Juga jika saya mengomentari synchronized(this) {}blok kosong maka kodenya juga berfungsi dan saya menduga itu karena penguncian menyebabkan penundaan yang cukup currentPosdan bidangnya dibaca ulang daripada digunakan dari cache.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}
Ed Plese
sumber
2
Ya, dan saya juga bisa mengunci semuanya. Apa maksudmu
Anjing
Saya menambahkan beberapa penjelasan tambahan untuk penggunaan volatile.
Ed Plese
19

Anda memiliki memori biasa, referensi 'currentpos' dan objek Point dan bidangnya di belakangnya, dibagi antara 2 utas, tanpa sinkronisasi. Jadi, tidak ada urutan yang pasti antara penulisan yang terjadi pada memori ini di utas utama dan pembacaan di utas yang dibuat (sebut saja T).

Utas utama melakukan penulisan berikut (mengabaikan pengaturan awal titik, akan menghasilkan px dan py memiliki nilai default):

  • ke px
  • untuk py
  • untuk currentpos

Karena tidak ada yang istimewa dari penulisan ini dalam hal sinkronisasi / penghalang, runtime bebas untuk membiarkan T thread melihatnya terjadi dalam urutan apa pun (utas utama tentu saja selalu melihat tulis dan bacaan dipesan sesuai dengan urutan program), dan terjadi di setiap titik antara bacaan di T.

Jadi T sedang melakukan:

  1. membaca currentpos ke hal
  2. baca px dan py (dalam urutan apa pun)
  3. bandingkan, dan ambil cabang
  4. baca px dan py (urutan apa pun) dan panggil System.out.println

Karena tidak ada hubungan pemesanan antara penulisan di main, dan membaca di T, jelas ada beberapa cara ini dapat menghasilkan hasil Anda, karena T dapat melihat main menulis ke currentpos sebelum menulis ke currentpos.y atau currentpos.x:

  1. Bunyinya currentpos.x pertama, sebelum x menulis telah terjadi - mendapat 0, kemudian membaca currentpos.y sebelum menulis y telah terjadi - mendapat 0. Bandingkan evals ke true. Tulisan menjadi terlihat oleh T. System.out.println disebut.
  2. Bunyinya currentpos.x pertama, setelah x menulis telah terjadi, kemudian membaca currentpos.y sebelum menulis y terjadi - mendapat 0. Bandingkan evals dengan true. Menulis menjadi terlihat oleh T ... dll.
  3. Bunyinya currentpos.y pertama, sebelum menulis y telah terjadi (0), kemudian membaca currentpos.x setelah x menulis, evals ke true. dll.

dan seterusnya ... Ada sejumlah perlombaan data di sini.

Saya menduga asumsi cacat di sini adalah berpikir bahwa tulisan yang dihasilkan dari baris ini dibuat terlihat di semua utas dalam urutan program utas yang menjalankannya:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java tidak membuat jaminan seperti itu (itu akan mengerikan untuk kinerja). Sesuatu yang lebih harus ditambahkan jika program Anda membutuhkan pemesanan tulisan yang relatif terjamin untuk dibaca di utas lainnya. Yang lain menyarankan agar x, y bidang final, atau sebagai alternatif membuat currentpos volatile.

  • Jika Anda membuat x, y bidang final, maka Java menjamin bahwa penulisan nilai-nilai mereka akan terlihat terjadi sebelum konstruktor kembali, di semua utas. Jadi, karena penugasan ke currentpos adalah setelah konstruktor, utas T dijamin untuk melihat tulisan dalam urutan yang benar.
  • Jika Anda membuat currentpos volatile, maka Java menjamin bahwa ini adalah titik sinkronisasi yang akan dipesan total dengan titik sinkronisasi lainnya. Seperti pada main menulis ke x dan y harus terjadi sebelum menulis ke currentpos, maka setiap membaca currentpos di utas lain harus melihat juga penulisan dari x, y yang terjadi sebelumnya.

Menggunakan final memiliki keuntungan bahwa itu membuat bidang tidak berubah, dan dengan demikian memungkinkan nilai di-cache. Menggunakan volatile lead untuk sinkronisasi pada setiap penulisan dan pembacaan currentpos, yang dapat merusak kinerja.

Lihat bab 17 dari Spesifikasi Bahasa Jawa untuk detail berdarah: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(Jawaban awal mengasumsikan model memori yang lebih lemah, karena saya tidak yakin JLS dijamin volatilnya cukup. Jawaban diedit untuk mencerminkan komentar dari para pengguna, menunjukkan bahwa model Java lebih kuat - terjadi-sebelum transitif - dan karenanya volatile pada currentpos juga cukup. ).

paulj
sumber
2
Ini adalah penjelasan terbaik menurut saya. Terima kasih banyak!
skyde
1
@skyde tapi salah pada semantik volatile. volatile guarantee yang membaca variabel volatil akan melihat penulisan variabel volatil terbaru yang tersedia serta setiap penulisan sebelumnya . Dalam hal ini, jika currentPosdibuat tidak stabil, penugasan memastikan publikasi yang aman dari currentPosobjek serta anggotanya, bahkan jika mereka tidak mudah menguap sendiri.
assylias
Yah, saya katakan bahwa saya sendiri tidak bisa, melihat persis bagaimana JLS menjamin bahwa volatile membentuk penghalang dengan yang lain, normal membaca dan menulis. Secara teknis, saya tidak bisa salah tentang itu;). Ketika datang ke model memori, adalah bijaksana untuk menganggap pemesanan tidak dijamin dan salah (Anda masih aman) daripada sebaliknya dan salah dan tidak aman. Sangat bagus jika volatile memberikan jaminan itu. Bisakah Anda jelaskan bagaimana ch 17 dari JLS menyediakannya?
paulj
2
Singkatnya, dalam Point currentPos = new Point(x, y), Anda memiliki 3 tulisan: (w1) this.x = x, (w2) this.y = ydan (w3) currentPos = the new point. Urutan program menjamin bahwa hb (w1, w3) dan hb (w2, w3). Kemudian di program yang Anda baca (r1)currentPos . Jika currentPostidak volatile, tidak ada hb antara r1 dan w1, w2, w3, sehingga r1 dapat mengamati (atau tidak ada) dari mereka. Dengan volatile, Anda memperkenalkan hb (w3, r1). Dan hubungan hb adalah transitif sehingga Anda juga memperkenalkan hb (w1, r1) dan hb (w2, r1). Ini dirangkum dalam Java Concurrency in Practice (3.5.3. Publikasi Aman Idiom).
assylias
2
Ah, jika hb transitif dengan cara itu, maka itu 'penghalang' yang cukup kuat, ya. Saya harus mengatakan, tidak mudah untuk menentukan bahwa 17.4.5 dari JLS mendefinisikan hb untuk memiliki properti itu. Ini tentu saja tidak ada dalam daftar properti yang diberikan pada awal 17.4.5. Penutupan transitif hanya disebutkan lebih jauh ke bawah setelah beberapa catatan penjelasan! Ngomong-ngomong, senang tahu, terima kasih atas jawabannya! :) Catatan: Saya akan memperbarui jawaban saya untuk mencerminkan komentar assylias.
paulj
-2

Anda bisa menggunakan objek untuk menyinkronkan tulisan dan bacaan. Kalau tidak, seperti yang orang lain katakan sebelumnya, menulis ke currentPos akan terjadi di tengah dua kali dibaca p.x + 1 dan py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}
Germano Fronza
sumber
Sebenarnya ini berhasil. Dalam upaya pertama saya, saya meletakkan bacaan di dalam blok yang disinkronkan, tetapi kemudian saya menyadari itu tidak benar-benar diperlukan.
Germano Fronza
1
-1 JVM dapat membuktikan bahwa semtidak dibagikan dan memperlakukan pernyataan yang disinkronkan sebagai no-op ... Fakta bahwa itu memecahkan masalah adalah keberuntungan murni.
assylias
4
Saya benci pemrograman multi-threaded, terlalu banyak hal yang berhasil karena keberuntungan.
Jonathan Allen
-3

Anda mengakses currentPos dua kali, dan tidak memberikan jaminan bahwa itu tidak diperbarui di antara kedua akses tersebut.

Sebagai contoh:

  1. x = 10, y = 11
  2. thread pekerja mengevaluasi px sebagai 10
  3. utas utama menjalankan pembaruan, sekarang x = 11 dan y = 12
  4. thread pekerja mengevaluasi py sebagai 12
  5. pemberitahuan utas pekerja yang 10 + 1! = 12, jadi cetak dan keluar.

Anda pada dasarnya membandingkan dua Poin yang berbeda .

Perhatikan bahwa bahkan membuat currentPos volatile tidak akan melindungi Anda dari ini, karena ini adalah dua bacaan terpisah oleh utas pekerja.

Tambahkan

boolean IsValid() { return x+1 == y; }

metode ke kelas poin Anda. Ini akan memastikan bahwa hanya satu nilai currentPos yang digunakan ketika memeriksa x + 1 == y.

pengguna2686913
sumber
currentPos hanya dibaca sekali, nilainya disalin ke p. p dibaca dua kali, tetapi selalu akan menunjuk ke lokasi yang sama.
Jonathan Allen