Szukaj…


Wprowadzenie

W tym temacie opisano szereg „pułapek” (tj. Błędów popełnianych przez początkujących programistów Java), które odnoszą się do wydajności aplikacji Java.

Uwagi

W tym temacie opisano niektóre nieefektywne praktyki kodowania Java „mikro”. W większości przypadków nieefektywności są stosunkowo niewielkie, ale nadal warto ich unikać, jest to możliwe.

Pitfall - koszty związane z tworzeniem komunikatów dziennika

Poziomy dziennika TRACE i DEBUG są w stanie przekazać szczegółowe informacje o działaniu danego kodu w czasie wykonywania. Zazwyczaj zalecane jest ustawienie poziomu dziennika powyżej tych wartości, jednak należy zachować ostrożność, aby te instrukcje nie wpływały na wydajność, nawet jeśli pozornie są „wyłączone”.

Rozważ tę instrukcję dziennika:

// Processing a request of some kind, logging the parameters
LOG.debug("Request coming from " + myInetAddress.toString() 
          + " parameters: " + Arrays.toString(veryLongParamArray));

Nawet jeśli poziom dziennika jest ustawiony na INFO , argumenty przekazywane do debug() będą oceniane przy każdym wykonaniu linii. To sprawia, że niepotrzebnie zużywa się go z kilku powodów:

  • String konkatenacji: wielokrotne String instancje będą tworzone
  • InetAddress może nawet InetAddress DNS.
  • parametr veryLongParamArray może być bardzo długi - utworzenie z niego ciągu zużywa pamięć i zajmuje dużo czasu

Rozwiązanie

Większość ram rejestrowania zapewnia środki do tworzenia komunikatów dziennika przy użyciu ciągów poprawek i odwołań do obiektów. Komunikat dziennika zostanie oceniony tylko wtedy, gdy komunikat zostanie faktycznie zarejestrowany. Przykład:

// No toString() evaluation, no string concatenation if debug is disabled
LOG.debug("Request coming from {} parameters: {}", myInetAddress, parameters));

Działa to bardzo dobrze, o ile wszystkie parametry można przekonwertować na ciągi znaków za pomocą String.valueOf (Object) . Jeśli obliczenie komunikatu dziennika jest bardziej złożone, poziom dziennika można sprawdzić przed zalogowaniem:

