HashSet <T> .removeAll yöntemi şaşırtıcı derecede yavaştır


92

Jon Skeet kısa süre önce blogunda ilginç bir programlama konusu gündeme getirdi: "Soyutlamamda bir boşluk var sevgili Liza, sevgili Liza" (vurgu eklenmiştir):

HashSetAslında bir setim var . Bazı öğeleri ondan kaldırmak istiyorum… ve öğelerin çoğu pek mevcut olmayabilir. Aslında, bizim test örneğimizde, "kaldırma" koleksiyonundaki hiçbir öğe orijinal sette olmayacak. Bu sesler - aslında ve bir son derece kolay koduna -. Sonuçta Set<T>.removeAllbize yardım etmeliyiz , değil mi?

Komut satırında "kaynak" kümesinin boyutunu ve "kaldırma" koleksiyonunun boyutunu belirleyip ikisini de oluşturuyoruz. Kaynak kümesi yalnızca negatif olmayan tam sayıları içerir; kaldırma kümesi yalnızca negatif tamsayılar içerir. Kullanarak tüm öğeleri kaldırmanın ne kadar sürdüğünü ölçüyoruz System.currentTimeMillis(), ki bu dünyanın en doğru kronometresi değil ama göreceğiniz gibi bu durumda fazlasıyla yeterli. İşte kod:

import java.util.*;
public class Test 
{ 
    public static void main(String[] args) 
    { 
       int sourceSize = Integer.parseInt(args[0]); 
       int removalsSize = Integer.parseInt(args[1]); 
        
       Set<Integer> source = new HashSet<Integer>(); 
       Collection<Integer> removals = new ArrayList<Integer>(); 
        
       for (int i = 0; i < sourceSize; i++) 
       { 
           source.add(i); 
       } 
       for (int i = 1; i <= removalsSize; i++) 
       { 
           removals.add(-i); 
       } 
        
       long start = System.currentTimeMillis(); 
       source.removeAll(removals); 
       long end = System.currentTimeMillis(); 
       System.out.println("Time taken: " + (end - start) + "ms"); 
    }
}

Kolay bir iş vererek başlayalım: 100 öğelik bir kaynak kümesi ve kaldırılacak 100 öğe:

c:UsersJonTest>java Test 100 100
Time taken: 1ms

Tamam, yani yavaş olmasını beklemiyorduk… açıkça işleri biraz hızlandırabiliriz. Kaldırılacak bir milyon öğe ve 300.000 öğe kaynağı nasıl olur?

c:UsersJonTest>java Test 1000000 300000
Time taken: 38ms

Hmm. Yine de oldukça hızlı görünüyor. Şimdi biraz acımasız olduğumu hissediyorum, tüm bunları kaldırmasını istedim. Bunu biraz daha kolaylaştıralım - 300.000 kaynak öğe ve 300.000 kaldırma:

c:UsersJonTest>java Test 300000 300000
Time taken: 178131ms

Affedersiniz? Yaklaşık üç dakika mı? Eyvah! Elbette , 38 ms'de yönettiğimizden daha küçük bir koleksiyondaki öğeleri çıkarmak daha kolay olmalı ?

Birisi bunun neden olduğunu açıklayabilir mi? HashSet<T>.removeAllYöntem neden bu kadar yavaş?


2
Kodunuzu test ettim ve hızlı çalıştı. Senin durumunda, bitirmek ~ 12ms sürdü. Ayrıca her iki giriş değerini de 10 artırdım ve 36ms sürdü. Siz testleri çalıştırırken bilgisayarınız bazı yoğun CPU görevleri yapıyor olabilir mi?
Slimu

4
Test ettim ve OP ile aynı sonucu aldım (peki, sonundan önce durdurdum). Gerçekten tuhaf. Windows, JDK 1.7.0_55
JB Nizet

2
Bununla ilgili açık bir bilet var: JDK-6982173
Haozhun

44
Meta'da tartışıldığı gibi , bu soru ilk olarak Jon Skeet'in blogundan çalındı ​​(şimdi bir moderatörün düzenlemesi nedeniyle sorudan doğrudan alıntı yapıldı ve soruyla bağlantılı). Gelecekteki okuyucular, intihal edilen blog gönderisinin, burada kabul edilen yanıta benzer şekilde, davranışın nedenini açıkladığını not etmelidir. Bu nedenle, buradaki cevapları okumak yerine, sadece tıklayıp blog gönderisinin tamamını okumak isteyebilirsiniz .
Mark Amery

1
Hata Java 15'te düzeltilecek: JDK-6394757
ZhekaKozlov

Yanıtlar:


139

Davranış (bir şekilde) javadoc'ta belgelenmiştir :

Bu uygulama, her birinde boyut yöntemini çağırarak bu kümeden ve belirtilen koleksiyondan hangisinin daha küçük olduğunu belirler. Bu kümede daha az öğe varsa, uygulama bu kümeyi yineler ve yineleyici tarafından döndürülen her öğeyi belirtilen koleksiyonda yer alıp almadığını kontrol eder . İçeriyorsa, yineleyicinin kaldırma yöntemi ile bu kümeden kaldırılır. Belirtilen koleksiyonda daha az öğe varsa, uygulama belirtilen koleksiyon üzerinde yinelenir ve bu kümenin remove yöntemini kullanarak yineleyici tarafından döndürülen her öğeyi bu kümeden kaldırır.

Bu pratikte ne anlama geliyor, aradığınızda source.removeAll(removals);:

  • eğer removalstoplama göre daha küçük boyutta olan source, removeyöntemine HashSethızlı denir.

  • Eğer removalstoplama eşit ya da daha büyük boyutta olan source, daha sonra removals.contains, bir ArrayList'deki yavaş olan denir.

Hızlı düzeltme:

Collection<Integer> removals = new HashSet<Integer>();

Olduğunu Not açık hata size açıklamak ne çok benziyor. Sonuç olarak, muhtemelen kötü bir seçim olduğu, ancak javadoc'ta belgelendiği için değiştirilemeyeceği gibi görünüyor.


Referans için, bu kod removeAll(Java 8'de - diğer sürümleri kontrol etmediniz):

public boolean removeAll(Collection<?> c) {
    Objects.requireNonNull(c);
    boolean modified = false;

    if (size() > c.size()) {
        for (Iterator<?> i = c.iterator(); i.hasNext(); )
            modified |= remove(i.next());
    } else {
        for (Iterator<?> i = iterator(); i.hasNext(); ) {
            if (c.contains(i.next())) {
                i.remove();
                modified = true;
            }
        }
    }
    return modified;
}

15
Vay. Bugün bir şey öğrendim. Bu bana kötü bir uygulama seçimi gibi görünüyor. Diğer koleksiyon bir Set değilse bunu yapmamalıdırlar.
JB Nizet

2
@JBNizet Evet bu tuhaf - burada önerinizle tartışıldı - neden geçmediğinden emin değilim ...
assylias

2
Çok teşekkürler @assylias .. Ama gerçekten nasıl anladın merak ediyorum .. :) Güzel, gerçekten güzel .... Bu problemle karşılaştın mı ???

8
@show_stopper Bir profil oluşturucu çalıştırdım ve ArrayList#containsbunun suçlu olduğunu gördüm . Koduna bir bakış AbstractSet#removeAllcevabın geri kalanını verdi.
assylias
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.