Görünüşe göre olmamalı (ve yapmamalı) rağmen bu Java programı neden sona eriyor?


205

Bugün laboratuvarımdaki hassas bir operasyon tamamen yanlış gitti. Bir elektron mikroskobu üzerindeki bir aktüatör sınırlarını aştı ve bir olay zincirinden sonra 12 milyon dolarlık ekipman kaybettim. Hatalı modülde 40K hattı üzerinde daralttım:

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);
    }
}

Bazı örnekler çıktı alıyorum:

$ 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

Burada herhangi bir kayan nokta aritmetiği olmadığından ve hepimiz işaretli tam sayıların Java'da taşma üzerinde iyi davrandığını bildiğimiz için, bu kodda yanlış bir şey olmadığını düşünürdüm. Ancak, programın çıkış durumuna ulaşmadığını gösteren çıktıya rağmen, çıkış durumuna ulaştı (hem ulaşıldı hem de ulaşılmadı?). Neden?


Bunun bazı ortamlarda olmadığını fark ettim. 64 bit Linux üzerinde OpenJDK 6 kullanıyorum.


41
12 milyon ekipman? gerçekten nasıl olabileceğini merak ediyorum ... neden boş senkronizasyon bloğu kullanıyorsunuz: senkronize (bu) {}?
Martin

84
Bu, uzaktan güvenli değildir.
Matt Ball

8
İlginçtir: ekleyerek finalalanlara (üretilen bayt üzerinde bir etkisi yoktur) eleme xve y"çözer" böcek. Bayt kodunu etkilemese de, alanlar onunla işaretlenir, bu da bunun bir JVM optimizasyonunun bir yan etkisi olduğunu düşündürüyor.
Niv Steingarten

9
@Eugene: O gerektiğini değil sonunda. Soru "neden sona eriyor?" A Point ptatmin edici bir şekilde inşa edilir p.x+1 == p.y, daha sonra yoklama ipliğine bir referans geçirilir. Sonunda yoklama ipliği çıkmaya karar verir, çünkü koşulun Pointaldığı slerden biri için tatmin olmadığını düşünür , ancak konsol çıktısı bunun yerine getirilmesi gerektiğini gösterir. volatileBurada eksiklik , yoklama iş parçacığının sıkışabileceği anlamına gelir, ancak bu açıkça sorun değildir.
Erma K. Pizarro

21
@JohnNicholas: Gerçek kod (açıkçası bu değil)% 100 test kapsamı ve binlerce teste sahipti, bunların birçoğu binlerce farklı sipariş ve permütasyonda bir şeyleri test etti ... Test, belirsizliğin neden olduğu her uç durumu sihirli bir şekilde bulamıyor JIT / cache / zamanlayıcı. Asıl sorun, bu kodu yazan geliştiricinin, nesneyi kullanmadan önce yapının gerçekleşmeyeceğini bilmemesidir. Boşluğu kaldırmanın synchronizedhatayı nasıl gerçekleştirdiğini fark ettiniz mi? Çünkü bu davranışı deterministik olarak yeniden oluşturacak bir kod bulana kadar rastgele kod yazmak zorunda kaldım.
Köpek

Yanıtlar:


140

Açıkçası currentPos'a yazma gerçekleşmez-okunmadan önce, ama sorunun nasıl olabileceğini göremiyorum.

currentPos = new Point(currentPos.x+1, currentPos.y+1);xve y(0) 'a varsayılan değerler yazıp ardından yapıcıya başlangıç ​​değerlerini yazmak dahil birkaç şey yapar . Nesneniz güvenli bir şekilde yayınlanmadığı için bu 4 yazma işlemi derleyici / JVM tarafından serbestçe yeniden düzenlenebilir.

Bu nedenle, okuma iş parçacığının bakış açısından x, yeni değeriyle, ancak yvarsayılan değeri 0 ile okumak yasal bir yürütmedir . İfadeye ulaştığınız zaman println(bu arada senkronize edilir ve bu nedenle okuma işlemlerini etkiler), değişkenlerin başlangıç ​​değerleri vardır ve program beklenen değerleri yazdırır.