if (LOG.isDebugEnabled()) {
    // Argument expression evaluated only when DEBUG is enabled
    LOG.debug("Request coming from {}, parameters: {}", myInetAddress,
              Arrays.toString(veryLongParamArray);
}

Tutaj LOG.debug() z kosztownym Arrays.toString(Obect[]) jest przetwarzany tylko wtedy, gdy DEBUG jest faktycznie włączony.

Pitfall - konkatenacja łańcuchów w pętli nie jest skalowana

Rozważ następujący kod jako ilustrację:

public String joinWords(List<String> words) {
    String message = "";
    for (String word : words) {
        message = message + " " + word;
    }
    return message;
}

Niestety, ten kod jest nieefektywny, jeśli lista words jest długa. Źródłem problemu jest to stwierdzenie:

message = message + " " + word;

Dla każdej iteracji pętli ta instrukcja tworzy nowy ciąg message zawierający kopię wszystkich znaków w oryginalnym ciągu message z dołączonymi dodatkowymi znakami. Generuje to wiele tymczasowych ciągów i dużo kopiuje.

Kiedy analizujemy joinWords , zakładając, że istnieje N słów o średniej długości M, stwierdzamy, że tworzone są tymczasowe ciągi O (N), a znaki O (MN 2 ) zostaną skopiowane. Szczególnie niepokojący jest składnik N 2 .

Zalecanym podejściem do tego rodzaju problemu 1 jest użycie StringBuilder zamiast łączenia łańcuchów w następujący sposób:

public String joinWords2(List<String> words) {
    StringBuilder message = new StringBuilder();
    for (String word : words) {
        message.append(" ").append(word);
    }
    return message.toString();
}

Analiza joinWords2 musi uwzględniać narzuty związane z „powiększaniem” tablicy podkładu StringBuilder która przechowuje postacie konstruktora. Okazuje się jednak, że liczba utworzonych nowych obiektów to O (logN), a liczba skopiowanych znaków to O (MN). Ten ostatni zawiera znaki skopiowane w ostatnim wywołaniu toString() .

(Może być możliwe dostrajanie tego, tworząc StringBuilder z właściwą zdolnością na początek. Jednak ogólna złożoność pozostaje taka sama.)

Wracając do oryginalnej metody joinWords , okazuje się, że krytyczne zdanie zostanie zoptymalizowane przez typowy kompilator Java do czegoś takiego:

  StringBuilder tmp = new StringBuilder();
  tmp.append(message).append(" ").append(word);
  message = tmp.toString();

Jednak kompilator Java nie „wyciągnie” StringBuilder z pętli, jak to zrobiliśmy ręcznie w kodzie dla joinWords2 .

Odniesienie:


1 - W Javie 8 i nowszych klasach Joiner można użyć do rozwiązania tego konkretnego problemu. Jednak nie o to tak naprawdę powinien wyglądać ten przykład.

Pitfall - Używanie „nowego” do tworzenia prymitywnych instancji opakowania jest nieefektywne

Język Java pozwala używać new do tworzenia instancji Integer , Boolean i tak dalej, ale ogólnie jest to zły pomysł. Lepiej użyć albo autoboxowania (Java 5 i nowsze), albo metody valueOf .

 Integer i1 = new Integer(1);      // BAD
 Integer i2 = 2;                   // BEST (autoboxing)
 Integer i3 = Integer.valueOf(3);  // OK

Powodem, dla którego jawne używanie new Integer(int) jest złym pomysłem, jest to, że tworzy nowy obiekt (chyba że zoptymalizowany przez kompilator JIT). Natomiast w przypadku użycia funkcji autoboxowania lub jawnego wywołania valueOf środowisko wykonawcze Java spróbuje ponownie użyć obiektu Integer z pamięci podręcznej wcześniej istniejących obiektów. Za każdym razem, gdy środowisko wykonawcze ma „trafienie” do pamięci podręcznej, unika tworzenia obiektu. Oszczędza to również pamięć sterty i zmniejsza narzuty GC spowodowane przez odejście obiektu.

Uwagi:

  1. W najnowszych implementacjach Java, autoboxing jest realizowany przez wywołanie valueOf , i istnieją pamięci podręczne dla wartości Boolean , Byte , Short , Integer , Long i Character .
  2. Zachowanie buforowania dla typów integralnych jest określone przez specyfikację języka Java.

Pitfall - Wywołanie „new String (String)” jest nieefektywne

Używanie new String(String) do duplikowania ciągu jest nieefektywne i prawie zawsze jest niepotrzebne.

  • Obiekty łańcuchowe są niezmienne, więc nie ma potrzeby kopiowania ich w celu ochrony przed zmianami.
  • W niektórych starszych wersjach Java obiekty String mogą współdzielić tablice podkładowe z innymi obiektami String . W tych wersjach możliwe jest wyciek pamięci, tworząc (mały) podciąg (dużego) łańcucha i zachowując go. Jednak od wersji Java 7 tablice wspierające String nie są współużytkowane.

W przypadku braku wymiernych korzyści wywołanie new String(String) jest po prostu marnotrawstwem:

  • Wykonanie kopii zajmuje czas procesora.
  • Kopia zużywa więcej pamięci, co zwiększa powierzchnię pamięci aplikacji i / lub zwiększa koszty ogólne GC.
  • Operacje takie jak equals(Object) i hashCode() mogą być wolniejsze, jeśli kopiowane są obiekty String.

Pitfall - wywołanie System.gc () jest nieefektywne

Wywołanie System.gc() jest (prawie zawsze) złym pomysłem.

Jawadoc dla metody gc() określa, co następuje:

„Wywołanie metody gc sugeruje, że wirtualna maszyna Java poświęca wysiłek na recykling nieużywanych obiektów, aby pamięć, którą aktualnie zajmują, była dostępna do szybkiego ponownego użycia. Gdy kontrola powraca z wywołania metody, wirtualna maszyna Java podjęła wszelkie starania, aby ją odzyskać odstęp od wszystkich odrzuconych obiektów. ”

Można z tego wyciągnąć kilka ważnych punktów:

  1. Użycie słowa „sugeruje” zamiast (powiedzmy) „mówi” oznacza, że JVM może zignorować sugestię. Domyślne zachowanie JVM (najnowsze wersje) jest zgodne z sugestią, ale można to zmienić, ustawiając -XX:+DisableExplicitGC podczas uruchamiania JVM.

  2. Wyrażenie „najlepszy wysiłek, aby odzyskać miejsce ze wszystkich odrzuconych obiektów” oznacza, że wywołanie gc spowoduje „pełne” wyrzucanie elementów bezużytecznych.

Dlaczego więc wywołanie System.gc() jest złym pomysłem?

Po pierwsze, prowadzenie pełnego śmiecia jest kosztowne. Pełny GC obejmuje odwiedzanie i „oznaczanie” każdego obiektu, który jest jeszcze osiągalny; tzn. każdy obiekt, który nie jest śmieci. Jeśli uruchomisz to, gdy nie ma dużo śmieci do zebrania, GC wykonuje wiele pracy, przynosząc stosunkowo niewielkie korzyści.

Po drugie, pełne wyrzucanie elementów bezużytecznych może zakłócać właściwości „lokalizacji” obiektów, które nie są gromadzone. Obiekty, które są przydzielane przez ten sam wątek mniej więcej w tym samym czasie, zwykle są przydzielane blisko siebie w pamięci. To jest dobre. Obiekty przydzielane w tym samym czasie prawdopodobnie będą powiązane; tj. nawzajem się odnosić. Jeśli aplikacja korzysta z tych odniesień, istnieje prawdopodobieństwo, że dostęp do pamięci będzie szybszy z powodu różnych efektów pamięci i buforowania strony. Niestety pełne wyrzucanie elementów bezużytecznych ma tendencję do przesuwania obiektów, dzięki czemu obiekty, które kiedyś były blisko, są teraz bardziej oddalone.

Po trzecie, uruchomienie pełnego wyrzucania elementów bezużytecznych może spowodować, że aplikacja zatrzyma się do momentu zakończenia zbierania. Gdy tak się dzieje, aplikacja nie będzie odpowiadać.

W rzeczywistości najlepszą strategią jest pozwolenie JVM zdecydować, kiedy uruchomić GC i jaki rodzaj kolekcji. Jeśli nie przeszkadzasz, JVM wybierze czas i typ kolekcji, który optymalizuje przepustowość lub minimalizuje czasy pauzy GC.


Na początku powiedzieliśmy „... (prawie zawsze) zły pomysł ...”. W rzeczywistości istnieje kilka scenariuszy, w których może być dobrym pomysłem:

  1. Jeśli wdrażasz test jednostkowy dla kodu wrażliwego na odśmiecanie (np. Coś z finalizatorami lub referencje słabe / miękkie / fantomowe System.gc() może być konieczne wywołanie System.gc() .

  2. W niektórych aplikacjach interaktywnych mogą istnieć określone punkty w czasie, w których użytkownik nie będzie dbał o to, czy nastąpi przerwa na odśmiecanie. Jednym z przykładów jest gra, w której występują naturalne przerwy w „grze”; np. podczas ładowania nowego poziomu.

Pitfall - Nadużywanie pierwotnych typów opakowań jest nieefektywne

Rozważ te dwa fragmenty kodu:

int a = 1000;
int b = a + 1;

i

Integer a = 1000;
Integer b = a + 1;

Pytanie: Która wersja jest bardziej wydajna?

Odpowiedź: Dwie wersje wyglądają prawie identycznie, ale pierwsza wersja jest o wiele bardziej wydajna niż druga.

Druga wersja używa reprezentacji liczb, które zajmują więcej miejsca, i opiera się na automatycznym boxowaniu i automatycznym rozpakowywaniu za kulisami. W rzeczywistości druga wersja jest bezpośrednio równoważna z następującym kodem:

Integer a = Integer.valueOf(1000);               // box 1000
Integer b = Integer.valueOf(a.intValue() + 1);   // unbox 1000, add 1, box 1001

Porównując to z inną wersją, która używa int , są wyraźnie trzy dodatkowe wywołania metod, gdy używana jest liczba Integer . W przypadku valueOf każde wywołanie utworzy i zainicjuje nowy obiekt Integer . Wszystkie te dodatkowe prace związane z boksowaniem i rozpakowywaniem prawdopodobnie spowodują, że druga wersja będzie o rząd wielkości wolniejsza niż pierwsza.

Oprócz tego druga wersja przydziela obiekty na stercie w każdym wywołaniu valueOf . Chociaż wykorzystanie przestrzeni zależy od platformy, prawdopodobnie będzie to około 16 bajtów dla każdego obiektu Integer . Natomiast int potrzeby wersja zera dodatkową przestrzeń sterty, przy założeniu, że i a b są zmienne lokalne.


Innym ważnym powodem, dla którego prymitywy są szybsze niż ich odpowiedniki w pudełkach, jest sposób, w jaki ich typy tablic są rozmieszczone w pamięci.

Jeśli weźmiesz jako przykład int[] i Integer[] , w przypadku int[] wartości int są ułożone w pamięci. Ale w przypadku liczby Integer[] nie są to określone wartości, ale odniesienia (wskaźniki) do obiektów Integer , które z kolei zawierają rzeczywiste wartości int .

Oprócz tego, że jest to dodatkowy poziom pośredni, może to być duży zbiornik, jeśli chodzi o lokalizację pamięci podręcznej podczas iteracji po wartościach. W przypadku int[] procesor może pobrać wszystkie wartości z tablicy, jednocześnie do swojej pamięci podręcznej, ponieważ są one ciągłe w pamięci. Ale w przypadku liczby Integer[] procesor potencjalnie musi wykonać dodatkowe pobieranie pamięci dla każdego elementu, ponieważ tablica zawiera tylko odniesienia do rzeczywistych wartości.


Krótko mówiąc, używanie pierwotnych typów opakowań jest stosunkowo drogie zarówno pod względem zasobów procesora, jak i pamięci. Używanie ich bez potrzeby jest wydajne.

Pitfall - Iteracja kluczy mapy może być nieefektywna

Poniższy przykładowy kod jest wolniejszy niż powinien:

Map<String, String> map = new HashMap<>(); 
for (String key : map.keySet()) {
    String value = map.get(key);
    // Do something with key and value
}

Jest tak, ponieważ wymaga wyszukiwania mapy get() metoda get() ) dla każdego klucza na mapie. To wyszukiwanie może nie być wydajne (w HashMap wymaga wywołania hashCode na kluczu, a następnie wyszukania poprawnego segmentu w wewnętrznych strukturach danych, a czasem nawet wywołania equals ). Na dużej mapie może to nie być trywialny narzut.

Prawidłowym sposobem uniknięcia tego jest iteracja wpisów na mapie, która została szczegółowo opisana w temacie Kolekcje

Pitfall - Używanie size () do testowania, czy kolekcja jest pusta, jest nieefektywne.

Framework kolekcji Java zapewnia dwie powiązane metody dla wszystkich obiektów Collection :

  • size() zwraca liczbę pozycji w Collection , a
  • isEmpty() zwraca wartość true, jeśli (i tylko wtedy) Collection jest pusta.

Obie metody można wykorzystać do testowania pustki kolekcji. Na przykład:

Collection<String> strings = new ArrayList<>();
boolean isEmpty_wrong = strings.size() == 0; // Avoid this
boolean isEmpty = strings.isEmpty();         // Best

Chociaż te podejścia wyglądają tak samo, niektóre implementacje kolekcji nie przechowują rozmiaru. W przypadku takiej kolekcji implementacja size() musi obliczyć rozmiar przy każdym wywołaniu. Na przykład:

  • Prosta połączona klasa listy (ale nie java.util.LinkedList ) może wymagać przejścia przez listę, aby policzyć elementy.
  • Klasa ConcurrentHashMap musi sumować wpisy we wszystkich „segmentach” mapy.
  • Leniwa implementacja kolekcji może wymagać zrealizowania całej kolekcji w pamięci, aby policzyć elementy.

Natomiast metoda isEmpty() musi tylko sprawdzić, czy w kolekcji znajduje się co najmniej jeden element. Nie wymaga to liczenia elementów.

Chociaż size() == 0 nie zawsze jest mniej wydajny niż isEmpty() , nie do pomyślenia jest, aby poprawnie zaimplementowana isEmpty() była mniej wydajna niż size() == 0 . Dlatego preferowana jest isEmpty() .

Pitfall - Obawy dotyczące wydajności wyrażeń regularnych

Dopasowywanie wyrażeń regularnych jest potężnym narzędziem (w Javie i innych kontekstach), ale ma pewne wady. Jednym z nich są wyrażenia regularne, które są raczej drogie.

Instancje Pattern i Matcher powinny zostać ponownie użyte

Rozważ następujący przykład:

/**
 * Test if all strings in a list consist of English letters and numbers.
 * @param strings the list to be checked
 * @return 'true' if an only if all strings satisfy the criteria
 * @throws NullPointerException if 'strings' is 'null' or a 'null' element.
 */
public boolean allAlphanumeric(List<String> strings) {
    for (String s : strings) {
        if (!s.matches("[A-Za-z0-9]*")) {
            return false;
        }  
    }
    return true;
}

Ten kod jest poprawny, ale jest nieefektywny. Problem polega na wywołaniu matches(...) . Pod maską s.matches("[A-Za-z0-9]*") jest równoważne z tym:

Pattern.matches(s, "[A-Za-z0-9]*")

co z kolei jest równoważne z

Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()

Pattern.compile("[A-Za-z0-9]*") analizuje wyrażenie regularne, analizuje je i konstruuje obiekt Pattern który przechowuje strukturę danych, która będzie używana przez silnik regex. Jest to nietrywialne obliczenie. Następnie tworzony jest obiekt Matcher celu zawinięcia argumentu s . Na koniec wywołujemy match() aby wykonać właściwe dopasowanie wzorca.

Problem polega na tym, że ta praca jest powtarzana dla każdej iteracji pętli. Rozwiązaniem jest restrukturyzacja kodu w następujący sposób:

private static Pattern ALPHA_NUMERIC = Pattern.compile("[A-Za-z0-9]*");

public boolean allAlphanumeric(List<String> strings) {
    Matcher matcher = ALPHA_NUMERIC.matcher("");
    for (String s : strings) {
        matcher.reset(s);
        if (!matcher.matches()) {
            return false;
        }  
    }
    return true;
}

Zauważ, że javadoc dla Pattern stwierdza:

Instancje tej klasy są niezmienne i są bezpieczne w użyciu dla wielu współbieżnych wątków. Instancje klasy Matcher nie są bezpieczne do takiego użycia.

Nie używaj match (), kiedy powinieneś używać find ()

Załóżmy, że chcesz sprawdzić, czy ciąg s zawiera trzy lub więcej cyfr z rzędu. Możesz wyrazić to na różne sposoby, w tym:

  if (s.matches(".*[0-9]{3}.*")) {
      System.out.println("matches");
  }

lub

  if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
      System.out.println("matches");
  }

