StringBuilder: czy zawsze taki szybki? | String vs StringBuilder vs StringBuffer

StringBuilder vs StringBuffer vs String

StringBuilder

Operacje na ciągach znaków występują praktycznie w każdej aplikacji, dlatego są jedną z pierwszych umiejętności, jakie zdobywają młodzi programiści.

Jednak co dobrze sprawdza się w prostej aplikacji w stylu: Hello World, w bardziej skomplikowanym projekcie, przy dużym obciążeniu, może już przysporzyć nam nie lada problemów.

Czas poszerzyć arsenał swoich umiejętności o nową klasę: StringBuilder, która jest uważana za panaceum na problemy wydajnościowe ze stringami. Tylko czy aby na pewno jest to zawsze najlepsze wyjście?

Niezmienność [Immutability]

Standardowa klasa String jest niemodyfikowalna, w efekcie czego każda zmiana stringa pociąga za sobą konieczność utworzenia nowego obiektu.

String value = "Java";
value = value + " Rocks";

Powyższy kod wymaga utworzenia nowego obiektu zawierającego połączone stringi i przypisania go do zmiennej: value . Przy tak małej skali nie stanowi to problemu, jednak gdyby kod był wykonywany wielokrotnie w jakimś newralgicznym punkcie aplikacji, mogłoby to znacząco wpłynąć na spadek wydajności.

➡ ZOBACZ 👉Immutable – niezmienne obiekty

Niezmienność obiektów sama w sobie nie jest zła, wręcz przeciwnie, nawet często zaleca się korzystanie z takich rozwiązań. Jednak zgodnie z zasadą złotego młotka, nie ma rozwiązań w 100% uniwersalnych i należy dobrać odpowiednie narzędzia do problemu, który chcemy rozwiązać.

Dzięki obiektom typu immutable możemy między innymi:

  • poprawić wydajność aplikacji, ponieważ łatwiej jest je keszować;
  • zwiększyć bezpieczeństwo aplikacji, ponieważ mamy pewność, że wykorzystywany obiekt nie ulegnie zmianie podczas działania aplikacji;
  • można je bezpiecznie wykorzystywać jako klucze np. w mapach.

StringBuilder

Klasa StringBuilder powstała jako rozszerzenie funkcjonalności oferowanych przez tradycyjnego stringa. W przeciwieństwie do pierwowzoru StringBuilder jest klasą modyfikowalną, czyli jej obiekty mogą się rozszerzać i zmieniać swoją zawartość. Dzięki temu można łączyć ze sobą różne fragmenty tekstu, bez konieczności generowania wielu niepotrzebnych obiektów.

Obiekty klasy StringBuilder (StringBuffer też, ale o tym za chwilę) to swego rodzaju bufory, które pozwalają na dynamiczną modyfikację ciągów znaków (napisów).

StringBuilder sb = new StringBuilder();
sb.append("Java");
sb.append(' ');
sb.append("Rocks").append("!").append(0);
String value = sb.toString(); // value => "Java Rocks!0"

Powyższy przykład demonstruje, w jaki sposób można korzystać ze StringBuildera.

  1. Pierwsza linijka to utworzenie pustego bufora. StringBuilder jako argument może też przyjąć tekst (wtedy zostanie automatycznie nim zainicjowany) lub liczbę (wtedy rozmiar bufora zostanie ustawiony na podaną wartość).
  2. Druga linijka to modyfikacja bufora przez metodę append. Metoda append modyfikuje obiekt, na którym jest wywoływana oraz zwraca dokładnie ten sam obiekt.
  3. Trzecia linijka dodaje pojedynczy znak spacji. Metoda append jest przeciążona, dzięki czemu można dodawać przy jej pomocy: stringi, pojedyncze znaki, zmienne boolean, liczby oraz dowolne inne obiekty – wtedy zostanie wywołana na nich metoda toString().
  4. W czwartej linii pokazane jest, w jaki sposób można tworzyć łańcuchy wywołań. Dzięki temu, że metoda append zwraca obiekt this, takie wywołanie jest jednoznaczne z rozbiciem tego na pojedyncze linie.
  5. Ostatnia linia pobiera zbuforowany tekst i zapisuje go w zmiennej typu String.

