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 programowania 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.

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzieliś spostrzeżeniami? Zapraszamy dyskusji na naszej grupe na Facebooku.