Pierwszy jest bardziej zwięzły, ale prawdopodobnie będzie mniej wydajny. Na pierwszy rzut oka pierwsza wersja będzie próbowała dopasować cały ciąg do wzorca. Ponadto, ponieważ „. *” Jest „chciwym” wzorem, dopasowujący wzorzec prawdopodobnie przesunie „chętnie” próbę do końca łańcucha i cofnie się, dopóki nie znajdzie pasującego wzorca.

Natomiast druga wersja będzie wyszukiwać od lewej do prawej i zatrzyma wyszukiwanie, gdy tylko znajdzie 3 cyfry z rzędu.

Użyj wydajniejszych alternatyw dla wyrażeń regularnych

Wyrażenia regularne są potężnym narzędziem, ale nie powinny być twoim jedynym narzędziem. Wiele zadań można wykonać wydajniej na inne sposoby. Na przykład:

 Pattern.compile("ABC").matcher(s).find()

robi to samo co:

 s.contains("ABC")

poza tym, że ten drugi jest o wiele bardziej wydajny. (Nawet jeśli możesz amortyzować koszt kompilacji wyrażenia regularnego).

Często forma nieregexowa jest bardziej skomplikowana. Na przykład test wykonywany przez metodę allAlplanumeric matches() wcześniejszej metody allAlplanumeric można przepisać jako:

 public boolean matches(String s) {
     for (char c : s) {
         if ((c >= 'A' && c <= 'Z') ||
             (c >= 'a' && c <= 'z') ||
             (c >= '0' && c <= '9')) {
              return false;
         }
     }
     return true;
 }