Najważniejsze metody klasy StringBuilder

  • StringBuilder append(String str) 
    Wykorzystywana jest do dodawania nowego elementu na końcu bufora. Dzięki przeciążeniu metody można przy jej pomocy dodać praktycznie dowolną wartość.

    String actualValue = sb.append("1").append('1').append(1).toString();
    String expectedValue = "111";
    
  • StringBuilder insert(int offset, String str)
    Przy jej pomocy można wstawić element w buforze na konkretnej pozycji. Wszystkie elementy znajdujące się dalej zostaną przesunięte. Metoda insert, podobnie jak append, jest przystosowana do obsługi wszystkich typów. Należy uważać, żeby podany indeks nie przekroczył zakresu istniejącego bufora, bo dostaniemy wyjątek: IndexOutOfBoundsException.

    sb.insert(0, "Java Rocks");
    String actualValue = sb.insert(4, 8).toString();
    String expectedValue = "Java8 Rocks";
    
  • StringBuilder replace(int start, int end, String str)
    Metoda wykorzystywana jest do zamiany wskazanego przedziału w buforze na inny ciąg znaków. W przypadku podania błędnego indeksu wyrzuci: IndexOutOfBoundsException.

    sb.replace(0, 0, "Java 8 Rocks");
    String actualValue = sb.replace(0, 6, "Java7").toString();
    String expectedValue = "Java7 Rocks";
    
  •  StringBuilder delete(int start, int end)
    Przy jej pomocy można usunąć z bufora ciąg znaków o podanym zakresie.

    StringBuilder sb = new StringBuilder("Java8 Rocks");
    String actualValue = sb.delete(4, 6).toString();
    String expectedValue = "JavaRocks";
    
  • StringBuilder reverse()
    Metoda reverse odwraca ciąg znaków znajdujący się w buforze.

    StringBuilder sb = new StringBuilder("Java Rocks"); 
    String actualValue = sb.reverse().toString();
    String expectedValue = "skcoR avaJ";
    
  • int capacity()
    Metoda capacity zwraca aktualny rozmiar bufora.
  • void ensureCapacity(int minimumCapacity)
    Przy jej pomocy można wymusić ustawienie minimalnego wymaganego rozmiaru bufora. Jeżeli nie zrobimy tego ręcznie, a skończy się aktualny rozmiar bufora, StringBuilder i tak go sam powiększy.
  • int length()
    Zwraca aktualny rozmiar przechowywanego ciągu znaków. Metoda length zawsze będzie zwracała mniejszą lub równą wartość dla metody capacity.
  • char charAt(int index)
    Metoda zwraca znak znajdujący się pod wskazanym indeksem w buforze.

    StringBuilder sb = new StringBuilder("Java Rocks");
    char actualValue = sb.charAt(1);
    char expectedValue = 'a';
    
  • String substring(int start, int end)
    Metoda substring zwraca wybrany fragment ciągu znaków z bufora.

    StringBuilder sb = new StringBuilder("Java Rocks");
    String actualValue = sb.substring(0, 4);
    String expectedValue = "Java";
    

Rozszerzalność bufora

Klasa StringBuilder opiera swoje działanie na implementacji wewnętrznego bufora. To właśnie dzięki niemu nie musimy się przejmować, czy dodawane przez nas znaki zmieszczą się, czy nie.

W celu poprawy wydajności możemy co prawda sterować rozmiarem bufora, np. dzięki metodzie ensureCapacity lub konstruktorowi klasy przyjmującemu początkowy rozmiar, jednak nie jest to konieczne.

Na przykładzie poniżej pokażę, w jaki sposób zwiększany jest rozmiar bufora podczas dodawania nowych elementów.

// 1
StringBuilder sb = new StringBuilder(3);
assertEquals(3, sb.capacity());
assertEquals(0, sb.length());
// 2
sb.append("1");
assertEquals(3, sb.capacity());
assertEquals(1, sb.length());
// 3
sb.append("23");
assertEquals(3, sb.capacity());
assertEquals(3, sb.length());
// 4
sb.append("4");
assertTrue(sb.capacity() > 4);
assertEquals(4, sb.length());
  1. Krok Pierwszy
    Na początku tworzymy nowy obiekt, ustawiając jednocześnie rozmiar bufora na 3. Bufor jest jeszcze pusty, dlatego metoda length zwraca 0.
    Zainicjowanie buildera bez podania argumentu lub podając ciąg znaków, ustawi rozmiar wewnętrznego bufora na długość podanego stringa + domyślny rozmiar bufora (w przypadku mojej implementacji Javy jest to 16).
  2. Krok Drugi
    W tym kroku dodajemy pierwszy element do bufora. Ponieważ ilość elementów bez problemu mieści się jeszcze w buforze, nie było potrzeby jego rozszerzania.
  3. Krok Trzeci
    W kolejnym kroku dodajemy 2 nowe elementy. Teraz bufor jest już pełny, czyli capacity i length są sobie równe.
  4. Krok Czwarty
    Dodanie kolejnego elementu do bufora wymusiło już jego powiększenie. Ilość elementów w buforze to 4, a jego rozmiar został tak zwiększony, by mógł przyjąć więcej nowych elementów.
    Rozszerzenie bufora polega na utworzeniu nowego bufora o rozmiarze: 2x[rozmiar aktualnego bufora]+2 oraz przekopiowaniu do niego zawartości starego bufora.

