String, StringBuffer i StringBuilder
Spis treści
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.
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 +.
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 grupie na Facebooku.