Teraz jest to więcej kodu niż użycie Matcher , ale będzie on znacznie szybszy.

Katastrofalne cofanie się

(Jest to potencjalnie problem ze wszystkimi implementacjami wyrażeń regularnych, ale wspomnimy o tym tutaj, ponieważ jest to pułapka dla użycia Pattern ).

Rozważ ten (wymyślony) przykład:

Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());

Pierwsze wywołanie println szybko wydrukuje true . Drugi wydrukuje false . Ostatecznie. Rzeczywiście, jeśli eksperymentujesz z powyższym kodem, zobaczysz, że za każdym razem, gdy dodasz literę A przed literą C , czas oczekiwania podwoi się.

Takie zachowanie jest przykładem katastrofalnego cofania . Wzór pasujący silnik, który implementuje regex dopasowywania się bezowocnie próbuje wszystkich możliwych sposobów, że wzór może równać.

Przyjrzyjmy się, co tak naprawdę oznacza (A+)+B Pozornie wydaje się, że mówi „jeden lub więcej znaków A po których następuje wartość B ”, ale w rzeczywistości mówi jedna lub więcej grup, z których każda składa się z jednego lub więcej znaków A Na przykład:

  • „AB” pasuje tylko w jedną stronę: „(A) B”
  • „AAB” pasuje na dwa sposoby: „(AA) B” lub „(A) (A) B”
  • „AAAB” pasuje na cztery sposoby: „(AAA) B” lub „(AA) (A) B or '(A)(AA)B lub „(A) (A) (A) B”
  • i tak dalej

