Baza Wiedzy

String, StringBuffer i StringBuilder

Istotnym zagadnieniem w każdym języku programowania oraz w większości programów jest przetwarzanie ciągów znaków - Stringów.

W języku Java Stringi zostały utworzone jako niemodyfikowalne - oznacza, to, że nie możemy na przykład dynamicznie dodawać do tego samego ciągu znaków czegoś nowego, aby tego dokonać zawsze musimy utworzyć nowy obiekt String.

Kurs Java

Konkatenacja Stringów🔗

Aby to zobrazować spójrzmy na przykład:

String s = "Kasia";
s = s + " i Tomek";

Utworzyliśmy tu jeden obiekt typu String, a następnie za pomocą operatora konkatenacji (łączenia) + dodaliśmy do niego kolejny człon.

Tak naprawdę nie zmodyfikowaliśmy jednak obiektu s, a przypisaliśmy do niego całkiem nową referencję.

Gdy używamy operatora + w przypadku obiektów String to przy kompilacji mogą się wydarzyć 2 rzeczy:

  • do Java 8 (włącznie) - zostanie utworzony nowy obiekt StringBuilder, następnie wywołujemy jego metodę append() z argumentem w postaci liczby, innego ciągu znaków, lub pojedynczego znaku. Na końcu musimy taki obiekt zamienić oczywiście z powrotem na String za pomocą metody toString()
  • od Java 9 - przy pierwszym wywołaniu tego miejsca zostanie utworzona "metoda" w locie, za pomocą mechanizmu invokedynamic, która przy kolejnym wywołaniu tego miejsca będzie użyta do połączenia Stringów. O tym jak zachowa się ta metoda decyduje wirtualna maszyna Javy i można tym sterować dodając odpowiednie parametry uruchomieniowe do programu.

Powyższy przykład wygląda więc w Java 8 tak naprawdę (niejawnie) następująco:

String s = "Kasia";
s = new StringBuilder(s).append(" i Tomek").toString();
System.out.println(s);

Jak widzimy złożoność obliczeniowa jest w tym wypadku po prostu fatalna i jeśli ktoś chciałby używać operatora + w przypadku Stringów na przykład w pętli to jest to bardzo złą praktyka programistyczną. Spójrzmy więc na przykład i zastanówmy się jak można by go usprawnić.

public class Strings1 {
  	public static void main(String[] args) {
  		String s = "a";
  		long start = System.nanoTime();
  		for(int i = 0; i < 10000; i++) {
  			s = s + "a";
  			//s = new StringBuilder(s).append("a").toString();
  		}
  		System.out.println("Time1: "+ (System.nanoTime() - start));
  	}
}

Jak widzimy nie wykonujemy tutaj zbyt skomplikowanej operacji dodajemy jedynie na końcu naszego Stringa 10 000 razy literę "a". W komentarzu widać co tak naprawdę się dzieje. Konkretnie tworzymy 10 000 obiektów typu StringBuilder oraz na każdym z nich wywołujemy 3 metody (w sumie to 2 i konstruktor). Dodatkowo wyświetlamy czas w nanosekundach jaki zajęło wykonanie tego przykładu - przyda się do porównania dalszych, efektywniejszych rozwiązań.

Jak sobie z tym problemem poradzić?

StringBuilder🔗

Przecież można utworzyć tylko jeden obiekt StringBuilder jeszcze przed pętlą, a wewnątrz niej wywoływać jedynie metodę append(), na końcu przypisując do s wynik metody toString(). Poprawmy więc nasz przykład:

public class Strings2 {
  	public static void main(String[] args) {
  		String s = "a";
  		long start = System.nanoTime();
  		StringBuilder sB = new StringBuilder(s);
  
  		for(int i = 0; i< 10000; i++) {
  			sB.append("a");
  		}
  
  		s = sB.toString();
  		System.out.println("Time2: "+ (System.nanoTime() - start));
  	}
}

Porównajmy jeszcze czasy jakie uzyskałem na swoim komputerze (Intel Dual Core T4200, 3GB DDR2):

Time1: 172046024
Time2: 3143797

Jak widać zyskaliśmy aż 2 rzędy czasu, konkretnie metoda z wykorzystaniem jednego obiektu StringBuilder wykonała się ok 55 razy szybciej niż program używający +.