İşaretleme currentPosolarak volatilenesne inşaat sonrası mutasyona uğrar gerçek kullanım durumunda ise, - senin nesne etkin bir değişmez olan güvenli yayını sağlayacak volatilegarantiler yeterli olmayacaktır ve tutarsız bir nesneyi tekrar görebiliyordu.

Alternatif olarak, Pointkullanmadan bile güvenli yayın yapılmasını sağlayacak olan değişmez hale getirebilirsiniz volatile. Değişmezliği sağlamak için işaretlemeniz xve ysonlandırmanız yeterlidir .

Bir yan not olarak ve daha önce de belirtildiği synchronized(this) {}gibi, JVM tarafından bir no-op olarak kabul edilebilir (davranışı yeniden üretmek için dahil ettiğinizi anlıyorum).


4
Emin değilim, ama x ve y finali aynı etkiye sahip olmaz, bellek bariyerinden kaçınır mı?
Michael Böckling

3
Daha basit bir tasarım, değişmezleri inşaatta test eden değişmez bir nokta nesnesidir. Bu yüzden asla tehlikeli bir yapılandırma yayınlama riskiniz yoktur.
Ron

@BuddyCasino Evet, gerçekten - Bunu ekledim. Dürüst olmak gerekirse, 3 ay önce tüm tartışmayı hatırlamıyorum (finalde kullanmak yorumlarda önerildi, bu yüzden neden bir seçenek olarak eklemediğimden emin değilim).
assylias

2
Değişmezliğin kendisi güvenli yayını garanti etmez (x ve y özel olsaydı, ancak yalnızca alıcılarla karşılaşsaydı, aynı yayın sorunu hala mevcut olurdu). nihai veya uçucu bunu garanti eder. Finali geçici olarak tercih ederdim.
Steve Kuo

@SteveKuo Immutability final gerektirir - final olmadan, elde edebileceğiniz en iyi şey, aynı semantiğe sahip olmayan etkili değişmezliktir.
assylias

29

İş currentPosparçacığının dışında değiştirildiği için şu şekilde işaretlenmelidir volatile:

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

Uçucu olmadan, ana iş parçacığında yapılan currentPos güncellemelerinde iş parçacığının okunacağı garanti edilmez. Dolayısıyla currentPos için yeni değerler yazılmaya devam ediyor, ancak iş parçacığı performans nedeniyle önceki önbelleğe alınmış sürümleri kullanmaya devam ediyor. CurrentPos'u yalnızca bir iş parçacığı değiştirdiğinden, performansı artıracak kilitler olmadan kurtulabilirsiniz.

Değerleri karşılaştırmada ve daha sonra görüntülemede kullanmak için değerleri iş parçacığında yalnızca bir kez okursanız sonuçlar çok farklı görünür. Bunu yaptığımda aşağıdakiler xher zaman gibi 1ve yarasında değişir 0ve bazı büyük tamsayılar. Ben bu noktada davranışı volatileanahtar kelime olmadan biraz tanımsız olduğunu düşünüyorum ve kod JIT derleme böyle davranarak katkıda olması mümkündür. Ayrıca ben boş synchronized(this) {}blok yorum sonra kod da çalışır ve kilitleme yeterli gecikme neden olduğundan currentPosve alanları önbellekten kullanılan yerine yeniden olduğundan şüpheleniyorum .

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

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}

2
Evet, ben de herşeyi kilitledim. Ne demek istiyorsun?
Köpek

Kullanımı için ek açıklamalar ekledim volatile.
Ed Plese

19

Senkronizasyon olmadan 2 iş parçacığı arasında paylaşılan sıradan belleğiniz, 'currentpos' başvurusu ve Point nesnesi ve arkasındaki alanları vardır. Bu nedenle, ana iş parçacığında bu belleğe gerçekleşen yazma işlemleri ile oluşturulan iş parçacığındaki okumalar (T olarak adlandırılır) arasında tanımlı bir sıralama yoktur.