Innymi słowy, liczba możliwych dopasowań wynosi 2 N, gdzie N jest liczbą znaków A

Powyższy przykład jest wyraźnie wymyślony, ale wzory, które wykazują tego rodzaju cechy wydajności (tj. O(2^N) lub O(N^K) dla dużego K ) pojawiają się często, gdy używane są źle rozważane wyrażenia regularne. Istnieje kilka standardowych środków zaradczych:

  • Unikaj zagnieżdżania powtarzających się wzorców w obrębie innych powtarzających się wzorców.
  • Unikaj używania zbyt wielu powtarzających się wzorów.
  • W razie potrzeby stosuj powtarzanie bez śledzenia.
  • Nie używaj wyrażeń regularnych do skomplikowanych zadań analizy. (Zamiast tego napisz odpowiedni parser.)

Na koniec uważaj na sytuacje, w których użytkownik lub klient API może dostarczyć ciąg wyrażeń regularnych o cechach patologicznych. Może to prowadzić do przypadkowej lub celowej „odmowy usługi”.

Bibliografia:

Pitfall - internowanie łańcuchów, dzięki czemu można użyć ==, jest złym pomysłem

Gdy niektórzy programiści zobaczą tę radę:

„Testowanie ciągów za pomocą == jest niepoprawne (chyba że ciągi są internowane)”

