Statik başlatıcıda lambda ile paralel akış neden kilitlenmeye neden oluyor?


86

Statik başlatıcıda bir lambda ile paralel akış kullanmanın, CPU kullanımı olmadan görünüşte sonsuza kadar sürdüğü garip bir durumla karşılaştım. İşte kod:

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

Bu, bu davranış için minimum yeniden üretim testi senaryosu gibi görünmektedir. Eğer ben:

  • Statik başlatıcı yerine bloğu ana yönteme koyun,
  • paralelleştirmeyi kaldırın veya
  • lambdayı çıkarın,

kod anında tamamlanır. Bu davranışı kimse açıklayabilir mi? Bu bir hata mı yoksa bu kasıtlı mı?

OpenJDK 1.8.0_66-dahili sürümünü kullanıyorum.


4
Aralık (0, 1) ile program normal şekilde sona erer. İle (0, 2) veya daha yüksek kilitleniyor.
Laszlo Hirdi


2
Aslında tam olarak aynı soru / sorun, sadece farklı bir API ile.
Didier L

3
Sınıfı bir arka plan iş parçacığında kullanılamaması için başlatmayı tamamlamadığınızda, arka planda bir iş parçacığında bir sınıfı kullanmaya çalışıyorsunuz.
Peter Lawrey

4
@ Solomonoff'sSecret as i -> ibir yöntem referansı değildir static method, Deadlock sınıfında uygulanmıştır. Değiştirirseniz i -> iile Function.identity()bu kod iyi olmalıdır.
Peter Lawrey

Yanıtlar:


71

Stuart Marks tarafından "Sorun Değil" olarak kapatılan çok benzer bir vakanın ( JDK-8143380 ) hata raporunu buldum :

Bu bir sınıf başlatma kilitlenmesidir. Test programının ana iş parçacığı, sınıf için devam eden başlatma bayrağını ayarlayan sınıf statik başlatıcısını yürütür; bu bayrak statik başlatıcı tamamlanıncaya kadar ayarlanmış olarak kalır. Statik başlatıcı, lambda ifadelerinin diğer evrelerde değerlendirilmesine neden olan paralel bir akışı yürütür. Bu iş parçacıkları, sınıfın başlatmayı tamamlamasını bekliyor. Ancak, paralel görevlerin tamamlanmasını beklerken ana iş parçacığı bloke edilir ve bu da kilitlenmeye neden olur.

Paralel akış mantığını sınıf statik başlatıcısının dışına taşımak için test programı değiştirilmelidir. Sorun Değil Olarak Kapanış.


Bununla ilgili başka bir hata raporu bulabildim ( JDK-8136753 ), ayrıca Stuart Marks tarafından "Sorun Değil" olarak kapatıldı:

Bu, Fruit numaralandırmasının statik başlatıcısının sınıf başlatma ile kötü bir şekilde etkileşime girmesi nedeniyle oluşan bir kilitlenmedir.

Sınıf başlatmayla ilgili ayrıntılar için Java Dil Belirtimi, bölüm 12.4.2'ye bakın.

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

Kısaca neler oluyor aşağıdaki gibidir.

  1. Ana iş parçacığı Fruit sınıfına atıfta bulunur ve başlatma sürecini başlatır. Bu işlem devam eden başlatma bayrağını ayarlar ve ana iş parçacığı üzerinde statik başlatıcıyı çalıştırır.
  2. Statik başlatıcı, başka bir iş parçacığında bazı kodları çalıştırır ve bitmesini bekler. Bu örnek paralel akışları kullanır, ancak bunun akışlarla hiçbir ilgisi yoktur. Kodu başka bir iş parçacığında herhangi bir şekilde çalıştırmak ve bu kodun bitmesini beklemek aynı etkiye sahip olacaktır.
  3. Diğer iş parçacığındaki kod, başlatma devam eden bayrağını kontrol eden Fruit sınıfına başvurur. Bu, bayrak temizlenene kadar diğer iş parçacığının engellenmesine neden olur. (JLS 12.4.2'nin 2. adımına bakın.)
  4. Ana iş parçacığı, diğer iş parçacığının sona ermesini beklerken engellenir, bu nedenle statik başlatıcı asla tamamlanmaz. Statik başlatıcı tamamlanıncaya kadar başlatma devam eden bayrağı temizlenmediğinden, iş parçacıkları kilitlenir.

Bu sorunu önlemek için, bir sınıfın statik başlatmasının, diğer iş parçacıklarının bu sınıfın tamamlanmış başlatmasını gerektiren kodu yürütmesine neden olmadan hızla tamamlandığından emin olun.

Sorun Değil Olarak Kapanış.


FindBugs'un bu durum için bir uyarı eklemek için açık bir sorunu olduğunu unutmayın .


20
"Biz özelliğini tasarlanan bu kabul edildi" ve "Biz bu hatayı neyin neden olduğunu biliyorum ama bunu düzeltmek için değil nasıl" do not ortalamaları "Bu bir hata değildir" . Bu kesinlikle bir hata.
BlueRaja - Dany Pflughoeft

13
@ bayou.io Ana sorun, lambdas değil, statik başlatıcılar içinde evrelerin kullanılmasıdır.
Stuart Marks

5
BTW Tunaki hata raporlarımı araştırdığınız için teşekkürler. :-)
Stuart Marks