Ana iş parçacığı aşağıdaki yazma işlemlerini yapıyor (noktanın ilk kurulumunu yok saymak, px ve py'nin varsayılan değerlere sahip olmasına neden olur):

  • piksel yapmak
  • py'ye
  • Şu anki konuma

Senkronizasyon / engeller açısından bu yazmalarla ilgili özel bir şey olmadığından, çalışma zamanı T iş parçacığının bunları herhangi bir sırada görmesine izin vermekte serbesttir (elbette ana iş parçacığı her zaman program sırasına göre sipariş edilen yazma ve okumaları görür) ve gerçekleşir T.'deki okumalar arasındaki herhangi bir noktada

Yani T yapıyor:

  1. akımları p'ye okur
  2. px ve py'yi oku (her iki sırayla)
  3. karşılaştır ve dalı al
  4. px ve py (siparişlerden birini) okuyun ve System.out.println'i arayın

Ana yazmalar ve T'deki okumalar arasında herhangi bir sipariş ilişkisi olmadığı göz önüne alındığında, T, currentpos.y veya currentpos.x'e yazmadan önce main'un currentpos'a yazdığını görebileceğinden, bunun sonucunuzu üretebileceği açıkça birkaç yol vardır :

  1. Önce x yazma gerçekleşmeden önce currentpos.x okur - 0 alır, sonra y yazma gerçekleşmeden önce currentpos.y okur - 0 alır. Yazmalar T. tarafından görülebilir hale gelir. System.out.println denir.
  2. Önce x yazma gerçekleştikten sonra currentpos.x, sonra y yazma gerçekleşmeden önce currentpos.y değerini okur - 0 değerini alır. Yazılar T ... vb. Tarafından görünür hale gelir.
  3. Önce currentpos.y, y yazma gerçekleşmeden önce (0), sonra x write'dan sonra currentpos.x değerini doğru olarak değiştirir. vb.

ve benzerleri ... Burada bir dizi veri yarışı var.

Buradaki kusurlu varsayımın, bu satırdan kaynaklanan yazımların, yürütme iş parçacığının program sırasında tüm iş parçacıkları arasında görünür hale getirildiğini düşündüğünden şüpheleniyorum:

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

Java böyle bir garanti vermez (performans için korkunç olurdu). Programınız diğer konulardaki okumalara göre yazmaların garantili olarak sıralanması gerektiğinde daha fazlası eklenmelidir. Diğerleri x, y alanlarını sonlandırmayı veya alternatif olarak currentpos'u uçucu hale getirmeyi önerdi.

  • X, y alanlarını sonlandırırsanız, Java, tüm iş parçacıklarında değerlerinin yazma işlemlerinin yapıcı dönmeden önce görüleceğini garanti eder. Bu nedenle, currentpos'a atama yapıcıdan sonra olduğu için, T iş parçacığının yazıları doğru sırada görmesi garanti edilir.
  • Currentpos'u geçici yaparsanız, Java, bunun diğer senkronizasyon noktaları için toplam sipariş edilen bir senkronizasyon noktası olduğunu garanti eder. Genelde x ve y yazmaları currentpos'a yazılmadan önce gerçekleşmelidir, o zaman başka bir iş parçacığındaki currentpos okumaları daha önce gerçekleşen x, y yazımlarını da görmelidir.

Final kullanmak, alanları değiştirilemez hale getirme avantajına sahiptir ve böylece değerlerin önbelleğe alınmasına izin verir. Uçucu kullanmak currentpos'un her yazma ve okunuşunda senkronizasyona yol açarak performansa zarar verebilir.

Kanlı detayları için Java Language Spec'in 17. bölümüne bakın: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(İlk cevap, JLS garantili uçucunun yeterli olduğundan emin olmadığım için daha zayıf bir bellek modeli olduğunu varsaydı. Cevap, assylias'ın yorumunu yansıtacak şekilde düzenlendi, Java modelinin daha güçlü olduğunu gösteriyor - daha önce gerçekleşen geçişli - ve bu yüzden mevcutlar üzerindeki uçucu da yeterli ).


2
Bence bu en iyi açıklama. Çok teşekkürler!
skyde

1
@skyde ancak uçucu anlambiliminde yanlış. uçucu bir değişkenin okunması, bir uçucu değişkenin mevcut en son yazımının yanı sıra önceki herhangi bir yazmayı da göreceğini garanti eder . Bu durumda, currentPosuçucu hale getirilirse, görev currentPoskendileri de uçucu olmasalar bile nesnenin ve üyelerinin güvenli bir şekilde yayınlanmasını sağlar .
assylias