ich początkowa reakcja jest na wewnętrzne łańcuchy, aby mogli użyć == . (W końcu == jest szybszy niż wywołanie String.equals(...) , prawda?)

To niewłaściwe podejście z wielu perspektyw:

Kruchość

Przede wszystkim możesz bezpiecznie używać == jeśli wiesz, że wszystkie testowane obiekty String zostały internowane. JLS gwarantuje, że literały łańcuchowe w kodzie źródłowym zostaną internowane. Jednak żaden ze standardowych interfejsów API Java SE nie gwarantuje zwrócenia wewnętrznych ciągów, oprócz String.intern(String) . Jeśli przegapisz tylko jedno źródło obiektów String , które nie zostały internowane, Twoja aplikacja będzie zawodna. Ta niewiarygodność przejawi się raczej jako fałszywe negatywy niż wyjątki, które mogą utrudnić wykrycie.

Koszty korzystania z „intern ()”

Pod maską internowanie polega na utrzymaniu tabeli skrótów, która zawiera wcześniej internowane obiekty String . Stosowany jest pewien rodzaj słabego mechanizmu odniesienia, dzięki czemu internalizująca tabela skrótów nie staje się przeciekiem pamięci. Chociaż tablica skrótów jest zaimplementowana w kodzie natywnym (w przeciwieństwie do HashMap , HashTable itp.), Połączenia intern są nadal stosunkowo kosztowne pod względem wykorzystywanego procesora i pamięci.

Koszt ten należy porównać z oszczędnościami, które uzyskamy, używając == zamiast equals . W rzeczywistości nie zamierzamy złamać nawet, jeśli każdy internowany łańcuch nie zostanie porównany z innymi łańcuchami „kilka” razy.

(Poza tym: nieliczne sytuacje, w których warto internować, zwykle polegają na zmniejszeniu śladu pamięci aplikacji, w której te same ciągi powtarzają się wiele razy, a ciągi te mają długi okres użytkowania).

Wpływ na wywóz śmieci

Oprócz opisanych powyżej bezpośrednich kosztów procesora i pamięci, internowane ciągi mają wpływ na wydajność modułu wyrzucania elementów bezużytecznych.

W przypadku wersji Java wcześniejszych niż Java 7 internowane ciągi są przechowywane w przestrzeni „PermGen”, która jest rzadko gromadzona. Jeśli konieczne jest pobranie PermGen, powoduje to (zwykle) pełne wyrzucanie elementów bezużytecznych. Jeśli przestrzeń PermGen całkowicie się zapełni, JVM ulega awarii, nawet jeśli w zwykłych miejscach na stosie było wolne miejsce.

W Javie 7 pula ciągów została przeniesiona z „PermGen” do normalnej sterty. Jednak tablica skrótów nadal będzie długo trwałą strukturą danych, co spowoduje, że wszelkie zawarte w niej łańcuchy będą długowieczne. (Nawet gdyby internowane obiekty łańcuchowe zostały przydzielone w przestrzeni Eden, najprawdopodobniej zostałyby wypromowane przed ich zebraniem).

Zatem we wszystkich przypadkach internowanie łańcucha przedłuży jego żywotność w stosunku do zwykłego łańcucha. Zwiększy to koszty związane z odśmiecaniem przez cały okres eksploatacji JVM.