Kurs Java

StringBuffer🔗

Co ciekawe programiści Javy dają nam do dyspozycji jeszcze jedną klasę do "modyfikowania" ciągów znaków. Jest to konkretnie StringBuffer.

Posiada ona identyczne metody jak StringBuilder i na pierwszy rzut oka nie widać między nimi żadnej różnicy, ponieważ jest ona dosyć subtelna - StringBuffer jest klasą synchronizowaną, natomiast StringBuilder nie. W drugim przypadku powoduje to jeszcze dodatkowe przyspieszenie i na poziomie początkującego programisty i programów jednowątkowych jest najlepszym rozwiązaniem.

Rzuć okiem na przykład poniżej.

public class Builder {

    public static void main(String[] args) throws InterruptedException {

        var stringB = new StringBuilder();

        new Thread(() -> {
            for (int i = 0; i < 100_000; i++) {
                stringB.append("a");
            }
        }).start();

        for (int i = 0; i < 100_000; i++) {
            stringB.append("b");
        }

        Thread.sleep(1000);

        String result = stringB.toString();
        System.out.println(result.length());
    }
}

Tworzony jest StringBuilder i dodawane do niego literki "a" oraz "b", z tym, że odbywa się to w osobnych wątkach. Jaka jest spodziewana długość wyniku? No 100 000 + 100 000 daje nam 200 000. Niestety ze względu na to, że StringBuilder nie jest przystosowany do pracy w wielu wątkach program ten często się wywala (błędy typu ArrayIndexOutOfBoundsException w środku w StringBuilderze), a jeśli już się udało, to otrzymywałem wyniki rzędu 120 tys. Wychodzi na to, że 80 tys. znaków przepadło!

Zróbmy to samo ze StringBufferem.

public class Buffer {

    public static void main(String[] args) throws InterruptedException {

        var stringB = new StringBuffer();

        new Thread(() -> {
            for (int i = 0; i < 100_000; i++) {
                stringB.append("a");
            }
        }).start();

        for (int i = 0; i < 100_000; i++) {
            stringB.append("b");
        }

        Thread.sleep(1000);

        String result = stringB.toString();
        System.out.println(result.length());
    }
}

Tym razem w wyniku dostaję za każdym razem spodziewane 200 tys. znaków.

Wróćmy do testów wydajnościowych i sprawdźmy więc jaki wynik uzyskamy przy StringBufferze.

public class Strings {
  	public static void main(String[] args) {
  		String s = "a";
  		long start = System.nanoTime();
  		StringBuffer strB = new StringBuffer(s);
  		for(int i=0; i < 10000; i++) {
  			strB.append("a");
  		}
  		s = strB.toString();
  		System.out.println("Time3: " + (System.nanoTime() - start));
  	}
}

W tym przypadku czas wyniósł: Time3: 1892437.

Zestawiając wszystkie 3 metody:

Time1: 172046024
Time2: 1892437
Time3: 3143797

Jak widać klasa StringBuilder okazała się dodatkowo ponad 1,5 raza szybsza od StringBuffer.

Najważniejsze w tej lekcji jest to, aby używać operatora + na Stringach, gdy nie kosztuje nas to ani dużo czasu, ani pamięci - czyli w zasadzie jedyne słuszne miejsce, gdzie można to robić to wnętrze metody print i podobnych do wyświetlania pojedynczych wyników. W każdym innym przypadku powinniśmy używać klasy StringBuffer, lub StringBuilder, szczególnie, gdy wielokrotnie dodajemy coś do naszego obiektu jak w podanych powyżej przykładach.

Najlepszy newsletter o Javie w Polsce

Czy chcesz otrzymywać nowości ze świata Javy oraz przykładowe pytania rekrutacyjne? Zapisz się na newsletter i bądź na bieżąco! Otrzymasz także ekskluzywne materiały oraz informacje o nowych kursach i promocjach.

Nikomu nie udostępniamy Twojego maila, a jeśli zechcesz to w każdej chwili możesz się wypisać.

Komentarze do artykułu

Wyłączyliśmy możliwość dodawania komentarzy. Poniżej znajdziesz archiwalne wpisy z czasów gdy strona była jeszcze hobbystycznym blogiem. Zapraszamy natomiast do zadawnia pytań i dyskusji na naszej grupe na facebooku.