Kendim için, JLS'nin uçucunun diğer normal okuma ve yazımlarla nasıl bir engel oluşturduğunu nasıl garanti ettiğini tam olarak göremediğimi söylüyordum. Teknik olarak, bu konuda yanlış olamam;). Bellek modelleri söz konusu olduğunda, bir siparişin garanti edilmediğini ve diğer yönden yanlış olduğunu (hala güvende olduğunuzu) ve yanlış ve güvensiz olduğunu varsaymak akıllıca olur. Uçucu bu garantiyi sağlamak harika. JLS'nin 17. bölümünün bunu nasıl sağladığını açıklayabilir misiniz?
paulj

2
Kısacası, Point currentPos = new Point(x, y)3 yazınız var: (w1) this.x = x, (w2) this.y = yve (w3) currentPos = the new point. Program sırası hb (w1, w3) ve hb (w2, w3) olduğunu garanti eder. Daha sonra okuduğunuz programda (r1) currentPos. currentPosUçucu değilse , r1 ve w1, w2, w3 arasında hb yoktur, bu nedenle r1 bunların hiçbirini (veya hiçbirini) gözlemleyemez. Uçucu ile hb'yi (w3, r1) tanıtırsınız. Ve hb ilişkisi geçişlidir, böylece hb (w1, r1) ve hb (w2, r1) öğelerini de tanıtırsınız. Bu, Uygulamada Java Eşzamanlılığı (3.5.3. Güvenli Yayın Deyimleri) ile özetlenmiştir.
assylias

2
Ah, eğer hb bu şekilde geçiş yaparsa, bu yeterince güçlü bir 'bariyer', evet. Söylemeliyim ki, JLS'nin 17.4.5'inin hb'yi bu özelliğe sahip olarak tanımladığını belirlemek kolay değildir. Kesinlikle 17.4.5'in başlangıcında verilen özellikler listesinde değil. Geçişli kapanış sadece bazı açıklayıcı notlardan sonra daha fazla bahsedilmektedir! Her neyse, bilmek güzel, cevap için teşekkürler! :). Not: Cevabımı, asillerin yorumunu yansıtacak şekilde güncelleyeceğim.
paulj

-2

Yazma ve okumaları senkronize etmek için bir nesne kullanabilirsiniz. Aksi takdirde, diğerlerinin daha önce söylediği gibi, p.x + 1 ve py iki okumanın ortasında currentPos'a bir yazma gerçekleşir

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);
    }
}

Aslında bu işi yapar. İlk denememde okumayı senkronize blok içine koydum, ancak daha sonra bunun gerçekten gerekli olmadığını anladım.
Germano Fronza

1
-1 JVM sempaylaşılmadığını ispatlayabilir ve senkronize ifadeyi bir op-op olarak görmez ... Sorunu çözmesi gerçeği tamamen şanstır.
assylias

4
Çok iş parçacıklı programlamadan nefret ediyorum, şans yüzünden çok fazla şey çalışıyor.
Jonathan Allen

-3

CurrentPos'a iki kez erişiyorsunuz ve bu iki erişim arasında güncellenmediğini garanti etmiyorsunuz.

Örneğin:

  1. x = 10, y = 11
  2. işçi iş parçacığı px 10 olarak değerlendirir
  3. ana iş parçacığı güncellemeyi yürütür, şimdi x = 11 ve y = 12
  4. işçi iş parçacığı py 12 olarak değerlendirir.
  5. işçi iş parçacığı 10 + 1! = 12 olduğunu fark eder, bu nedenle yazdırır ve çıkar.

Aslında iki farklı noktayı karşılaştırıyorsunuz .

CurrentPos'u uçucu hale getirmenin bile işçi iş parçacığının iki ayrı okuması olduğundan sizi bundan koruyamayacağını unutmayın.

Bir ekle

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

puan sınıfına. Bu, x + 1 == y kontrol edilirken currentPos değerinin yalnızca bir değerinin kullanılmasını sağlar.


currentPos sadece bir kez okunur, değeri p olarak kopyalanır. p iki kez okunur, ancak her zaman aynı yeri gösterecektir.
Jonathan Allen
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.