Drugi problem polega na tym, że tablica skrótów musi używać pewnego rodzaju słabego mechanizmu odniesienia, aby zapobiec przeciekaniu pamięci przez łańcuchy wewnętrzne. Ale taki mechanizm to więcej pracy dla śmieciarza.

Te koszty narzucania śmieci są trudne do oszacowania, ale nie ma wątpliwości, że istnieją. Jeśli używasz intern intensywnie, mogą być znaczące.

Rozmiar tablicy hashtable

Według tego źródła , począwszy od Javy 6, pula ciągów znaków jest implementowana jako tablica skrótów o stałej wielkości z łańcuchami, aby radzić sobie z łańcuchami, które mają skrót do tego samego segmentu. We wczesnych wersjach Java 6 tabela skrótów miała stały rozmiar. Parametr strojenia ( -XX:StringTableSize ) został dodany jako aktualizacja w połowie okresu do Java 6. Następnie w aktualizacji w połowie okresu do Java 7 zmieniono domyślny rozmiar puli z 1009 na 60013 .

Najważniejsze jest to, że jeśli zamierzasz intensywnie używać intern w swoim kodzie, wskazane jest wybranie wersji Javy, w której można ustawić rozmiar haszowania i upewnić się, że odpowiednio go dostroiłeś. W przeciwnym razie wydajność intern może ulec pogorszeniu w miarę powiększania się puli.

Internowanie jako potencjalny wektor odmowy usługi

Algorytm hashcode dla ciągów jest dobrze znany. Jeśli internujesz ciągi dostarczone przez złośliwych użytkowników lub aplikacje, może to być użyte jako część ataku typu „odmowa usługi” (DoS). Jeśli złośliwy agent zadba o to, aby wszystkie ciągi, które udostępnia, miały ten sam kod skrótu, może to prowadzić do niezrównoważonej tabeli skrótów i wydajności O(N) dla intern ... gdzie N jest liczbą zderzonych ciągów.

(Istnieją prostsze / bardziej skuteczne sposoby przeprowadzenia ataku DoS na usługę. Jednak ten wektor może być użyty, jeśli celem ataku DoS jest złamanie zabezpieczeń lub obejście obrony przed atakiem DoS pierwszej linii.)

Pitfall - Małe odczyty / zapisy w niebuforowanych strumieniach są nieefektywne

Rozważ następujący kod, aby skopiować jeden plik do drugiego:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new FileInputStream(args[0]);
             OutputStream os = new FileOutputStream(args[1])) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

(Rozmyślnie pominęliśmy normalne sprawdzanie argumentów, zgłaszanie błędów itp., Ponieważ nie mają one związku z punktem tego przykładu).

Jeśli skompilujesz powyższy kod i użyjesz go do skopiowania dużego pliku, zauważysz, że jest on bardzo wolny. W rzeczywistości będzie to co najmniej kilka rzędów wielkości wolniejsze niż standardowe narzędzia do kopiowania plików systemu operacyjnego.

( Dodaj tutaj rzeczywiste pomiary wydajności! )

Głównym powodem, dla którego powyższy przykład jest powolny (w przypadku dużych plików) jest to, że wykonuje odczyty bajtów i zapisuje je w niebuforowanych strumieniach bajtów. Prostym sposobem na poprawę wydajności jest owijanie strumieni buforowanymi strumieniami. Na przykład:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new BufferedInputStream(
                     new FileInputStream(args[0]));
             OutputStream os = new BufferedOutputStream(
                     new FileOutputStream(args[1]))) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

Te niewielkie zmiany poprawią szybkość kopiowania danych o co najmniej kilka rzędów wielkości, w zależności od różnych czynników związanych z platformą. Buforowane opakowania strumieniowe powodują, że dane są odczytywane i zapisywane w większych porcjach. Obie instancje mają bufory zaimplementowane jako tablice bajtów.

  • Dzięki is dane są odczytywane z pliku do bufora kilka kilobajtów jednocześnie. Po wywołaniu read() implementacja zwykle zwraca bajt z bufora. Odczyta z podstawowego strumienia wejściowego tylko wtedy, gdy bufor zostanie opróżniony.

  • Zachowanie dla systemu os jest analogiczne. Wywołania do os.write(int) zapisują pojedyncze bajty w buforze. Dane są zapisywane w strumieniu wyjściowym tylko wtedy, gdy bufor jest pełny lub gdy system os jest opróżniany lub zamykany.

Co ze strumieniami znakowymi?

Jak należy pamiętać, Java I / O zapewnia różne interfejsy API do odczytu i zapisu danych binarnych i tekstowych.

  • InputStream i OutputStream są podstawowymi interfejsami API dla binarnych operacji we / wy opartych na strumieniu
  • Reader i Writer są podstawowymi interfejsami API dla tekstowych operacji we / wy opartych na strumieniu.