StringBuffer vs StringBuilder

Prawie wszystkie wnioski odnośnie klasy StringBuilder pasują również do klasy StringBuffer. Te dwie klasy różnią się przede wszystkim tym, że StringBuffer posiada metody synchronizowane a  StringBuilder nie.

Dzięki synchronizowanym metodom klasa StingBuffer nadaje się do programowania współbieżnego, gdzie niektóre metody mogą być wywoływane z równolegle pracujących wątków. Niesie to ze sobą pewne ograniczenia wydajnościowe, dlatego jeżeli nie piszemy aplikacji wykorzystującej równoległe wątki, lepiej zostać przy standardowej wersji bez synchronizacji.

Testy wydajnościowe: StringBuilder vs StringBuffer vs String

Testy wydajnościowe: StringBuilder vs StringBuffer vs String

Testy wydajnościowe
[StringBuffer vs StringBuilder vs String]

Wśród wielu programistów panuje przekonanie, że konkatenacja stringów jest wolna, zła i powinno się jej unikać na korzyść StringBuffera.

W poniższych testach postaram się pokazać, że nie zawsze jest to prawda. Łączenie stringów przy pomocy StringBuffera rzeczywiście może być wielokrotnie szybsze, jednak tylko przy zachowaniu konkretnych warunków.

Na potrzeby testu przygotowałem 3 fragmenty kodu odpowiedzialne za łączenie stringów za pomocą: standardowej konkatenacji stringów (znaczek +), StringBuilder oraz StringBuffera.