13
@ bayou.io: thisNesne oluşturma sırasında kaçışa izin veren bir kurucuda olduğu gibi sınıf düzeyinde de aynı şey . Temel kural, başlatıcılarda çok iş parçacıklı işlemler kullanmayın. Bunu anlamanın zor olduğunu sanmıyorum. Lambda uygulanmış bir işlevi bir kayıt defterine kaydetme örneğiniz farklı bir şeydir, bu engellenmiş arka plan iş parçacıklarından birini beklemediğiniz sürece kilitlenme oluşturmaz. Yine de, bu tür işlemleri bir sınıf başlatıcıda yapmaktan kesinlikle vazgeçiyorum. Onların amacı bu değil.
Holger

9
Sanırım programlama stili dersi şudur: statik başlatıcıları basit tutun.
Raedwald

16

DeadlockSınıfın kendisini referans alan diğer konuların nerede olduğunu merak edenler için , Java lambdaları sizin yazdığınız gibi davranır:

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

Normal anonim sınıflarda kilitlenme olmaz:

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

5
@ Solomonoff'sSecret Bu bir uygulama seçimi. Lambda'daki kod bir yere gitmeli. Javac bunu içeren sınıfta statik bir yöntemde derler ( lambda1bu örnekte i'ye benzer ). Her lambdayı kendi sınıfına koymak çok daha pahalı olurdu.
Stuart Marks

1
@StuartMarks Lambda'nın işlevsel arabirimi uygulayan bir sınıf oluşturduğu göz önüne alındığında, lambda uygulamasını işlevsel arabirimin lambda'sının uygulamasına yerleştirmek bu yazının ikinci örneğindeki kadar verimli olmaz mıydı? Bu kesinlikle bir şeyleri yapmanın en bariz yolu ama eminim bu şekilde yapılmalarının bir nedeni vardır.
Monica'yı eski

6
@ Solomonoff'sSecret Lambda, çalışma zamanında ( java.lang.invoke.LambdaMetafactory aracılığıyla ) bir sınıf oluşturabilir , ancak lambda gövdesi derleme zamanında bir yere yerleştirilmelidir. Böylece lambda sınıfları, .class dosyalarından yüklenen normal sınıflardan daha ucuz olması için bazı sanal makine sihrinden yararlanabilir.
Jeffrey Bosboom

1
@ Solomonoff'sSecret Evet, Jeffrey Bosboom'un yanıtı doğru. Gelecekteki bir JVM'de mevcut bir sınıfa bir yöntem eklemek mümkün olursa, meta fabrika bunu yeni bir sınıf döndürmek yerine yapabilir. (Saf spekülasyon.)
Stuart Marks

3
@ Solomonoff's Secret: sizin gibi önemsiz lambda ifadelerine bakarak yargılamayın i -> i; norm olmayacaklar. Lambda ifadeleri, sınıfları da dahil olmak üzere çevreleyen sınıfın tüm üyelerini kullanabilir privateve bu da tanımlayıcı sınıfın kendisini doğal yerleri haline getirir. Tüm bu kullanım durumlarının, kendi tanımlama sınıfının üyelerini kullanmadan, önemsiz lambda ifadelerinin çok iş parçacıklı kullanımıyla özel sınıf başlatıcıları için optimize edilmiş bir uygulamadan zarar görmesine izin vermek, uygun bir seçenek değildir.
Holger

14

Bu sorunun mükemmel bir açıklaması, 07 Nisan 2015 tarihli Andrei Pangin tarafından yapılmıştır. Burada bulunabilir , ancak Rusça olarak yazılmıştır (yine de kod örneklerini incelemenizi öneririm - bunlar uluslararasıdır). Genel sorun, sınıf başlatma sırasında bir kilitlenmedir.

İşte makaleden bazı alıntılar:


JLS'ye göre , her sınıfın başlatma sırasında yakalanan benzersiz bir başlatma kilidi vardır. Başlatma sırasında diğer iş parçacığı bu sınıfa erişmeye çalıştığında, başlatma tamamlanana kadar kilit üzerinde engellenecektir. Sınıflar eşzamanlı olarak başlatıldığında, bir kilitlenme elde etmek mümkündür.

Tam sayıların toplamını hesaplayan basit bir program yazdım, neyi yazdırmalı?

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

Şimdi parallel()lambda'yı Integer::sumcall ile kaldırın veya değiştirin - ne değişecek?

Burada yine kilitlenme görüyoruz [makalede daha önce sınıf başlatıcılarında bazı kilitlenme örnekleri vardı]. parallel()Akış işlemleri nedeniyle ayrı bir iş parçacığı havuzunda çalışır. Bu evreler, sınıf private staticiçinde bir yöntem olarak bayt kodu ile yazılan lambda gövdesini çalıştırmaya çalışır StreamSum. Ancak bu yöntem, akımın tamamlanmasının sonuçlarını bekleyen sınıf statik başlatıcısı tamamlanmadan çalıştırılamaz.

Daha akıllara durgunluk veren şey: bu kod farklı ortamlarda farklı şekilde çalışır. Tek bir CPU makinesinde doğru şekilde çalışacak ve büyük olasılıkla çok CPU'lu bir makinede asılı kalacaktır. Bu fark, Fork-Join havuzu uygulamasından gelir. Parametreyi değiştirerek kendiniz doğrulayabilirsiniz-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

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.