W przypadku tekstowych operacji we / wy BufferedReader i BufferedWriter są odpowiednikami BufferedInputStream i BufferedOutputStream .

Dlaczego buforowane strumienie mają tak wielką różnicę?

Prawdziwym powodem, dla którego strumienie buforowane zwiększają wydajność, jest sposób, w jaki aplikacja komunikuje się z systemem operacyjnym:

  • Metoda Java w aplikacji Java lub natywne wywołania procedur w natywnych bibliotekach wykonawczych JVM są szybkie. Zazwyczaj biorą kilka instrukcji maszynowych i mają minimalny wpływ na wydajność.

  • Natomiast wywołania środowiska wykonawczego JVM do systemu operacyjnego nie są szybkie. Zawierają coś zwanego „syscall”. Typowy wzorzec wywołania systemowego jest następujący:

    1. Umieść argumenty syscall w rejestrach.
    2. Wykonaj instrukcję pułapki SYSENTER.
    3. Procedura obsługi pułapek przełączyła się w stan uprzywilejowany i zmienia odwzorowania pamięci wirtualnej. Następnie wysyła do kodu, aby obsłużyć określone wywołanie systemowe.
    4. Program obsługi syscall sprawdza argumenty, uważając, aby nie było dostępu do pamięci, której proces użytkownika nie powinien widzieć.
    5. Wykonywana jest praca specyficzna dla syscall. W przypadku read systemowego może to obejmować:
      1. sprawdzanie, czy w bieżącej pozycji deskryptora pliku znajdują się dane do odczytania
      2. wywoływanie procedury obsługi systemu plików w celu pobrania wymaganych danych z dysku (lub z dowolnego miejsca w pamięci) do bufora pamięci podręcznej,
      3. kopiowanie danych z bufora pamięci podręcznej na adres podany przez JVM
      4. dostosowywanie pozycji deskryptora pliku wskaźnika strumieniowego
    6. Wróć z połączenia systemowego. Oznacza to ponowną zmianę mapowania VM i wyjście z uprzywilejowanego stanu.

Jak możesz sobie wyobrazić, wykonanie pojedynczego połączenia systemowego może spowodować tysiące instrukcji maszynowych. Konserwatywnie, co najmniej dwa rzędy wielkości dłuższe niż zwykłe wywołanie metody. (Prawdopodobnie trzy lub więcej.)

Biorąc to pod uwagę, powodem, dla którego buforowane strumienie mają dużą różnicę, jest to, że drastycznie zmniejszają liczbę wywołań systemowych. Zamiast wykonywać syscall dla każdego wywołania read() , buforowany strumień wejściowy odczytuje dużą ilość danych do bufora zgodnie z wymaganiami. Większość wywołań read() w zbuforowanym strumieniu wykonuje proste sprawdzanie granic i zwraca byte który został wcześniej odczytany. Podobne rozumowanie stosuje się w przypadku strumienia wyjściowego, a także w przypadku strumienia znaków.

(Niektóre osoby uważają, że buforowana wydajność we / wy wynika z niedopasowania między wielkością żądania odczytu a rozmiarem bloku dysku, opóźnieniem rotacji dysku itp. W rzeczywistości nowoczesny system operacyjny stosuje szereg strategii, aby zapewnić, że aplikacja zazwyczaj nie musi czekać na dysk. To nie jest prawdziwe wytłumaczenie).

Czy buforowane strumienie zawsze wygrywają?

Nie zawsze. Buforowane strumienie są zdecydowanie zwycięstwem, jeśli aplikacja zamierza wykonać wiele „małych” odczytów lub zapisów. Jeśli jednak aplikacja musi wykonywać tylko duże odczyty lub zapisy do / z dużego byte[] lub char[] , wówczas buforowane strumienie nie przyniosą żadnych rzeczywistych korzyści. Rzeczywiście może istnieć nawet (niewielka) kara za wydajność.

Czy to najszybszy sposób na skopiowanie pliku w Javie?

Nie, nie jest. Gdy używasz interfejsów API opartych na strumieniu Java do kopiowania pliku, ponosisz koszt co najmniej jednej dodatkowej kopii danych z pamięci do pamięci. Można tego uniknąć, jeśli korzystasz z interfejsów API NIO ByteBuffer i Channel . ( Dodaj link do osobnego przykładu tutaj. )



Modified text is an extract of the original Stack Overflow Documentation
Licencjonowany na podstawie CC BY-SA 3.0
Nie związany z Stack Overflow