W celu symulacji rzeczywistych sytuacji będziemy sterować dwoma parametrami testów:

  • ilość ciągów znaków, które zostaną połączone w ramach jednego testu (zmienna 'n’ z przykładu) – czyli jak dużo jest konkatenacji w ramach jednego wywołania;
  • ilość wywołań całej metody – czyli jak dużo razy wywoływana jest metoda zawierająca konkatenacje.
String v = "";
for (int i = 0; i < n; i++) {
    v += "a";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
    sb.append("a");
}
StringBuffer sb = new StringBuffer();
for (int i = 0; i < n; i++) {
    sb.append("a");
}

Wyniki poszczególnych testów (w milisekundach) podane w tabeli poniżej.

Test 1: Łączenie małej ilości stringów (10)

Test polega na łączeniu ze sobą 10 prostych stringów.

Iteracje StringBuilder StringBuffer String
10 1 1 1
1000 1 1 1
10000 4 4 3

Przy tak małej ilości konkatenacji widać, że czasy dla wszystkich sposobów są bardzo zbliżone, a nawet standardowa klasa String wypadła minimalnie szybciej.

Test 2: Łączenie średniej ilości stringów (1000)

Test polega na łączeniu ze sobą 1000 prostych stringów.

Iteracje StringBuilder StringBuffer String
10 1 1 2
1000 9 13 128
10000 63 73 1317

Podczas łączenia 1000 stringów ze sobą StringBuilder wysunął się już na prowadzenie. Szczególnie dużą różnicę widać w porównaniu do zwykłego stringa.

Test 3: Łączenie dużej ilości stringów (10 000)

W teście z dużą ilością stringów różnice pogłębiły się jeszcze bardziej.

Dla tak dużej ilości konkatenacji wykorzystanie zwykłego stringa stanowi już poważny problem wydajnościowy.

Pojawiły się też różnice miedzy StringBuilderem a StringBufferem wynikające z synchronizowania metod. Stąd wniosek, że jeżeli nie wykorzystujemy programowania wielowątkowego, to poiwinniśmy zostać przy wykorzystaniu StringBuildera.

Iteracje StringBuilder StringBuffer String
10 1 1 149
1000 92 65 12328
10000 648 810 124743

Test 4: Wielokrotne uruchamianie prostych konkatenacji

Ten test polega na wielokrotnym uruchomieniu bardzo prostej konkatenacji. Może to symulować np. budowanie komunikatu do loga w bardzo często wykorzystywanej metodzie.

String value = "a"+"b";
StringBuilder sb = new StringBuilder();
sb.append("a").append("b");
Iteracje String StringBuilder
1000000 5 26
1000000000 7 10985

Dla tego przypadku klasa StringBuilder została bardzo daleko w tyle. Pokazuje to tylko tyle, że nie zawsze wykorzystanie „lepszych” mechanizmów przynosi zamierzony skutek.

Test 5: Wykorzystanie ensureCapacity dla StringBuildera

Ten test miał na celu pokazanie, jak duży wpływ na wydajność może mieć prawidłowe wykorzystanie metody ensureCapacity,  jeżeli z góry znamy, przynajmniej przybliżony, docelowy rozmiar wynikowego ciągu znaków.

Konkatenacje Iteracje Czas z ensureCapacity Czas z domyślnymi ustawieniami
10 10000 3 4
1000 10000 57 63
10000 10000 568 810

Dzięki wykorzystaniu metody ensureCapacity rzeczywiście udało się osiągnąć kilkudziesięciu procentową poprawę wydajności, jednak nie są to już tak spektakularne różnice, jak w przypadku poprzednich testów.

Dlaczego tak wolno?

W tym miejscu trzeba się przyjrzeć, co dokładnie Java robi podczas wywoływania standardowej konkatenacji stringów, bo jest to dosyć zaskakujące.

Java podczas kompilacji kodu źródłowego do bytecode zamienia konkatenację na wywołanie klasy StringBuilder!

Można to zweryfikować np. przy wykorzystaniu narzędzia: javap:

public class Concatenation {

    public void stringConcatenation() {
        String value = "a";
        value += "b";
    }

    public void StringBuilderConcatenation() {
        StringBuilder sb = new StringBuilder("a");
        sb.append("b");
        String value = sb.toString();
    }
}
# javap -c Concatenation.class 
Compiled from "Concatenation.java"
public class pl.stormit.stringbuilder.Concatenation {
  public pl.stormit.stringbuilder.Concatenation();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object.<init>":()V
       4: return

  public void stringConcatenation();
    Code:
       0: ldc           #2                  // String a
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: aload_1
      11: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      14: ldc           #6                  // String b
      16: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_1                                                                                                                                                                                                   
      23: return                                                                                                                                                                                                     
                                                                                                                                                                                                                     
  public void StringBuilderConcatenation();                                                                                                                                                                          
    Code:                                                                                                                                                                                                            
       0: new           #3                  // class java/lang/StringBuilder                                                                                                                                         
       3: dup                                                                                                                                                                                                        
       4: ldc           #2                  // String a
       6: invokespecial #8                  // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
       9: astore_1
      10: aload_1
      11: ldc           #6                  // String b
      13: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      16: pop
      17: aload_1
      18: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      21: astore_2
      22: return
}

Niewinnie wyglądający '+’ wymusza utworzenie tymczasowego obiektu StringBuilder z nową wartością i przekonwertowanie go znowu do stringa przy pomocy metody toString().

Skoro pod spodem zawsze jest taki sam bufor, to o co to całe zamieszanie i skąd te różnice?
Różnice wynikają z drobnych, wydawałoby się, szczegółów, takich jak inicjalny rozmiar bufora oraz to, czy będzie on wykorzystywany między wywołaniami pętli, czy za każdym razem tworzony od nowa.

Wady klasy String

Konkatenacja przy pomocy stringa za każdym razem wymaga utworzenia tymczasowego obiektu StringBuildera i  ponownej jego zmiany do stringa. Dlatego to rozwiązanie absolutnie nie nadaje się, jeżeli chcemy łączyć ze sobą dużą ilość ciągów znaków.

Wady klasy StringBuilder

Domyślny rozmiar bufora nie zawsze jest odpowiedni, jednak jeżeli znamy docelowy rozmiar stringa, możemy dostosować go do naszych potrzeb.

Wnioski

W powyższym tekście pokazałem, w jaki sposób można zoptymalizować podstawowe operacje na ciągach znaków.

Jednak niezależnie od przedstawionych tutaj przykładów zachęcam do przeprowadzania testów samodzielnie i weryfikacji niektórych założeń. Może bowiem okazać się, że problem tkwi nie tam, gdzie się go pierwotnie spodziewaliśmy. Warto również pamiętać, że nawet drobne różnice, jeżeli trafią na podatny grunt (np. wielokrotne wywołanie metody), mogą przyczynić się do dużych problemów.

 

kierunek java


20+ BONUSOWYCH materiałów z programowania

e-book – „8 rzeczy, które musisz wiedzieć, żeby dostać pracę jako programista”,
e-book – „Java Cheat Sheet”,
checklista – „Pytania rekrutacyjne”
i wiele, wiele wiecej!

Jak zostać programistą

2 komentarze
Share:

2 Comments

  1. Elon says:

    Wygląda na to, że nawet z artykułu o podstawach można się dowiedzieć czegoś ciekawego, np. że Java i tak użyje StringBuildera do łączenia Stringów. Więcej takich rzeczy! 😉

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *